From 48f672241fc3ebdd12e82ca5fd91f83a330b8674 Mon Sep 17 00:00:00 2001 From: Harmon Date: Tue, 2 Jun 2026 01:57:01 +0800 Subject: [PATCH] Fix login auth race and flood UI wording --- frontend/src/FloodAnalysisWorkspace.jsx | 170 +++++++++++----------- frontend/src/LoginPage.jsx | 23 +-- frontend/src/hooks/useAppAuthLifecycle.js | 41 ++++-- 3 files changed, 128 insertions(+), 106 deletions(-) diff --git a/frontend/src/FloodAnalysisWorkspace.jsx b/frontend/src/FloodAnalysisWorkspace.jsx index 364a1c3..c974406 100644 --- a/frontend/src/FloodAnalysisWorkspace.jsx +++ b/frontend/src/FloodAnalysisWorkspace.jsx @@ -35,13 +35,13 @@ const LIST_PAGE_SIZE = 20; const VIEWS = [ { key: 'extract', label: '水体提取' }, { key: 'detect', label: '洪涝检测' }, - { key: 'impact', label: '套合分析' }, - { key: 'results', label: '结果与任务' }, + { key: 'impact', label: '影响评估' }, + { key: 'results', label: '成果管理' }, ]; const SENSOR_ROWS = [ - { name: 'LT-1', status: '精密链路可用', tone: 'ok', capability: 'ENVI/SARscape' }, - { name: 'GF3', status: '快速路线可用', tone: 'warn', capability: 'Python/GDAL + Otsu' }, + { name: 'LT-1', status: 'Gamma 标准化可用', tone: 'ok', capability: 'ENVI/SARscape' }, + { name: 'GF3', status: '分析就绪链路可用', tone: 'ok', capability: 'SARscape + GDAL' }, { name: 'Sentinel-1', status: '待接入', tone: 'muted', capability: '导入适配器' }, ]; @@ -305,7 +305,7 @@ function SceneRow({ scene, readOnly, onShowMap, onExtractWater, onReset }) { - + {isActiveStatus(scene.status) && ( )} @@ -321,7 +321,7 @@ function WaterResultRow({ item, onShowMap }) {
- 水体 #{item.id} + 水体提取 #{item.id} {item.scene_id && 场景 #{item.scene_id}} {item.satellite && {item.satellite}} @@ -335,7 +335,7 @@ function WaterResultRow({ item, onShowMap }) {
{item.error_msg &&
{item.error_msg}
}
- +
); @@ -347,17 +347,17 @@ function FloodProductRow({ product, onOpenManifest }) {
- {product.product_id || `产品 #${product.id}`} + {product.product_id || `成果 #${product.id}`} - {product.detection_id && 洪涝 #{product.detection_id}} + {product.detection_id && 洪涝检测 #{product.detection_id}}
- + - +
- +
); @@ -372,18 +372,18 @@ function FloodEventRow({ event, mapLayerVis, mapLoading, productBusy, onShowMap,
- 洪涝 #{event.id} + 洪涝检测 #{event.id} {event.pre_satellite || event.post_satellite || '-'}
- +
{event.error_msg &&
{event.error_msg}
} @@ -400,17 +400,17 @@ function FloodEventRow({ event, mapLayerVis, mapLoading, productBusy, onShowMap, style={buttonStyle(layers[key] ? 'success' : 'quiet', false, { padding: '3px 8px', fontSize: 11 })} onClick={() => onToggleLayer(event.id, key, !layers[key])} > - {layers[key] ? '已显示' : '已隐藏'} {label} + {layers[key] ? '隐藏' : '显示'}{label} ))} {onSelectImpact && ( )} {onCreateProduct && ( )}
@@ -566,7 +566,7 @@ export default function FloodAnalysisWorkspace({ } catch (error) { setScenes([]); setScenesTotal(0); - showMessage('error', `水体场景加载失败:${getErrorText(error)}`); + showMessage('error', `分析就绪场景加载失败:${getErrorText(error)}`); } finally { setScenesLoading(false); } @@ -582,7 +582,7 @@ export default function FloodAnalysisWorkspace({ } catch (error) { setWaterResults([]); setWaterTotal(0); - showMessage('error', `水体结果加载失败:${getErrorText(error)}`); + showMessage('error', `水体提取成果加载失败:${getErrorText(error)}`); } finally { setWaterLoading(false); } @@ -595,7 +595,7 @@ export default function FloodAnalysisWorkspace({ setFloodEvents(res.items || []); } catch (error) { setFloodEvents([]); - showMessage('error', `洪涝结果加载失败:${getErrorText(error)}`); + showMessage('error', `洪涝检测成果加载失败:${getErrorText(error)}`); } finally { setFloodLoading(false); } @@ -608,7 +608,7 @@ export default function FloodAnalysisWorkspace({ setFloodProducts(res.items || []); } catch (error) { setFloodProducts([]); - showMessage('error', `洪涝产品加载失败:${getErrorText(error)}`); + showMessage('error', `发布成果加载失败:${getErrorText(error)}`); } finally { setProductLoading(false); } @@ -735,15 +735,15 @@ export default function FloodAnalysisWorkspace({ const handleSubmitPreprocess = async () => { if (readOnly || selectedRadars.length === 0) return; setActionBusy('geocode'); - showMessage('info', `正在提交 ${selectedRadars.length} 景水体预处理任务...`); - onTaskStart?.(null, '正在提交水体预处理任务...'); + showMessage('info', `正在提交 ${selectedRadars.length} 景分析就绪影像生成任务...`); + onTaskStart?.(null, '正在提交分析就绪影像生成任务...'); let ok = 0; let fail = 0; for (const radar of selectedRadars) { try { const res = await submitFloodPreprocess({ radar_data_id: radar.id }); ok += 1; - if (res.data?.task_id) onTaskStart?.(res.data.task_id, '水体预处理任务已启动'); + if (res.data?.task_id) onTaskStart?.(res.data.task_id, '分析就绪影像生成任务已启动'); } catch { fail += 1; } @@ -751,7 +751,7 @@ export default function FloodAnalysisWorkspace({ setActionBusy(''); setSelectedRadars([]); await refreshAll(); - showMessage(fail ? 'warn' : 'success', fail ? `提交完成:${ok} 成功,${fail} 失败。` : `已提交 ${ok} 个水体预处理任务。`); + showMessage(fail ? 'warn' : 'success', fail ? `提交完成:${ok} 成功,${fail} 失败。` : `已提交 ${ok} 个分析就绪影像生成任务。`); }; const handleResetScene = async (scene) => { @@ -794,9 +794,9 @@ export default function FloodAnalysisWorkspace({ { id: `water_${item.id}`, label: `水体提取 #${item.id}` }, { classified: preview }, ); - showMessage('success', `水体提取 #${item.id} 已加载到地图。`); + showMessage('success', `水体提取 #${item.id} 已显示在地图中。`); } catch (error) { - showMessage('error', `水体图层加载失败:${getErrorText(error)}`); + showMessage('error', `水体图层显示失败:${getErrorText(error)}`); } finally { setMapLoadingId(null); } @@ -887,9 +887,9 @@ export default function FloodAnalysisWorkspace({ classified: !!layers.classified, }, })); - showMessage('success', `洪涝结果 #${event.id} 已加载到地图。`); + showMessage('success', `洪涝检测 #${event.id} 已显示在地图中。`); } catch (error) { - showMessage('error', `洪涝图层加载失败:${getErrorText(error)}`); + showMessage('error', `洪涝图层显示失败:${getErrorText(error)}`); } finally { setMapLoadingId(null); } @@ -897,20 +897,20 @@ export default function FloodAnalysisWorkspace({ const handleShowPairCoverage = (pair) => { if (!pair?.pre?.coverage_polygon || !pair?.post?.coverage_polygon) { - showMessage('warn', '该候选配对缺少覆盖范围,无法上图。'); + showMessage('warn', '该候选配对缺少覆盖范围,无法显示。'); return; } floodPanel.onShowFloodPairOnMap?.(pair); - showMessage('success', '候选配对覆盖范围已加载到地图。'); + showMessage('success', '候选配对覆盖范围已显示。'); }; const handleShowFloodVector = () => { if (!impactResult?.flood_vector_geojson) { - showMessage('warn', '该套合结果没有可用洪涝矢量。'); + showMessage('warn', '该影响评估结果没有可用洪涝范围矢量。'); return; } floodPanel.onShowFloodVectorOnMap?.(impactResult); - showMessage('success', '洪涝矢量已加载到地图。'); + showMessage('success', '洪涝范围矢量已显示。'); }; const loadImpactResult = useCallback(async (detectionId, { silent = false } = {}) => { @@ -920,11 +920,11 @@ export default function FloodAnalysisWorkspace({ const data = await getFloodImpact(detectionId); setImpactResult(data); if (!silent && data?.warnings?.includes?.('overlay has not been run')) { - showMessage('warn', '该洪涝结果尚未运行套合分析。'); + showMessage('warn', '该洪涝检测尚未执行影响评估。'); } } catch (error) { setImpactResult(null); - if (!silent) showMessage('error', `套合结果加载失败:${getErrorText(error)}`); + if (!silent) showMessage('error', `影响评估结果加载失败:${getErrorText(error)}`); } finally { setImpactLoading(false); } @@ -937,9 +937,9 @@ export default function FloodAnalysisWorkspace({ await runFloodOverlay(selectedImpactEvent.id, { near_threshold_m: 500 }); await loadImpactResult(selectedImpactEvent.id, { silent: true }); await loadFloodEvents(); - showMessage('success', `洪涝结果 #${selectedImpactEvent.id} 套合分析已完成。`); + showMessage('success', `洪涝检测 #${selectedImpactEvent.id} 影响评估已完成。`); } catch (error) { - showMessage('error', `套合分析失败:${getErrorText(error)}`); + showMessage('error', `影响评估失败:${getErrorText(error)}`); } finally { setOverlayRunning(false); } @@ -951,10 +951,10 @@ export default function FloodAnalysisWorkspace({ try { await createFloodProduct(event.id); await loadFloodProducts(); - showMessage('success', `洪涝结果 #${event.id} 产品已生成。`); + showMessage('success', `洪涝检测 #${event.id} 成果已发布。`); setResultMode('products'); } catch (error) { - showMessage('error', `产品生成失败:${getErrorText(error)}`); + showMessage('error', `成果发布失败:${getErrorText(error)}`); } finally { setProductBusyId(null); } @@ -963,9 +963,9 @@ export default function FloodAnalysisWorkspace({ const handleOpenProductManifest = async (product) => { try { 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) { - showMessage('error', `Manifest 读取失败:${getErrorText(error)}`); + showMessage('error', `成果清单读取失败:${getErrorText(error)}`); } }; @@ -1004,10 +1004,10 @@ export default function FloodAnalysisWorkspace({
- - - - + + + +
@@ -1059,7 +1059,7 @@ export default function FloodAnalysisWorkspace({
)}
- loadScenes(scenesPage)}>刷新场景} /> + loadScenes(scenesPage)}>刷新场景} />
{scenesLoading && 场景加载中...} - {!scenesLoading && scenes.length === 0 && 暂无水体预处理场景。} + {!scenesLoading && scenes.length === 0 && 暂无分析就绪场景。} {!scenesLoading && scenes.map(scene => (
- loadWaterResults(waterPage)}>刷新结果} /> + loadWaterResults(waterPage)}>刷新结果} />
- {waterLoading && 水体结果加载中...} - {!waterLoading && waterResults.length === 0 && 暂无水体提取结果。} + {waterLoading && 水体提取成果加载中...} + {!waterLoading && waterResults.length === 0 && 暂无水体提取成果。} {!waterLoading && waterResults.map(item => ( ))} @@ -1233,7 +1233,7 @@ export default function FloodAnalysisWorkspace({ <> )} @@ -1330,7 +1330,7 @@ export default function FloodAnalysisWorkspace({ } }} > - 预览覆盖 + 显示覆盖范围
@@ -1340,10 +1340,10 @@ export default function FloodAnalysisWorkspace({
- 刷新结果} /> + 刷新结果} />
- {floodLoading && 洪涝结果加载中...} - {!floodLoading && floodEvents.length === 0 && 暂无洪涝检测结果。} + {floodLoading && 洪涝检测成果加载中...} + {!floodLoading && floodEvents.length === 0 && 暂无洪涝检测成果。} {!floodLoading && floodEvents.slice(0, 6).map(event => (
selectedImpactEvent && loadImpactResult(selectedImpactEvent.id)}>刷新结果} />
{[ - ['洪涝分类栅格', 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'], + ['分类栅格', 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 ? `${impactResult.hazard_points?.inside_flood?.length || 0} 个` : '待运行', impactResult ? 'ok' : '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'], ].map(([name, status, tone]) => (
@@ -1387,9 +1387,9 @@ export default function FloodAnalysisWorkspace({
- +
- {doneFloodEvents.length === 0 && 暂无已完成洪涝结果。} + {doneFloodEvents.length === 0 && 暂无已完成洪涝检测成果。} {doneFloodEvents.map(event => { const active = String(selectedImpactEvent?.id) === String(event.id); return ( @@ -1406,39 +1406,39 @@ export default function FloodAnalysisWorkspace({ }} >
- 洪涝 #{event.id} + 洪涝检测 #{event.id} {active ? '当前' : '可选'}
- +
); })}
- - + +
- - {impactLoading && 套合结果加载中...} - {!impactLoading && !impactResult && 选择一个已完成的洪涝结果后运行套合分析。} + + {impactLoading && 影响评估结果加载中...} + {!impactLoading && !impactResult && 选择一个已完成的洪涝检测成果后执行影响评估。} {!impactLoading && impactResult && (
- + - +
{impactResult.warnings?.length > 0 && ( @@ -1490,19 +1490,19 @@ export default function FloodAnalysisWorkspace({
- - - + + + )} /> {resultMode === 'flood' && (
- {floodEvents.length === 0 && 暂无洪涝检测结果。} + {floodEvents.length === 0 && 暂无洪涝检测成果。} {floodEvents.map(event => ( - {waterResults.length === 0 && 暂无水体提取结果。} + {waterResults.length === 0 && 暂无水体提取成果。} {waterResults.map(item => ( ))} @@ -1528,8 +1528,8 @@ export default function FloodAnalysisWorkspace({ )} {resultMode === 'products' && (
- {productLoading && 洪涝产品加载中...} - {!productLoading && floodProducts.length === 0 && 暂无洪涝产品。可在洪涝结果行点击“生成产品”。} + {productLoading && 发布成果加载中...} + {!productLoading && floodProducts.length === 0 && 暂无发布成果。可在洪涝检测成果中点击“发布成果”。} {!productLoading && floodProducts.map(product => ( ))} @@ -1538,9 +1538,9 @@ export default function FloodAnalysisWorkspace({
- +
- +
diff --git a/frontend/src/LoginPage.jsx b/frontend/src/LoginPage.jsx index 582a129..4a2c3ef 100644 --- a/frontend/src/LoginPage.jsx +++ b/frontend/src/LoginPage.jsx @@ -1,11 +1,5 @@ import React, { useEffect, useState } from 'react'; -import axios from 'axios'; - - -const apiClient = axios.create({ - baseURL: '/api', - withCredentials: true, -}); +import apiClient from './api/client'; const LoginPage = ({ onLoginSuccess }) => { @@ -32,15 +26,22 @@ const LoginPage = ({ onLoginSuccess }) => { setLoading(true); setMessage(''); try { - await apiClient.post('/auth/login', { - username, + const response = await apiClient.post('/auth/login', { + username: username.trim(), password, }); if (onLoginSuccess) { - await onLoginSuccess(); + await onLoginSuccess(response.data?.user || null); } } 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 { setLoading(false); } diff --git a/frontend/src/hooks/useAppAuthLifecycle.js b/frontend/src/hooks/useAppAuthLifecycle.js index c847a85..d768da3 100644 --- a/frontend/src/hooks/useAppAuthLifecycle.js +++ b/frontend/src/hooks/useAppAuthLifecycle.js @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import apiClient from '../api/client'; import { getHealth } from '../api/health'; @@ -28,22 +28,41 @@ export default function useAppAuthLifecycle({ setAllData, setRadarPagination, }) { - const fetchCurrentUser = useCallback(async () => { + const authGenerationRef = useRef(0); + + const fetchCurrentUser = useCallback(async (options = {}) => { + const { clearOnFailure = true } = options; + const requestGeneration = authGenerationRef.current; try { - const response = await apiClient.get('/auth/me'); - setCurrentUser(response.data || null); + const response = await apiClient.get('/auth/me', { + skipAuthReset: !clearOnFailure, + authGeneration: requestGeneration, + }); + if (requestGeneration === authGenerationRef.current) { + setCurrentUser(response.data || null); + } } catch { - setCurrentUser(null); + if (clearOnFailure && requestGeneration === authGenerationRef.current) { + setCurrentUser(null); + } } finally { - setAuthChecked(true); + if (requestGeneration === authGenerationRef.current || !clearOnFailure) { + setAuthChecked(true); + } } }, [setCurrentUser, setAuthChecked]); - const handleLoginSuccess = useCallback(async () => { - await fetchCurrentUser(); - }, [fetchCurrentUser]); + const handleLoginSuccess = useCallback(async (userFromLogin = null) => { + authGenerationRef.current += 1; + if (userFromLogin) { + setCurrentUser(userFromLogin); + setAuthChecked(true); + } + await fetchCurrentUser({ clearOnFailure: !userFromLogin }); + }, [fetchCurrentUser, setCurrentUser, setAuthChecked]); const handleLogout = useCallback(async () => { + authGenerationRef.current += 1; try { await apiClient.post('/auth/logout'); } catch (error) { @@ -127,7 +146,9 @@ export default function useAppAuthLifecycle({ const interceptorId = apiClient.interceptors.response.use( (response) => response, (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; setHasRadarSearched(false); if (aoeLayerRef.current) {