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
+8 -1
View File
@@ -284,7 +284,7 @@ class PairingRequest(BaseModel):
slave_date_to: Optional[str] = Field(default=None, pattern=r'^\d{8}$|^$')
# === 配对策略(新增) ===
strategy: str = Field(default="dinsar_production", pattern=r'^dinsar_production$')
strategy: str = Field(default="dinsar_production", pattern=r'^(dinsar_production|dinsar_province_coverage)$')
num_connections: int = Field(default=1, ge=1, le=10)
reference_image_id: Optional[int] = None
@@ -307,6 +307,7 @@ class PairingRequest(BaseModel):
normalized['overlap_threshold'] = normalized['pair_footprint_overlap_min_ratio']
if normalized.get('footprint_center_distance_max_meters') not in (None, ''):
normalized['spatial_baseline_max_meters'] = normalized['footprint_center_distance_max_meters']
if normalized.get('strategy') not in {'dinsar_production', 'dinsar_province_coverage'}:
normalized['strategy'] = 'dinsar_production'
return normalized
@@ -390,6 +391,11 @@ class RadarPair(BaseModel):
selection_strategy: Optional[str] = None
selection_score: Optional[float] = None
selection_reason: Optional[str] = None
coverage_rank: Optional[int] = None
aoi_new_area_ratio: Optional[float] = None
aoi_pair_area_ratio: Optional[float] = None
aoi_coverage_ratio_after: Optional[float] = None
effective_coverage_geojson: Optional[Dict[str, Any]] = None
time_baseline_days: int
spatial_baseline_meters: float
scene_center_distance_meters: Optional[float] = None
@@ -416,6 +422,7 @@ class PairingResponse(BaseModel):
network_run_id: Optional[str] = None
candidate_count: int = 0
selected_edge_count: int = 0
coverage: Optional[Dict[str, Any]] = None
class PsRequest(BaseModel):
+1 -6
View File
@@ -17,12 +17,7 @@ router = APIRouter(prefix="/ops-maintenance", tags=["ops-maintenance"])
class CleanupRequest(BaseModel):
confirm: bool = False
delete_task_records: bool = True
delete_logs: bool = True
delete_production_records: bool = True
delete_result_products: bool = True
delete_production_dirs: bool = True
delete_task_pool_dir: bool = True
delete_landsar_work_dir: bool = True
@router.get("/tasks")
+61
View File
@@ -329,6 +329,7 @@ async def find_pairs_endpoint(
network_run_id=pairing_metadata.get("network_run_id"),
candidate_count=int(pairing_metadata.get("candidate_count") or 0),
selected_edge_count=int(pairing_metadata.get("selected_edge_count") or 0),
coverage=None,
)
except Exception as e:
if isinstance(e, HTTPException):
@@ -339,6 +340,66 @@ async def find_pairs_endpoint(
raise HTTPException(status_code=500, detail="处理 AOI 或查找干涉对时发生错误,请查看后端日志")
@router.post("/pairing/coverage-plan", response_model=PairingResponse)
async def build_coverage_pairing_plan_endpoint(
params: PairingRequest = Depends(get_pairing_request_from_form),
target_date_from: str = Form(...),
target_date_to: str = Form(...),
extension_days: int = Form(15),
max_pairs: int = Form(200),
target_coverage_ratio: float = Form(0.98),
min_new_coverage_ratio: float = Form(0.0005),
files: Optional[List[UploadFile]] = File(None),
aoi_geojson: Optional[str] = Form(None),
require_orbit_data: bool = Form(True),
db: AsyncSession = Depends(get_db),
):
try:
resolved_aoi = await _parse_aoi_from_files(files)
if resolved_aoi is None:
resolved_aoi = _parse_aoi_geojson_form_value(aoi_geojson)
if files and resolved_aoi is None:
raise HTTPException(status_code=400, detail="Uploaded files must include a valid SHP or GeoJSON AOI.")
aoi_wkt = resolved_aoi[0] if resolved_aoi else None
response_aoi_geojson = resolved_aoi[1] if resolved_aoi else None
if not aoi_wkt:
raise HTTPException(status_code=400, detail="区域空间覆盖规划必须选择行政区或上传 AOI。")
coverage_params = params.model_copy(update={"strategy": "dinsar_province_coverage"})
pairs, runtime_warnings, pairing_metadata = await spatial_service.find_dinsar_coverage_pairs(
db,
coverage_params,
target_date_from=target_date_from,
target_date_to=target_date_to,
extension_days=extension_days,
max_pairs=max_pairs,
target_coverage_ratio=target_coverage_ratio,
min_new_coverage_ratio=min_new_coverage_ratio,
aoi_wkt=aoi_wkt,
require_orbit_data=require_orbit_data,
)
return PairingResponse(
pairs=pairs,
aoi_geojson=response_aoi_geojson,
warnings=runtime_warnings,
fallback_used=bool(pairing_metadata.get("fallback_used")),
degraded=bool(pairing_metadata.get("degraded")),
policy_version=pairing_metadata.get("policy_version"),
network_run_id=pairing_metadata.get("network_run_id"),
candidate_count=int(pairing_metadata.get("candidate_count") or 0),
selected_edge_count=int(pairing_metadata.get("selected_edge_count") or 0),
coverage=pairing_metadata.get("coverage"),
)
except Exception as e:
if isinstance(e, HTTPException):
raise e
if isinstance(e, (RuntimeError, ValueError)):
raise HTTPException(status_code=409, detail=str(e))
logger.exception("Failed to build D-InSAR coverage pairing plan")
raise HTTPException(status_code=500, detail="Failed to build D-InSAR coverage pairing plan.")
@router.post("/find-ps-timeseries", response_model=Dict[str, List[RadarData]])
async def find_ps_timeseries_endpoint(
params: PsRequest = Depends(get_ps_request_from_form),
+220 -51
View File
@@ -11,6 +11,8 @@ from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
SOURCE_METADATA_MANAGED_FORMATS = ("LT1_ARCHIVE", "S1_ZIP")
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import distinct, func, text
from sqlalchemy.ext.asyncio import AsyncSession
@@ -51,7 +53,6 @@ from ..services.data_service import data_service
from ..services.dinsar_read_service import dinsar_read_service
from ..services.pairing_state_service import pairing_state_service
from ..services.admin_region_lookup_service import _build_region_path, _load_region_records
from ..utils import find_xml_file
from . import dependencies as _deps
from .dependencies import _get_current_user
@@ -80,6 +81,10 @@ def _ratio(numerator: int, denominator: int) -> float:
return round(float(numerator) / float(denominator), 4)
def _is_source_metadata_managed_format(value: Any) -> bool:
return str(value or "").strip().upper() in SOURCE_METADATA_MANAGED_FORMATS
def _family_label(value: Any) -> str:
text = str(value or "").strip().upper()
if text in {"LT1", "LT-1", "LUTAN", "LUTAN1"}:
@@ -102,6 +107,14 @@ def _month_from_yyyymmdd(value: Any) -> Optional[str]:
return None
def _compact_yyyymmdd(value: Any) -> Optional[str]:
text = str(value or "").strip()
digits = "".join(ch for ch in text if ch.isdigit())
if len(digits) >= 8:
return digits[:8]
return None
def _month_from_datetime(value: Any) -> Optional[str]:
if not value:
return None
@@ -594,16 +607,36 @@ async def get_statistics_dashboard(
db,
select(func.count(SourceProductAssetORM.id)).where(SourceProductAssetORM.is_active == True),
)
metadata_source_total = await _scalar_count(
db,
select(func.count(SourceProductAssetORM.id)).where(
SourceProductAssetORM.is_active == True,
SourceProductAssetORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
),
)
radar_total = await _scalar_count(db, select(func.count(RadarDataORM.id)))
metadata_asset_total = await _scalar_count(
db,
select(func.count(distinct(SourceMetadataDocumentORM.source_asset_id))),
select(func.count(distinct(SourceMetadataDocumentORM.source_asset_id))).where(
SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
),
)
metadata_doc_total = await _scalar_count(
db,
select(func.count(SourceMetadataDocumentORM.id)).where(
SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
),
)
geometry_total = await _scalar_count(
db,
select(func.count(SARSceneGeometryProfileORM.id)).where(
SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
),
)
metadata_doc_total = await _scalar_count(db, select(func.count(SourceMetadataDocumentORM.id)))
geometry_total = await _scalar_count(db, select(func.count(SARSceneGeometryProfileORM.id)))
geometry_ready = await _scalar_count(
db,
select(func.count(SARSceneGeometryProfileORM.id)).where(
SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
SARSceneGeometryProfileORM.metadata_quality == "READY",
SARSceneGeometryProfileORM.production_readiness == "READY",
),
@@ -633,6 +666,7 @@ async def get_statistics_dashboard(
for family, source_format, parse_status, count in source_group_rows.all():
family_label = _family_label(family)
status_label = _status_label(parse_status)
format_label = str(source_format or "UNKNOWN")
count_int = _safe_int(count)
family_bucket = source_by_family_map.setdefault(
family_label,
@@ -649,7 +683,6 @@ async def get_statistics_dashboard(
family_bucket["ready_count"] += count_int
else:
family_bucket["issue_count"] += count_int
format_label = str(source_format or "UNKNOWN")
family_bucket["formats"][format_label] = family_bucket["formats"].get(format_label, 0) + count_int
source_by_format.append(
{
@@ -673,19 +706,22 @@ async def get_statistics_dashboard(
geometry_rows = await db.execute(
select(
SARSceneGeometryProfileORM.satellite_family,
SARSceneGeometryProfileORM.source_format,
SARSceneGeometryProfileORM.metadata_quality,
SARSceneGeometryProfileORM.production_readiness,
func.count(SARSceneGeometryProfileORM.id),
)
.where(SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS))
.group_by(
SARSceneGeometryProfileORM.satellite_family,
SARSceneGeometryProfileORM.source_format,
SARSceneGeometryProfileORM.metadata_quality,
SARSceneGeometryProfileORM.production_readiness,
)
.order_by(SARSceneGeometryProfileORM.satellite_family)
)
geometry_by_family_map: dict[str, dict[str, Any]] = {}
for family, metadata_quality, production_readiness, count in geometry_rows.all():
for family, source_format, metadata_quality, production_readiness, count in geometry_rows.all():
family_label = _family_label(family)
count_int = _safe_int(count)
bucket = geometry_by_family_map.setdefault(
@@ -1075,6 +1111,82 @@ async def get_statistics_dashboard(
issue_by_severity[severity_label] = issue_by_severity.get(severity_label, 0) + count_int
issue_by_code[code_label] = issue_by_code.get(code_label, 0) + count_int
missing_orbit_issue_rows = await db.execute(
select(AssetInventoryIssueORM.metadata_json)
.where(AssetInventoryIssueORM.status == "OPEN")
.where(AssetInventoryIssueORM.issue_code == "scene_missing_orbit")
)
orbit_missing_satellite_map: dict[str, dict[str, Any]] = {}
orbit_missing_top_dates: dict[tuple[str, str], int] = {}
orbit_missing_unknown_date_scene_count = 0
for (metadata_json,) in missing_orbit_issue_rows.all():
metadata = metadata_json if isinstance(metadata_json, dict) else {}
satellite = str(metadata.get("satellite") or "UNKNOWN").strip().upper() or "UNKNOWN"
ymd = _compact_yyyymmdd(metadata.get("imaging_date") or metadata.get("acquisition_start_time_utc"))
month = _month_from_yyyymmdd(ymd) if ymd else None
satellite_bucket = orbit_missing_satellite_map.setdefault(
satellite,
{
"satellite": satellite,
"affected_scene_count": 0,
"dates": defaultdict(int),
"months": defaultdict(lambda: {"affected_scene_count": 0, "dates": set()}),
"unknown_date_scene_count": 0,
},
)
satellite_bucket["affected_scene_count"] += 1
if not ymd:
satellite_bucket["unknown_date_scene_count"] += 1
orbit_missing_unknown_date_scene_count += 1
continue
satellite_bucket["dates"][ymd] += 1
orbit_missing_top_dates[(satellite, ymd)] = orbit_missing_top_dates.get((satellite, ymd), 0) + 1
if month:
month_bucket = satellite_bucket["months"][month]
month_bucket["affected_scene_count"] += 1
month_bucket["dates"].add(ymd)
orbit_missing_by_satellite: list[dict[str, Any]] = []
for satellite, bucket in sorted(orbit_missing_satellite_map.items()):
date_items = [
{"date": date_text, "affected_scene_count": _safe_int(count)}
for date_text, count in sorted(bucket["dates"].items())
]
month_items = [
{
"month": month,
"affected_scene_count": _safe_int(month_bucket["affected_scene_count"]),
"missing_orbit_date_count": len(month_bucket["dates"]),
}
for month, month_bucket in sorted(bucket["months"].items())
]
orbit_missing_by_satellite.append(
{
"satellite": satellite,
"affected_scene_count": _safe_int(bucket["affected_scene_count"]),
"missing_orbit_date_count": len(date_items),
"unknown_date_scene_count": _safe_int(bucket["unknown_date_scene_count"]),
"first_missing_date": date_items[0]["date"] if date_items else None,
"last_missing_date": date_items[-1]["date"] if date_items else None,
"months": month_items,
"dates": date_items,
}
)
orbit_missing_summary = {
"affected_scene_count": sum(item["affected_scene_count"] for item in orbit_missing_by_satellite),
"missing_orbit_date_count": sum(item["missing_orbit_date_count"] for item in orbit_missing_by_satellite),
"unknown_date_scene_count": orbit_missing_unknown_date_scene_count,
"by_satellite": orbit_missing_by_satellite,
"top_dates": [
{"satellite": satellite, "date": date_text, "affected_scene_count": _safe_int(count)}
for (satellite, date_text), count in sorted(
orbit_missing_top_dates.items(),
key=lambda item: (-item[1], item[0][0], item[0][1]),
)[:20]
],
}
inventory_state_rows = await db.execute(
select(
AssetInventoryStateORM.inventory_type,
@@ -1114,16 +1226,24 @@ async def get_statistics_dashboard(
avg_duration_seconds = round(sum(duration_seconds) / len(duration_seconds), 1) if duration_seconds else None
selected_orbit_rate = _ratio(selected_orbit_bindings, orbit_required_total)
geometry_ready_rate = _ratio(geometry_ready, source_total)
metadata_ready_rate = _ratio(metadata_asset_total, source_total)
geometry_ready_rate = _ratio(geometry_ready, geometry_total)
metadata_ready_rate = _ratio(metadata_asset_total, metadata_source_total)
result_ready_total = sum(item["ready_count"] for item in results_by_catalog)
metadata_missing_count = max(0, metadata_source_total - metadata_asset_total)
geometry_not_ready_count = max(0, geometry_total - geometry_ready)
orbit_missing_gap_count = max(0, orbit_required_total - selected_orbit_bindings)
non_orbit_open_issue_total = sum(
count
for code, count in issue_by_code.items()
if code != "scene_missing_orbit"
)
risk_count = (
max(0, source_total - metadata_asset_total)
+ max(0, source_total - geometry_ready)
+ max(0, orbit_required_total - selected_orbit_bindings)
metadata_missing_count
+ geometry_not_ready_count
+ orbit_missing_gap_count
+ result_assets_missing
+ open_issue_total
+ non_orbit_open_issue_total
)
kpis = [
@@ -1140,7 +1260,7 @@ async def get_statistics_dashboard(
"label": "元数据入库率",
"value": round(metadata_ready_rate * 100, 1),
"unit": "%",
"note": f"{metadata_asset_total}/{source_total} 景已提取 XML/元数据",
"note": f"{metadata_asset_total}/{metadata_source_total} 景已提取 XML/元数据",
"tone": "success" if metadata_ready_rate >= 0.98 else "warning",
},
{
@@ -1148,7 +1268,7 @@ async def get_statistics_dashboard(
"label": "几何画像可用率",
"value": round(geometry_ready_rate * 100, 1),
"unit": "%",
"note": f"{geometry_ready}/{source_total} 景可用于覆盖统计",
"note": f"{geometry_ready}/{geometry_total} 景可用于覆盖统计",
"tone": "success" if geometry_ready_rate >= 0.95 else "warning",
},
{
@@ -1172,7 +1292,7 @@ async def get_statistics_dashboard(
"label": "待关注项",
"value": risk_count,
"unit": "",
"note": f"开放问题 {open_issue_total},缺失成果资产 {result_assets_missing}",
"note": f"缺精轨 {orbit_missing_gap_count}开放问题 {open_issue_total},缺失成果资产 {result_assets_missing}",
"tone": "danger" if risk_count else "success",
},
]
@@ -1186,20 +1306,23 @@ async def get_statistics_dashboard(
"source_by_family": source_by_family,
"source_by_format": source_by_format,
"source_by_month": source_by_month,
"metadata_source_total": metadata_source_total,
"metadata_asset_total": metadata_asset_total,
"metadata_doc_total": metadata_doc_total,
"metadata_missing_count": metadata_missing_count,
"metadata_ready_rate": metadata_ready_rate,
"geometry_total": geometry_total,
"geometry_ready": geometry_ready,
"geometry_not_ready_count": geometry_not_ready_count,
"geometry_ready_rate": geometry_ready_rate,
"geometry_by_family": geometry_by_family,
"preview_ready": preview_ready,
"pipeline": [
{"key": "source", "label": "源资产登记", "value": source_total, "rate": 1.0},
{"key": "metadata", "label": "元数据入库", "value": metadata_asset_total, "rate": metadata_ready_rate},
{"key": "geometry", "label": "几何画像", "value": geometry_total, "rate": _ratio(geometry_total, source_total)},
{"key": "ready", "label": "可生产画像", "value": geometry_ready, "rate": geometry_ready_rate},
{"key": "preview", "label": "预览缓存", "value": preview_ready, "rate": _ratio(preview_ready, radar_total)},
{"key": "source", "label": "源资产登记", "value": source_total, "denominator": source_total, "rate": 1.0},
{"key": "metadata", "label": "元数据入库", "value": metadata_asset_total, "denominator": metadata_source_total, "rate": metadata_ready_rate},
{"key": "geometry", "label": "几何画像", "value": geometry_total, "denominator": metadata_source_total, "rate": _ratio(geometry_total, metadata_source_total)},
{"key": "ready", "label": "可生产画像", "value": geometry_ready, "denominator": geometry_total, "rate": geometry_ready_rate},
{"key": "preview", "label": "预览缓存", "value": preview_ready, "denominator": radar_total, "rate": _ratio(preview_ready, radar_total)},
],
},
"orbit": {
@@ -1208,6 +1331,8 @@ async def get_statistics_dashboard(
"orbit_required_total": orbit_required_total,
"selected_bindings": selected_orbit_bindings,
"matched_bindings": matched_orbit_bindings,
"missing_scene_count": orbit_missing_gap_count,
"missing_summary": orbit_missing_summary,
"selected_rate": selected_orbit_rate,
},
"coverage": {
@@ -1242,6 +1367,9 @@ async def get_statistics_dashboard(
"issues": {
"issue_total": issue_total,
"open_issue_total": open_issue_total,
"risk_count": risk_count,
"non_orbit_open_issue_total": non_orbit_open_issue_total,
"orbit_missing_gap_count": orbit_missing_gap_count,
"by_severity": [
{"severity": severity, "count": count}
for severity, count in sorted(issue_by_severity.items(), key=lambda kv: (-kv[1], kv[0]))
@@ -1361,12 +1489,16 @@ async def get_statistics(
"db_ready_and_cache_exists_count": 0,
"db_ready_but_cache_missing_count": 0,
}
source_xml_consistency = {
source_metadata_consistency = {
"total_records_count": 0,
"xml_detected_count": 0,
"xml_missing_count": 0,
"xml_parsed_ok_count": 0,
"xml_detected_but_unparsed_count": 0,
"metadata_document_asset_count": 0,
"metadata_document_count": 0,
"metadata_document_missing_count": 0,
"metadata_parsed_ok_count": 0,
"metadata_detected_but_unparsed_count": 0,
"geometry_ready_count": 0,
"geometry_missing_count": 0,
"geometry_not_ready_count": 0,
}
try:
@@ -1399,7 +1531,6 @@ async def get_statistics(
)
source_rows = source_rows_res.all()
source_preview_consistency["total_records_count"] = len(source_rows)
source_xml_consistency["total_records_count"] = len(source_rows)
for (
unique_id,
@@ -1415,7 +1546,6 @@ async def get_statistics(
) in source_rows:
if not file_path:
source_preview_consistency["preview_missing_count"] += 1
source_xml_consistency["xml_missing_count"] += 1
continue
cache_key = unique_id or file_path
@@ -1443,34 +1573,64 @@ async def get_statistics(
else:
source_preview_consistency["db_ready_but_cache_missing_count"] += 1
scene_dir = file_path if os.path.isdir(file_path) else os.path.dirname(file_path)
xml_path = find_xml_file(scene_dir) if scene_dir else None
has_xml = bool(xml_path and os.path.exists(xml_path))
if has_xml:
source_xml_consistency["xml_detected_count"] += 1
parsed_ok = any(
value is not None and value != ""
for value in [
scene_center_lon,
scene_center_lat,
acquisition_time_utc,
satellite_mode,
receiving_station,
product_level,
product_unique_id,
]
active_source_count = await _scalar_count(
db,
select(func.count(SourceProductAssetORM.id)).where(
SourceProductAssetORM.is_active == True,
SourceProductAssetORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
),
)
metadata_asset_count = await _scalar_count(
db,
select(func.count(distinct(SourceMetadataDocumentORM.source_asset_id))).where(
SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
),
)
metadata_doc_count = await _scalar_count(
db,
select(func.count(SourceMetadataDocumentORM.id)).where(
SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
),
)
metadata_parse_issue_count = await _scalar_count(
db,
select(func.count(SourceMetadataDocumentORM.id)).where(
SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
SourceMetadataDocumentORM.parse_status != "OK",
),
)
geometry_ready_count = await _scalar_count(
db,
select(func.count(SARSceneGeometryProfileORM.id)).where(
SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
SARSceneGeometryProfileORM.metadata_quality == "READY",
SARSceneGeometryProfileORM.production_readiness == "READY",
),
)
geometry_total_count = await _scalar_count(
db,
select(func.count(SARSceneGeometryProfileORM.id)).where(
SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
),
)
source_metadata_consistency.update(
{
"total_records_count": active_source_count,
"metadata_document_asset_count": metadata_asset_count,
"metadata_document_count": metadata_doc_count,
"metadata_document_missing_count": max(0, active_source_count - metadata_asset_count),
"metadata_parsed_ok_count": max(0, metadata_doc_count - metadata_parse_issue_count),
"metadata_detected_but_unparsed_count": metadata_parse_issue_count,
"geometry_ready_count": geometry_ready_count,
"geometry_missing_count": max(0, active_source_count - geometry_total_count),
"geometry_not_ready_count": max(0, geometry_total_count - geometry_ready_count),
}
)
if parsed_ok:
source_xml_consistency["xml_parsed_ok_count"] += 1
else:
source_xml_consistency["xml_detected_but_unparsed_count"] += 1
else:
source_xml_consistency["xml_missing_count"] += 1
except Exception as e:
logger.warning("统计源数据时发生错误 (可能是表不存在): %s", e)
source_preview_consistency["error"] = str(e)
source_xml_consistency["error"] = str(e)
source_metadata_consistency["error"] = str(e)
# 4. AI 质量统计
labeled_good_count = sum(1 for record in dinsar_records if record.product.user_label == 1)
@@ -1756,7 +1916,16 @@ async def get_statistics(
"with_orbit_data_count": with_orbit_data_count,
},
"source_preview_consistency": source_preview_consistency,
"source_xml_consistency": source_xml_consistency,
"source_metadata_consistency": source_metadata_consistency,
"source_xml_consistency": {
"deprecated": True,
"replacement": "source_metadata_consistency",
"total_records_count": source_metadata_consistency.get("total_records_count", 0),
"xml_detected_count": source_metadata_consistency.get("metadata_document_asset_count", 0),
"xml_missing_count": 0,
"xml_parsed_ok_count": source_metadata_consistency.get("metadata_parsed_ok_count", 0),
"xml_detected_but_unparsed_count": 0,
},
"water_geo_consistency": water_geo_consistency,
"pairing_consistency": pairing_consistency,
"by_satellite": by_satellite,
@@ -80,6 +80,16 @@ TERMINAL_ITEM_STATUSES = {
_SAFE_POINTER_RE = re.compile(r"[^0-9A-Za-z._-]+")
def _item_status_counts(items: List[DinsarProductionRunItemORM]) -> Dict[str, int]:
counts: Dict[str, int] = {}
for item in items:
status = str(item.status or "").strip().upper()
if not status:
continue
counts[status] = counts.get(status, 0) + 1
return counts
def _task_type_for_engine(engine_code: str) -> str:
normalized = str(engine_code or "").strip().lower()
if normalized == "sarscape":
@@ -997,9 +1007,11 @@ class DinsarProductionService:
)
for item in items_result.scalars().all():
items_by_run_id.setdefault(item.run_id, []).append(item)
def serialize_run(run: DinsarProductionRunORM) -> Dict[str, Any]:
run_items = items_by_run_id.get(run.run_id, [])
item_counts = _item_status_counts(run_items)
return {
"runs": [
{
"run_id": run.run_id,
"product_family": run.product_family,
"engine": run.engine_code,
@@ -1014,10 +1026,13 @@ class DinsarProductionService:
"publish_root_dir": run.publish_root_dir,
"message": run.latest_message,
"summary_json": run.summary_json if isinstance(run.summary_json, dict) else {},
"total_items": run.total_items,
"completed_items": run.completed_items,
"failed_items": run.failed_items,
"skipped_items": run.skipped_items,
"total_items": max(int(run.total_items or 0), len(run_items)),
"completed_items": item_counts.get(RUN_ITEM_STATUS_COMPLETED, int(run.completed_items or 0)),
"running_items": item_counts.get(RUN_ITEM_STATUS_RUNNING, 0),
"pending_items": item_counts.get(RUN_ITEM_STATUS_PENDING, 0),
"failed_items": item_counts.get(RUN_ITEM_STATUS_FAILED, int(run.failed_items or 0)),
"skipped_items": item_counts.get(RUN_ITEM_STATUS_SKIPPED, int(run.skipped_items or 0)),
"cancelled_items": item_counts.get(RUN_ITEM_STATUS_CANCELLED, 0),
"items": [
{
"task_name": item.task_name,
@@ -1039,11 +1054,12 @@ class DinsarProductionService:
if item.latest_run_key
else {},
}
for item in items_by_run_id.get(run.run_id, [])[:5]
for item in run_items[:5]
],
}
for run in runs
],
return {
"runs": [serialize_run(run) for run in runs],
"total": total,
}
+78 -236
View File
@@ -4,9 +4,9 @@ import os
import shutil
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from typing import Any, Dict, List, Optional
from sqlalchemy import delete, func, or_, select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
@@ -14,25 +14,18 @@ from ..models import (
DinsarProductionExecutionORM,
DinsarProductionRunItemORM,
DinsarProductionRunORM,
DinsarResultORM,
ResultProductORM,
SystemJobORM,
SystemTaskORM,
TaskLogORM,
WorkflowArtifactORM,
WorkflowRunORM,
WorkflowStepORM,
)
from .dinsar_production_service import dinsar_production_service
TERMINAL_TASK_STATUSES = {"COMPLETED", "FAILED", "PARTIAL_SUCCESS", "CANCELLED"}
MAINTENANCE_LIST_STATUSES = {"FAILED", "PARTIAL_SUCCESS", "CANCELLED", "PENDING", "RUNNING"}
MAINTENANCE_LIST_STATUSES = {"COMPLETED", "FAILED", "PARTIAL_SUCCESS", "CANCELLED", "PENDING", "RUNNING"}
ACTIVE_TASK_STATUSES = {"PENDING", "RUNNING"}
ACTIVE_JOB_STATUSES = {"READY", "RETRY", "RUNNING"}
DINSAR_TASK_TYPES = {"LANDSAR_RUN", "LANDSAR_CLUSTER_RUN", "PYINT_RUN", "IDL_RUN_DINSAR"}
SUPPORTED_CLEANUP_TASK_TYPES = DINSAR_TASK_TYPES | {"COPY_DATA"}
DEFAULT_TASK_TYPES = SUPPORTED_CLEANUP_TASK_TYPES | {"PAIRING_CACHE_REBUILD"}
SUPPORTED_CLEANUP_TASK_TYPES = {"LANDSAR_RUN", "LANDSAR_CLUSTER_RUN"}
DEFAULT_TASK_TYPES = DINSAR_TASK_TYPES | {"COPY_DATA", "PAIRING_CACHE_REBUILD"}
LANDSAR_WORK_TERMINAL_EXECUTION_STATUSES = {"COMPLETED", "FAILED", "CANCELLED"}
def _utcnow_naive() -> datetime:
@@ -68,8 +61,15 @@ def _path_exists(path: str) -> bool:
return bool(path) and os.path.exists(path)
def _safe_count(rows: Iterable[Any]) -> int:
return len(list(rows))
def _is_within_path(path: str, parent: str) -> bool:
path_text = _normalize_path_text(path)
parent_text = _normalize_path_text(parent)
if not path_text or not parent_text:
return False
try:
return os.path.commonpath([os.path.abspath(path_text), os.path.abspath(parent_text)]) == os.path.abspath(parent_text)
except ValueError:
return False
class OpsMaintenanceService:
@@ -108,11 +108,12 @@ class OpsMaintenanceService:
if item.get("issue_level") in {"warning", "danger"}
or _norm_status(item.get("status")) in {"FAILED", "PARTIAL_SUCCESS", "CANCELLED"}
]
visible_items = items if status_filter else abnormal_items
return {
"items": abnormal_items,
"items": visible_items,
"limit": safe_limit,
"offset": safe_offset,
"returned": len(abnormal_items),
"returned": len(visible_items),
}
async def diagnose_task(self, db: AsyncSession, task_id: str) -> Optional[Dict[str, Any]]:
@@ -124,19 +125,11 @@ class OpsMaintenanceService:
item_counts: Dict[str, int] = {}
execution_counts: Dict[str, int] = {}
disk_paths: List[Dict[str, Any]] = []
products: List[Dict[str, Any]] = []
if run is not None:
item_counts = await self._status_counts(db, DinsarProductionRunItemORM, run.run_id)
execution_counts = await self._status_counts(db, DinsarProductionExecutionORM, run.run_id)
disk_paths.extend(await self._collect_run_disk_paths(db, run))
products = await self._collect_result_products(db, run)
related_tasks = await self._related_copy_tasks_for_run(db, run) if run is not None else []
copy_dest = self._copy_task_dest_dir(task)
if copy_dest:
disk_paths.append(self._path_payload(copy_dest, "task_pool"))
recent_logs = await self._recent_logs(db, task.task_id)
findings, cleanup_supported, cleanup_blockers = self._diagnose_findings(
task=task,
@@ -152,8 +145,6 @@ class OpsMaintenanceService:
"production_run": self._run_payload(run) if run else None,
"production_item_counts": item_counts,
"production_execution_counts": execution_counts,
"result_products": products,
"related_tasks": [self._task_payload(item) for item in related_tasks],
"recent_logs": recent_logs,
"disk_paths": disk_paths,
"diagnosis": {
@@ -179,15 +170,17 @@ class OpsMaintenanceService:
if task_status in ACTIVE_TASK_STATUSES:
blockers.append("任务仍处于活动状态,不能清理。")
db_counts = await self._cleanup_db_counts(db, diagnosis)
disk_deletes = self._cleanup_disk_targets(diagnosis)
has_existing_target = any(item.get("exists") and item.get("allowed") for item in disk_deletes)
if not has_existing_target:
blockers.append("未发现可清理的 LandSAR_WORK_ROOT/run_* 目录。")
blocked = bool(blockers) or not cleanup_supported
return {
"task_id": task_id,
"blocked": blocked,
"blockers": blockers,
"cleanup_supported": cleanup_supported and not blocked,
"database_deletes": db_counts,
"database_deletes": {},
"disk_deletes": disk_deletes,
}
@@ -204,82 +197,17 @@ class OpsMaintenanceService:
if preview.get("blocked"):
raise ValueError("; ".join(preview.get("blockers") or ["清理被阻止。"]))
deleted_db: Dict[str, int] = {}
task = await self._get_task(db, task_id)
if task is None:
return None
jobs = await self._get_jobs(db, task_id)
run = await self._get_production_run_for_task(db, task, jobs)
delete_logs = bool(options.get("delete_logs", True))
delete_task_records = bool(options.get("delete_task_records", True))
delete_production_records = bool(options.get("delete_production_records", True))
delete_result_products = bool(options.get("delete_result_products", True))
delete_task_pool_dir = bool(options.get("delete_task_pool_dir", True))
related_copy_tasks = await self._related_copy_tasks_for_run(db, run) if run is not None else []
if delete_result_products and run is not None:
products = await self._result_product_orms_for_run(db, run)
product_ids = [item.product_id for item in products]
compat_ids = await self._compat_ids_for_products(db, product_ids)
if product_ids:
result = await db.execute(delete(ResultProductORM).where(ResultProductORM.product_id.in_(product_ids)))
deleted_db["result_products"] = int(result.rowcount or 0)
if compat_ids:
result = await db.execute(delete(DinsarResultORM).where(DinsarResultORM.id.in_(compat_ids)))
deleted_db["dinsar_results"] = int(result.rowcount or 0)
if delete_production_records and run is not None:
deleted_run = await dinsar_production_service.delete_run_record(run.run_id, db=db)
deleted_db["dinsar_production_runs"] = 1 if deleted_run else 0
deleted_db["dinsar_production_run_items"] = int(preview["database_deletes"].get("dinsar_production_run_items", 0))
deleted_db["dinsar_production_executions"] = int(preview["database_deletes"].get("dinsar_production_executions", 0))
deleted_db["system_jobs"] = int(preview["database_deletes"].get("system_jobs", 0))
deleted_db["system_tasks"] = 1
deleted_db["task_logs"] = int(preview["database_deletes"].get("task_logs", 0))
if delete_task_pool_dir:
related_deleted = await self._delete_related_tasks(db, related_copy_tasks)
for key, value in related_deleted.items():
deleted_db[key] = int(deleted_db.get(key, 0)) + int(value or 0)
else:
if delete_logs:
result = await db.execute(delete(TaskLogORM).where(TaskLogORM.task_id == task_id))
deleted_db["task_logs"] = int(result.rowcount or 0)
if delete_task_records:
result = await db.execute(delete(SystemJobORM).where(SystemJobORM.task_id == task_id))
deleted_db["system_jobs"] = int(result.rowcount or 0)
result = await db.execute(delete(SystemTaskORM).where(SystemTaskORM.task_id == task_id))
deleted_db["system_tasks"] = int(result.rowcount or 0)
await db.commit()
disk_result = await self._delete_disk_targets(preview, options)
return {
"task_id": task_id,
"deleted_database": deleted_db,
"deleted_database": {},
"deleted_disk": disk_result,
}
async def _delete_related_tasks(
self,
db: AsyncSession,
tasks: List[SystemTaskORM],
) -> Dict[str, int]:
task_ids = [task.task_id for task in tasks if task.task_id]
if not task_ids:
return {}
result = await db.execute(delete(TaskLogORM).where(TaskLogORM.task_id.in_(task_ids)))
logs = int(result.rowcount or 0)
result = await db.execute(delete(SystemJobORM).where(SystemJobORM.task_id.in_(task_ids)))
jobs = int(result.rowcount or 0)
result = await db.execute(delete(SystemTaskORM).where(SystemTaskORM.task_id.in_(task_ids)))
task_count = int(result.rowcount or 0)
await db.commit()
return {
"related_task_logs": logs,
"related_system_jobs": jobs,
"related_system_tasks": task_count,
}
async def _get_task(self, db: AsyncSession, task_id: str) -> Optional[SystemTaskORM]:
result = await db.execute(select(SystemTaskORM).where(SystemTaskORM.task_id == str(task_id or "").strip()))
return result.scalar_one_or_none()
@@ -383,87 +311,33 @@ class OpsMaintenanceService:
async def _collect_run_disk_paths(self, db: AsyncSession, run: DinsarProductionRunORM) -> List[Dict[str, Any]]:
paths: Dict[str, Dict[str, Any]] = {}
if run.source_root:
paths[_normalize_path_text(run.source_root)] = self._path_payload(run.source_root, "task_pool")
result = await db.execute(select(DinsarProductionExecutionORM).where(DinsarProductionExecutionORM.run_id == run.run_id))
for execution in result.scalars().all():
if execution.output_dir:
publish_dir = self._publish_package_dir(execution.output_dir)
paths[publish_dir] = self._path_payload(publish_dir, "production_result")
log_path = dinsar_production_service.read_run_log(run.run_id, max_bytes=1).get("path")
if log_path:
paths[_normalize_path_text(log_path)] = self._path_payload(log_path, "run_log")
landsar_work_dir = self._landsar_work_dir_for_execution(run, execution)
if landsar_work_dir:
payload = self._path_payload(landsar_work_dir, "landsar_work")
payload["run_key"] = execution.run_key
payload["execution_status"] = execution.status
paths[_normalize_path_text(landsar_work_dir).lower()] = payload
return list(paths.values())
async def _collect_result_products(self, db: AsyncSession, run: DinsarProductionRunORM) -> List[Dict[str, Any]]:
products = await self._result_product_orms_for_run(db, run)
return [
{
"product_id": item.product_id,
"display_name": item.display_name,
"status": item.status,
"health_status": item.health_status,
"publish_dir": item.publish_dir,
"manifest_path": item.manifest_path,
}
for item in products
]
async def _result_product_orms_for_run(self, db: AsyncSession, run: DinsarProductionRunORM) -> List[ResultProductORM]:
result = await db.execute(select(DinsarProductionExecutionORM).where(DinsarProductionExecutionORM.run_id == run.run_id))
dirs = [self._publish_package_dir(item.output_dir) for item in result.scalars().all() if item.output_dir]
clauses = []
for path in dirs:
clauses.append(ResultProductORM.publish_dir == path)
clauses.append(ResultProductORM.native_output_dir.like(path + "%"))
clauses.append(ResultProductORM.manifest_path.like(path + "%"))
clauses.append(ResultProductORM.primary_asset_path.like(path + "%"))
if not clauses:
return []
products = await db.execute(select(ResultProductORM).where(or_(*clauses)))
by_id: Dict[str, ResultProductORM] = {}
for product in products.scalars().all():
by_id[product.product_id] = product
return list(by_id.values())
async def _compat_ids_for_products(self, db: AsyncSession, product_ids: List[str]) -> List[int]:
if not product_ids:
return []
result = await db.execute(select(DinsarResultORM).where(DinsarResultORM.compat_product_id.in_(product_ids)))
return [int(item.id) for item in result.scalars().all()]
def _publish_package_dir(self, output_dir: str) -> str:
normalized = _normalize_path_text(output_dir)
marker = os.sep + "runs" + os.sep
if marker.lower() in normalized.lower():
lower = normalized.lower()
index = lower.index(marker.lower())
return normalized[:index]
return normalized
def _copy_task_dest_dir(self, task: SystemTaskORM) -> str:
params = task.params if isinstance(task.params, dict) else {}
return _normalize_path_text(params.get("dest_dir")) if params.get("dest_dir") else ""
async def _related_copy_tasks_for_run(
def _landsar_work_dir_for_execution(
self,
db: AsyncSession,
run: Optional[DinsarProductionRunORM],
) -> List[SystemTaskORM]:
source_root = _normalize_path_text(run.source_root if run is not None else "")
if not source_root:
return []
result = await db.execute(
select(SystemTaskORM)
.where(SystemTaskORM.task_type == "COPY_DATA")
.order_by(SystemTaskORM.updated_at.desc(), SystemTaskORM.id.desc())
.limit(1000)
)
tasks = []
for task in result.scalars().all():
if _normalize_path_text(self._copy_task_dest_dir(task)).lower() == source_root.lower():
tasks.append(task)
return tasks
run: DinsarProductionRunORM,
execution: DinsarProductionExecutionORM,
) -> str:
if _norm_status(run.engine_code) != "LANDSAR":
return ""
if _norm_status(run.status) in ACTIVE_TASK_STATUSES:
return ""
if _norm_status(execution.status) not in LANDSAR_WORK_TERMINAL_EXECUTION_STATUSES:
return ""
work_root = _normalize_path_text(settings.LANDSAR_WORK_ROOT)
run_key = str(execution.run_key or "").strip()
if not work_root or not run_key or Path(run_key).name != run_key or not run_key.startswith("run_"):
return ""
candidate = _normalize_path_text(os.path.join(work_root, run_key))
return candidate if self._is_landsar_work_delete_path(candidate) else ""
def _diagnose_findings(
self,
@@ -481,9 +355,9 @@ class OpsMaintenanceService:
if status == "FAILED":
findings.append("任务已失败,需要人工确认后清理。")
elif status == "PARTIAL_SUCCESS":
findings.append("任务部分成功,清理前请确认保留策略")
findings.append("任务部分成功,可按需清理 LandSAR 工作目录")
elif status == "CANCELLED":
findings.append("任务已取消,可按需清理残留记录和目录。")
findings.append("任务已取消,可按需清理 LandSAR 工作目录。")
elif status in ACTIVE_TASK_STATUSES:
blockers.append("任务仍处于活动状态。")
@@ -507,58 +381,29 @@ class OpsMaintenanceService:
missing_paths = [item for item in disk_paths if item.get("path") and not item.get("exists")]
if missing_paths:
findings.append(f"{len(missing_paths)} 个登记路径已不存在。")
landsar_work_paths = [
item
for item in disk_paths
if item.get("kind") == "landsar_work" and item.get("exists")
]
if landsar_work_paths:
findings.append(f"发现 {len(landsar_work_paths)} 个可清理的 LandSAR 工作目录。")
cleanup_supported = _norm_status(task.task_type) in SUPPORTED_CLEANUP_TASK_TYPES and not blockers
cleanup_supported = (
_norm_status(task.task_type) in SUPPORTED_CLEANUP_TASK_TYPES
and not blockers
and bool(landsar_work_paths)
)
return findings, cleanup_supported, blockers
async def _cleanup_db_counts(self, db: AsyncSession, diagnosis: Dict[str, Any]) -> Dict[str, int]:
task_id = diagnosis["task"]["task_id"]
run = diagnosis.get("production_run") or {}
run_id = run.get("run_id")
workflow_run_id = run.get("workflow_run_id")
counts = {
"system_tasks": await self._count(db, select(func.count()).select_from(SystemTaskORM).where(SystemTaskORM.task_id == task_id)),
"system_jobs": await self._count(db, select(func.count()).select_from(SystemJobORM).where(SystemJobORM.task_id == task_id)),
"task_logs": await self._count(db, select(func.count()).select_from(TaskLogORM).where(TaskLogORM.task_id == task_id)),
"related_system_tasks": 0,
"related_system_jobs": 0,
"related_task_logs": 0,
"dinsar_production_runs": 0,
"dinsar_production_run_items": 0,
"dinsar_production_executions": 0,
"result_products": len(diagnosis.get("result_products") or []),
"dinsar_results": 0,
"workflow_runs": 0,
"workflow_steps": 0,
"workflow_artifacts": 0,
}
if run_id:
counts["dinsar_production_runs"] = await self._count(db, select(func.count()).select_from(DinsarProductionRunORM).where(DinsarProductionRunORM.run_id == run_id))
counts["dinsar_production_run_items"] = await self._count(db, select(func.count()).select_from(DinsarProductionRunItemORM).where(DinsarProductionRunItemORM.run_id == run_id))
counts["dinsar_production_executions"] = await self._count(db, select(func.count()).select_from(DinsarProductionExecutionORM).where(DinsarProductionExecutionORM.run_id == run_id))
run_obj = await self._get_run_by_id(db, run_id)
related_tasks = await self._related_copy_tasks_for_run(db, run_obj)
related_task_ids = [item.task_id for item in related_tasks if item.task_id]
counts["related_system_tasks"] = len(related_task_ids)
if related_task_ids:
counts["related_system_jobs"] = await self._count(db, select(func.count()).select_from(SystemJobORM).where(SystemJobORM.task_id.in_(related_task_ids)))
counts["related_task_logs"] = await self._count(db, select(func.count()).select_from(TaskLogORM).where(TaskLogORM.task_id.in_(related_task_ids)))
if workflow_run_id:
counts["workflow_runs"] = await self._count(db, select(func.count()).select_from(WorkflowRunORM).where(WorkflowRunORM.run_id == workflow_run_id))
counts["workflow_steps"] = await self._count(db, select(func.count()).select_from(WorkflowStepORM).where(WorkflowStepORM.run_id == workflow_run_id))
counts["workflow_artifacts"] = await self._count(db, select(func.count()).select_from(WorkflowArtifactORM).where(WorkflowArtifactORM.run_id == workflow_run_id))
return counts
async def _get_run_by_id(self, db: AsyncSession, run_id: str) -> Optional[DinsarProductionRunORM]:
result = await db.execute(select(DinsarProductionRunORM).where(DinsarProductionRunORM.run_id == run_id))
return result.scalar_one_or_none()
async def _count(self, db: AsyncSession, stmt: Any) -> int:
return int((await db.execute(stmt)).scalar_one() or 0)
def _cleanup_disk_targets(self, diagnosis: Dict[str, Any]) -> List[Dict[str, Any]]:
targets: Dict[str, Dict[str, Any]] = {}
for item in diagnosis.get("disk_paths") or []:
if item.get("kind") != "landsar_work":
continue
path = _normalize_path_text(item.get("path"))
if not path:
continue
@@ -581,29 +426,26 @@ class OpsMaintenanceService:
normalized = _normalize_path_text(path)
if not normalized:
return False
roots = [
settings.DINSAR_TASK_POOL_ROOT,
settings.DINSAR_PRODUCT_DIR,
os.path.join(settings.PROJECT_ROOT, "backend", "runtime", "dinsar_production"),
]
return self._is_landsar_work_delete_path(normalized)
def _is_landsar_work_delete_path(self, path: str) -> bool:
work_root = _normalize_path_text(settings.LANDSAR_WORK_ROOT)
normalized = _normalize_path_text(path)
if not work_root or not normalized:
return False
root_full = os.path.abspath(work_root)
full = os.path.abspath(normalized)
for root in roots:
root_text = _normalize_path_text(root)
if not root_text:
continue
root_full = os.path.abspath(root_text)
if full == root_full:
if full == root_full or not _is_within_path(full, root_full):
return False
try:
if os.path.commonpath([full, root_full]) == root_full:
return True
relative = os.path.relpath(full, root_full)
except ValueError:
continue
return False
parts = [part for part in relative.split(os.sep) if part]
return len(parts) == 1 and parts[0].startswith("run_")
async def _delete_disk_targets(self, preview: Dict[str, Any], options: Dict[str, bool]) -> Dict[str, Any]:
delete_production_dirs = bool(options.get("delete_production_dirs", True))
delete_task_pool_dir = bool(options.get("delete_task_pool_dir", True))
delete_landsar_work_dir = bool(options.get("delete_landsar_work_dir", True))
deleted: List[str] = []
missing: List[str] = []
skipped: List[str] = []
@@ -611,13 +453,13 @@ class OpsMaintenanceService:
for item in preview.get("disk_deletes") or []:
kind = item.get("kind")
path = _normalize_path_text(item.get("path"))
if kind != "landsar_work":
skipped.append(path)
continue
if not item.get("allowed"):
skipped.append(path)
continue
if kind == "task_pool" and not delete_task_pool_dir:
skipped.append(path)
continue
if kind == "production_result" and not delete_production_dirs:
if not delete_landsar_work_dir:
skipped.append(path)
continue
if not _path_exists(path):
+436 -5
View File
@@ -7,8 +7,10 @@ import hashlib
import json
import logging
import math
import re
import uuid
from collections import defaultdict
from datetime import datetime, timedelta
from itertools import combinations
from typing import Any, Dict, List, Optional, Tuple
@@ -20,7 +22,7 @@ from sqlalchemy.orm import aliased
from geoalchemy2 import Geography
from geoalchemy2.shape import to_shape
from geoalchemy2.functions import ST_Intersects, ST_Intersection, ST_Area, ST_Centroid, ST_Covers
from shapely.geometry import Polygon
from shapely.geometry import Polygon, mapping, shape
from shapely.ops import unary_union
from ..models import (
@@ -196,6 +198,112 @@ class SpatialService:
}
return result_pairs, warnings, metadata
async def find_dinsar_coverage_pairs(
self,
db: AsyncSession,
params: PairingRequest,
*,
target_date_from: str,
target_date_to: str,
extension_days: int = 15,
max_pairs: int = 200,
target_coverage_ratio: float = 0.98,
min_new_coverage_ratio: float = 0.0005,
aoi_wkt: Optional[str] = None,
require_orbit_data: bool = True,
) -> Tuple[List[RadarPair], List[str], Dict[str, Any]]:
warnings: List[str] = []
target_start = self._parse_yyyymmdd(target_date_from, field_name="target_date_from")
target_end = self._parse_yyyymmdd(target_date_to, field_name="target_date_to")
if target_end < target_start:
raise ValueError("target_date_to must be greater than or equal to target_date_from.")
safe_extension_days = max(0, min(180, int(extension_days or 0)))
safe_max_pairs = max(1, min(5000, int(max_pairs or 200)))
safe_target_coverage_ratio = max(0.0, min(1.0, float(target_coverage_ratio or 0.98)))
safe_min_new_coverage_ratio = max(0.0, min(1.0, float(min_new_coverage_ratio or 0.0)))
query_start = target_start - timedelta(days=safe_extension_days)
query_end = target_end + timedelta(days=safe_extension_days)
effective_params = self._normalize_pairing_request(params).model_copy(
update={
"master_date_from": self._format_yyyymmdd(query_start),
"master_date_to": self._format_yyyymmdd(query_end),
"slave_date_from": self._format_yyyymmdd(query_start),
"slave_date_to": self._format_yyyymmdd(query_end),
"strategy": "dinsar_province_coverage",
}
)
pairing_status = await pairing_state_service.get_pairing_system_status(db)
cache_status = str(pairing_status.get("status") or "UNINITIALIZED")
scene_count = int(pairing_status.get("scene_count") or 0)
pair_count = int(pairing_status.get("pair_count") or 0)
degraded = bool(pairing_status.get("needs_rebuild"))
if cache_status in {"FAILED", "UNINITIALIZED", "ERROR"} or (scene_count > 1 and pair_count == 0):
raise RuntimeError(
"Pairing candidate cache is not available. Repair or rebuild the pairing foundation first."
)
if degraded:
warnings.append(
f"Pairing foundation status is {cache_status}; coverage plan uses current cached candidates."
)
candidate_pool = await self._query_pairing_metric_cache(
db,
effective_params,
aoi_wkt=aoi_wkt,
require_orbit_data=require_orbit_data,
)
selected_candidates, coverage_meta, strategy_warnings = self._apply_province_coverage_strategy(
candidate_pool,
target_start=target_start,
target_end=target_end,
query_start=query_start,
query_end=query_end,
max_pairs=safe_max_pairs,
target_coverage_ratio=safe_target_coverage_ratio,
min_new_coverage_ratio=safe_min_new_coverage_ratio,
aoi_wkt=aoi_wkt,
)
warnings.extend(strategy_warnings)
if not selected_candidates:
warnings.extend(
await self._build_empty_pairing_diagnostics(
db,
effective_params,
aoi_wkt=aoi_wkt,
require_orbit_data=require_orbit_data,
)
)
for candidate in selected_candidates:
candidate["selection_strategy"] = "dinsar_province_coverage"
self._ensure_candidate_identity(candidate)
network_run_id = await self._persist_network_run(
db,
params=effective_params,
aoi_wkt=aoi_wkt,
require_orbit_data=require_orbit_data,
warnings=warnings,
candidate_pool=candidate_pool,
selected_candidates=selected_candidates,
)
await self._attach_dinsar_production_summaries(db, selected_candidates)
result_pairs = self._generate_task_names(self._build_radar_pairs(selected_candidates))
metadata = {
"fallback_used": False,
"degraded": degraded,
"policy_version": PAIRING_POLICY_VERSION,
"network_run_id": network_run_id,
"candidate_count": len(candidate_pool),
"selected_edge_count": len(result_pairs),
"coverage": coverage_meta,
}
return result_pairs, warnings, metadata
def _normalize_pairing_request(self, params: PairingRequest) -> PairingRequest:
updates: Dict[str, Any] = {}
@@ -408,6 +516,27 @@ class SpatialService:
selection_strategy=candidate.get("selection_strategy"),
selection_score=float(selection_score) if selection_score is not None else None,
selection_reason=candidate.get("selection_reason"),
coverage_rank=(
int(candidate["coverage_rank"])
if candidate.get("coverage_rank") is not None
else None
),
aoi_new_area_ratio=(
float(candidate["aoi_new_area_ratio"])
if candidate.get("aoi_new_area_ratio") is not None
else None
),
aoi_pair_area_ratio=(
float(candidate["aoi_pair_area_ratio"])
if candidate.get("aoi_pair_area_ratio") is not None
else None
),
aoi_coverage_ratio_after=(
float(candidate["aoi_coverage_ratio_after"])
if candidate.get("aoi_coverage_ratio_after") is not None
else None
),
effective_coverage_geojson=candidate.get("effective_coverage_geojson"),
time_baseline_days=int(candidate["days"]),
spatial_baseline_meters=float(candidate["dist"]),
scene_center_distance_meters=float(
@@ -701,6 +830,253 @@ class SpatialService:
output.append(item)
return output
def _apply_province_coverage_strategy(
self,
candidate_pool: List[dict],
*,
target_start: datetime,
target_end: datetime,
query_start: datetime,
query_end: datetime,
max_pairs: int,
target_coverage_ratio: float,
min_new_coverage_ratio: float,
aoi_wkt: Optional[str],
) -> Tuple[List[dict], Dict[str, Any], List[str]]:
warnings: List[str] = []
target_days = self._date_set(target_start, target_end)
covered_days: set[datetime] = set()
selected: List[dict] = []
remaining = [candidate for candidate in candidate_pool if self._candidate_date_window(candidate) is not None]
seen_metric_ids: set[int] = set()
aoi_poly = self._parse_optional_aoi_polygon(aoi_wkt)
geometry_cache: Dict[int, Any] = {}
selected_coverage = Polygon()
aoi_area = float(aoi_poly.area or 0.0) if aoi_poly is not None else 0.0
stop_reason = "no_more_gain"
max_pairs_reached = False
while remaining and len(selected) < max_pairs:
best_candidate = None
best_score: Optional[Tuple[float, float, float, float, float, float, str]] = None
current_aoi_coverage_ratio = (
float(selected_coverage.area or 0.0) / aoi_area
if aoi_poly is not None and aoi_area > 0
else None
)
if current_aoi_coverage_ratio is not None and current_aoi_coverage_ratio >= target_coverage_ratio:
stop_reason = "target_coverage_reached"
break
for candidate in remaining:
window = self._candidate_date_window(candidate)
if window is None:
continue
candidate_days = self._date_set(*window) & target_days
new_days = candidate_days - covered_days
new_area = 0.0
candidate_area = 0.0
if aoi_poly is not None:
candidate_geom = self._get_candidate_intersection_geom(
candidate,
aoi_poly=aoi_poly,
geometry_cache=geometry_cache,
)
if candidate_geom is not None and not candidate_geom.is_empty:
candidate_area = float(candidate_geom.area or 0.0)
new_area = float(candidate_geom.difference(selected_coverage).area or 0.0)
if aoi_poly is not None:
new_area_ratio = (new_area / aoi_area) if aoi_area > 0 else 0.0
if new_area_ratio <= 0:
continue
elif not new_days:
continue
quality_score = float(candidate.get("dinsar_quality_score") or 0.0)
overlap = float(candidate.get("overlap_ratio") or 0.0)
temporal_days = float(candidate.get("days") or 0.0)
distance = float(candidate.get("scene_center_distance_meters") or candidate.get("dist") or 0.0)
if aoi_poly is not None and aoi_area > 0:
score = (
new_area_ratio,
candidate_area / aoi_area,
float(len(new_days)) / max(1, len(target_days)),
overlap,
quality_score,
-temporal_days - (distance / 1000000.0),
str(candidate.get("pair_uid") or ""),
)
else:
score = (
float(len(new_days)),
float(len(candidate_days)),
overlap,
quality_score,
0.0,
-temporal_days - (distance / 1000000.0),
str(candidate.get("pair_uid") or ""),
)
if best_score is None or score > best_score:
best_candidate = candidate
best_score = score
if best_candidate is None:
stop_reason = "no_candidate_adds_coverage"
break
if aoi_poly is None and covered_days == target_days:
stop_reason = "target_time_reached"
break
window = self._candidate_date_window(best_candidate)
candidate_days = self._date_set(*window) & target_days if window else set()
new_days = candidate_days - covered_days
candidate_geom = self._get_candidate_intersection_geom(
best_candidate,
aoi_poly=aoi_poly,
geometry_cache=geometry_cache,
) if aoi_poly is not None else None
new_area = 0.0
candidate_area = 0.0
if candidate_geom is not None and not candidate_geom.is_empty:
candidate_area = float(candidate_geom.area or 0.0)
new_area = float(candidate_geom.difference(selected_coverage).area or 0.0)
new_area_ratio = (new_area / aoi_area) if aoi_area > 0 else 0.0
if aoi_poly is not None and len(selected) > 0 and new_area_ratio < min_new_coverage_ratio:
stop_reason = "marginal_gain_below_threshold"
break
best_candidate["selection_reason"] = "province_coverage_new_days"
if aoi_poly is not None and new_area > 0:
best_candidate["selection_reason"] = "province_aoi_new_area"
best_candidate["selection_score"] = float(best_score[0] if best_score else len(new_days))
best_candidate["coverage_rank"] = len(selected) + 1
best_candidate["coverage_new_days"] = len(new_days)
best_candidate["coverage_total_days"] = len(candidate_days)
best_candidate["aoi_new_area_ratio"] = new_area_ratio if aoi_area > 0 else None
best_candidate["aoi_pair_area_ratio"] = (candidate_area / aoi_area) if aoi_area > 0 else None
next_selected_coverage = selected_coverage
if candidate_geom is not None and not candidate_geom.is_empty:
next_selected_coverage = unary_union([selected_coverage, candidate_geom])
best_candidate["aoi_coverage_ratio_after"] = (
float(next_selected_coverage.area or 0.0) / aoi_area
if aoi_area > 0 else None
)
best_candidate["effective_coverage_geojson"] = self._geometry_to_geojson(candidate_geom)
best_candidate["target_coverage_ratio_after"] = (
len(covered_days | candidate_days) / max(1, len(target_days))
)
selected.append(best_candidate)
covered_days |= candidate_days
selected_coverage = next_selected_coverage
seen_metric_ids.add(int(best_candidate.get("metric_cache_ref_id") or 0))
remaining = [
candidate for candidate in remaining
if int(candidate.get("metric_cache_ref_id") or 0) not in seen_metric_ids
]
if remaining and len(selected) >= max_pairs:
max_pairs_reached = True
stop_reason = "max_pairs_reached"
uncovered_ranges = self._date_ranges_from_days(target_days - covered_days)
if uncovered_ranges:
warnings.append(
"Coverage plan did not fully cover the requested time range. "
f"Uncovered ranges: {', '.join(f'{item[0]}~{item[1]}' for item in uncovered_ranges[:6])}"
)
if max_pairs_reached:
warnings.append(
f"Coverage planning reached task limit max_pairs={max_pairs}; increase the limit for better spatial coverage."
)
selected_area = float(selected_coverage.area or 0.0) if aoi_poly is not None else 0.0
aoi_coverage_ratio = (selected_area / aoi_area) if aoi_area > 0 else None
if aoi_coverage_ratio is not None and aoi_coverage_ratio < target_coverage_ratio:
warnings.append(
f"AOI spatial coverage is {aoi_coverage_ratio:.1%}; target is {target_coverage_ratio:.1%}."
)
temporal_ratio = len(covered_days) / max(1, len(target_days))
coverage_meta = {
"strategy": "dinsar_province_coverage",
"optimization_goal": "minimize_pair_count_for_aoi_spatial_coverage",
"greedy_rule": "select_pair_with_largest_new_aoi_intersection_area_each_step",
"stop_reason": stop_reason,
"max_pairs": max_pairs,
"target_coverage_ratio": round(target_coverage_ratio, 6),
"min_new_coverage_ratio": round(min_new_coverage_ratio, 6),
"coverage_basis": "aoi_spatial" if aoi_coverage_ratio is not None else "temporal",
"target_date_from": self._format_yyyymmdd(target_start),
"target_date_to": self._format_yyyymmdd(target_end),
"query_date_from": self._format_yyyymmdd(query_start),
"query_date_to": self._format_yyyymmdd(query_end),
"target_day_count": len(target_days),
"covered_day_count": len(covered_days),
"temporal_coverage_ratio": round(temporal_ratio, 6),
"aoi_coverage_ratio": round(aoi_coverage_ratio, 6) if aoi_coverage_ratio is not None else None,
"coverage_ratio": round(aoi_coverage_ratio if aoi_coverage_ratio is not None else temporal_ratio, 6),
"uncovered_ranges": [
{"date_from": start, "date_to": end}
for start, end in uncovered_ranges
],
"selected_pair_count": len(selected),
"candidate_count": len(candidate_pool),
}
return selected, coverage_meta, warnings
def _candidate_date_window(self, candidate: dict) -> Optional[Tuple[datetime, datetime]]:
master = candidate.get("master")
slave = candidate.get("slave")
master_date = self._try_parse_yyyymmdd(getattr(master, "imaging_date", None))
slave_date = self._try_parse_yyyymmdd(getattr(slave, "imaging_date", None))
if master_date is None or slave_date is None:
return None
return (master_date, slave_date) if master_date <= slave_date else (slave_date, master_date)
def _sort_coverage_candidates(self, candidates: List[dict]) -> List[dict]:
return sorted(
candidates,
key=lambda item: (
str(getattr(item.get("master"), "imaging_date", "") or ""),
str(getattr(item.get("slave"), "imaging_date", "") or ""),
-float(item.get("selection_score") or 0.0),
str(item.get("pair_uid") or ""),
),
)
def _date_set(self, start: datetime, end: datetime) -> set[datetime]:
if end < start:
return set()
return {start + timedelta(days=offset) for offset in range((end - start).days + 1)}
def _date_ranges_from_days(self, days: set[datetime]) -> List[Tuple[str, str]]:
if not days:
return []
ordered = sorted(days)
ranges: List[Tuple[datetime, datetime]] = []
start = previous = ordered[0]
for day in ordered[1:]:
if day == previous + timedelta(days=1):
previous = day
continue
ranges.append((start, previous))
start = previous = day
ranges.append((start, previous))
return [(self._format_yyyymmdd(start), self._format_yyyymmdd(end)) for start, end in ranges]
def _parse_yyyymmdd(self, value: str, *, field_name: str) -> datetime:
parsed = self._try_parse_yyyymmdd(value)
if parsed is None:
raise ValueError(f"{field_name} must be YYYYMMDD.")
return parsed
def _try_parse_yyyymmdd(self, value: Any) -> Optional[datetime]:
text_value = str(value or "").strip()
if not re.match(r"^\d{8}$", text_value):
return None
try:
return datetime.strptime(text_value, "%Y%m%d")
except ValueError:
return None
def _format_yyyymmdd(self, value: datetime) -> str:
return value.strftime("%Y%m%d")
async def _persist_network_run(
self,
db: AsyncSession,
@@ -797,6 +1173,13 @@ class SpatialService:
if candidate.get("pair_aoi_overlap_ratio") is not None
else None
),
"coverage_rank": candidate.get("coverage_rank"),
"coverage_new_days": candidate.get("coverage_new_days"),
"coverage_total_days": candidate.get("coverage_total_days"),
"aoi_new_area_ratio": candidate.get("aoi_new_area_ratio"),
"aoi_pair_area_ratio": candidate.get("aoi_pair_area_ratio"),
"aoi_coverage_ratio_after": candidate.get("aoi_coverage_ratio_after"),
"target_coverage_ratio_after": candidate.get("target_coverage_ratio_after"),
}
def _stable_sha1(self, value: Any) -> str:
@@ -949,7 +1332,12 @@ class SpatialService:
warnings.extend(strategy_warnings)
edges: List[Dict[str, Any]] = []
for edge_rank, candidate in enumerate(self._sorted_candidates(selected_candidates), start=1):
ordered_candidates = (
selected_candidates
if params.strategy == "dinsar_province_coverage"
else self._sorted_candidates(selected_candidates)
)
for edge_rank, candidate in enumerate(ordered_candidates, start=1):
master = candidate["master"]
slave = candidate["slave"]
edges.append(
@@ -1820,9 +2208,14 @@ class SpatialService:
return geometry_cache[cache_key]
try:
master_poly = Polygon(candidate["master"].coverage_polygon)
slave_poly = Polygon(candidate["slave"].coverage_polygon)
if master_poly.is_empty or slave_poly.is_empty:
master_poly = self._coverage_polygon_to_shape(getattr(candidate["master"], "coverage_polygon", None))
slave_poly = self._coverage_polygon_to_shape(getattr(candidate["slave"], "coverage_polygon", None))
if (
master_poly is None
or slave_poly is None
or master_poly.is_empty
or slave_poly.is_empty
):
geometry_cache[cache_key] = None
return None
pair_geom = master_poly.intersection(slave_poly)
@@ -1834,6 +2227,44 @@ class SpatialService:
geometry_cache[cache_key] = None
return None
def _coverage_polygon_to_shape(self, coverage_polygon: Any):
if not coverage_polygon:
return None
try:
if isinstance(coverage_polygon, dict):
if coverage_polygon.get("type") == "Feature":
geometry = coverage_polygon.get("geometry")
if not geometry:
return None
geom = shape(geometry)
else:
geom = shape(coverage_polygon)
elif isinstance(coverage_polygon, list):
points = []
for point in coverage_polygon:
if isinstance(point, (list, tuple)) and len(point) >= 2:
lon = float(point[0])
lat = float(point[1])
points.append((lon, lat))
if len(points) < 3:
return None
geom = Polygon(points)
else:
return None
if geom.is_empty or not geom.is_valid:
return None
return geom
except Exception:
return None
def _geometry_to_geojson(self, geometry: Any) -> Optional[Dict[str, Any]]:
try:
if geometry is None or geometry.is_empty:
return None
return mapping(geometry)
except Exception:
return None
def _score_sbas_candidate(
self,
candidate: dict,
@@ -0,0 +1,63 @@
# D-InSAR Province Coverage Pairing Design
Date: 2026-07-06
## Goal
Provide a planning mode for near-term province-wide D-InSAR delivery. The user enters a target production time range as the scene pool and selects a province/AOI. The system builds standard D-InSAR candidate pairs, then greedily selects pairs that add the most new AOI spatial coverage. The time range is a freshness/pool constraint, not the primary coverage objective.
## Scope
- Reuse `pairing_metric_cache` as the only candidate source.
- Keep the existing standard D-InSAR thresholds: temporal baseline, overlap, center distance, same mode, same polarization, same family, orbit availability.
- Add a planning endpoint that returns normal `RadarPair` rows, so existing pair list, batch save, copy, and production flow remain unchanged.
- Store the selected network through `pairing_network_runs` / `pairing_network_edges` for traceability.
## Greedy Policy
1. Build candidate windows from `master_imaging_date` to `slave_imaging_date`.
2. Start with the requested target window.
3. Query candidates in the target pool plus `extension_days`.
4. When an AOI is available, prefer candidates that add new uncovered AOI area.
5. Use target-window temporal coverage as a secondary score and diagnostic.
6. Score each candidate:
- new AOI area covered
- total AOI area covered by the pair
- new target days covered
- existing target coverage overlap
- scene overlap ratio
- D-InSAR quality score
- shorter temporal baseline and smaller center distance as tie-breakers
7. Select the best edge, mark covered AOI area and days, then repeat until:
- AOI area has no meaningful new coverage candidates
- no candidate adds coverage
- `max_pairs` is reached
## Coverage Semantics
Coverage here means AOI spatial coverage when an AOI is supplied. Temporal coverage is reported separately to show whether the selected pairs span the requested production window. For province-wide use, the user must select the province AOI and set an AOI overlap threshold appropriate for production planning.
## API
`POST /pairing/coverage-plan`
Form fields:
- `target_date_from`, `target_date_to`: required `YYYYMMDD`
- standard pairing fields: `time_baseline_min`, `time_baseline_max`, `overlap_threshold`, `spatial_baseline_max_meters`, `aoi_overlap_threshold`, `allowed_satellites`
- `extension_days`: default 15
- `max_pairs`: default 200
- `require_orbit_data`: default true
- `files` or `aoi_geojson`: optional AOI, same as `/find-pairs`
Response:
- `pairs`: selected `RadarPair` rows
- `coverage`: requested range, effective query range, covered days, coverage ratio, uncovered ranges
- `warnings`, `network_run_id`, `candidate_count`, `selected_edge_count`
## Notes
- This is a planning tool, not a new production engine.
- Existing batch splitting remains the operational control for producing 100-by-100 or any user-selected batch size.
- Produced pairs should still be filtered in the pair list by existing result status indicators before batch creation.
+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>
+20 -34
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>
<div className="ops-task-section-title">LANDSAR_WORK_ROOT</div>
<DiskPreview paths={preview.disk_deletes} />
</div>
</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}
/>
);
+112 -6
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,7 +1045,13 @@ export default function StatisticsDashboard() {
<Chart option={sourceTrendOption} className="statistics-chart-md" />
</Section>
<Section title="精轨保障" subtitle={`${formatNumber(data?.orbit?.selected_bindings)} 景已选中`}>
<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>
@@ -972,6 +1075,9 @@ export default function StatisticsDashboard() {
</div>
))}
</div>
</div>
<MissingOrbitSummary summary={data?.orbit?.missing_summary} />
</div>
</Section>
<Section title="D-InSAR 生产运行" subtitle={`平均耗时 ${formatDuration(data?.production?.avg_duration_seconds)}`}>
+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>
+7 -3
View File
@@ -112,14 +112,18 @@ function PairsListPanel({
) : (
<>
<div className="list-toolbar">
<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 htmlFor="select-all-pairs">
全选 ({selectedPairsCount} / {foundPairs.length} 已选择)
全选
</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':