Fix login auth race and flood UI wording

This commit is contained in:
2026-06-02 01:57:01 +08:00
parent 79e08b3a47
commit 48f672241f
3 changed files with 128 additions and 106 deletions
+85 -85
View File
@@ -35,13 +35,13 @@ const LIST_PAGE_SIZE = 20;
const VIEWS = [ const VIEWS = [
{ key: 'extract', label: '水体提取' }, { key: 'extract', label: '水体提取' },
{ key: 'detect', label: '洪涝检测' }, { key: 'detect', label: '洪涝检测' },
{ key: 'impact', label: '套合分析' }, { key: 'impact', label: '影响评估' },
{ key: 'results', label: '结果与任务' }, { key: 'results', label: '成果管理' },
]; ];
const SENSOR_ROWS = [ const SENSOR_ROWS = [
{ name: 'LT-1', status: '精密链路可用', tone: 'ok', capability: 'ENVI/SARscape' }, { name: 'LT-1', status: 'Gamma 标准化可用', tone: 'ok', capability: 'ENVI/SARscape' },
{ name: 'GF3', status: '快速路线可用', tone: 'warn', capability: 'Python/GDAL + Otsu' }, { name: 'GF3', status: '分析就绪链路可用', tone: 'ok', capability: 'SARscape + GDAL' },
{ name: 'Sentinel-1', status: '待接入', tone: 'muted', capability: '导入适配器' }, { name: 'Sentinel-1', status: '待接入', tone: 'muted', capability: '导入适配器' },
]; ];
@@ -305,7 +305,7 @@ function SceneRow({ scene, readOnly, onShowMap, onExtractWater, onReset }) {
<button type="button" style={buttonStyle(coverageVisible ? 'success' : 'quiet', !scene.coverage_polygon)} disabled={!scene.coverage_polygon} onClick={handleToggleCoverage}> <button type="button" style={buttonStyle(coverageVisible ? 'success' : 'quiet', !scene.coverage_polygon)} disabled={!scene.coverage_polygon} onClick={handleToggleCoverage}>
{coverageVisible ? '清除范围' : '显示范围'} {coverageVisible ? '清除范围' : '显示范围'}
</button> </button>
<button type="button" style={buttonStyle('primary', readOnly || !canExtract)} disabled={readOnly || !canExtract} onClick={() => onExtractWater(scene)}>提取水体</button> <button type="button" style={buttonStyle('primary', readOnly || !canExtract)} disabled={readOnly || !canExtract} onClick={() => onExtractWater(scene)}>执行提取</button>
{isActiveStatus(scene.status) && ( {isActiveStatus(scene.status) && (
<button type="button" style={buttonStyle('danger', readOnly)} disabled={readOnly} onClick={() => onReset(scene)}>重置</button> <button type="button" style={buttonStyle('danger', readOnly)} disabled={readOnly} onClick={() => onReset(scene)}>重置</button>
)} )}
@@ -321,7 +321,7 @@ function WaterResultRow({ item, onShowMap }) {
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 8, alignItems: 'center' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 8, alignItems: 'center' }}>
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<strong>水体 #{item.id}</strong> <strong>水体提取 #{item.id}</strong>
<StatusBadge status={item.status} /> <StatusBadge status={item.status} />
{item.scene_id && <span style={{ color: palette.muted }}>场景 #{item.scene_id}</span>} {item.scene_id && <span style={{ color: palette.muted }}>场景 #{item.scene_id}</span>}
{item.satellite && <span style={{ color: palette.muted }}>{item.satellite}</span>} {item.satellite && <span style={{ color: palette.muted }}>{item.satellite}</span>}
@@ -335,7 +335,7 @@ function WaterResultRow({ item, onShowMap }) {
</div> </div>
{item.error_msg && <div style={{ color: palette.red, fontSize: 11, marginTop: 6 }}>{item.error_msg}</div>} {item.error_msg && <div style={{ color: palette.red, fontSize: 11, marginTop: 6 }}>{item.error_msg}</div>}
</div> </div>
<button type="button" style={buttonStyle('secondary', asStatus(item.status) !== 'DONE')} disabled={asStatus(item.status) !== 'DONE'} onClick={() => onShowMap(item)}>上图</button> <button type="button" style={buttonStyle('secondary', asStatus(item.status) !== 'DONE')} disabled={asStatus(item.status) !== 'DONE'} onClick={() => onShowMap(item)}>显示图层</button>
</div> </div>
</div> </div>
); );
@@ -347,17 +347,17 @@ function FloodProductRow({ product, onOpenManifest }) {
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 8, alignItems: 'center' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 8, alignItems: 'center' }}>
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
<strong>{product.product_id || `产品 #${product.id}`}</strong> <strong>{product.product_id || `成果 #${product.id}`}</strong>
<StatusBadge status={product.status} /> <StatusBadge status={product.status} />
{product.detection_id && <span style={{ color: palette.muted }}>洪涝 #{product.detection_id}</span>} {product.detection_id && <span style={{ color: palette.muted }}>洪涝检测 #{product.detection_id}</span>}
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 8, marginTop: 8 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 8, marginTop: 8 }}>
<KeyValue label="洪涝面积" value={formatArea(product.flood_area_km2 || product.summary?.flood_area_km2)} strong /> <KeyValue label="新增淹没面积" value={formatArea(product.flood_area_km2 || product.summary?.flood_area_km2)} strong />
<KeyValue label="影响面积" value={formatArea(product.affected_area_km2)} /> <KeyValue label="影响面积" value={formatArea(product.affected_area_km2)} />
<KeyValue label="生成时间" value={formatYmd(product.created_at, 'zh')} /> <KeyValue label="发布时间" value={formatYmd(product.created_at, 'zh')} />
</div> </div>
</div> </div>
<button type="button" style={buttonStyle('secondary')} onClick={() => onOpenManifest(product)}>Manifest</button> <button type="button" style={buttonStyle('secondary')} onClick={() => onOpenManifest(product)}>查看清单</button>
</div> </div>
</div> </div>
); );
@@ -372,18 +372,18 @@ function FloodEventRow({ event, mapLayerVis, mapLoading, productBusy, onShowMap,
<div style={rowStyle}> <div style={rowStyle}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', minWidth: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', minWidth: 0 }}>
<strong>洪涝 #{event.id}</strong> <strong>洪涝检测 #{event.id}</strong>
<StatusBadge status={event.status} /> <StatusBadge status={event.status} />
<span style={{ color: palette.muted }}>{event.pre_satellite || event.post_satellite || '-'}</span> <span style={{ color: palette.muted }}>{event.pre_satellite || event.post_satellite || '-'}</span>
</div> </div>
<button type="button" style={buttonStyle('secondary', !done || mapLoading)} disabled={!done || mapLoading} onClick={() => onShowMap(event)}> <button type="button" style={buttonStyle('secondary', !done || mapLoading)} disabled={!done || mapLoading} onClick={() => onShowMap(event)}>
{mapLoading ? '加载中' : '上图'} {mapLoading ? '显示中' : '显示图层'}
</button> </button>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 8, marginTop: 10 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 8, marginTop: 10 }}>
<KeyValue label="灾前" value={preDate} /> <KeyValue label="灾前" value={preDate} />
<KeyValue label="灾后" value={postDate} /> <KeyValue label="灾后" value={postDate} />
<KeyValue label="洪涝面积" value={formatArea(event.flood_area_km2)} strong /> <KeyValue label="新增淹没面积" value={formatArea(event.flood_area_km2)} strong />
<KeyValue label="稳定水体" value={formatArea(event.stable_water_area_km2)} /> <KeyValue label="稳定水体" value={formatArea(event.stable_water_area_km2)} />
</div> </div>
{event.error_msg && <div style={{ color: palette.red, fontSize: 11, marginTop: 8 }}>{event.error_msg}</div>} {event.error_msg && <div style={{ color: palette.red, fontSize: 11, marginTop: 8 }}>{event.error_msg}</div>}
@@ -400,17 +400,17 @@ function FloodEventRow({ event, mapLayerVis, mapLoading, productBusy, onShowMap,
style={buttonStyle(layers[key] ? 'success' : 'quiet', false, { padding: '3px 8px', fontSize: 11 })} style={buttonStyle(layers[key] ? 'success' : 'quiet', false, { padding: '3px 8px', fontSize: 11 })}
onClick={() => onToggleLayer(event.id, key, !layers[key])} onClick={() => onToggleLayer(event.id, key, !layers[key])}
> >
{layers[key] ? '已显示' : '已隐藏'} {label} {layers[key] ? '隐藏' : '显示'}{label}
</button> </button>
))} ))}
{onSelectImpact && ( {onSelectImpact && (
<button type="button" style={buttonStyle('secondary', false, { padding: '3px 8px', fontSize: 11 })} onClick={() => onSelectImpact(event)}> <button type="button" style={buttonStyle('secondary', false, { padding: '3px 8px', fontSize: 11 })} onClick={() => onSelectImpact(event)}>
套合分析 影响评估
</button> </button>
)} )}
{onCreateProduct && ( {onCreateProduct && (
<button type="button" style={buttonStyle('primary', productBusy, { padding: '3px 8px', fontSize: 11 })} disabled={productBusy} onClick={() => onCreateProduct(event)}> <button type="button" style={buttonStyle('primary', productBusy, { padding: '3px 8px', fontSize: 11 })} disabled={productBusy} onClick={() => onCreateProduct(event)}>
{productBusy ? '生成中' : '生成产品'} {productBusy ? '发布中' : '发布成果'}
</button> </button>
)} )}
</div> </div>
@@ -566,7 +566,7 @@ export default function FloodAnalysisWorkspace({
} catch (error) { } catch (error) {
setScenes([]); setScenes([]);
setScenesTotal(0); setScenesTotal(0);
showMessage('error', `水体场景加载失败:${getErrorText(error)}`); showMessage('error', `分析就绪场景加载失败:${getErrorText(error)}`);
} finally { } finally {
setScenesLoading(false); setScenesLoading(false);
} }
@@ -582,7 +582,7 @@ export default function FloodAnalysisWorkspace({
} catch (error) { } catch (error) {
setWaterResults([]); setWaterResults([]);
setWaterTotal(0); setWaterTotal(0);
showMessage('error', `水体果加载失败:${getErrorText(error)}`); showMessage('error', `水体提取成果加载失败:${getErrorText(error)}`);
} finally { } finally {
setWaterLoading(false); setWaterLoading(false);
} }
@@ -595,7 +595,7 @@ export default function FloodAnalysisWorkspace({
setFloodEvents(res.items || []); setFloodEvents(res.items || []);
} catch (error) { } catch (error) {
setFloodEvents([]); setFloodEvents([]);
showMessage('error', `洪涝果加载失败:${getErrorText(error)}`); showMessage('error', `洪涝检测成果加载失败:${getErrorText(error)}`);
} finally { } finally {
setFloodLoading(false); setFloodLoading(false);
} }
@@ -608,7 +608,7 @@ export default function FloodAnalysisWorkspace({
setFloodProducts(res.items || []); setFloodProducts(res.items || []);
} catch (error) { } catch (error) {
setFloodProducts([]); setFloodProducts([]);
showMessage('error', `洪涝产品加载失败:${getErrorText(error)}`); showMessage('error', `发布成果加载失败:${getErrorText(error)}`);
} finally { } finally {
setProductLoading(false); setProductLoading(false);
} }
@@ -735,15 +735,15 @@ export default function FloodAnalysisWorkspace({
const handleSubmitPreprocess = async () => { const handleSubmitPreprocess = async () => {
if (readOnly || selectedRadars.length === 0) return; if (readOnly || selectedRadars.length === 0) return;
setActionBusy('geocode'); setActionBusy('geocode');
showMessage('info', `正在提交 ${selectedRadars.length}水体预处理任务...`); showMessage('info', `正在提交 ${selectedRadars.length}分析就绪影像生成任务...`);
onTaskStart?.(null, '正在提交水体预处理任务...'); onTaskStart?.(null, '正在提交分析就绪影像生成任务...');
let ok = 0; let ok = 0;
let fail = 0; let fail = 0;
for (const radar of selectedRadars) { for (const radar of selectedRadars) {
try { try {
const res = await submitFloodPreprocess({ radar_data_id: radar.id }); const res = await submitFloodPreprocess({ radar_data_id: radar.id });
ok += 1; ok += 1;
if (res.data?.task_id) onTaskStart?.(res.data.task_id, '水体预处理任务已启动'); if (res.data?.task_id) onTaskStart?.(res.data.task_id, '分析就绪影像生成任务已启动');
} catch { } catch {
fail += 1; fail += 1;
} }
@@ -751,7 +751,7 @@ export default function FloodAnalysisWorkspace({
setActionBusy(''); setActionBusy('');
setSelectedRadars([]); setSelectedRadars([]);
await refreshAll(); await refreshAll();
showMessage(fail ? 'warn' : 'success', fail ? `提交完成:${ok} 成功,${fail} 失败。` : `已提交 ${ok}水体预处理任务。`); showMessage(fail ? 'warn' : 'success', fail ? `提交完成:${ok} 成功,${fail} 失败。` : `已提交 ${ok}分析就绪影像生成任务。`);
}; };
const handleResetScene = async (scene) => { const handleResetScene = async (scene) => {
@@ -794,9 +794,9 @@ export default function FloodAnalysisWorkspace({
{ id: `water_${item.id}`, label: `水体提取 #${item.id}` }, { id: `water_${item.id}`, label: `水体提取 #${item.id}` },
{ classified: preview }, { classified: preview },
); );
showMessage('success', `水体提取 #${item.id}加载到地图。`); showMessage('success', `水体提取 #${item.id}显示在地图`);
} catch (error) { } catch (error) {
showMessage('error', `水体图层加载失败:${getErrorText(error)}`); showMessage('error', `水体图层显示失败:${getErrorText(error)}`);
} finally { } finally {
setMapLoadingId(null); setMapLoadingId(null);
} }
@@ -887,9 +887,9 @@ export default function FloodAnalysisWorkspace({
classified: !!layers.classified, classified: !!layers.classified,
}, },
})); }));
showMessage('success', `洪涝结果 #${event.id}加载到地图。`); showMessage('success', `洪涝检测 #${event.id}显示在地图`);
} catch (error) { } catch (error) {
showMessage('error', `洪涝图层加载失败:${getErrorText(error)}`); showMessage('error', `洪涝图层显示失败:${getErrorText(error)}`);
} finally { } finally {
setMapLoadingId(null); setMapLoadingId(null);
} }
@@ -897,20 +897,20 @@ export default function FloodAnalysisWorkspace({
const handleShowPairCoverage = (pair) => { const handleShowPairCoverage = (pair) => {
if (!pair?.pre?.coverage_polygon || !pair?.post?.coverage_polygon) { if (!pair?.pre?.coverage_polygon || !pair?.post?.coverage_polygon) {
showMessage('warn', '该候选配对缺少覆盖范围,无法上图。'); showMessage('warn', '该候选配对缺少覆盖范围,无法显示。');
return; return;
} }
floodPanel.onShowFloodPairOnMap?.(pair); floodPanel.onShowFloodPairOnMap?.(pair);
showMessage('success', '候选配对覆盖范围已加载到地图。'); showMessage('success', '候选配对覆盖范围已显示。');
}; };
const handleShowFloodVector = () => { const handleShowFloodVector = () => {
if (!impactResult?.flood_vector_geojson) { if (!impactResult?.flood_vector_geojson) {
showMessage('warn', '该套合结果没有可用洪涝矢量。'); showMessage('warn', '该影响评估结果没有可用洪涝范围矢量。');
return; return;
} }
floodPanel.onShowFloodVectorOnMap?.(impactResult); floodPanel.onShowFloodVectorOnMap?.(impactResult);
showMessage('success', '洪涝矢量已加载到地图。'); showMessage('success', '洪涝范围矢量已显示。');
}; };
const loadImpactResult = useCallback(async (detectionId, { silent = false } = {}) => { const loadImpactResult = useCallback(async (detectionId, { silent = false } = {}) => {
@@ -920,11 +920,11 @@ export default function FloodAnalysisWorkspace({
const data = await getFloodImpact(detectionId); const data = await getFloodImpact(detectionId);
setImpactResult(data); setImpactResult(data);
if (!silent && data?.warnings?.includes?.('overlay has not been run')) { if (!silent && data?.warnings?.includes?.('overlay has not been run')) {
showMessage('warn', '该洪涝结果尚未运行套合分析。'); showMessage('warn', '该洪涝检测尚未执行影响评估。');
} }
} catch (error) { } catch (error) {
setImpactResult(null); setImpactResult(null);
if (!silent) showMessage('error', `套合结果加载失败:${getErrorText(error)}`); if (!silent) showMessage('error', `影响评估结果加载失败:${getErrorText(error)}`);
} finally { } finally {
setImpactLoading(false); setImpactLoading(false);
} }
@@ -937,9 +937,9 @@ export default function FloodAnalysisWorkspace({
await runFloodOverlay(selectedImpactEvent.id, { near_threshold_m: 500 }); await runFloodOverlay(selectedImpactEvent.id, { near_threshold_m: 500 });
await loadImpactResult(selectedImpactEvent.id, { silent: true }); await loadImpactResult(selectedImpactEvent.id, { silent: true });
await loadFloodEvents(); await loadFloodEvents();
showMessage('success', `洪涝结果 #${selectedImpactEvent.id} 套合分析已完成。`); showMessage('success', `洪涝检测 #${selectedImpactEvent.id} 影响评估已完成。`);
} catch (error) { } catch (error) {
showMessage('error', `套合分析失败:${getErrorText(error)}`); showMessage('error', `影响评估失败:${getErrorText(error)}`);
} finally { } finally {
setOverlayRunning(false); setOverlayRunning(false);
} }
@@ -951,10 +951,10 @@ export default function FloodAnalysisWorkspace({
try { try {
await createFloodProduct(event.id); await createFloodProduct(event.id);
await loadFloodProducts(); await loadFloodProducts();
showMessage('success', `洪涝结果 #${event.id} 产品已生成。`); showMessage('success', `洪涝检测 #${event.id}果已发布`);
setResultMode('products'); setResultMode('products');
} catch (error) { } catch (error) {
showMessage('error', `产品生成失败:${getErrorText(error)}`); showMessage('error', `果发布失败:${getErrorText(error)}`);
} finally { } finally {
setProductBusyId(null); setProductBusyId(null);
} }
@@ -963,9 +963,9 @@ export default function FloodAnalysisWorkspace({
const handleOpenProductManifest = async (product) => { const handleOpenProductManifest = async (product) => {
try { try {
const manifest = await getFloodProductManifest(product.id || product.product_id); const manifest = await getFloodProductManifest(product.id || product.product_id);
showMessage('success', `Manifest 已读取:${manifest?.schema || product.product_id || product.id}`); showMessage('success', `成果清单已读取:${manifest?.schema || product.product_id || product.id}`);
} catch (error) { } catch (error) {
showMessage('error', `Manifest 读取失败:${getErrorText(error)}`); showMessage('error', `成果清单读取失败:${getErrorText(error)}`);
} }
}; };
@@ -1004,10 +1004,10 @@ export default function FloodAnalysisWorkspace({
<button type="button" style={buttonStyle('secondary', actionBusy === 'refresh')} disabled={actionBusy === 'refresh'} onClick={refreshAll}>刷新</button> <button type="button" style={buttonStyle('secondary', actionBusy === 'refresh')} disabled={actionBusy === 'refresh'} onClick={refreshAll}>刷新</button>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 8, marginTop: 12 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 8, marginTop: 12 }}>
<KeyValue label="可提取场景" value={String(readyScenes.length)} strong /> <KeyValue label="分析就绪场景" value={String(readyScenes.length)} strong />
<KeyValue label="水体果" value={String(waterTotal)} strong /> <KeyValue label="水体提取成果" value={String(waterTotal)} strong />
<KeyValue label="洪涝果" value={String(floodEvents.length)} strong /> <KeyValue label="洪涝检测成果" value={String(floodEvents.length)} strong />
<KeyValue label="运行" value={String(runningCount)} strong /> <KeyValue label="运行任务" value={String(runningCount)} strong />
</div> </div>
</header> </header>
@@ -1059,7 +1059,7 @@ export default function FloodAnalysisWorkspace({
<main> <main>
<section style={{ ...sectionStyle, borderTop: 'none' }}> <section style={{ ...sectionStyle, borderTop: 'none' }}>
<SectionHeader <SectionHeader
title="入库影像" title="源影像筛选"
actions={( actions={(
<> <>
<button type="button" style={buttonStyle('primary', radarLoading)} disabled={radarLoading} onClick={() => runRadarSearch(0)}> <button type="button" style={buttonStyle('primary', radarLoading)} disabled={radarLoading} onClick={() => runRadarSearch(0)}>
@@ -1124,7 +1124,7 @@ export default function FloodAnalysisWorkspace({
<strong>{item.satellite || '-'}</strong> <strong>{item.satellite || '-'}</strong>
<span style={{ color: palette.muted }}>{formatYmd(item.imaging_date, language)}</span> <span style={{ color: palette.muted }}>{formatYmd(item.imaging_date, language)}</span>
<span style={{ color: palette.subtle }}>ID {item.id}</span> <span style={{ color: palette.subtle }}>ID {item.id}</span>
{done && <StatusBadge tone="ok">预处理</StatusBadge>} {done && <StatusBadge tone="ok">标准化</StatusBadge>}
{active && <StatusBadge tone="info">处理中</StatusBadge>} {active && <StatusBadge tone="info">处理中</StatusBadge>}
</div> </div>
<div style={{ color: palette.subtle, fontSize: 11, marginTop: 4 }}> <div style={{ color: palette.subtle, fontSize: 11, marginTop: 4 }}>
@@ -1173,17 +1173,17 @@ export default function FloodAnalysisWorkspace({
<div style={{ marginTop: 10, display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}> <div style={{ marginTop: 10, display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ color: palette.text2, fontSize: 12 }}>已选 {selectedRadars.length} </span> <span style={{ color: palette.text2, fontSize: 12 }}>已选 {selectedRadars.length} </span>
<button type="button" style={buttonStyle('primary', readOnly || actionBusy === 'geocode')} disabled={readOnly || actionBusy === 'geocode'} onClick={handleSubmitPreprocess}> <button type="button" style={buttonStyle('primary', readOnly || actionBusy === 'geocode')} disabled={readOnly || actionBusy === 'geocode'} onClick={handleSubmitPreprocess}>
{actionBusy === 'geocode' ? '提交中' : '提交水体预处理'} {actionBusy === 'geocode' ? '提交中' : '生成分析就绪影像'}
</button> </button>
</div> </div>
)} )}
</section> </section>
<section style={sectionStyle}> <section style={sectionStyle}>
<SectionHeader title={`可提取场景 (${scenesTotal})`} actions={<button type="button" style={buttonStyle('quiet', scenesLoading)} disabled={scenesLoading} onClick={() => loadScenes(scenesPage)}>刷新场景</button>} /> <SectionHeader title={`分析就绪场景 (${scenesTotal})`} actions={<button type="button" style={buttonStyle('quiet', scenesLoading)} disabled={scenesLoading} onClick={() => loadScenes(scenesPage)}>刷新场景</button>} />
<div style={{ display: 'grid', gap: 8 }}> <div style={{ display: 'grid', gap: 8 }}>
{scenesLoading && <EmptyState>场景加载中...</EmptyState>} {scenesLoading && <EmptyState>场景加载中...</EmptyState>}
{!scenesLoading && scenes.length === 0 && <EmptyState>暂无水体预处理场景</EmptyState>} {!scenesLoading && scenes.length === 0 && <EmptyState>暂无分析就绪场景</EmptyState>}
{!scenesLoading && scenes.map(scene => ( {!scenesLoading && scenes.map(scene => (
<SceneRow <SceneRow
key={scene.id} key={scene.id}
@@ -1205,10 +1205,10 @@ export default function FloodAnalysisWorkspace({
</section> </section>
<section style={sectionStyle}> <section style={sectionStyle}>
<SectionHeader title={`水体提取果 (${waterTotal})`} actions={<button type="button" style={buttonStyle('quiet', waterLoading)} disabled={waterLoading} onClick={() => loadWaterResults(waterPage)}>刷新结果</button>} /> <SectionHeader title={`水体提取果 (${waterTotal})`} actions={<button type="button" style={buttonStyle('quiet', waterLoading)} disabled={waterLoading} onClick={() => loadWaterResults(waterPage)}>刷新结果</button>} />
<div style={{ display: 'grid', gap: 8 }}> <div style={{ display: 'grid', gap: 8 }}>
{waterLoading && <EmptyState>水体果加载中...</EmptyState>} {waterLoading && <EmptyState>水体提取成果加载中...</EmptyState>}
{!waterLoading && waterResults.length === 0 && <EmptyState>暂无水体提取</EmptyState>} {!waterLoading && waterResults.length === 0 && <EmptyState>暂无水体提取</EmptyState>}
{!waterLoading && waterResults.map(item => ( {!waterLoading && waterResults.map(item => (
<WaterResultRow key={item.id} item={item} onShowMap={handleShowWaterResult} /> <WaterResultRow key={item.id} item={item} onShowMap={handleShowWaterResult} />
))} ))}
@@ -1233,7 +1233,7 @@ export default function FloodAnalysisWorkspace({
<> <>
<button type="button" style={buttonStyle('primary', pairSearching)} disabled={pairSearching} onClick={handleFindPairs}>{pairSearching ? '推荐中' : '推荐配对'}</button> <button type="button" style={buttonStyle('primary', pairSearching)} disabled={pairSearching} onClick={handleFindPairs}>{pairSearching ? '推荐中' : '推荐配对'}</button>
<button type="button" style={buttonStyle('success', readOnly || !selectedPair || actionBusy === 'flood_detect')} disabled={readOnly || !selectedPair || actionBusy === 'flood_detect'} onClick={handleSubmitFloodDetection}> <button type="button" style={buttonStyle('success', readOnly || !selectedPair || actionBusy === 'flood_detect')} disabled={readOnly || !selectedPair || actionBusy === 'flood_detect'} onClick={handleSubmitFloodDetection}>
{actionBusy === 'flood_detect' ? '提交中' : '提交检测'} {actionBusy === 'flood_detect' ? '提交中' : '提交洪涝检测'}
</button> </button>
</> </>
)} )}
@@ -1330,7 +1330,7 @@ export default function FloodAnalysisWorkspace({
} }
}} }}
> >
预览覆盖 显示覆盖范围
</span> </span>
</div> </div>
</button> </button>
@@ -1340,10 +1340,10 @@ export default function FloodAnalysisWorkspace({
</section> </section>
<section style={sectionStyle}> <section style={sectionStyle}>
<SectionHeader title="近期洪涝结果" actions={<button type="button" style={buttonStyle('quiet', floodLoading)} disabled={floodLoading} onClick={loadFloodEvents}>刷新结果</button>} /> <SectionHeader title="洪涝检测成果" actions={<button type="button" style={buttonStyle('quiet', floodLoading)} disabled={floodLoading} onClick={loadFloodEvents}>刷新结果</button>} />
<div style={{ display: 'grid', gap: 8 }}> <div style={{ display: 'grid', gap: 8 }}>
{floodLoading && <EmptyState>洪涝果加载中...</EmptyState>} {floodLoading && <EmptyState>洪涝检测成果加载中...</EmptyState>}
{!floodLoading && floodEvents.length === 0 && <EmptyState>暂无洪涝检测</EmptyState>} {!floodLoading && floodEvents.length === 0 && <EmptyState>暂无洪涝检测</EmptyState>}
{!floodLoading && floodEvents.slice(0, 6).map(event => ( {!floodLoading && floodEvents.slice(0, 6).map(event => (
<FloodEventRow <FloodEventRow
key={event.id} key={event.id}
@@ -1366,16 +1366,16 @@ export default function FloodAnalysisWorkspace({
<main> <main>
<section style={{ ...sectionStyle, borderTop: 'none' }}> <section style={{ ...sectionStyle, borderTop: 'none' }}>
<SectionHeader <SectionHeader
title="套合对象" title="影响评估对象"
actions={<button type="button" style={buttonStyle('quiet', impactLoading)} disabled={impactLoading || !selectedImpactEvent} onClick={() => selectedImpactEvent && loadImpactResult(selectedImpactEvent.id)}>刷新结果</button>} actions={<button type="button" style={buttonStyle('quiet', impactLoading)} disabled={impactLoading || !selectedImpactEvent} onClick={() => selectedImpactEvent && loadImpactResult(selectedImpactEvent.id)}>刷新结果</button>}
/> />
<div style={{ display: 'grid', gap: 8 }}> <div style={{ display: 'grid', gap: 8 }}>
{[ {[
['洪涝分类栅格', selectedImpactEvent ? `结果 #${selectedImpactEvent.id}` : '请选择果', selectedImpactEvent ? 'ok' : 'muted'], ['分类栅格', selectedImpactEvent ? `检测 #${selectedImpactEvent.id}` : '请选择果', selectedImpactEvent ? 'ok' : 'muted'],
['洪涝矢量', impactResult?.warnings?.includes?.('overlay has not been run') ? '未生成' : (impactResult ? '已生成' : '待运行'), impactResult && !impactResult?.warnings?.includes?.('overlay has not been run') ? 'ok' : 'warn'], ['洪涝范围矢量', impactResult?.warnings?.includes?.('overlay has not been run') ? '未生成' : (impactResult ? '已生成' : '待评估'), impactResult && !impactResult?.warnings?.includes?.('overlay has not been run') ? 'ok' : 'warn'],
['灾害点命中', impactResult ? `${impactResult.hazard_points?.inside_flood?.length || 0}` : '待运行', impactResult ? 'ok' : 'muted'], ['灾害点命中', impactResult ? `${impactResult.hazard_points?.inside_flood?.length || 0}` : '待运行', impactResult ? 'ok' : 'muted'],
['近洪涝风险点', impactResult ? `${impactResult.hazard_points?.near_flood?.length || 0}` : '待运行', impactResult ? 'info' : 'muted'], ['近洪涝风险点', impactResult ? `${impactResult.hazard_points?.near_flood?.length || 0}` : '待运行', impactResult ? 'info' : 'muted'],
['DInSAR关联', impactResult ? `${impactResult.dinsar_products?.length || 0}` : '待运行', impactResult ? 'info' : 'muted'], ['D-InSAR 关联', impactResult ? `${impactResult.dinsar_products?.length || 0}` : '待运行', impactResult ? 'info' : 'muted'],
['行政区统计', impactResult?.affected_aois?.length ? `${impactResult.affected_aois.length}` : '暂无数据', impactResult ? 'muted' : 'muted'], ['行政区统计', impactResult?.affected_aois?.length ? `${impactResult.affected_aois.length}` : '暂无数据', impactResult ? 'muted' : 'muted'],
].map(([name, status, tone]) => ( ].map(([name, status, tone]) => (
<div key={name} style={{ ...rowStyle, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}> <div key={name} style={{ ...rowStyle, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
@@ -1387,9 +1387,9 @@ export default function FloodAnalysisWorkspace({
</section> </section>
<section style={sectionStyle}> <section style={sectionStyle}>
<SectionHeader title="选择洪涝果" /> <SectionHeader title="选择洪涝检测成果" />
<div style={{ display: 'grid', gap: 8 }}> <div style={{ display: 'grid', gap: 8 }}>
{doneFloodEvents.length === 0 && <EmptyState>暂无已完成洪涝</EmptyState>} {doneFloodEvents.length === 0 && <EmptyState>暂无已完成洪涝检测成</EmptyState>}
{doneFloodEvents.map(event => { {doneFloodEvents.map(event => {
const active = String(selectedImpactEvent?.id) === String(event.id); const active = String(selectedImpactEvent?.id) === String(event.id);
return ( return (
@@ -1406,39 +1406,39 @@ export default function FloodAnalysisWorkspace({
}} }}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center' }}>
<strong>洪涝 #{event.id}</strong> <strong>洪涝检测 #{event.id}</strong>
<StatusBadge tone={active ? 'info' : 'muted'}>{active ? '当前' : '可选'}</StatusBadge> <StatusBadge tone={active ? 'info' : 'muted'}>{active ? '当前' : '可选'}</StatusBadge>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 8, marginTop: 8 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 8, marginTop: 8 }}>
<KeyValue label="灾前" value={formatYmd(event.pre_imaging_date, language)} /> <KeyValue label="灾前" value={formatYmd(event.pre_imaging_date, language)} />
<KeyValue label="灾后" value={formatYmd(event.post_imaging_date, language)} /> <KeyValue label="灾后" value={formatYmd(event.post_imaging_date, language)} />
<KeyValue label="洪涝面积" value={formatArea(event.flood_area_km2)} strong /> <KeyValue label="新增淹没面积" value={formatArea(event.flood_area_km2)} strong />
</div> </div>
</button> </button>
); );
})} })}
</div> </div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 10 }}> <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 10 }}>
<button type="button" style={buttonStyle('secondary', !selectedImpactEvent)} disabled={!selectedImpactEvent} onClick={() => selectedImpactEvent && handleShowFloodEvent(selectedImpactEvent)}>加载洪涝图层</button> <button type="button" style={buttonStyle('secondary', !selectedImpactEvent)} disabled={!selectedImpactEvent} onClick={() => selectedImpactEvent && handleShowFloodEvent(selectedImpactEvent)}>显示分类图层</button>
<button type="button" style={buttonStyle('secondary', !impactResult?.flood_vector_geojson)} disabled={!impactResult?.flood_vector_geojson} onClick={handleShowFloodVector}>加载洪涝矢量</button> <button type="button" style={buttonStyle('secondary', !impactResult?.flood_vector_geojson)} disabled={!impactResult?.flood_vector_geojson} onClick={handleShowFloodVector}>显示洪涝范围</button>
<button type="button" style={buttonStyle('primary', readOnly || !selectedImpactEvent || overlayRunning)} disabled={readOnly || !selectedImpactEvent || overlayRunning} onClick={handleRunOverlay}> <button type="button" style={buttonStyle('primary', readOnly || !selectedImpactEvent || overlayRunning)} disabled={readOnly || !selectedImpactEvent || overlayRunning} onClick={handleRunOverlay}>
{overlayRunning ? '套合中' : '运行套合分析'} {overlayRunning ? '评估中' : '执行影响评估'}
</button> </button>
<button type="button" style={buttonStyle('quiet', true)} disabled>导出影响清单</button> <button type="button" style={buttonStyle('quiet', true)} disabled>导出影响清单</button>
</div> </div>
</section> </section>
<section style={sectionStyle}> <section style={sectionStyle}>
<SectionHeader title="套合结果" /> <SectionHeader title="影响评估结果" />
{impactLoading && <EmptyState>套合结果加载中...</EmptyState>} {impactLoading && <EmptyState>影响评估结果加载中...</EmptyState>}
{!impactLoading && !impactResult && <EmptyState>选择一个已完成的洪涝结果后运行套合分析</EmptyState>} {!impactLoading && !impactResult && <EmptyState>选择一个已完成的洪涝检测成果后执行影响评估</EmptyState>}
{!impactLoading && impactResult && ( {!impactLoading && impactResult && (
<div style={{ display: 'grid', gap: 10 }}> <div style={{ display: 'grid', gap: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 8 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 8 }}>
<KeyValue label="洪涝面积" value={formatArea(impactResult.flood_area_km2)} strong /> <KeyValue label="新增淹没面积" value={formatArea(impactResult.flood_area_km2)} strong />
<KeyValue label="命中灾害点" value={String(impactResult.hazard_points?.inside_flood?.length || 0)} strong /> <KeyValue label="命中灾害点" value={String(impactResult.hazard_points?.inside_flood?.length || 0)} strong />
<KeyValue label="近洪涝风险点" value={String(impactResult.hazard_points?.near_flood?.length || 0)} /> <KeyValue label="近洪涝风险点" value={String(impactResult.hazard_points?.near_flood?.length || 0)} />
<KeyValue label="DInSAR产品" value={String(impactResult.dinsar_products?.length || 0)} /> <KeyValue label="D-InSAR 产品" value={String(impactResult.dinsar_products?.length || 0)} />
<KeyValue label="影响行政区" value={String(impactResult.affected_aois?.length || 0)} /> <KeyValue label="影响行政区" value={String(impactResult.affected_aois?.length || 0)} />
</div> </div>
{impactResult.warnings?.length > 0 && ( {impactResult.warnings?.length > 0 && (
@@ -1490,19 +1490,19 @@ export default function FloodAnalysisWorkspace({
<main> <main>
<section style={{ ...sectionStyle, borderTop: 'none' }}> <section style={{ ...sectionStyle, borderTop: 'none' }}>
<SectionHeader <SectionHeader
title="果列表" title="果列表"
actions={( actions={(
<> <>
<button type="button" style={buttonStyle(resultMode === 'flood' ? 'primary' : 'quiet')} onClick={() => setResultMode('flood')}>洪涝</button> <button type="button" style={buttonStyle(resultMode === 'flood' ? 'primary' : 'quiet')} onClick={() => setResultMode('flood')}>洪涝检测</button>
<button type="button" style={buttonStyle(resultMode === 'water' ? 'primary' : 'quiet')} onClick={() => setResultMode('water')}>水体</button> <button type="button" style={buttonStyle(resultMode === 'water' ? 'primary' : 'quiet')} onClick={() => setResultMode('water')}>水体提取</button>
<button type="button" style={buttonStyle(resultMode === 'products' ? 'primary' : 'quiet')} onClick={() => setResultMode('products')}>产品</button> <button type="button" style={buttonStyle(resultMode === 'products' ? 'primary' : 'quiet')} onClick={() => setResultMode('products')}>发布成果</button>
<button type="button" style={buttonStyle('secondary')} onClick={refreshAll}>刷新</button> <button type="button" style={buttonStyle('secondary')} onClick={refreshAll}>刷新</button>
</> </>
)} )}
/> />
{resultMode === 'flood' && ( {resultMode === 'flood' && (
<div style={{ display: 'grid', gap: 8 }}> <div style={{ display: 'grid', gap: 8 }}>
{floodEvents.length === 0 && <EmptyState>暂无洪涝检测</EmptyState>} {floodEvents.length === 0 && <EmptyState>暂无洪涝检测</EmptyState>}
{floodEvents.map(event => ( {floodEvents.map(event => (
<FloodEventRow <FloodEventRow
key={event.id} key={event.id}
@@ -1520,7 +1520,7 @@ export default function FloodAnalysisWorkspace({
)} )}
{resultMode === 'water' && ( {resultMode === 'water' && (
<div style={{ display: 'grid', gap: 8 }}> <div style={{ display: 'grid', gap: 8 }}>
{waterResults.length === 0 && <EmptyState>暂无水体提取</EmptyState>} {waterResults.length === 0 && <EmptyState>暂无水体提取</EmptyState>}
{waterResults.map(item => ( {waterResults.map(item => (
<WaterResultRow key={item.id} item={item} onShowMap={handleShowWaterResult} /> <WaterResultRow key={item.id} item={item} onShowMap={handleShowWaterResult} />
))} ))}
@@ -1528,8 +1528,8 @@ export default function FloodAnalysisWorkspace({
)} )}
{resultMode === 'products' && ( {resultMode === 'products' && (
<div style={{ display: 'grid', gap: 8 }}> <div style={{ display: 'grid', gap: 8 }}>
{productLoading && <EmptyState>洪涝产品加载中...</EmptyState>} {productLoading && <EmptyState>发布成果加载中...</EmptyState>}
{!productLoading && floodProducts.length === 0 && <EmptyState>暂无洪涝产品可在洪涝结果行点击生成产品</EmptyState>} {!productLoading && floodProducts.length === 0 && <EmptyState>暂无发布成果可在洪涝检测成果中点击发布成果</EmptyState>}
{!productLoading && floodProducts.map(product => ( {!productLoading && floodProducts.map(product => (
<FloodProductRow key={product.id || product.product_id} product={product} onOpenManifest={handleOpenProductManifest} /> <FloodProductRow key={product.id || product.product_id} product={product} onOpenManifest={handleOpenProductManifest} />
))} ))}
@@ -1538,9 +1538,9 @@ export default function FloodAnalysisWorkspace({
</section> </section>
<section style={sectionStyle}> <section style={sectionStyle}>
<SectionHeader title="产品出口" /> <SectionHeader title="成果发布" />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 8 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 8 }}>
<button type="button" style={buttonStyle('secondary')} onClick={() => setResultMode('products')}>产品列表</button> <button type="button" style={buttonStyle('secondary')} onClick={() => setResultMode('products')}>发布成果列表</button>
<button type="button" style={buttonStyle('quiet', true)} disabled>GeoJSON 导出</button> <button type="button" style={buttonStyle('quiet', true)} disabled>GeoJSON 导出</button>
<button type="button" style={buttonStyle('quiet', true)} disabled>报告</button> <button type="button" style={buttonStyle('quiet', true)} disabled>报告</button>
</div> </div>
+12 -11
View File
@@ -1,11 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import axios from 'axios'; import apiClient from './api/client';
const apiClient = axios.create({
baseURL: '/api',
withCredentials: true,
});
const LoginPage = ({ onLoginSuccess }) => { const LoginPage = ({ onLoginSuccess }) => {
@@ -32,15 +26,22 @@ const LoginPage = ({ onLoginSuccess }) => {
setLoading(true); setLoading(true);
setMessage(''); setMessage('');
try { try {
await apiClient.post('/auth/login', { const response = await apiClient.post('/auth/login', {
username, username: username.trim(),
password, password,
}); });
if (onLoginSuccess) { if (onLoginSuccess) {
await onLoginSuccess(); await onLoginSuccess(response.data?.user || null);
} }
} catch (error) { } catch (error) {
setMessage(error.response?.data?.detail || '登录失败,请检查用户名和密码。'); const status = error.response?.status;
if (status === 401) {
setMessage('用户名或密码不正确。');
} else if (status === 429) {
setMessage(error.response?.data?.detail || '登录尝试过于频繁,请稍后再试。');
} else {
setMessage(error.response?.data?.detail || '登录请求失败,请稍后重试。');
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
+28 -7
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import apiClient from '../api/client'; import apiClient from '../api/client';
import { getHealth } from '../api/health'; import { getHealth } from '../api/health';
@@ -28,22 +28,41 @@ export default function useAppAuthLifecycle({
setAllData, setAllData,
setRadarPagination, setRadarPagination,
}) { }) {
const fetchCurrentUser = useCallback(async () => { const authGenerationRef = useRef(0);
const fetchCurrentUser = useCallback(async (options = {}) => {
const { clearOnFailure = true } = options;
const requestGeneration = authGenerationRef.current;
try { try {
const response = await apiClient.get('/auth/me'); const response = await apiClient.get('/auth/me', {
skipAuthReset: !clearOnFailure,
authGeneration: requestGeneration,
});
if (requestGeneration === authGenerationRef.current) {
setCurrentUser(response.data || null); setCurrentUser(response.data || null);
}
} catch { } catch {
if (clearOnFailure && requestGeneration === authGenerationRef.current) {
setCurrentUser(null); setCurrentUser(null);
}
} finally { } finally {
if (requestGeneration === authGenerationRef.current || !clearOnFailure) {
setAuthChecked(true); setAuthChecked(true);
} }
}
}, [setCurrentUser, setAuthChecked]); }, [setCurrentUser, setAuthChecked]);
const handleLoginSuccess = useCallback(async () => { const handleLoginSuccess = useCallback(async (userFromLogin = null) => {
await fetchCurrentUser(); authGenerationRef.current += 1;
}, [fetchCurrentUser]); if (userFromLogin) {
setCurrentUser(userFromLogin);
setAuthChecked(true);
}
await fetchCurrentUser({ clearOnFailure: !userFromLogin });
}, [fetchCurrentUser, setCurrentUser, setAuthChecked]);
const handleLogout = useCallback(async () => { const handleLogout = useCallback(async () => {
authGenerationRef.current += 1;
try { try {
await apiClient.post('/auth/logout'); await apiClient.post('/auth/logout');
} catch (error) { } catch (error) {
@@ -127,7 +146,9 @@ export default function useAppAuthLifecycle({
const interceptorId = apiClient.interceptors.response.use( const interceptorId = apiClient.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (error?.response?.status === 401) { const requestGeneration = error?.config?.authGeneration;
const staleAuthFailure = typeof requestGeneration === 'number' && requestGeneration < authGenerationRef.current;
if (error?.response?.status === 401 && !error?.config?.skipAuthReset && !staleAuthFailure) {
radarSearchRequestSeqRef.current += 1; radarSearchRequestSeqRef.current += 1;
setHasRadarSearched(false); setHasRadarSearched(false);
if (aoeLayerRef.current) { if (aoeLayerRef.current) {