Apply current workspace changes
This commit is contained in:
@@ -19,7 +19,7 @@ const getTaskTypeLabel = (taskType) => {
|
||||
case 'SCAN_HAZARD':
|
||||
return '灾害点同步';
|
||||
case 'UNPACK_ARCHIVES':
|
||||
return 'Archive unpack';
|
||||
return 'LT-1 解包';
|
||||
case 'IDL_IMPORT':
|
||||
return 'ENVI 数据导入';
|
||||
case 'IDL_DINSAR':
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import apiClient from '../api/client';
|
||||
import {
|
||||
getDinsarCatalogStatus,
|
||||
getDinsarProductDetail,
|
||||
listDinsarProducts,
|
||||
queueDinsarCatalogRebuild,
|
||||
queueDinsarProductPublish,
|
||||
} from '../api/dinsarProducts';
|
||||
import {
|
||||
DINSAR_ENGINE_ALL,
|
||||
buildDinsarEngineOptions,
|
||||
getDinsarEngineMeta,
|
||||
} from '../utils/dinsarEngines';
|
||||
|
||||
const STATUS_TONE_MAP = {
|
||||
READY: 'ready',
|
||||
PARTIAL: 'warn',
|
||||
QUARANTINED: 'error',
|
||||
WARN: 'warn',
|
||||
ERROR: 'error',
|
||||
REBUILDING: 'info',
|
||||
};
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '-';
|
||||
try {
|
||||
return new Date(value).toLocaleString();
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function parseDirectoryList(value) {
|
||||
return [...new Set(
|
||||
String(value || '')
|
||||
.split(/[\r\n,;]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
)];
|
||||
}
|
||||
|
||||
function getMessageTone(message) {
|
||||
return /失败|error|Error|ERROR/.test(String(message || '')) ? 'error' : 'success';
|
||||
}
|
||||
|
||||
function StatusPill({ label, tone = 'neutral' }) {
|
||||
return <span className={`dinsar-status-pill tone-${tone}`}>{label}</span>;
|
||||
}
|
||||
|
||||
function MetaField({ label, value, multiline = false }) {
|
||||
const displayValue = value === null || value === undefined || value === '' ? '-' : value;
|
||||
return (
|
||||
<div className="dinsar-catalog-meta-field">
|
||||
<span>{label}</span>
|
||||
<strong className={multiline ? 'break-all' : ''}>{displayValue}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DinsarCatalogPanel({
|
||||
readOnly = false,
|
||||
compact = false,
|
||||
initialSourceDir = '',
|
||||
onTaskQueued,
|
||||
}) {
|
||||
const [catalogStatus, setCatalogStatus] = useState(null);
|
||||
const [products, setProducts] = useState([]);
|
||||
const [selectedProductId, setSelectedProductId] = useState(null);
|
||||
const [selectedProduct, setSelectedProduct] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [actionMessage, setActionMessage] = useState('');
|
||||
const [sourceDirectoriesText, setSourceDirectoriesText] = useState(initialSourceDir || '');
|
||||
const [publishRoot, setPublishRoot] = useState('');
|
||||
const [engineFilter, setEngineFilter] = useState(DINSAR_ENGINE_ALL);
|
||||
const [queryDraft, setQueryDraft] = useState('');
|
||||
const [queryApplied, setQueryApplied] = useState('');
|
||||
|
||||
const listLimit = compact ? 8 : 24;
|
||||
const previewBaseUrl = apiClient.defaults.baseURL || '/api';
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialSourceDir) return;
|
||||
setSourceDirectoriesText((current) => (current.trim() ? current : initialSourceDir));
|
||||
}, [initialSourceDir]);
|
||||
|
||||
const sourceDirectories = useMemo(
|
||||
() => parseDirectoryList(sourceDirectoriesText),
|
||||
[sourceDirectoriesText]
|
||||
);
|
||||
|
||||
const engineOptions = useMemo(
|
||||
() => buildDinsarEngineOptions(products, { includeKnown: true }),
|
||||
[products]
|
||||
);
|
||||
const selectedEngineMeta = useMemo(
|
||||
() => (engineFilter === DINSAR_ENGINE_ALL ? null : getDinsarEngineMeta(engineFilter)),
|
||||
[engineFilter]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (engineFilter === DINSAR_ENGINE_ALL) return;
|
||||
if (!engineOptions.some((option) => option.value === engineFilter)) {
|
||||
setEngineFilter(DINSAR_ENGINE_ALL);
|
||||
}
|
||||
}, [engineFilter, engineOptions]);
|
||||
|
||||
const loadCatalog = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [statusData, productData] = await Promise.all([
|
||||
getDinsarCatalogStatus(),
|
||||
listDinsarProducts({
|
||||
limit: listLimit,
|
||||
offset: 0,
|
||||
engine_code: engineFilter === DINSAR_ENGINE_ALL ? undefined : engineFilter,
|
||||
query: queryApplied || undefined,
|
||||
}),
|
||||
]);
|
||||
setCatalogStatus(statusData);
|
||||
const nextItems = Array.isArray(productData?.items) ? productData.items : [];
|
||||
setProducts(nextItems);
|
||||
setSelectedProductId((current) => {
|
||||
if (current && nextItems.some((item) => item.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return nextItems[0]?.id ?? null;
|
||||
});
|
||||
} catch (error) {
|
||||
setActionMessage(`结果目录状态加载失败:${error?.response?.data?.detail || error.message}`);
|
||||
setCatalogStatus(null);
|
||||
setProducts([]);
|
||||
setSelectedProductId(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [engineFilter, listLimit, queryApplied]);
|
||||
|
||||
const loadProductDetail = useCallback(async (productId) => {
|
||||
if (!productId) {
|
||||
setSelectedProduct(null);
|
||||
return;
|
||||
}
|
||||
setSelectedProduct(null);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const detail = await getDinsarProductDetail(productId);
|
||||
setSelectedProduct(detail);
|
||||
} catch (error) {
|
||||
setSelectedProduct({
|
||||
error: error?.response?.data?.detail || error.message || '结果详情加载失败',
|
||||
});
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadCatalog();
|
||||
}, [loadCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
loadProductDetail(selectedProductId);
|
||||
}, [loadProductDetail, selectedProductId]);
|
||||
|
||||
const handleApplyFilters = useCallback(() => {
|
||||
setQueryApplied(queryDraft.trim());
|
||||
}, [queryDraft]);
|
||||
|
||||
const handleResetFilters = useCallback(() => {
|
||||
setEngineFilter(DINSAR_ENGINE_ALL);
|
||||
setQueryDraft('');
|
||||
setQueryApplied('');
|
||||
}, []);
|
||||
|
||||
const handleQueuePublish = async () => {
|
||||
if (readOnly || sourceDirectories.length === 0) return;
|
||||
setActionLoading(true);
|
||||
setActionMessage('');
|
||||
try {
|
||||
const result = await queueDinsarProductPublish({
|
||||
source_directories: sourceDirectories,
|
||||
publish_root: publishRoot.trim() || null,
|
||||
rebuild_catalog: true,
|
||||
});
|
||||
setActionMessage(`结果包发布任务已入队:${result.task_id}`);
|
||||
onTaskQueued?.(result.task_id);
|
||||
await loadCatalog();
|
||||
} catch (error) {
|
||||
setActionMessage(`结果包发布失败:${error?.response?.data?.detail || error.message}`);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQueueRebuild = async () => {
|
||||
if (readOnly) return;
|
||||
setActionLoading(true);
|
||||
setActionMessage('');
|
||||
try {
|
||||
const result = await queueDinsarCatalogRebuild({
|
||||
publish_root: publishRoot.trim() || null,
|
||||
full_rebuild: true,
|
||||
});
|
||||
setActionMessage(`结果目录重建任务已入队:${result.task_id}`);
|
||||
onTaskQueued?.(result.task_id);
|
||||
await loadCatalog();
|
||||
} catch (error) {
|
||||
setActionMessage(`结果目录重建失败:${error?.response?.data?.detail || error.message}`);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const catalogTone = STATUS_TONE_MAP[catalogStatus?.status] || 'neutral';
|
||||
const actionTone = getMessageTone(actionMessage);
|
||||
const selectedIssues = Array.isArray(selectedProduct?.issues) ? selectedProduct.issues : [];
|
||||
const selectedAssets = Array.isArray(selectedProduct?.assets) ? selectedProduct.assets : [];
|
||||
const selectedPairingTrace = selectedProduct?.pairing_trace || null;
|
||||
const selectedPairingNetwork = selectedProduct?.pairing_network || null;
|
||||
const selectedPairingRun = selectedPairingNetwork?.run || null;
|
||||
const selectedPairingEdge = selectedPairingNetwork?.edge || null;
|
||||
const selectedPairingMetric = selectedPairingNetwork?.metric || null;
|
||||
const selectedProductEngine = getDinsarEngineMeta(selectedProduct?.engine_code);
|
||||
const selectedStatusTone = STATUS_TONE_MAP[selectedProduct?.status] || 'neutral';
|
||||
|
||||
return (
|
||||
<div className={`dinsar-catalog-shell ${compact ? 'compact' : ''}`}>
|
||||
<div className="dinsar-catalog-header">
|
||||
<div className="dinsar-catalog-header-copy">
|
||||
<strong>{compact ? '结果目录状态' : '标准结果包目录'}</strong>
|
||||
<p>
|
||||
{compact
|
||||
? '查看结果包目录与数据库索引是否一致。'
|
||||
: '统一结果目录按 engine + pair + run 管理,便于同一对影像保留多套生产结果并行对比。'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="dinsar-catalog-header-actions">
|
||||
{selectedEngineMeta && (
|
||||
<span className={`dinsar-engine-badge tone-${selectedEngineMeta.tone}`}>
|
||||
{selectedEngineMeta.shortLabel}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={loadCatalog} disabled={loading || actionLoading}>
|
||||
{loading ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dinsar-catalog-summary">
|
||||
<div className="dinsar-catalog-stat-card">
|
||||
<span>目录状态</span>
|
||||
<strong>{catalogStatus?.status || '未知'}</strong>
|
||||
<StatusPill label={catalogStatus?.status || 'UNKNOWN'} tone={catalogTone} />
|
||||
</div>
|
||||
<div className="dinsar-catalog-stat-card">
|
||||
<span>需要重建</span>
|
||||
<strong>{catalogStatus?.needs_rebuild ? '是' : '否'}</strong>
|
||||
<small>{catalogStatus?.needs_rebuild ? 'Manifest 与数据库存在漂移' : '目录登记正常'}</small>
|
||||
</div>
|
||||
<div className="dinsar-catalog-stat-card">
|
||||
<span>Manifest / 数据库</span>
|
||||
<strong>{catalogStatus?.manifest_count ?? 0} / {catalogStatus?.db_count ?? 0}</strong>
|
||||
<small>已登记结果包总量</small>
|
||||
</div>
|
||||
<div className="dinsar-catalog-stat-card">
|
||||
<span>问题数量</span>
|
||||
<strong>{catalogStatus?.issue_count ?? 0}</strong>
|
||||
<small>含缺失文件与健康异常</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dinsar-catalog-meta-strip">
|
||||
<div><strong>结果包根目录:</strong>{catalogStatus?.storage_root || '-'}</div>
|
||||
<div><strong>最近消息:</strong>{catalogStatus?.last_message || '-'}</div>
|
||||
<div><strong>最近全量重建:</strong>{formatDateTime(catalogStatus?.last_full_rebuild_at)}</div>
|
||||
</div>
|
||||
|
||||
{actionMessage && (
|
||||
<div className={`dinsar-catalog-message tone-${actionTone}`}>
|
||||
{actionMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!compact && (
|
||||
<div className="dinsar-catalog-manage">
|
||||
<div className="dinsar-catalog-manage-copy">
|
||||
<strong>手动发布与目录重建</strong>
|
||||
<p>
|
||||
这里用于把既有结果目录重新发布为标准结果包,并按最新规则重建目录索引。
|
||||
如果同一对影像存在 ENVI 与 ISCE2 两套结果,它们会依赖 `engine_code` 与 `run_key` 分别登记,不会互相覆盖。
|
||||
</p>
|
||||
</div>
|
||||
<div className="dinsar-catalog-manage-form">
|
||||
<textarea
|
||||
value={sourceDirectoriesText}
|
||||
onChange={(event) => setSourceDirectoriesText(event.target.value)}
|
||||
placeholder="输入一个或多个结果源目录,支持换行、逗号或分号分隔"
|
||||
disabled={readOnly || actionLoading}
|
||||
/>
|
||||
<input
|
||||
value={publishRoot}
|
||||
onChange={(event) => setPublishRoot(event.target.value)}
|
||||
placeholder="可选:自定义标准结果包根目录,留空使用系统配置"
|
||||
disabled={readOnly || actionLoading}
|
||||
/>
|
||||
<div className="dinsar-catalog-manage-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
onClick={handleQueuePublish}
|
||||
disabled={readOnly || actionLoading || sourceDirectories.length === 0}
|
||||
>
|
||||
{actionLoading ? '处理中...' : '发布结果包并重建'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleQueueRebuild}
|
||||
disabled={readOnly || actionLoading}
|
||||
>
|
||||
仅重建目录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`dinsar-catalog-workspace ${compact ? 'compact' : ''}`}>
|
||||
<aside className="dinsar-catalog-list-card">
|
||||
<div className="dinsar-catalog-card-head">
|
||||
<div>
|
||||
<strong>结果包列表</strong>
|
||||
<span>
|
||||
{loading ? '加载中...' : `当前展示 ${products.length} 条`}
|
||||
</span>
|
||||
</div>
|
||||
{queryApplied && <StatusPill label={`检索: ${queryApplied}`} tone="info" />}
|
||||
</div>
|
||||
|
||||
<div className="dinsar-catalog-filter-bar">
|
||||
<label className="dinsar-catalog-filter-field">
|
||||
<span>生产引擎</span>
|
||||
<select value={engineFilter} onChange={(event) => setEngineFilter(event.target.value)}>
|
||||
<option value={DINSAR_ENGINE_ALL}>全部引擎</option>
|
||||
{engineOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="dinsar-catalog-filter-field search">
|
||||
<span>检索</span>
|
||||
<input
|
||||
value={queryDraft}
|
||||
onChange={(event) => setQueryDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleApplyFilters();
|
||||
}
|
||||
}}
|
||||
placeholder="搜索任务名 / pair / run / 引擎"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="dinsar-catalog-filter-actions">
|
||||
<button type="button" onClick={handleApplyFilters}>查询</button>
|
||||
<button type="button" onClick={handleResetFilters}>重置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{products.length === 0 ? (
|
||||
<div className="dinsar-catalog-empty">
|
||||
{loading ? '正在加载结果包...' : '当前筛选条件下没有结果包。'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="dinsar-catalog-list">
|
||||
{products.map((item) => {
|
||||
const tone = STATUS_TONE_MAP[item.status] || 'neutral';
|
||||
const engineMeta = getDinsarEngineMeta(item.engine_code);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`dinsar-catalog-list-item ${selectedProductId === item.id ? 'active' : ''}`}
|
||||
onClick={() => setSelectedProductId(item.id)}
|
||||
>
|
||||
<div className="dinsar-catalog-list-item-top">
|
||||
<strong>{item.display_name || item.product_id}</strong>
|
||||
<StatusPill label={item.status || 'UNKNOWN'} tone={tone} />
|
||||
</div>
|
||||
<div className="dinsar-catalog-list-item-badges">
|
||||
<span className={`dinsar-engine-badge tone-${engineMeta.tone}`}>{engineMeta.shortLabel}</span>
|
||||
<span>{formatDateTime(item.published_at)}</span>
|
||||
</div>
|
||||
<div className="dinsar-catalog-list-item-meta">
|
||||
{(item.task_alias || item.task_name || '-')}{item.run_key ? ` / ${item.run_key}` : ''}
|
||||
</div>
|
||||
<div className="dinsar-catalog-list-item-meta">
|
||||
{item.pair_key || '-'}
|
||||
</div>
|
||||
{(item.selection_strategy || item.network_run_id || item.network_edge_id != null) && (
|
||||
<div className="dinsar-catalog-list-item-trace">
|
||||
{(item.selection_strategy || 'trace')}
|
||||
{item.network_edge_id != null ? ` / edge ${item.network_edge_id}` : ''}
|
||||
{item.network_run_id ? ` / ${item.network_run_id}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<section className="dinsar-catalog-detail-card">
|
||||
<div className="dinsar-catalog-card-head">
|
||||
<div>
|
||||
<strong>结果包详情</strong>
|
||||
<span>查看选中结果的发布信息、配对溯源与资产健康</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedProductId ? (
|
||||
<div className="dinsar-catalog-empty">请选择一个结果包查看详情。</div>
|
||||
) : detailLoading || !selectedProduct ? (
|
||||
<div className="dinsar-catalog-empty">正在加载详情...</div>
|
||||
) : selectedProduct?.error ? (
|
||||
<div className="dinsar-catalog-empty error">{selectedProduct.error}</div>
|
||||
) : (
|
||||
<div className="dinsar-catalog-detail-body">
|
||||
<div className="dinsar-catalog-hero">
|
||||
<div className="dinsar-catalog-preview-frame">
|
||||
<img
|
||||
src={`${previewBaseUrl}/dinsar-products/${selectedProduct.id}/preview`}
|
||||
alt={selectedProduct.display_name || selectedProduct.product_id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dinsar-catalog-hero-meta">
|
||||
<div className="dinsar-catalog-hero-title-row">
|
||||
<div>
|
||||
<h4>{selectedProduct.display_name || selectedProduct.product_id}</h4>
|
||||
<p>{selectedProduct.task_alias || selectedProduct.task_name || '未命名任务'}</p>
|
||||
</div>
|
||||
<div className="dinsar-catalog-hero-badges">
|
||||
<span className={`dinsar-engine-badge tone-${selectedProductEngine.tone}`}>
|
||||
{selectedProductEngine.shortLabel}
|
||||
</span>
|
||||
<StatusPill label={selectedProduct.status || 'UNKNOWN'} tone={selectedStatusTone} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dinsar-catalog-kv-grid">
|
||||
<MetaField label="产品编号" value={selectedProduct.product_id} multiline />
|
||||
<MetaField label="配对标识" value={selectedProduct.pair_key} multiline />
|
||||
<MetaField label="场景配对 UID" value={selectedProduct.pair_uid} multiline />
|
||||
<MetaField label="运行标识" value={selectedProduct.run_key} multiline />
|
||||
<MetaField label="生产配置" value={selectedProduct.profile_code} />
|
||||
<MetaField label="健康状态" value={selectedProduct.health_status} />
|
||||
<MetaField label="主文件" value={selectedProduct.primary_asset_path} multiline />
|
||||
<MetaField label="源文件" value={selectedProduct.source_primary_path} multiline />
|
||||
<MetaField label="结果包目录" value={selectedProduct.publish_dir} multiline />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dinsar-catalog-detail-grid">
|
||||
<div className="dinsar-catalog-section-card">
|
||||
<div className="dinsar-catalog-section-title">时空概览</div>
|
||||
<MetaField label="主影像日期" value={selectedProduct.profile?.master_imaging_date} />
|
||||
<MetaField label="从影像日期" value={selectedProduct.profile?.slave_imaging_date} />
|
||||
<MetaField label="时间基线" value={selectedProduct.profile?.time_baseline_days} />
|
||||
<MetaField label="空间基线" value={selectedProduct.profile?.spatial_baseline_meters} />
|
||||
</div>
|
||||
<div className="dinsar-catalog-section-card">
|
||||
<div className="dinsar-catalog-section-title">空间范围</div>
|
||||
<MetaField label="最小坐标" value={`${selectedProduct.min_lon ?? '-'}, ${selectedProduct.min_lat ?? '-'}`} />
|
||||
<MetaField label="最大坐标" value={`${selectedProduct.max_lon ?? '-'}, ${selectedProduct.max_lat ?? '-'}`} />
|
||||
<MetaField label="登记时间" value={formatDateTime(selectedProduct.registered_at)} />
|
||||
<MetaField label="发布时间" value={formatDateTime(selectedProduct.published_at)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dinsar-catalog-section-card">
|
||||
<div className="dinsar-catalog-section-title">配对追踪</div>
|
||||
{!selectedPairingTrace?.network_run_id ? (
|
||||
<div className="dinsar-catalog-empty inline">当前结果未携带完整的配对网络追踪信息。</div>
|
||||
) : (
|
||||
<div className="dinsar-catalog-detail-grid">
|
||||
<div className="dinsar-catalog-section-card nested">
|
||||
<MetaField label="network_run_id" value={selectedPairingTrace.network_run_id} multiline />
|
||||
<MetaField label="network_edge_id" value={selectedPairingTrace.network_edge_id} />
|
||||
<MetaField label="pair_uid" value={selectedPairingTrace.pair_uid} multiline />
|
||||
<MetaField label="选择策略" value={selectedPairingTrace.selection_strategy} />
|
||||
<MetaField label="策略版本" value={selectedPairingTrace.policy_version} />
|
||||
</div>
|
||||
<div className="dinsar-catalog-section-card nested">
|
||||
<MetaField label="网络记录" value={selectedPairingNetwork?.run_found ? '已找到' : '未找到'} />
|
||||
<MetaField label="边记录" value={selectedPairingNetwork?.edge_found ? '已找到' : '未找到'} />
|
||||
<MetaField label="运行状态" value={selectedPairingRun?.status} />
|
||||
<MetaField label="候选边数" value={selectedPairingRun?.candidate_count} />
|
||||
<MetaField label="入选边数" value={selectedPairingRun?.selected_edge_count} />
|
||||
<MetaField label="告警数" value={selectedPairingRun?.warning_count} />
|
||||
</div>
|
||||
<div className="dinsar-catalog-section-card nested">
|
||||
<MetaField label="edge_rank" value={selectedPairingEdge?.edge_rank} />
|
||||
<MetaField label="selection_reason" value={selectedPairingEdge?.selection_reason} multiline />
|
||||
<MetaField label="selection_score" value={selectedPairingEdge?.selection_score} />
|
||||
<MetaField label="reference_edge" value={selectedPairingEdge?.is_reference_edge ? '是' : '否'} />
|
||||
<MetaField label="metric_cache_ref_id" value={selectedPairingEdge?.metric_cache_ref_id} />
|
||||
</div>
|
||||
<div className="dinsar-catalog-section-card nested">
|
||||
<MetaField label="主从日期" value={`${selectedPairingMetric?.master_imaging_date || '-'} / ${selectedPairingMetric?.slave_imaging_date || '-'}`} />
|
||||
<MetaField label="主从卫星" value={`${selectedPairingMetric?.master_satellite || '-'} / ${selectedPairingMetric?.slave_satellite || '-'}`} />
|
||||
<MetaField label="主从模式" value={`${selectedPairingMetric?.master_imaging_mode || '-'} / ${selectedPairingMetric?.slave_imaging_mode || '-'}`} />
|
||||
<MetaField label="主从极化" value={`${selectedPairingMetric?.master_polarization || '-'} / ${selectedPairingMetric?.slave_polarization || '-'}`} />
|
||||
<MetaField label="时间基线" value={selectedPairingMetric?.time_baseline_days} />
|
||||
<MetaField label="空间基线" value={selectedPairingMetric?.spatial_baseline_meters} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dinsar-catalog-detail-grid">
|
||||
<div className="dinsar-catalog-section-card">
|
||||
<div className="dinsar-catalog-section-title">资产列表 ({selectedAssets.length})</div>
|
||||
{selectedAssets.length === 0 ? (
|
||||
<div className="dinsar-catalog-empty inline">暂无资产记录。</div>
|
||||
) : (
|
||||
<div className="dinsar-catalog-asset-list">
|
||||
{selectedAssets.map((asset) => (
|
||||
<div key={asset.id} className={`dinsar-catalog-asset-item ${asset.exists_flag ? 'ok' : 'missing'}`}>
|
||||
<div className="dinsar-catalog-asset-top">
|
||||
<strong>{asset.asset_role}</strong>
|
||||
<span>{asset.exists_flag ? '文件存在' : '文件缺失'}</span>
|
||||
</div>
|
||||
<div>{asset.asset_name}</div>
|
||||
<div className="break-all">{asset.absolute_path}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dinsar-catalog-section-card">
|
||||
<div className="dinsar-catalog-section-title">问题列表 ({selectedIssues.length})</div>
|
||||
{selectedIssues.length === 0 ? (
|
||||
<div className="dinsar-catalog-empty inline ok">当前没有登记问题。</div>
|
||||
) : (
|
||||
<div className="dinsar-catalog-issue-list">
|
||||
{selectedIssues.map((issue) => (
|
||||
<div key={issue.id} className={`dinsar-catalog-issue-item ${String(issue.severity || '').toUpperCase() === 'ERROR' ? 'error' : 'warn'}`}>
|
||||
<div className="dinsar-catalog-issue-top">
|
||||
<strong>{issue.issue_code}</strong>
|
||||
<span>{issue.severity}</span>
|
||||
</div>
|
||||
<div>{issue.message}</div>
|
||||
{issue.repair_action && (
|
||||
<div className="dinsar-catalog-issue-action">
|
||||
建议修复动作:{issue.repair_action}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ function PsStackModal({
|
||||
return (
|
||||
<div className="modal-overlay visible">
|
||||
<div className="modal-content">
|
||||
<h3>准备PS时序数据栈</h3>
|
||||
<h3>准备时序InSAR候选栈</h3>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="form-group">
|
||||
<label>研究区域来源:</label>
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function PsinsarCatalogPanel({
|
||||
return nextItems[0]?.id ?? null;
|
||||
});
|
||||
} catch (error) {
|
||||
setActionMessage(`PS-InSAR 结果目录状态加载失败:${error?.response?.data?.detail || error.message}`);
|
||||
setActionMessage(`时序InSAR产物目录状态加载失败:${error?.response?.data?.detail || error.message}`);
|
||||
setCatalogStatus(null);
|
||||
setProducts([]);
|
||||
setSelectedProductId(null);
|
||||
@@ -146,11 +146,11 @@ export default function PsinsarCatalogPanel({
|
||||
publish_root: publishRoot.trim() || null,
|
||||
full_rebuild: true,
|
||||
});
|
||||
setActionMessage(`PS-InSAR 结果目录重建任务已入队:${result.task_id}`);
|
||||
setActionMessage(`时序InSAR产物目录重建任务已入队:${result.task_id}`);
|
||||
onTaskQueued?.(result.task_id);
|
||||
await loadCatalog();
|
||||
} catch (error) {
|
||||
setActionMessage(`PS-InSAR 结果目录重建失败:${error?.response?.data?.detail || error.message}`);
|
||||
setActionMessage(`时序InSAR产物目录重建失败:${error?.response?.data?.detail || error.message}`);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
@@ -164,9 +164,9 @@ export default function PsinsarCatalogPanel({
|
||||
<div style={panelCardStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 10 }}>
|
||||
<div>
|
||||
<strong style={{ fontSize: 14 }}>PS-InSAR 结果目录</strong>
|
||||
<strong style={{ fontSize: 14 }}>时序InSAR 产物目录</strong>
|
||||
<div style={{ fontSize: 11, color: '#64748b', marginTop: 2 }}>
|
||||
结果目录以 `psinsar.publish.v1` bundle 为事实源,数据库仅保存索引与展示信息。
|
||||
当前默认登记 SBAS 流程产物。结果目录以 `psinsar.publish.v1` bundle 为事实源,数据库仅保存索引与展示信息,后续可继续兼容 PS-InSAR / SBAS-InSAR。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -224,7 +224,7 @@ export default function PsinsarCatalogPanel({
|
||||
<input
|
||||
value={publishRoot}
|
||||
onChange={event => setPublishRoot(event.target.value)}
|
||||
placeholder="可选:自定义 PS-InSAR 发布根目录,留空使用系统默认目录"
|
||||
placeholder="可选:自定义时序InSAR发布根目录,留空使用系统默认目录"
|
||||
disabled={readOnly || actionLoading}
|
||||
style={{
|
||||
width: '100%',
|
||||
@@ -249,7 +249,7 @@ export default function PsinsarCatalogPanel({
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{actionLoading ? '处理中...' : '重建 PS-InSAR 结果目录'}
|
||||
{actionLoading ? '处理中...' : '重建时序InSAR产物目录'}
|
||||
</button>
|
||||
{actionMessage && (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: actionMessage.includes('失败') ? '#dc2626' : '#166534' }}>
|
||||
@@ -259,14 +259,14 @@ export default function PsinsarCatalogPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(260px, 360px) 1fr', gap: 12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 12 }}>
|
||||
<div style={{ border: '1px solid #e2e8f0', borderRadius: 6, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '8px 10px', background: '#f8fafc', fontSize: 12, fontWeight: 600 }}>
|
||||
产品列表 ({products.length})
|
||||
</div>
|
||||
{products.length === 0 ? (
|
||||
<div style={{ padding: '12px', fontSize: 12, color: '#94a3b8' }}>
|
||||
{loading ? '正在加载结果...' : '当前没有已登记的 PS-InSAR 产品。'}
|
||||
{loading ? '正在加载结果...' : '当前没有已登记的时序InSAR产物。'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ maxHeight: 420, overflowY: 'auto' }}>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { exportDinsarResults } from '../api/dinsar';
|
||||
import { getDinsarEngineMeta } from '../utils/dinsarEngines';
|
||||
|
||||
const EXAMPLE_TARGET_DIR = String.raw`例如: D:\Export\Results 或 \\server\share\results`;
|
||||
|
||||
export default function ResultExportModal({ results = [], onClose }) {
|
||||
const [targetDir, setTargetDir] = useState('');
|
||||
const [selectedIds, setSelectedIds] = useState(() => new Set(results.map((result) => result.id)));
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportResult, setExportResult] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const selectedCount = selectedIds.size;
|
||||
const sortedResults = useMemo(() => [...results], [results]);
|
||||
|
||||
const toggleSelect = (id) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selectedIds.size === results.length) {
|
||||
setSelectedIds(new Set());
|
||||
return;
|
||||
}
|
||||
setSelectedIds(new Set(results.map((result) => result.id)));
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
const dir = targetDir.trim();
|
||||
if (!dir) {
|
||||
setError('请输入目标路径。');
|
||||
return;
|
||||
}
|
||||
if (selectedIds.size === 0) {
|
||||
setError('请至少选择一个结果。');
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setExporting(true);
|
||||
setExportResult(null);
|
||||
try {
|
||||
const response = await exportDinsarResults([...selectedIds], dir);
|
||||
setExportResult(response);
|
||||
} catch (eventualError) {
|
||||
setError(eventualError.response?.data?.detail || eventualError.message || '提取失败。');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay visible" onClick={onClose}>
|
||||
<div className="modal-content result-export-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>提取 D-InSAR 结果</h3>
|
||||
<button type="button" className="modal-close-btn" onClick={onClose} aria-label="关闭">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="export-path-section">
|
||||
<label>目标路径,支持本地盘符或 UNC 路径,例如 `D:\Export\Results`。</label>
|
||||
<input
|
||||
type="text"
|
||||
value={targetDir}
|
||||
onChange={(event) => setTargetDir(event.target.value)}
|
||||
placeholder={EXAMPLE_TARGET_DIR}
|
||||
disabled={exporting}
|
||||
className="export-path-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="export-select-section">
|
||||
<div className="export-select-header">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.size === results.length && results.length > 0}
|
||||
onChange={toggleAll}
|
||||
disabled={exporting || results.length === 0}
|
||||
/>
|
||||
全选 ({selectedCount}/{results.length})
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="export-select-hint">
|
||||
导出时会优先按任务名创建子目录;如果同名结果已存在且内容不同,会自动追加后缀避免覆盖。
|
||||
</div>
|
||||
|
||||
<ul className="export-result-list">
|
||||
{sortedResults.map((result) => {
|
||||
const engineMeta = getDinsarEngineMeta(result.engine_code);
|
||||
return (
|
||||
<li key={result.id} className="export-result-item">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(result.id)}
|
||||
onChange={() => toggleSelect(result.id)}
|
||||
disabled={exporting}
|
||||
/>
|
||||
<span className="export-result-name" title={result.file_path || result.name}>
|
||||
{result.name}
|
||||
</span>
|
||||
<span className={`dinsar-engine-badge tone-${engineMeta.tone}`}>
|
||||
{engineMeta.shortLabel}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{error && <div className="export-error">{error}</div>}
|
||||
|
||||
{exportResult && (
|
||||
<div className="export-summary">
|
||||
<div className="export-summary-title">提取完成</div>
|
||||
<div className="export-summary-stats">
|
||||
<span className="stat-ok">复制: {exportResult.copied}</span>
|
||||
<span className="stat-skip">跳过: {exportResult.skipped}</span>
|
||||
{exportResult.failed > 0 && (
|
||||
<span className="stat-fail">失败: {exportResult.failed}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="export-summary-dir">
|
||||
目标目录: {exportResult.target_dir}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-secondary" onClick={onClose} disabled={exporting}>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
disabled={exporting || selectedIds.size === 0 || !targetDir.trim()}
|
||||
className="btn-primary"
|
||||
>
|
||||
{exporting ? '提取中...' : `确定提取 ${selectedIds.size} 个结果`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
LEFT_GROUP_TABS,
|
||||
LEFT_TAB_GROUP,
|
||||
LEFT_TAB_SECTION,
|
||||
PRODUCTION_WORKSPACE_ROUTE_TABS,
|
||||
} from '../../config/appConstants';
|
||||
import { getLeftTabLabel } from '../../utils/appUiHelpers';
|
||||
import { PanelLoadingBody, PanelLoadingPanel } from './AppLoadingFallbacks';
|
||||
@@ -15,27 +16,26 @@ import { PanelLoadingBody, PanelLoadingPanel } from './AppLoadingFallbacks';
|
||||
const LazyDataMonitorPanel = lazy(() => import('../../DataMonitorPanel'));
|
||||
const LazyDataCopierPanel = lazy(() => import('../../DataCopierPanel'));
|
||||
const LazyIDLAutomationPanel = lazy(() => import('../../IDLAutomationPanel'));
|
||||
const LazyDinsarProductionPanel = lazy(() => import('../../DinsarProductionPanel'));
|
||||
const LazyDinsarProductsPanel = lazy(() => import('../../DinsarProductsPanel'));
|
||||
const LazyHazardPointPanel = lazy(() => import('../../HazardPointPanel'));
|
||||
const LazyHealthCheckPanel = lazy(() => import('../../HealthCheckPanel'));
|
||||
const LazyTimeseriesProductionPanel = lazy(() => import('../../TimeseriesProductionPanel'));
|
||||
const LazyWaterMonitorPanel = lazy(() => import('../../WaterMonitorPanel'));
|
||||
const LazyUserAdminPanel = lazy(() => import('../../UserAdminPanel'));
|
||||
const LazyAuditLogPanel = lazy(() => import('../../AuditLogPanel'));
|
||||
const LazyAiQualityPanel = lazy(() => import('../../panels/AiQualityPanel'));
|
||||
const LazyAiAnalysisPanel = lazy(() => import('../../AiAnalysisPanel'));
|
||||
const LazyPairingPanel = lazy(() => import('../../panels/PairPlanningPanel'));
|
||||
const LazyDinsarResultPanel = lazy(() => import('../../panels/DinsarResultPanel'));
|
||||
const LazyDinsarResultPanel = lazy(() => import('../../panels/DinsarResultPanel.rewrite'));
|
||||
const LazyBatchPanel = lazy(() => import('../../panels/BatchPanel'));
|
||||
const LazyPairsListPanel = lazy(() => import('../../panels/PairsListPanel'));
|
||||
const LazyPsResultsPanel = lazy(() => import('../../panels/PsResultsPanel'));
|
||||
const LazyPsinsarCatalogPanel = lazy(() => import('../PsinsarCatalogPanel'));
|
||||
const LazyProductionWorkspace = lazy(() => import('../../ProductionWorkspace'));
|
||||
|
||||
export default function AppSidePanel({
|
||||
leftPanelWidth,
|
||||
leftPanelTab,
|
||||
setLeftPanelTab,
|
||||
isStandalone,
|
||||
isAdmin,
|
||||
isReadOnlyUser,
|
||||
currentUser,
|
||||
@@ -62,7 +62,13 @@ export default function AppSidePanel({
|
||||
pairsPanel,
|
||||
psPanel,
|
||||
}) {
|
||||
const isProductionWorkspace = PRODUCTION_WORKSPACE_ROUTE_TABS.has(leftPanelTab);
|
||||
const activeLeftGroup = LEFT_TAB_GROUP[leftPanelTab] || 'data';
|
||||
const leftTabLabelContext = {
|
||||
pairCount: foundPairs.length,
|
||||
psResultCount: psResults ? Object.keys(psResults).length : 0,
|
||||
dinsarTotal,
|
||||
};
|
||||
const getVisibleTabs = (tabs = []) => tabs.filter((tab) => isAdmin || !ADMIN_ONLY_TABS.has(tab));
|
||||
const getVisibleSections = (groupKey) => (
|
||||
(LEFT_GROUP_SECTIONS[groupKey] || [])
|
||||
@@ -79,6 +85,7 @@ export default function AppSidePanel({
|
||||
}
|
||||
return getVisibleTabs(LEFT_GROUP_TABS[groupKey] || [])[0] || '';
|
||||
};
|
||||
const mainWorkspaceTab = getDefaultGroupTab('data') || 'data';
|
||||
const activeGroupSections = getVisibleSections(activeLeftGroup);
|
||||
const hasSectionNav = activeGroupSections.length > 0;
|
||||
const preferredActiveSection = LEFT_TAB_SECTION[leftPanelTab];
|
||||
@@ -88,59 +95,105 @@ export default function AppSidePanel({
|
||||
const activeLeafTabs = hasSectionNav
|
||||
? (activeGroupSections.find((section) => section.key === activeLeftSection)?.tabs || [])
|
||||
: getVisibleTabs(LEFT_GROUP_TABS[activeLeftGroup] || []);
|
||||
const psResultCount = psResults ? Object.keys(psResults).length : 0;
|
||||
const standaloneSectionTabs = isProductionWorkspace
|
||||
? []
|
||||
: (
|
||||
hasSectionNav
|
||||
? (activeGroupSections.find((section) => section.key === activeLeftSection)?.tabs || activeLeafTabs)
|
||||
: activeLeafTabs
|
||||
);
|
||||
const standaloneEyebrow = [LEFT_GROUP_LABELS[activeLeftGroup], activeGroupSections.find((section) => section.key === activeLeftSection)?.label]
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
const standaloneTitle = isProductionWorkspace
|
||||
? '生产管理'
|
||||
: getLeftTabLabel(leftPanelTab, leftTabLabelContext);
|
||||
const standaloneDescription = isProductionWorkspace
|
||||
? '这里统一承载 D-InSAR 与时序InSAR的运行和产物页面,当前时序流程默认接入 SBAS,后续可继续扩展 PS-InSAR / SBAS-InSAR。'
|
||||
: '当前模块已切换为独立工作区模式。';
|
||||
|
||||
return (
|
||||
<aside className="panel data-panel" style={{ display: 'flex', flexDirection: 'column', width: leftPanelWidth }}>
|
||||
<div className="panel-tabs">
|
||||
<div className="tabs-header group-tabs">
|
||||
{Object.entries(LEFT_GROUP_LABELS)
|
||||
.filter(([groupKey]) => {
|
||||
if (isAdmin) return true;
|
||||
return !!getDefaultGroupTab(groupKey);
|
||||
})
|
||||
.map(([groupKey, label]) => (
|
||||
<button
|
||||
key={groupKey}
|
||||
className={activeLeftGroup === groupKey ? 'active-tab' : ''}
|
||||
onClick={() => {
|
||||
const nextTab = getDefaultGroupTab(groupKey);
|
||||
if (nextTab) setLeftPanelTab(nextTab);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<aside
|
||||
className={`panel data-panel${isStandalone ? ' panel--standalone' : ''}`}
|
||||
style={{ display: 'flex', flexDirection: 'column', width: leftPanelWidth }}
|
||||
>
|
||||
{isStandalone ? (
|
||||
<div className="panel-standalone-header">
|
||||
<div className="panel-standalone-header-main">
|
||||
<span className="panel-standalone-eyebrow">{standaloneEyebrow}</span>
|
||||
<strong>{standaloneTitle}</strong>
|
||||
<p>{standaloneDescription}</p>
|
||||
</div>
|
||||
<div className="panel-standalone-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="panel-standalone-return"
|
||||
onClick={() => setLeftPanelTab(mainWorkspaceTab)}
|
||||
>
|
||||
返回主界面
|
||||
</button>
|
||||
{standaloneSectionTabs.length > 1 && (
|
||||
<>
|
||||
{standaloneSectionTabs.map((tabKey) => (
|
||||
<button
|
||||
key={tabKey}
|
||||
className={leftPanelTab === tabKey ? 'active-tab' : ''}
|
||||
onClick={() => setLeftPanelTab(tabKey)}
|
||||
>
|
||||
{getLeftTabLabel(tabKey, leftTabLabelContext)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasSectionNav && (
|
||||
<div className="tabs-header section-tabs">
|
||||
{activeGroupSections.map((section) => (
|
||||
) : (
|
||||
<div className="panel-tabs">
|
||||
<div className="tabs-header group-tabs">
|
||||
{Object.entries(LEFT_GROUP_LABELS)
|
||||
.filter(([groupKey]) => {
|
||||
if (isAdmin) return true;
|
||||
return !!getDefaultGroupTab(groupKey);
|
||||
})
|
||||
.map(([groupKey, label]) => (
|
||||
<button
|
||||
key={groupKey}
|
||||
className={activeLeftGroup === groupKey ? 'active-tab' : ''}
|
||||
onClick={() => {
|
||||
const nextTab = getDefaultGroupTab(groupKey);
|
||||
if (nextTab) setLeftPanelTab(nextTab);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{hasSectionNav && (
|
||||
<div className="tabs-header section-tabs">
|
||||
{activeGroupSections.map((section) => (
|
||||
<button
|
||||
key={section.key}
|
||||
className={activeLeftSection === section.key ? 'active-tab' : ''}
|
||||
onClick={() => setLeftPanelTab(section.tabs[0])}
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="tabs-header left-tabs sub-tabs">
|
||||
{activeLeafTabs.map((tabKey) => (
|
||||
<button
|
||||
key={section.key}
|
||||
className={activeLeftSection === section.key ? 'active-tab' : ''}
|
||||
onClick={() => setLeftPanelTab(section.tabs[0])}
|
||||
key={tabKey}
|
||||
className={leftPanelTab === tabKey ? 'active-tab' : ''}
|
||||
onClick={() => setLeftPanelTab(tabKey)}
|
||||
>
|
||||
{section.label}
|
||||
{getLeftTabLabel(tabKey, leftTabLabelContext)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="tabs-header left-tabs sub-tabs">
|
||||
{activeLeafTabs.map((tabKey) => (
|
||||
<button
|
||||
key={tabKey}
|
||||
className={leftPanelTab === tabKey ? 'active-tab' : ''}
|
||||
onClick={() => setLeftPanelTab(tabKey)}
|
||||
>
|
||||
{getLeftTabLabel(tabKey, {
|
||||
pairCount: foundPairs.length,
|
||||
psResultCount,
|
||||
dinsarTotal,
|
||||
})}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leftPanelTab === 'data' && (
|
||||
<RadarDataPanel
|
||||
@@ -227,48 +280,14 @@ export default function AppSidePanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leftPanelTab === 'dinsar_production' && (
|
||||
{isProductionWorkspace && (
|
||||
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载 D-InSAR 生产面板..." />}>
|
||||
<LazyDinsarProductionPanel
|
||||
readOnly={isReadOnlyUser}
|
||||
currentUser={currentUser}
|
||||
onJobQueued={(taskId) => taskPanel.onTaskStart(taskId, 'D-InSAR 任务已入队,等待处理...')}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leftPanelTab === 'dinsar_products' && (
|
||||
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载 D-InSAR 产物面板..." />}>
|
||||
<LazyDinsarProductsPanel
|
||||
readOnly={isReadOnlyUser}
|
||||
onJobQueued={(taskId) => taskPanel.onTaskStart(taskId, 'D-InSAR 产物任务已入队,等待处理...')}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leftPanelTab === 'ps_production' && (
|
||||
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载 PS-InSAR 生产面板..." />}>
|
||||
<LazyTimeseriesProductionPanel
|
||||
readOnly={isReadOnlyUser}
|
||||
onJobQueued={(taskId) => taskPanel.onTaskStart(taskId, 'SBAS 运行已入队,正在执行 prepare...')}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leftPanelTab === 'ps_products' && (
|
||||
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载 PS-InSAR 目录面板..." />}>
|
||||
<div style={{ padding: '16px' }}>
|
||||
<LazyPsinsarCatalogPanel
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载生产管理工作台..." />}>
|
||||
<div style={{ minHeight: '100%' }}>
|
||||
<LazyProductionWorkspace
|
||||
activeEntry={leftPanelTab}
|
||||
readOnly={isReadOnlyUser}
|
||||
showActions
|
||||
onTaskQueued={(taskId) => taskPanel.onTaskStart(taskId, 'PS-InSAR 结果目录任务已入队,等待处理...')}
|
||||
onTaskStart={taskPanel.onTaskStart}
|
||||
/>
|
||||
</div>
|
||||
</Suspense>
|
||||
@@ -380,7 +399,7 @@ export default function AppSidePanel({
|
||||
|
||||
{leftPanelTab === 'psinsar_results' && (
|
||||
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载 PS-InSAR 结果目录..." />}>
|
||||
<Suspense fallback={<PanelLoadingBody message="正在加载时序InSAR结果目录..." />}>
|
||||
<div style={{ padding: '16px' }}>
|
||||
<LazyPsinsarCatalogPanel
|
||||
readOnly
|
||||
@@ -395,7 +414,7 @@ export default function AppSidePanel({
|
||||
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
|
||||
<div style={{ padding: '16px' }}>
|
||||
<div className="empty-state">
|
||||
PS-InSAR 分析页已预留。
|
||||
时序InSAR 分析页已预留。
|
||||
<br />
|
||||
后续可以在这里放置时序分析、速率分级、热点识别和专题统计能力。
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { memo } from 'react';
|
||||
import { parseDatesFromName, formatYmd } from '../../utils/appUiHelpers';
|
||||
import { getDinsarEngineMeta } from '../../utils/dinsarEngines';
|
||||
|
||||
function truncateMiddle(value, maxLength = 28) {
|
||||
const text = String(value || '').trim();
|
||||
if (!text || text.length <= maxLength) {
|
||||
return text || '-';
|
||||
}
|
||||
const sideLength = Math.max(6, Math.floor((maxLength - 3) / 2));
|
||||
return `${text.slice(0, sideLength)}...${text.slice(-sideLength)}`;
|
||||
}
|
||||
|
||||
function DinsarResultRow({
|
||||
result,
|
||||
language,
|
||||
showDates,
|
||||
isLoading,
|
||||
isReadOnlyUser,
|
||||
onLabel,
|
||||
onAnalyze,
|
||||
onToggleVisibility,
|
||||
}) {
|
||||
const dates = showDates ? parseDatesFromName(result.name, (value) => formatYmd(value, language)) : null;
|
||||
const engineMeta = getDinsarEngineMeta(result.engine_code);
|
||||
const hasTrace = !!(
|
||||
result.selection_strategy ||
|
||||
result.network_run_id ||
|
||||
result.network_edge_id ||
|
||||
result.pair_uid ||
|
||||
result.pair_key ||
|
||||
result.run_key
|
||||
);
|
||||
|
||||
return (
|
||||
<li className="data-item dinsar-item">
|
||||
<div className="dinsar-row-header">
|
||||
<span className="data-item-name" title={result.name}>
|
||||
{result.name}
|
||||
</span>
|
||||
<div className="dinsar-row-badges">
|
||||
<span
|
||||
className={`dinsar-engine-badge tone-${engineMeta.tone}`}
|
||||
title={`${language === 'en' ? 'Engine' : '生产引擎'}: ${engineMeta.label}`}
|
||||
>
|
||||
{engineMeta.shortLabel}
|
||||
</span>
|
||||
{result.ai_score !== null && (
|
||||
<span
|
||||
className={`ai-score ${result.ai_score > 0.7 ? 'good' : (result.ai_score < 0.4 ? 'bad' : 'medium')}`}
|
||||
title={language === 'en' ? 'AI quality score' : 'AI 质量评分'}
|
||||
>
|
||||
AI {Math.round(result.ai_score * 100)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasTrace && (
|
||||
<div className="dinsar-trace-info">
|
||||
<div className="dinsar-trace-line">
|
||||
<span
|
||||
className="dinsar-trace-pill"
|
||||
title={language === 'en' ? 'Pairing selection strategy' : '配对选择策略'}
|
||||
>
|
||||
{result.selection_strategy || 'legacy'}
|
||||
</span>
|
||||
{result.run_key && (
|
||||
<span
|
||||
className="dinsar-trace-stat"
|
||||
title={`${language === 'en' ? 'Run key' : '运行标识'}: ${result.run_key}`}
|
||||
>
|
||||
<strong>{language === 'en' ? 'run' : '运行'}</strong>
|
||||
<span>{truncateMiddle(result.run_key, 24)}</span>
|
||||
</span>
|
||||
)}
|
||||
{result.network_edge_id != null && (
|
||||
<span
|
||||
className="dinsar-trace-stat"
|
||||
title={language === 'en' ? 'Network edge id' : '网络边编号'}
|
||||
>
|
||||
<strong>edge</strong>
|
||||
<span>{result.network_edge_id}</span>
|
||||
</span>
|
||||
)}
|
||||
{result.network_run_id && (
|
||||
<span
|
||||
className="dinsar-trace-stat"
|
||||
title={`${language === 'en' ? 'Network run id' : '网络运行编号'}: ${result.network_run_id}`}
|
||||
>
|
||||
<strong>{language === 'en' ? 'network' : '网络'}</strong>
|
||||
<span>{truncateMiddle(result.network_run_id, 22)}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="dinsar-trace-line">
|
||||
<span
|
||||
className="dinsar-trace-stat"
|
||||
title={result.pair_uid || result.pair_key || '-'}
|
||||
>
|
||||
<strong>{language === 'en' ? 'pair' : '配对'}</strong>
|
||||
<span>{truncateMiddle(result.pair_uid || result.pair_key, 36)}</span>
|
||||
</span>
|
||||
{result.policy_version && (
|
||||
<span className="dinsar-trace-stat">
|
||||
<strong>{language === 'en' ? 'policy' : '策略版本'}</strong>
|
||||
<span>{result.policy_version}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dates && (
|
||||
<div className="date-info">
|
||||
<span className="date-tag master" title={language === 'en' ? 'Master date' : '主影像日期'}>
|
||||
{dates.master}
|
||||
</span>
|
||||
<span className="date-arrow">-></span>
|
||||
<span className="date-tag slave" title={language === 'en' ? 'Slave date' : '从影像日期'}>
|
||||
{dates.slave}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="data-item-controls">
|
||||
<div className="label-buttons">
|
||||
<button
|
||||
className={`label-btn good ${result.user_label === 1 ? 'active' : ''}`}
|
||||
onClick={() => onLabel(result.id, result.user_label === 1 ? null : 1)}
|
||||
title={language === 'en' ? 'Mark as high quality' : '标记为高质量'}
|
||||
disabled={isReadOnlyUser}
|
||||
>
|
||||
{language === 'en' ? 'Good' : '良好'}
|
||||
</button>
|
||||
<button
|
||||
className={`label-btn bad ${result.user_label === 0 ? 'active' : ''}`}
|
||||
onClick={() => onLabel(result.id, result.user_label === 0 ? null : 0)}
|
||||
title={language === 'en' ? 'Mark as low quality' : '标记为低质量'}
|
||||
disabled={isReadOnlyUser}
|
||||
>
|
||||
{language === 'en' ? 'Poor' : '较差'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="dinsar-control-cluster">
|
||||
<button
|
||||
className="ai-analyze-btn"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onAnalyze(result.id);
|
||||
}}
|
||||
disabled={isLoading || isReadOnlyUser}
|
||||
title={language === 'en' ? 'Use AI to analyze this result' : '使用 AI 分析该结果'}
|
||||
>
|
||||
{language === 'en' ? 'AI Diagnose' : 'AI 诊断'}
|
||||
</button>
|
||||
<label className="dinsar-toggle-label" title={language === 'en' ? 'Show or hide on map' : '在地图上显示或隐藏'}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!result.isVisible}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleVisibility(result.id);
|
||||
}}
|
||||
/>
|
||||
<span>{language === 'en' ? 'Map' : '地图'}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DinsarResultRow);
|
||||
Reference in New Issue
Block a user