chore: initialize insar management system v2

This commit is contained in:
2026-04-14 13:16:01 +08:00
commit ecc72ec9cd
361 changed files with 2142522 additions and 0 deletions
+193
View File
@@ -0,0 +1,193 @@
import { useCallback, useEffect } from 'react';
import apiClient from '../api/client';
import { getHealth } from '../api/health';
const HEALTH_POLL_INTERVAL_MS = 30000;
export default function useAppAuthLifecycle({
ensureCanOperate,
clearRadarSearchResults,
radarSearchRequestSeqRef,
prevLicenseOkRef,
aoeLayerRef,
activeLayersRef,
radarPreviewLayersRef,
setHasRadarSearched,
setCurrentUser,
setAuthChecked,
setIsGlobalLocked,
setPendingTaskIds,
setLicenseLoading,
setLicenseStatus,
setHealthLoading,
setHealthError,
setHealthStatus,
setLicenseFileName,
setLicenseUploadStatus,
setAoiLayer,
setAllData,
setRadarPagination,
}) {
const fetchCurrentUser = useCallback(async () => {
try {
const response = await apiClient.get('/auth/me');
setCurrentUser(response.data || null);
} catch {
setCurrentUser(null);
} finally {
setAuthChecked(true);
}
}, [setCurrentUser, setAuthChecked]);
const handleLoginSuccess = useCallback(async () => {
await fetchCurrentUser();
}, [fetchCurrentUser]);
const handleLogout = useCallback(async () => {
try {
await apiClient.post('/auth/logout');
} catch (error) {
console.error('Logout failed:', error);
} finally {
radarSearchRequestSeqRef.current += 1;
setHasRadarSearched(false);
clearRadarSearchResults();
setCurrentUser(null);
setIsGlobalLocked(false);
setPendingTaskIds([]);
prevLicenseOkRef.current = false;
setAuthChecked(true);
}
}, [
clearRadarSearchResults,
radarSearchRequestSeqRef,
setHasRadarSearched,
setCurrentUser,
setIsGlobalLocked,
setPendingTaskIds,
prevLicenseOkRef,
setAuthChecked,
]);
const fetchLicenseStatus = useCallback(async () => {
try {
setLicenseLoading(true);
const response = await apiClient.get('/license/status');
const data = response.data || {};
if (!data.ok && !data.reason) {
data.reason = '未授权';
}
setLicenseStatus(data);
} catch (error) {
setLicenseStatus({
ok: false,
reason: error.response?.data?.detail || '无法获取授权状态',
});
} finally {
setLicenseLoading(false);
}
}, [setLicenseLoading, setLicenseStatus]);
const fetchHealthStatus = useCallback(async (options = {}) => {
const { refresh = false, silent = false } = options;
try {
if (!silent) {
setHealthLoading(true);
}
setHealthError('');
const data = await getHealth(refresh ? { refresh: true } : {});
setHealthStatus(data || null);
} catch (error) {
setHealthError(error.response?.data?.detail || '运维自检失败');
setHealthStatus(null);
} finally {
if (!silent) {
setHealthLoading(false);
}
}
}, [setHealthLoading, setHealthError, setHealthStatus]);
const handleLicenseUpload = useCallback(async (file) => {
if (!file) return;
if (!ensureCanOperate()) return;
try {
setLicenseFileName(file.name);
setLicenseUploadStatus({ type: 'info', message: '正在上传授权文件...' });
const form = new FormData();
form.append('file', file);
const response = await apiClient.post('/license/upload', form);
setLicenseUploadStatus({ type: 'success', message: response.data?.message || '授权文件已上传' });
await fetchLicenseStatus();
} catch (error) {
setLicenseUploadStatus({ type: 'error', message: error.response?.data?.detail || '授权文件上传失败' });
}
}, [ensureCanOperate, setLicenseFileName, setLicenseUploadStatus, fetchLicenseStatus]);
useEffect(() => {
const interceptorId = apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error?.response?.status === 401) {
radarSearchRequestSeqRef.current += 1;
setHasRadarSearched(false);
if (aoeLayerRef.current) {
aoeLayerRef.current.remove();
aoeLayerRef.current = null;
}
setAoiLayer(null);
Object.values(activeLayersRef.current).forEach((layer) => layer.remove());
activeLayersRef.current = {};
Object.values(radarPreviewLayersRef.current).forEach((layer) => layer.remove());
radarPreviewLayersRef.current = {};
setAllData([]);
setRadarPagination((prev) => ({
...prev,
total: 0,
offset: 0,
hasMore: false,
}));
setCurrentUser(null);
setAuthChecked(true);
prevLicenseOkRef.current = false;
}
return Promise.reject(error);
}
);
return () => {
apiClient.interceptors.response.eject(interceptorId);
};
}, [
radarSearchRequestSeqRef,
setHasRadarSearched,
aoeLayerRef,
setAoiLayer,
activeLayersRef,
radarPreviewLayersRef,
setAllData,
setRadarPagination,
setCurrentUser,
setAuthChecked,
prevLicenseOkRef,
]);
useEffect(() => {
fetchCurrentUser();
fetchLicenseStatus();
}, [fetchCurrentUser, fetchLicenseStatus]);
useEffect(() => {
void fetchHealthStatus();
const interval = setInterval(() => {
void fetchHealthStatus({ silent: true });
}, HEALTH_POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [fetchHealthStatus]);
return {
handleLoginSuccess,
handleLogout,
fetchLicenseStatus,
fetchHealthStatus,
handleLicenseUpload,
};
}
+149
View File
@@ -0,0 +1,149 @@
/**
* useBatchOperations — batch (D-InSAR / PS) CRUD logic extracted from App.jsx
*
* Contains: fetchDinsarBatches, fetchPsBatches, refreshBatchList,
* fetchBatchItems, updateBatchItemLocal, saveBatchItem, completeBatch
*/
import { useCallback } from 'react';
import apiClient from '../api/client';
import {
BATCH_API_PAGE_LIMIT,
BATCH_API_MAX_PAGES,
} from '../config/appConstants';
export default function useBatchOperations({
addLog,
ensureCanOperate,
batchTab,
selectedBatchId,
setDinsarBatches,
setPsBatches,
setBatchItems,
setBatchLoading,
setBatchError,
}) {
const fetchDinsarBatches = useCallback(async () => {
try {
const batches = [];
for (let page = 0; page < BATCH_API_MAX_PAGES; page += 1) {
const offset = page * BATCH_API_PAGE_LIMIT;
const response = await apiClient.get('/task-batches/dinsar', {
params: { limit: BATCH_API_PAGE_LIMIT, offset }
});
const items = Array.isArray(response.data) ? response.data : [];
batches.push(...items);
if (items.length < BATCH_API_PAGE_LIMIT) break;
}
setDinsarBatches(batches);
} catch {
setDinsarBatches([]);
}
}, [setDinsarBatches]);
const fetchPsBatches = useCallback(async () => {
try {
const batches = [];
for (let page = 0; page < BATCH_API_MAX_PAGES; page += 1) {
const offset = page * BATCH_API_PAGE_LIMIT;
const response = await apiClient.get('/task-batches/ps', {
params: { limit: BATCH_API_PAGE_LIMIT, offset }
});
const items = Array.isArray(response.data) ? response.data : [];
batches.push(...items);
if (items.length < BATCH_API_PAGE_LIMIT) break;
}
setPsBatches(batches);
} catch {
setPsBatches([]);
}
}, [setPsBatches]);
const refreshBatchList = useCallback(async () => {
setBatchLoading(true);
setBatchError('');
try {
await Promise.all([fetchDinsarBatches(), fetchPsBatches()]);
} catch {
setBatchError('加载批次失败');
} finally {
setBatchLoading(false);
}
}, [fetchDinsarBatches, fetchPsBatches, setBatchLoading, setBatchError]);
const fetchBatchItems = useCallback(async (type, batchId) => {
if (!batchId) {
setBatchItems([]);
return;
}
setBatchLoading(true);
setBatchError('');
try {
const endpoint = type === 'ps'
? `/task-batches/ps/${batchId}/items`
: `/task-batches/dinsar/${batchId}/items`;
const allItems = [];
for (let page = 0; page < BATCH_API_MAX_PAGES; page += 1) {
const offset = page * BATCH_API_PAGE_LIMIT;
const response = await apiClient.get(endpoint, {
params: { limit: BATCH_API_PAGE_LIMIT, offset }
});
const items = Array.isArray(response.data) ? response.data : [];
allItems.push(...items);
if (items.length < BATCH_API_PAGE_LIMIT) break;
}
setBatchItems(allItems);
} catch {
setBatchError('加载批次明细失败');
setBatchItems([]);
} finally {
setBatchLoading(false);
}
}, [setBatchItems, setBatchLoading, setBatchError]);
const updateBatchItemLocal = useCallback((id, field, value) => {
setBatchItems(prev =>
prev.map(item => (item.id === id ? { ...item, [field]: value } : item))
);
}, [setBatchItems]);
const saveBatchItem = useCallback(async (item) => {
if (!ensureCanOperate()) return;
try {
const endpoint = batchTab === 'ps'
? `/task-batches/ps/items/${item.id}`
: `/task-batches/dinsar/items/${item.id}`;
await apiClient.patch(endpoint, {
status: item.status,
remark: item.remark ?? ''
});
await refreshBatchList();
} catch {
addLog('error', '批次明细更新失败');
}
}, [batchTab, ensureCanOperate, refreshBatchList, addLog]);
const completeBatch = useCallback(async () => {
if (!ensureCanOperate()) return;
if (!selectedBatchId) return;
try {
const endpoint = batchTab === 'ps'
? `/task-batches/ps/${selectedBatchId}/complete-all`
: `/task-batches/dinsar/${selectedBatchId}/complete-all`;
await apiClient.patch(endpoint);
await fetchBatchItems(batchTab, selectedBatchId);
await refreshBatchList();
} catch {
addLog('error', '批次一键完成失败');
}
}, [batchTab, selectedBatchId, ensureCanOperate, fetchBatchItems, refreshBatchList, addLog]);
return {
fetchDinsarBatches,
fetchPsBatches,
refreshBatchList,
fetchBatchItems,
updateBatchItemLocal,
saveBatchItem,
completeBatch,
};
}
+305
View File
@@ -0,0 +1,305 @@
/**
* useDinsarOperations — D-InSAR / AI / hazard business logic extracted from App.jsx
*
* Contains: fetchDinsarResults, fetchAiStatus, fetchHazardPoints,
* handleTaskCompletion, handleLabelResult, handleTrainAi, handlePredictAll,
* handleAnalyzeResult, handleTaskStart, initializeAppData
*/
import apiClient from '../api/client';
import { useUiStore, useDinsarStore, useHazardStore, useTaskStore, useAuthStore, useRadarStore } from '../store';
import { normalizePagePayload } from '../utils/appHelpers';
import { normalizeTaskStatus } from '../utils/appUiHelpers';
import { DEFAULT_LIST_PAGE_SIZE } from '../config/appConstants';
export default function useDinsarOperations({
onCleanupDinsarLayers,
fetchRadarImagingDates,
fetchRadarSearchOptions,
fetchAllData,
radarSearchRequestSeqRef,
}) {
const { addLog, setIsLoading } = useUiStore();
const {
dinsarResults, setDinsarResults, dinsarPagination, setDinsarPagination,
setAiStatus, setActiveAiReport,
} = useDinsarStore();
const { setHazardPoints } = useHazardStore();
const { setPendingTaskIds, setIsGlobalLocked } = useTaskStore();
const { currentUser } = useAuthStore();
const {
hasRadarSearched, radarPagination,
radarSearchApplied, radarSearchAppliedAoiMode,
radarSearchAppliedRegionTreeId, radarSearchAoiToken,
} = useRadarStore();
const isAdmin = currentUser?.role === 'admin';
const ensureCanOperate = () => {
if (!isAdmin) {
addLog('warn', '当前账号为只读用户,无法执行写操作。');
return false;
}
return true;
};
const fetchAiStatus = async () => {
try {
const response = await apiClient.get('/ai/status');
setAiStatus(response.data);
} catch (error) {
console.error("Failed to fetch AI status", error);
}
};
const fetchHazardPoints = async () => {
try {
const response = await apiClient.get('/hazard-points');
setHazardPoints(response.data);
} catch (error) {
console.error("获取灾害点失败:", error);
}
};
const fetchDinsarResults = async (options = {}) => {
const requestedLimit = Math.max(
1,
Math.min(
Number(options.limit ?? dinsarPagination.limit ?? DEFAULT_LIST_PAGE_SIZE) || DEFAULT_LIST_PAGE_SIZE,
2000
)
);
const requestedOffset = Math.max(
0,
Number(options.offset ?? dinsarPagination.offset ?? 0) || 0
);
addLog('info', `正在获取D-InSAR结果(offset=${requestedOffset}, limit=${requestedLimit}...`);
try {
const response = await apiClient.get('/dinsar-results', {
params: {
limit: requestedLimit,
offset: requestedOffset,
},
});
const pagePayload = normalizePagePayload(response.data, requestedLimit, requestedOffset);
if (
pagePayload.items.length === 0 &&
pagePayload.total > 0 &&
requestedOffset >= pagePayload.total &&
requestedOffset > 0
) {
const fallbackOffset = Math.max(0, requestedOffset - requestedLimit);
await fetchDinsarResults({ limit: requestedLimit, offset: fallbackOffset });
return;
}
setDinsarPagination({
total: pagePayload.total,
limit: pagePayload.limit,
offset: pagePayload.offset,
hasMore: pagePayload.hasMore,
});
onCleanupDinsarLayers();
setDinsarResults(prevResults => {
const visibilityMap = {};
prevResults.forEach(r => {
visibilityMap[r.id] = r.isVisible;
});
return pagePayload.items.map(item => ({
...item,
isVisible: visibilityMap[item.id] || false
}));
});
const currentPage = Math.floor(pagePayload.offset / pagePayload.limit) + 1;
const totalPages = Math.max(1, Math.ceil(pagePayload.total / pagePayload.limit));
addLog('success', `D-InSAR结果已加载:第 ${currentPage}/${totalPages} 页,当前页 ${pagePayload.items.length} 条,总计 ${pagePayload.total} 条。`);
fetchAiStatus();
} catch (error) {
addLog('error', `获取Dinsar结果失败: ${error.message}`);
}
};
const handleTaskStart = (taskId, message) => {
if (taskId) {
setPendingTaskIds(prev => [...prev, taskId]);
}
setIsGlobalLocked(true);
if (message) addLog('info', message);
};
const handleTaskCompletion = (taskInfo) => {
console.log("收到任务完成通知:", taskInfo);
const taskStatus = normalizeTaskStatus(taskInfo?.status);
if (taskInfo.task_type === 'AI_ANALYZE') {
if (taskInfo.message) {
try {
const result = JSON.parse(taskInfo.message);
console.log("解析 AI 诊断结果:", result);
if (taskStatus === 'COMPLETED') {
const analysisContent = result.analysis || "AI 未能生成有效文字描述,请检查影像质量或重试。";
addLog('success', `AI 诊断完成: ${result.result_name || '未知结果'}`);
setActiveAiReport({
title: result.result_name || `诊断报告 (ID: ${result.result_id})`,
content: analysisContent
});
} else if (taskStatus === 'FAILED') {
const errorDetail = result.error || "未知错误";
addLog('error', `AI 诊断失败: ${errorDetail}`);
setActiveAiReport({
title: "AI 诊断失败",
content: `### 诊断任务执行失败\n\n**错误详情**\n> ${errorDetail}\n\n**建议**\n1. 检查本地 Ollama 服务是否已启动。\n2. 检查网络连接或模型加载是否超时。\n3. 请稍后重试。\n\n<strong class="disclaimer">免责声明</strong>`
});
}
} catch (e) {
console.error("解析任务结果失败:", e);
if (taskStatus === 'FAILED') {
addLog('error', `AI 诊断失败: ${taskInfo.message}`);
}
}
}
} else if (taskInfo.task_type === 'AI_WARMUP') {
if (taskStatus === 'COMPLETED') {
addLog('success', taskInfo.message || 'AI 模型预热完成,显存已就绪。');
} else if (taskStatus === 'FAILED') {
addLog('error', `AI 预热失败: ${taskInfo.message}`);
}
} else if (taskInfo.task_type === 'AI_TRAIN') {
if (taskStatus === 'COMPLETED') {
addLog('success', taskInfo.message || 'AI 模型训练完成。');
fetchAiStatus();
} else if (taskStatus === 'FAILED') {
addLog('error', `AI 训练失败: ${taskInfo.message || '未知错误'}`);
}
} else if (taskInfo.task_type === 'AI_PREDICT') {
if (taskStatus === 'COMPLETED') {
addLog('success', taskInfo.message || 'AI 质量预测完成。');
fetchDinsarResults();
} else if (taskStatus === 'FAILED') {
addLog('error', `AI 质量预测失败: ${taskInfo.message || '未知错误'}`);
}
} else if (taskInfo.task_type === 'SCAN_HAZARD') {
if (taskStatus === 'COMPLETED') {
addLog('success', taskInfo.message || '灾害点同步完成。');
fetchHazardPoints();
} else if (taskStatus === 'FAILED') {
addLog('error', `灾害点同步失败: ${taskInfo.message || '未知错误'}`);
}
}
};
const handleLabelResult = async (resultId, label) => {
if (!ensureCanOperate()) return;
try {
const newResults = dinsarResults.map(r =>
r.id === resultId ? { ...r, user_label: label } : r
);
setDinsarResults(newResults);
await apiClient.post(`/dinsar-results/${resultId}/label`,
new URLSearchParams({ label: label === null ? '' : label })
);
fetchAiStatus();
} catch (error) {
addLog('error', `标记失败: ${error.message}`);
fetchDinsarResults();
}
};
const handleTrainAi = async () => {
if (!ensureCanOperate()) return;
setIsLoading(true);
addLog('info', '开始训练AI模型...');
try {
const response = await apiClient.post('/ai/train');
const taskId = response.data.task_id;
handleTaskStart(taskId, 'AI 模型训练任务已启动,请稍候...');
} catch (error) {
const msg = error.response?.data?.detail || error.message;
addLog('error', `训练失败: ${msg}`);
} finally {
setIsLoading(false);
}
};
const handlePredictAll = async () => {
if (!ensureCanOperate()) return;
setIsLoading(true);
addLog('info', '开始全量预测...');
try {
const response = await apiClient.post('/ai/predict-all');
const taskId = response.data.task_id;
handleTaskStart(taskId, 'AI 质量预测任务已启动,请稍候...');
} catch (error) {
const msg = error.response?.data?.detail || error.message;
addLog('error', `预测失败: ${msg}`);
} finally {
setIsLoading(false);
}
};
const handleAnalyzeResult = async (resultId) => {
if (!ensureCanOperate()) return;
addLog('info', `正在对结果 ID:${resultId} 发起 AI 智能诊断任务...`);
setIsGlobalLocked(true);
try {
const response = await apiClient.post(`/ai/analyze-result/${resultId}`);
const taskId = response.data.task_id;
handleTaskStart(taskId);
addLog('info', `AI 诊断任务已启动 (ID: ${taskId}),请稍候...`);
} catch (error) {
const msg = error.response?.data?.detail || error.message;
addLog('error', `发起 AI 诊断失败: ${msg}`);
setIsGlobalLocked(false);
}
};
const initializeAppData = async (options = {}) => {
const shouldRefreshRadarSearch = !!options.refreshRadarSearch && hasRadarSearched;
setIsLoading(true);
addLog('info', '开始加载初始数据...');
try {
await Promise.all([
fetchDinsarResults({ offset: 0 }),
fetchRadarImagingDates(),
fetchRadarSearchOptions(),
fetchHazardPoints(),
]);
if (shouldRefreshRadarSearch) {
addLog('info', '检测到已有源数据检索结果,正在刷新当前检索页...');
const requestId = radarSearchRequestSeqRef.current + 1;
radarSearchRequestSeqRef.current = requestId;
await fetchAllData({
limit: radarPagination.limit,
offset: radarPagination.offset,
criteria: radarSearchApplied,
aoiMode: radarSearchAppliedAoiMode,
regionTreeId: radarSearchAppliedRegionTreeId,
aoiToken: radarSearchAoiToken,
files: null,
requestId,
});
}
addLog('success', shouldRefreshRadarSearch ? '系统数据与检索结果已同步。' : '系统初始数据加载完毕。');
} catch {
addLog('error', '加载初始数据时发生一个或多个错误。');
} finally {
setIsLoading(false);
}
};
return {
fetchDinsarResults,
fetchAiStatus,
fetchHazardPoints,
handleTaskCompletion,
handleLabelResult,
handleTrainAi,
handlePredictAll,
handleAnalyzeResult,
handleTaskStart,
initializeAppData,
};
}
+206
View File
@@ -0,0 +1,206 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import apiClient from '../api/client';
import { normalizeTaskStatus } from '../utils/appUiHelpers';
export default function useGlobalTaskControl({
currentUser,
licenseOk,
activeTasks,
setActiveTasks,
pendingTaskIds,
setPendingTaskIds,
isGlobalLocked,
setIsGlobalLocked,
setIsCheckingTasks,
handleTaskCompletionRef,
initializeAppDataRef,
addLog,
}) {
const [forceUnlockPwd, setForceUnlockPwd] = useState('');
const [showForceUnlock, setShowForceUnlock] = useState(false);
const lastLockTimeRef = useRef(null);
const isGlobalLockedRef = useRef(isGlobalLocked);
useEffect(() => {
isGlobalLockedRef.current = isGlobalLocked;
if (isGlobalLocked) {
lastLockTimeRef.current = Date.now();
}
}, [isGlobalLocked]);
// Stable refs so SSE handler doesn't need to re-subscribe on every render
const pendingTaskIdsRef = useRef(pendingTaskIds);
useEffect(() => { pendingTaskIdsRef.current = pendingTaskIds; }, [pendingTaskIds]);
const handleTasksUpdate = useCallback(async (tasks) => {
setActiveTasks(tasks);
const hasRunningTasks = tasks.length > 0;
// 首次检查完成,清除检查状态
setIsCheckingTasks(false);
const currentPending = pendingTaskIdsRef.current;
let updatedPending = currentPending;
// 如果 pendingTaskIds 为空,但 activeTasks 有任务,说明是刷新后的初始化
// 需要将 activeTasks 中的任务添加到 pendingTaskIds
if (currentPending.length === 0 && hasRunningTasks) {
const activeTaskIds = tasks.map((t) => t.task_id);
console.log('初始化:将活跃任务添加到 pending 列表:', activeTaskIds);
setPendingTaskIds(activeTaskIds);
updatedPending = activeTaskIds;
}
if (currentPending.length > 0) {
const currentTaskIds = new Set(tasks.map((t) => t.task_id));
const finishedTaskIds = currentPending.filter((id) => !currentTaskIds.has(id));
if (finishedTaskIds.length > 0) {
console.log('检测到可能已结束的任务:', finishedTaskIds);
const reallyFinishedIds = [];
for (const taskId of finishedTaskIds) {
try {
const statusRes = await apiClient.get(`/tasks/${taskId}`);
const taskInfo = statusRes.data;
const taskStatus = normalizeTaskStatus(taskInfo?.status);
// 检查任务是否真正完成:解析 message 中的进度信息
let isReallyFinished = taskStatus === 'COMPLETED' || taskStatus === 'FAILED';
// 如果任务状态是 PENDING,检查进度信息
if (taskStatus === 'PENDING' && taskInfo?.message) {
// 匹配格式:(current/total)
const match = taskInfo.message.match(/\((\d+)\/(\d+)\)/);
if (match) {
const current = parseInt(match[1], 10);
const total = parseInt(match[2], 10);
// 如果还没处理完,任务还在运行
if (current < total) {
console.log(`任务 ${taskId} 还在运行,进度: ${current}/${total}`);
isReallyFinished = false;
} else {
console.log(`任务 ${taskId} 进度已完成: ${current}/${total}`);
}
}
}
if (isReallyFinished) {
reallyFinishedIds.push(taskId);
if (taskStatus === 'COMPLETED' || taskStatus === 'FAILED') {
handleTaskCompletionRef.current?.(taskInfo);
}
}
} catch (error) {
console.error(`获取任务 ${taskId} 结果失败:`, error);
// 查询失败时,保守处理:认为任务已完成
reallyFinishedIds.push(taskId);
}
}
if (reallyFinishedIds.length > 0) {
console.log('真正完成的任务:', reallyFinishedIds);
setPendingTaskIds((prev) => {
const newPending = prev.filter((id) => !reallyFinishedIds.includes(id));
updatedPending = newPending;
return newPending;
});
} else {
// 没有真正完成的任务,保持 updatedPending 不变
updatedPending = currentPending;
}
}
}
// 只有当没有运行中的任务且没有待处理的任务时,才解锁
const shouldBeLocked = hasRunningTasks || updatedPending.length > 0;
if (shouldBeLocked !== isGlobalLockedRef.current) {
setIsGlobalLocked(shouldBeLocked);
if (!shouldBeLocked) {
addLog('success', '后台任务已完成,正在同步最新数据...');
setTimeout(() => {
initializeAppDataRef.current?.({ refreshRadarSearch: true });
}, 500);
}
}
}, [
setActiveTasks,
setIsCheckingTasks,
handleTaskCompletionRef,
setPendingTaskIds,
setIsGlobalLocked,
addLog,
initializeAppDataRef,
]);
// Fallback polling (used when SSE is unavailable)
const syncActiveTasks = useCallback(async () => {
try {
const response = await apiClient.get('/tasks/active');
const tasks = Array.isArray(response.data) ? response.data : [];
await handleTasksUpdate(tasks);
} catch (error) {
console.error('同步任务状态失败:', error);
}
}, [handleTasksUpdate]);
useEffect(() => {
if (!currentUser || !licenseOk) return;
// Initial fetch
syncActiveTasks();
// Try SSE first; fall back to polling on error
let es = null;
let fallbackInterval = null;
const startSSE = () => {
const baseURL = apiClient.defaults.baseURL || '';
es = new EventSource(`${baseURL}/tasks/active/stream`);
es.onmessage = (event) => {
try {
const tasks = JSON.parse(event.data);
handleTasksUpdate(Array.isArray(tasks) ? tasks : []);
} catch (e) {
console.error('SSE parse error:', e);
}
};
es.onerror = () => {
console.warn('SSE 连接断开,降级为轮询模式');
es.close();
es = null;
if (!fallbackInterval) {
fallbackInterval = setInterval(syncActiveTasks, 5000);
}
};
};
startSSE();
return () => {
if (es) es.close();
if (fallbackInterval) clearInterval(fallbackInterval);
};
}, [currentUser, licenseOk, syncActiveTasks, handleTasksUpdate]);
const handleForceUnlock = useCallback(() => {
if (!forceUnlockPwd || activeTasks.length === 0) return;
Promise.all(activeTasks.map((task) =>
apiClient.post(`/tasks/${task.task_id}/force-cancel`, { password: forceUnlockPwd }).catch(() => {})
)).then(() => {
setForceUnlockPwd('');
setShowForceUnlock(false);
syncActiveTasks();
});
}, [activeTasks, forceUnlockPwd, syncActiveTasks]);
return {
forceUnlockPwd,
setForceUnlockPwd,
showForceUnlock,
setShowForceUnlock,
handleForceUnlock,
};
}
+294
View File
@@ -0,0 +1,294 @@
import { useState, useRef, useCallback } from 'react';
import {
detectVisibleLayers,
calculateScaleBar,
compositeExportCanvas,
canvasToBlob,
downloadBlob,
generatePreviewDataUrl,
} from '../utils/mapExportHelpers';
let html2canvasLoader = null;
const MAP_EXPORT_RESOLUTIONS = {
'1920x1080': { width: 1920, height: 1080, label: '1920x1080 (Full HD)' },
'2560x1440': { width: 2560, height: 1440, label: '2560x1440 (2K)' },
'3840x2160': { width: 3840, height: 2160, label: '3840x2160 (4K)' },
};
async function loadHtml2Canvas() {
if (!html2canvasLoader) {
html2canvasLoader = import('html2canvas').then((module) => module.default || module);
}
return html2canvasLoader;
}
export default function useMapExport({ mapRef, getVisibleLayerRefs, addLog, language }) {
const [showExportModal, setShowExportModal] = useState(false);
const [exportTitle, setExportTitle] = useState('');
const [exportFormat, setExportFormat] = useState('png');
const [exportResolution, setExportResolution] = useState('1920x1080');
const [showLegend, setShowLegend] = useState(true);
const [showScaleBar, setShowScaleBar] = useState(true);
const [showNorthArrow, setShowNorthArrow] = useState(true);
const [legendItems, setLegendItems] = useState([]);
const [previewUrl, setPreviewUrl] = useState('');
const [isCapturing, setIsCapturing] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [exportOrg, setExportOrg] = useState(import.meta.env.VITE_MAP_EXPORT_ORG || '');
const [logoDataUrl, setLogoDataUrl] = useState('');
const mapCanvasRef = useRef(null);
const scaleBarInfoRef = useRef(null);
const logoImgRef = useRef(null);
const originalMapSizeRef = useRef(null);
const en = language === 'en';
const loadLogoImg = useCallback((dataUrl) => {
return new Promise((resolve) => {
if (!dataUrl) {
logoImgRef.current = null;
resolve(null);
return;
}
const img = new Image();
img.onload = () => {
logoImgRef.current = img;
resolve(img);
};
img.onerror = () => {
logoImgRef.current = null;
resolve(null);
};
img.src = dataUrl;
});
}, []);
const handleLogoUpload = useCallback((e) => {
const file = e.target.files?.[0];
if (!file || !file.type.startsWith('image/')) {
return;
}
const reader = new FileReader();
reader.onload = async (ev) => {
const dataUrl = ev.target.result;
setLogoDataUrl(dataUrl);
await loadLogoImg(dataUrl);
};
reader.readAsDataURL(file);
}, [loadLogoImg]);
const removeLogo = useCallback(() => {
setLogoDataUrl('');
logoImgRef.current = null;
}, []);
const buildComposite = useCallback((opts = {}) => {
if (!mapCanvasRef.current) {
return null;
}
return compositeExportCanvas({
mapCanvas: mapCanvasRef.current,
title: opts.title ?? exportTitle,
legendItems: opts.legendItems ?? legendItems,
showLegend: opts.showLegend ?? showLegend,
showScaleBar: opts.showScaleBar ?? showScaleBar,
showNorthArrow: opts.showNorthArrow ?? showNorthArrow,
scaleBarInfo: scaleBarInfoRef.current,
format: opts.format ?? exportFormat,
orgName: opts.orgName ?? exportOrg,
logoImg: logoImgRef.current,
});
}, [exportTitle, legendItems, showLegend, showScaleBar, showNorthArrow, exportFormat, exportOrg]);
const refreshPreview = useCallback((opts = {}) => {
const canvas = buildComposite(opts);
if (canvas) {
setPreviewUrl(generatePreviewDataUrl(canvas, 480));
}
}, [buildComposite]);
const captureMapCanvas = useCallback(async (resolutionKey, detectedLegendItems = legendItems) => {
const map = mapRef.current;
const mapEl = document.getElementById('map');
if (!map || !mapEl) {
throw new Error('Map element not found');
}
const resolution = MAP_EXPORT_RESOLUTIONS[resolutionKey] || MAP_EXPORT_RESOLUTIONS['1920x1080'];
if (!originalMapSizeRef.current) {
originalMapSizeRef.current = {
width: mapEl.style.width,
height: mapEl.style.height,
};
}
mapEl.style.width = `${resolution.width}px`;
mapEl.style.height = `${resolution.height}px`;
map.invalidateSize();
try {
await new Promise((resolve) => setTimeout(resolve, 100));
const scaleBarInfo = calculateScaleBar(map);
scaleBarInfoRef.current = scaleBarInfo;
const html2canvas = await loadHtml2Canvas();
const canvas = await html2canvas(mapEl, {
useCORS: true,
allowTaint: true,
scale: 2,
logging: false,
backgroundColor: null,
ignoreElements: (el) => el.classList?.contains('leaflet-control-container'),
});
mapCanvasRef.current = canvas;
return {
canvas,
scaleBarInfo,
legendItems: detectedLegendItems,
};
} finally {
mapEl.style.width = originalMapSizeRef.current.width;
mapEl.style.height = originalMapSizeRef.current.height;
map.invalidateSize();
}
}, [legendItems, mapRef]);
const openExportModal = useCallback(async () => {
setShowExportModal(true);
setExportTitle('');
setExportFormat('png');
setExportResolution('1920x1080');
setShowLegend(true);
setShowScaleBar(true);
setShowNorthArrow(true);
setPreviewUrl('');
setIsCapturing(true);
setExportOrg(import.meta.env.VITE_MAP_EXPORT_ORG || '');
try {
const detected = detectVisibleLayers(getVisibleLayerRefs(), language);
setLegendItems(detected);
const { canvas, scaleBarInfo } = await captureMapCanvas('1920x1080', detected);
const composite = compositeExportCanvas({
mapCanvas: canvas,
title: '',
legendItems: detected,
showLegend: true,
showScaleBar: true,
showNorthArrow: true,
scaleBarInfo,
format: 'png',
orgName: import.meta.env.VITE_MAP_EXPORT_ORG || '',
logoImg: logoImgRef.current,
});
setPreviewUrl(generatePreviewDataUrl(composite, 480));
} catch (err) {
addLog('error', en ? `Map capture failed: ${err.message}` : `地图截图失败: ${err.message}`);
setShowExportModal(false);
} finally {
setIsCapturing(false);
}
}, [addLog, captureMapCanvas, en, getVisibleLayerRefs, language]);
const closeExportModal = useCallback(() => {
setShowExportModal(false);
mapCanvasRef.current = null;
setPreviewUrl('');
}, []);
const recaptureWithResolution = useCallback(async (resolutionKey) => {
setIsCapturing(true);
try {
await captureMapCanvas(resolutionKey);
refreshPreview();
} catch (err) {
addLog('error', en ? `Recapture failed: ${err.message}` : `重新截图失败: ${err.message}`);
} finally {
setIsCapturing(false);
}
}, [addLog, captureMapCanvas, en, refreshPreview]);
const handleResolutionChange = useCallback((resolutionKey) => {
setExportResolution(resolutionKey);
void recaptureWithResolution(resolutionKey);
}, [recaptureWithResolution]);
const executeExport = useCallback(async () => {
setIsExporting(true);
try {
const canvas = buildComposite();
if (!canvas) {
throw new Error('No canvas');
}
const blob = await canvasToBlob(canvas, exportFormat);
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `map_export_${ts}.${exportFormat === 'jpeg' ? 'jpg' : 'png'}`;
downloadBlob(blob, filename);
addLog('info', en ? `Map exported: ${filename}` : `地图已导出: ${filename}`);
closeExportModal();
} catch (err) {
addLog('error', en ? `Export failed: ${err.message}` : `导出失败: ${err.message}`);
} finally {
setIsExporting(false);
}
}, [addLog, buildComposite, closeExportModal, en, exportFormat]);
const updateLegendItem = useCallback((id, changes) => {
setLegendItems((prev) => prev.map((item) => (item.id === id ? { ...item, ...changes } : item)));
}, []);
const removeLegendItem = useCallback((id) => {
setLegendItems((prev) => prev.filter((item) => item.id !== id));
}, []);
const addLegendItem = useCallback(() => {
setLegendItems((prev) => ([
...prev,
{
id: `custom_${Date.now()}`,
type: 'polygon',
color: '#888888',
dash: false,
label: en ? 'New Item' : '新图例',
},
]));
}, [en]);
return {
showExportModal,
exportTitle,
setExportTitle,
exportFormat,
setExportFormat,
exportResolution,
RESOLUTIONS: MAP_EXPORT_RESOLUTIONS,
handleResolutionChange,
showLegend,
setShowLegend,
showScaleBar,
setShowScaleBar,
showNorthArrow,
setShowNorthArrow,
legendItems,
previewUrl,
isCapturing,
isExporting,
exportOrg,
setExportOrg,
logoDataUrl,
handleLogoUpload,
removeLogo,
openExportModal,
closeExportModal,
refreshPreview,
executeExport,
updateLegendItem,
removeLegendItem,
addLegendItem,
};
}
+195
View File
@@ -0,0 +1,195 @@
import { useCallback, useEffect } from 'react';
import { DEFAULT_LIST_PAGE_SIZE } from '../config/appConstants';
import { getPageInputErrorText } from '../utils/appUiHelpers';
export default function usePaginationControls({
language,
hasRadarSearched,
addLog,
radarPagination,
dinsarPagination,
radarPageInput,
dinsarPageInput,
radarPageInputTouched,
dinsarPageInputTouched,
setRadarPageInput,
setRadarPageInputTouched,
setDinsarPageInput,
setDinsarPageInputTouched,
setIsLoading,
fetchAllData,
fetchDinsarResults,
radarSearchRequestSeqRef,
}) {
const radarCurrentPage = Math.floor(radarPagination.offset / radarPagination.limit) + 1;
const radarTotalPages = Math.max(1, Math.ceil(radarPagination.total / radarPagination.limit));
const dinsarCurrentPage = Math.floor(dinsarPagination.offset / dinsarPagination.limit) + 1;
const dinsarTotalPages = Math.max(1, Math.ceil(dinsarPagination.total / dinsarPagination.limit));
const radarPageInputValidationError = getPageInputErrorText(radarPageInput, radarTotalPages, language);
const dinsarPageInputValidationError = getPageInputErrorText(dinsarPageInput, dinsarTotalPages, language);
const showRadarPageInputError = radarPageInputTouched && !!radarPageInputValidationError;
const showDinsarPageInputError = dinsarPageInputTouched && !!dinsarPageInputValidationError;
useEffect(() => {
setRadarPageInput(String(radarCurrentPage));
setRadarPageInputTouched(false);
}, [radarCurrentPage, setRadarPageInput, setRadarPageInputTouched]);
useEffect(() => {
setDinsarPageInput(String(dinsarCurrentPage));
setDinsarPageInputTouched(false);
}, [dinsarCurrentPage, setDinsarPageInput, setDinsarPageInputTouched]);
const handleRadarPageSizeChange = useCallback(async (event) => {
if (!hasRadarSearched) {
addLog('warn', '请先执行检索,再调整分页参数。');
return;
}
const nextLimit = Math.max(1, Math.min(Number(event.target.value) || DEFAULT_LIST_PAGE_SIZE, 2000));
const requestId = radarSearchRequestSeqRef.current + 1;
radarSearchRequestSeqRef.current = requestId;
setIsLoading(true);
try {
await fetchAllData({ limit: nextLimit, offset: 0, requestId });
} finally {
setIsLoading(false);
}
}, [hasRadarSearched, addLog, radarSearchRequestSeqRef, setIsLoading, fetchAllData]);
const handleDinsarPageSizeChange = useCallback(async (event) => {
const nextLimit = Math.max(1, Math.min(Number(event.target.value) || DEFAULT_LIST_PAGE_SIZE, 2000));
setIsLoading(true);
try {
await fetchDinsarResults({ limit: nextLimit, offset: 0 });
} finally {
setIsLoading(false);
}
}, [setIsLoading, fetchDinsarResults]);
const goToRadarPage = useCallback(async () => {
if (!hasRadarSearched) {
addLog('warn', '请先执行检索,再进行翻页。');
return;
}
if (radarPageInputValidationError) {
setRadarPageInputTouched(true);
return;
}
const requestedPage = Number(radarPageInput);
if (!Number.isFinite(requestedPage)) {
setRadarPageInput(String(radarCurrentPage));
return;
}
const targetPage = Math.max(1, Math.min(Math.floor(requestedPage), radarTotalPages));
const nextOffset = (targetPage - 1) * radarPagination.limit;
if (nextOffset === radarPagination.offset) {
setRadarPageInput(String(targetPage));
return;
}
const requestId = radarSearchRequestSeqRef.current + 1;
radarSearchRequestSeqRef.current = requestId;
setIsLoading(true);
try {
await fetchAllData({ offset: nextOffset, requestId });
} finally {
setIsLoading(false);
}
}, [
hasRadarSearched,
addLog,
radarPageInputValidationError,
setRadarPageInputTouched,
radarPageInput,
radarCurrentPage,
radarTotalPages,
radarPagination.limit,
radarPagination.offset,
setRadarPageInput,
radarSearchRequestSeqRef,
setIsLoading,
fetchAllData,
]);
const goToDinsarPage = useCallback(async () => {
if (dinsarPageInputValidationError) {
setDinsarPageInputTouched(true);
return;
}
const requestedPage = Number(dinsarPageInput);
if (!Number.isFinite(requestedPage)) {
setDinsarPageInput(String(dinsarCurrentPage));
return;
}
const targetPage = Math.max(1, Math.min(Math.floor(requestedPage), dinsarTotalPages));
const nextOffset = (targetPage - 1) * dinsarPagination.limit;
if (nextOffset === dinsarPagination.offset) {
setDinsarPageInput(String(targetPage));
return;
}
setIsLoading(true);
try {
await fetchDinsarResults({ offset: nextOffset });
} finally {
setIsLoading(false);
}
}, [
dinsarPageInputValidationError,
setDinsarPageInputTouched,
dinsarPageInput,
dinsarCurrentPage,
dinsarTotalPages,
dinsarPagination.limit,
dinsarPagination.offset,
setDinsarPageInput,
setIsLoading,
fetchDinsarResults,
]);
const changeRadarPage = useCallback(async (direction) => {
if (!hasRadarSearched) {
addLog('warn', '请先执行检索,再进行翻页。');
return;
}
const nextOffset = Math.max(0, radarPagination.offset + direction * radarPagination.limit);
if (nextOffset === radarPagination.offset) return;
if (direction > 0 && !radarPagination.hasMore) return;
const requestId = radarSearchRequestSeqRef.current + 1;
radarSearchRequestSeqRef.current = requestId;
setIsLoading(true);
try {
await fetchAllData({ offset: nextOffset, requestId });
} finally {
setIsLoading(false);
}
}, [hasRadarSearched, addLog, radarPagination, radarSearchRequestSeqRef, setIsLoading, fetchAllData]);
const changeDinsarPage = useCallback(async (direction) => {
const nextOffset = Math.max(0, dinsarPagination.offset + direction * dinsarPagination.limit);
if (nextOffset === dinsarPagination.offset) return;
if (direction > 0 && !dinsarPagination.hasMore) return;
setIsLoading(true);
try {
await fetchDinsarResults({ offset: nextOffset });
} finally {
setIsLoading(false);
}
}, [dinsarPagination, setIsLoading, fetchDinsarResults]);
return {
radarCurrentPage,
radarTotalPages,
dinsarCurrentPage,
dinsarTotalPages,
radarPageInputValidationError,
dinsarPageInputValidationError,
showRadarPageInputError,
showDinsarPageInputError,
handleRadarPageSizeChange,
handleDinsarPageSizeChange,
goToRadarPage,
goToDinsarPage,
changeRadarPage,
changeDinsarPage,
};
}
+287
View File
@@ -0,0 +1,287 @@
/**
* usePairingLogic — pairing and PS stack business logic extracted from App.jsx
*
* Contains: findPairs, handleFindPsStack, createDinsarBatch, createPsBatch,
* focusBatchAfterCreate, clearPsResults
*/
import apiClient from '../api/client';
import {
useUiStore, usePairingStore, useMapStore, useBatchStore, useAuthStore,
} from '../store';
import { getSelectedRegionTreeId } from '../utils/appUiHelpers';
export default function usePairingLogic({
fetchRegionGeometry,
refreshBatchList,
fetchBatchItems,
onClearAoiLayer,
}) {
const { addLog, setIsLoading, setLeftPanelTab } = useUiStore();
const {
pairingParams, pairingAoiMode,
pairingFiles, setPairingFiles,
pairingRegionSelection,
setShowPairingModal, setFoundPairs, setPairingAlert,
psAoiMode, psFiles, setPsFiles, psRegionSelection,
psParams, setShowPsModal, setPsResults,
} = usePairingStore();
const { setAoiLayer } = useMapStore();
const { setBatchTab, setSelectedBatchId, setBatchItems } = useBatchStore();
const { currentUser } = useAuthStore();
const isAdmin = currentUser?.role === 'admin';
const ensureCanOperate = () => {
if (!isAdmin) {
addLog('warn', '当前账号为只读用户,无法执行写操作。');
return false;
}
return true;
};
const focusBatchAfterCreate = async (type, batchId) => {
if (!batchId) return;
setBatchTab(type);
setLeftPanelTab('batches');
setSelectedBatchId(batchId);
setBatchItems([]);
await refreshBatchList();
await fetchBatchItems(type, batchId);
};
const createPsBatch = async (direction, stack, options = {}) => {
if (!ensureCanOperate()) return;
const { focusAfterCreate = true } = options;
try {
const response = await apiClient.post('/task-batches/ps', {
direction,
stack,
name: `PS_${direction}_${new Date().toISOString().slice(0, 10)}`
});
const batchId = response.data?.batch_id || '';
addLog('success', `已创建 PS 批次: ${batchId || direction}`);
if (focusAfterCreate && batchId) {
await focusBatchAfterCreate('ps', batchId);
}
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message || '未知错误';
addLog('error', `PS 批次创建失败: ${errorMessage}`);
}
};
const createDinsarBatch = async () => {
if (!ensureCanOperate()) return;
const foundPairs = usePairingStore.getState().foundPairs;
const selectedPairs = foundPairs.filter(p => p.isSelected);
if (selectedPairs.length === 0) {
addLog('warn', '没有选中的配对可保存。');
return;
}
try {
const response = await apiClient.post('/task-batches/dinsar', {
name: `DINSAR_${new Date().toISOString().slice(0, 10)}`,
pairs: selectedPairs
});
const batchId = response.data?.batch_id || '';
addLog('success', `已创建 D-InSAR 批次: ${batchId || 'OK'}`);
if (batchId) {
await focusBatchAfterCreate('dinsar', batchId);
}
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message || '未知错误';
addLog('error', `D-InSAR 批次创建失败: ${errorMessage}`);
}
};
const clearPsResults = () => {
setPsResults(null);
onClearAoiLayer();
setAoiLayer(null);
addLog('info', 'PS-InSAR 结果已清空。');
};
const findPairs = async (e, externalRequireOrbitRef) => {
e.preventDefault();
if (!ensureCanOperate()) return;
setShowPairingModal(false);
setIsLoading(true);
addLog('info', '开始寻找干涉对...');
setPairingAlert({ warnings: [], fallbackUsed: false });
const formData = new FormData();
for (const key in pairingParams) {
const value = pairingParams[key];
// 跳过 null/undefined 值
if (value === null || value === undefined) continue;
// allowed_satellites 是数组,需要序列化为 JSON
if (key === 'allowed_satellites' && Array.isArray(value)) {
formData.append(key, JSON.stringify(value));
} else {
formData.append(key, value);
}
}
if (externalRequireOrbitRef?.current) {
formData.append('require_orbit_data', externalRequireOrbitRef.current.checked);
}
if (pairingAoiMode === 'shp') {
if (pairingFiles) {
Array.from(pairingFiles).forEach(file => {
formData.append('files', file);
});
}
} else {
const selectedRegionTreeId = getSelectedRegionTreeId(pairingRegionSelection);
if (!selectedRegionTreeId) {
addLog('warn', '请选择行政区后再执行配对。');
setIsLoading(false);
return;
}
try {
const selectedAoiGeoJson = await fetchRegionGeometry(selectedRegionTreeId);
if (!selectedAoiGeoJson) {
addLog('error', '未获取到行政区边界,请检查后端行政区边界数据。');
setIsLoading(false);
return;
}
formData.append('aoi_geojson', JSON.stringify(selectedAoiGeoJson));
setAoiLayer(selectedAoiGeoJson);
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message || '行政区边界加载失败';
addLog('error', `加载行政区边界失败: ${errorMessage}`);
setIsLoading(false);
return;
}
}
try {
const response = await apiClient.post('/find-pairs', formData);
const { pairs, aoi_geojson } = response.data;
const warnings = Array.isArray(response.data?.warnings) ? response.data.warnings : [];
const fallbackUsed = Boolean(response.data?.fallback_used ?? response.data?.fallbackUsed);
const degraded = Boolean(response.data?.degraded);
const networkRunId = response.data?.network_run_id || response.data?.networkRunId;
const policyVersion = response.data?.policy_version || response.data?.policyVersion;
const candidateCount = Number(response.data?.candidate_count ?? response.data?.candidateCount ?? pairs.length ?? 0);
const selectedEdgeCount = Number(response.data?.selected_edge_count ?? response.data?.selectedEdgeCount ?? pairs.length ?? 0);
setFoundPairs(pairs.map(p => ({ ...p, isSelected: true, isVis: false })));
if (aoi_geojson) {
setAoiLayer(aoi_geojson);
}
setPairingAlert({ warnings, fallbackUsed });
if (warnings.length > 0) {
warnings.forEach(msg => addLog('warn', msg));
}
if (fallbackUsed && warnings.length === 0) {
addLog('warn', '配对进入回退路径,请检查数据库函数或收紧筛选条件。');
}
if (networkRunId) {
addLog('info', `配对网络已生成: ${networkRunId} (${policyVersion || 'unknown policy'})`);
}
if (degraded) {
addLog('warn', '当前配对结果来自降级缓存状态,建议尽快执行缓存修复。');
}
addLog('success', `成功找到 ${pairs.length} 个干涉对(候选 ${candidateCount},入选 ${selectedEdgeCount})。`);
setLeftPanelTab('pairs');
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message;
addLog('error', `寻找干涉对失败: ${errorMessage}`);
} finally {
setIsLoading(false);
setPairingFiles(null);
}
};
const handleFindPsStack = async (e) => {
e.preventDefault();
if (!ensureCanOperate()) return;
if (psAoiMode === 'shp') {
if (!psFiles || psFiles.length === 0) {
addLog('warn', '请先选择有效的Shapefile文件。');
return;
}
} else {
const selectedRegionTreeId = getSelectedRegionTreeId(psRegionSelection);
if (!selectedRegionTreeId) {
addLog('warn', '请先选择行政区。');
return;
}
}
setShowPsModal(false);
setIsLoading(true);
addLog('info', '开始准备PS时序数据栈...');
const formData = new FormData();
for (const key in psParams) {
formData.append(key, psParams[key]);
}
if (psAoiMode === 'shp') {
Array.from(psFiles).forEach(file => {
formData.append('files', file);
});
} else {
const selectedRegionTreeId = getSelectedRegionTreeId(psRegionSelection);
try {
const selectedAoiGeoJson = await fetchRegionGeometry(selectedRegionTreeId);
if (!selectedAoiGeoJson) {
addLog('error', '未获取到行政区边界,请检查后端行政区边界数据。');
setIsLoading(false);
return;
}
formData.append('aoi_geojson', JSON.stringify(selectedAoiGeoJson));
setAoiLayer(selectedAoiGeoJson);
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message || '行政区边界加载失败';
addLog('error', `加载行政区边界失败: ${errorMessage}`);
setIsLoading(false);
return;
}
}
try {
const response = await apiClient.post('/find-ps-timeseries', formData);
const results = response.data;
const processedResults = {};
for (const [direction, stack] of Object.entries(results)) {
const nameCounts = {};
processedResults[direction] = stack.map(item => {
const baseName = `${item.satellite}_${item.imaging_mode}_${item.imaging_date}`;
nameCounts[baseName] = (nameCounts[baseName] || 0) + 1;
const count = nameCounts[baseName];
const displayName = count > 1 ? `${baseName}_${count - 1}` : baseName;
return { ...item, displayName };
});
}
setPsResults(processedResults);
if (Object.keys(processedResults).length > 0) {
addLog('success', `成功找到 ${Object.keys(processedResults).length} 个PS时序栈。`);
setLeftPanelTab('ps_results');
for (const [direction, stack] of Object.entries(processedResults)) {
await createPsBatch(direction, stack, { focusAfterCreate: false });
}
} else {
addLog('info', '在给定的AOI和阈值下,未找到合适的时序影像栈。');
setLeftPanelTab('ps_results');
}
} catch (error) {
console.error("PS时序准备失败:", error);
const errorMessage = error.response?.data?.detail || error.message || '未知错误';
addLog('error', `PS时序准备失败: ${errorMessage}`);
} finally {
setIsLoading(false);
setPsFiles(null);
}
};
return {
findPairs,
handleFindPsStack,
createDinsarBatch,
createPsBatch,
clearPsResults,
};
}
+55
View File
@@ -0,0 +1,55 @@
import { useCallback, useEffect } from 'react';
import { clamp } from '../utils/appUiHelpers';
export default function usePanelResize({
isResizing,
setIsResizing,
leftPanelWidth,
rightPanelWidth,
setLeftPanelWidth,
setRightPanelWidth,
resizeStateRef,
}) {
const startResize = useCallback((side, event) => {
event.preventDefault();
resizeStateRef.current = {
side,
startX: event.clientX,
startLeft: leftPanelWidth,
startRight: rightPanelWidth,
};
setIsResizing(true);
}, [resizeStateRef, leftPanelWidth, rightPanelWidth, setIsResizing]);
useEffect(() => {
if (!isResizing) {
return;
}
const handleMove = (event) => {
const { side, startX, startLeft, startRight } = resizeStateRef.current;
const delta = event.clientX - startX;
if (side === 'left') {
setLeftPanelWidth(clamp(startLeft + delta, 320, 620));
} else if (side === 'right') {
setRightPanelWidth(clamp(startRight - delta, 280, 560));
}
};
const handleUp = () => {
setIsResizing(false);
};
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', handleUp);
return () => {
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', handleUp);
};
}, [isResizing, resizeStateRef, setLeftPanelWidth, setRightPanelWidth, setIsResizing]);
return {
startResize,
};
}
+453
View File
@@ -0,0 +1,453 @@
/**
* useRadarSearch — radar data search logic extracted from App.jsx
*
* Contains: fetchRadarImagingDates, fetchRadarSearchOptions, fetchAllData,
* applyRadarSearch, resetRadarSearch, searchAllRadarData,
* refreshCurrentRadarSearch, processAndSetAllData
*/
import { useCallback } from 'react';
import apiClient from '../api/client';
import { useRadarStore } from '../store';
import {
DEFAULT_LIST_PAGE_SIZE,
RADAR_SEARCH_DEFAULTS,
RADAR_SEARCH_OPTIONS_DEFAULTS,
BATCH_API_PAGE_LIMIT,
BATCH_API_MAX_PAGES,
SATELLITE_GROUPS,
} from '../config/appConstants';
import {
normalizePreviewStatus,
buildRadarSearchFormData,
normalizeRadarSearchCriteria,
getSelectedRegionTreeId,
} from '../utils/appUiHelpers';
import { normalizePagePayload } from '../utils/appHelpers';
export default function useRadarSearch({
addLog,
setIsLoading,
setAllData,
setRadarImagingDates,
setRadarSearchOptions,
setRadarSearchOptionsLoading,
radarPagination,
setRadarPagination,
radarSearchDraft,
setRadarSearchDraft,
radarSearchApplied,
setRadarSearchApplied,
radarSearchAoiMode,
setRadarSearchAoiMode,
setRadarSearchAppliedAoiMode,
radarSearchAppliedAoiMode,
radarSearchFiles,
setRadarSearchFiles,
setRadarSearchRegionOptions,
radarSearchRegionSelection,
setRadarSearchRegionSelection,
radarSearchAppliedRegionTreeId,
setRadarSearchAppliedRegionTreeId,
setRadarSearchRegionError,
radarSearchAoiToken,
setRadarSearchAoiToken,
hasRadarSearched,
setHasRadarSearched,
selectedSatelliteGroup,
setSelectedSatelliteGroup,
radarSearchRequestSeqRef,
clearRadarSearchResults,
clearRadarMapLayers,
}) {
const fetchRadarImagingDates = useCallback(async () => {
try {
const response = await apiClient.get('/radar-data/imaging-dates');
const dates = Array.isArray(response?.data?.dates) ? response.data.dates : [];
setRadarImagingDates(dates);
} catch (error) {
console.error("获取成像日期列表失败:", error);
setRadarImagingDates([]);
}
}, [setRadarImagingDates]);
const fetchRadarSearchOptions = useCallback(async (satelliteFilter) => {
try {
setRadarSearchOptionsLoading(true);
const params = {};
if (Array.isArray(satelliteFilter) && satelliteFilter.length > 0) {
params.satellite = satelliteFilter;
}
const response = await apiClient.get('/radar-data/search/options', { params });
const payload = response?.data && typeof response.data === 'object' ? response.data : {};
setRadarSearchOptions({
satellite: Array.isArray(payload.satellite) ? payload.satellite : [],
satellite_mode: Array.isArray(payload.satellite_mode) ? payload.satellite_mode : [],
receiving_station: Array.isArray(payload.receiving_station) ? payload.receiving_station : [],
imaging_mode: Array.isArray(payload.imaging_mode) ? payload.imaging_mode : [],
orbit_circle: Array.isArray(payload.orbit_circle) ? payload.orbit_circle : [],
acquisition_time_utc: Array.isArray(payload.acquisition_time_utc) ? payload.acquisition_time_utc : [],
product_type: Array.isArray(payload.product_type) ? payload.product_type : [],
polarization: Array.isArray(payload.polarization) ? payload.polarization : [],
product_level: Array.isArray(payload.product_level) ? payload.product_level : [],
product_unique_id: Array.isArray(payload.product_unique_id) ? payload.product_unique_id : [],
orbit_direction: Array.isArray(payload.orbit_direction) ? payload.orbit_direction : [],
imaging_dates: Array.isArray(payload.imaging_dates) ? payload.imaging_dates : [],
});
} catch (error) {
console.error('获取源数据检索选项失败:', error);
setRadarSearchOptions(RADAR_SEARCH_OPTIONS_DEFAULTS);
} finally {
setRadarSearchOptionsLoading(false);
}
}, [setRadarSearchOptions, setRadarSearchOptionsLoading]);
const changeSatelliteGroup = useCallback((groupKey) => {
setSelectedSatelliteGroup(groupKey);
// Clear sub-filters that may be invalid for the new satellite group
setRadarSearchDraft((prev) => ({
...prev,
satellite: '',
imaging_mode: '',
polarization: '',
satellite_mode: '',
receiving_station: '',
orbit_circle: '',
acquisition_time_utc: '',
product_type: '',
product_level: '',
product_unique_id: '',
orbit_direction: '',
}));
if (groupKey === 'all') {
fetchRadarSearchOptions();
} else {
const group = SATELLITE_GROUPS.find((g) => g.key === groupKey);
if (!group) return;
const allSatellites = useRadarStore.getState().radarSearchOptions.satellite;
const matched = allSatellites.filter((sat) =>
group.prefixes.some((prefix) => sat.startsWith(prefix))
);
if (matched.length > 0) {
fetchRadarSearchOptions(matched);
}
}
}, [setSelectedSatelliteGroup, setRadarSearchDraft, fetchRadarSearchOptions]);
const processAndSetAllData = useCallback((data) => {
const nameCounts = {};
const dataWithDisplayNames = data.map(item => {
const baseName = `${item.satellite}_${item.imaging_mode}_${item.imaging_date}`;
nameCounts[baseName] = (nameCounts[baseName] || 0) + 1;
const count = nameCounts[baseName];
const displayName = count > 1 ? `${baseName}_${count - 1}` : baseName;
const previewStatus = normalizePreviewStatus(item.preview_cache_status);
return {
...item,
isVisible: false,
isPreviewVisible: false,
displayName,
previewStatus,
previewFallbackInUse: false,
previewHasGeoCache: previewStatus === 'READY',
previewHasRawCache: false,
previewSourceFound: false,
previewMessage: '',
previewError: item.preview_cache_error || '',
previewCacheKey: item.preview_cache_updated_at || `${previewStatus}-${item.id}`,
};
});
setAllData(dataWithDisplayNames);
}, [setAllData]);
const fetchAllData = useCallback(async (options = {}) => {
const requestedLimit = Math.max(
1,
Math.min(
Number(options.limit ?? radarPagination.limit ?? DEFAULT_LIST_PAGE_SIZE) || DEFAULT_LIST_PAGE_SIZE,
2000
)
);
const requestedOffset = Math.max(
0,
Number(options.offset ?? radarPagination.offset ?? 0) || 0
);
const requestId = Number.isFinite(Number(options.requestId))
? Number(options.requestId)
: (radarSearchRequestSeqRef.current + 1);
if (!Number.isFinite(Number(options.requestId))) {
radarSearchRequestSeqRef.current = requestId;
}
const isStaleRequest = () => requestId !== radarSearchRequestSeqRef.current;
const effectiveCriteria = options.criteria ?? radarSearchApplied;
const effectiveAoiMode = options.aoiMode ?? radarSearchAppliedAoiMode;
const effectiveRegionTreeId = options.regionTreeId ?? radarSearchAppliedRegionTreeId;
const effectiveFiles = options.files ?? null;
const effectiveAoiToken = options.aoiToken ?? radarSearchAoiToken;
addLog('info', `正在从后端获取源数据(offset=${requestedOffset}, limit=${requestedLimit}...`);
try {
const formData = buildRadarSearchFormData({
limit: requestedLimit,
offset: requestedOffset,
criteria: effectiveCriteria,
aoiMode: effectiveAoiMode,
regionTreeId: effectiveRegionTreeId,
aoiToken: effectiveAoiToken,
files: effectiveFiles,
});
const response = await apiClient.post('/radar-data/search', formData);
if (isStaleRequest()) {
return false;
}
const pagePayload = normalizePagePayload(response.data, requestedLimit, requestedOffset);
if (
pagePayload.items.length === 0 &&
pagePayload.total > 0 &&
requestedOffset >= pagePayload.total &&
requestedOffset > 0
) {
const fallbackOffset = Math.max(0, requestedOffset - requestedLimit);
await fetchAllData({
limit: requestedLimit,
offset: fallbackOffset,
criteria: effectiveCriteria,
aoiMode: effectiveAoiMode,
regionTreeId: effectiveRegionTreeId,
aoiToken: effectiveAoiToken,
files: effectiveFiles,
requestId,
});
return true;
}
const returnedAoiToken = typeof response?.data?.aoi_token === 'string'
? response.data.aoi_token
: '';
if (isStaleRequest()) {
return false;
}
if (effectiveAoiMode === 'none') {
setRadarSearchAoiToken('');
} else if (returnedAoiToken) {
setRadarSearchAoiToken(returnedAoiToken);
} else if (!effectiveFiles) {
setRadarSearchAoiToken(effectiveAoiToken || '');
}
setRadarPagination({
total: pagePayload.total,
limit: pagePayload.limit,
offset: pagePayload.offset,
hasMore: pagePayload.hasMore,
});
clearRadarMapLayers();
processAndSetAllData(pagePayload.items);
const currentPage = Math.floor(pagePayload.offset / pagePayload.limit) + 1;
const totalPages = Math.max(1, Math.ceil(pagePayload.total / pagePayload.limit));
addLog('success', `源数据已加载:第 ${currentPage}/${totalPages} 页,当前页 ${pagePayload.items.length} 条,总计 ${pagePayload.total} 条。`);
return true;
} catch (error) {
if (isStaleRequest()) {
return false;
}
console.error("获取全部数据失败:", error);
addLog('error', '无法连接到后端或获取数据失败。');
return false;
}
}, [
radarPagination.limit, radarPagination.offset,
radarSearchApplied, radarSearchAppliedAoiMode,
radarSearchAppliedRegionTreeId, radarSearchAoiToken,
radarSearchRequestSeqRef,
addLog, setRadarSearchAoiToken, setRadarPagination,
clearRadarMapLayers, processAndSetAllData,
]);
const applyRadarSearch = useCallback(async () => {
const draftWithSatelliteGroup = { ...radarSearchDraft };
if (selectedSatelliteGroup && selectedSatelliteGroup !== 'all') {
const group = SATELLITE_GROUPS.find((g) => g.key === selectedSatelliteGroup);
if (group) {
const allSatellites = useRadarStore.getState().radarSearchOptions.satellite;
const matched = allSatellites.filter((sat) =>
group.prefixes.some((prefix) => sat.startsWith(prefix))
);
if (matched.length > 0) {
draftWithSatelliteGroup.satellite = matched.join(',');
}
}
}
const normalizedCriteria = normalizeRadarSearchCriteria(draftWithSatelliteGroup, RADAR_SEARCH_DEFAULTS);
const selectedRegionTreeId = getSelectedRegionTreeId(radarSearchRegionSelection);
const hasUploadedFiles = !!(radarSearchFiles && radarSearchFiles.length > 0);
const requestAoiToken = radarSearchAoiMode === 'shp' && !hasUploadedFiles
? radarSearchAoiToken
: '';
if (radarSearchAoiMode === 'region' && !selectedRegionTreeId) {
addLog('warn', '请先选择行政区。');
return;
}
if (radarSearchAoiMode === 'shp' && !hasUploadedFiles && !radarSearchAoiToken) {
addLog('warn', '请先选择包含 .shp 的 AOI 文件。');
return;
}
setRadarSearchApplied(normalizedCriteria);
setRadarSearchAppliedAoiMode(radarSearchAoiMode);
setRadarSearchAppliedRegionTreeId(radarSearchAoiMode === 'region' ? selectedRegionTreeId : '');
if (radarSearchAoiMode !== 'shp') {
setRadarSearchAoiToken('');
} else if (hasUploadedFiles) {
setRadarSearchAoiToken('');
}
const requestId = radarSearchRequestSeqRef.current + 1;
radarSearchRequestSeqRef.current = requestId;
setHasRadarSearched(true);
setIsLoading(true);
addLog('info', '开始检索源数据...');
clearRadarSearchResults({ limit: radarPagination.limit });
try {
await fetchAllData({
limit: radarPagination.limit,
offset: 0,
criteria: normalizedCriteria,
aoiMode: radarSearchAoiMode,
regionTreeId: radarSearchAoiMode === 'region' ? selectedRegionTreeId : '',
files: hasUploadedFiles ? radarSearchFiles : null,
aoiToken: requestAoiToken,
requestId,
});
} finally {
setIsLoading(false);
if (hasUploadedFiles) {
setRadarSearchFiles(null);
}
}
}, [
radarSearchDraft, radarSearchRegionSelection, radarSearchFiles,
radarSearchAoiMode, radarSearchAoiToken, radarPagination.limit,
selectedSatelliteGroup, radarSearchRequestSeqRef,
addLog, setIsLoading, setRadarSearchApplied, setRadarSearchAppliedAoiMode,
setRadarSearchAppliedRegionTreeId, setRadarSearchAoiToken,
setHasRadarSearched, setRadarSearchFiles,
clearRadarSearchResults, fetchAllData,
]);
const resetRadarSearch = useCallback(() => {
setRadarSearchDraft(RADAR_SEARCH_DEFAULTS);
setRadarSearchApplied(RADAR_SEARCH_DEFAULTS);
setRadarSearchAoiMode('none');
setRadarSearchAppliedAoiMode('none');
setRadarSearchFiles(null);
setRadarSearchRegionOptions({ provinces: [], cities: [] });
setRadarSearchRegionSelection({ province: '', city: '' });
setRadarSearchAppliedRegionTreeId('');
setRadarSearchRegionError('');
setRadarSearchAoiToken('');
setSelectedSatelliteGroup('all');
radarSearchRequestSeqRef.current += 1;
setHasRadarSearched(false);
clearRadarSearchResults({ limit: radarPagination.limit });
fetchRadarSearchOptions();
addLog('info', '已清除检索条件,请点击"搜索"或"搜索全部"获取数据。');
}, [
radarPagination.limit, radarSearchRequestSeqRef,
addLog, setRadarSearchDraft, setRadarSearchApplied,
setRadarSearchAoiMode, setRadarSearchAppliedAoiMode,
setRadarSearchFiles, setRadarSearchRegionOptions,
setRadarSearchRegionSelection, setRadarSearchAppliedRegionTreeId,
setRadarSearchRegionError, setRadarSearchAoiToken,
setSelectedSatelliteGroup, setHasRadarSearched,
clearRadarSearchResults, fetchRadarSearchOptions,
]);
const searchAllRadarData = useCallback(async () => {
const requestId = radarSearchRequestSeqRef.current + 1;
radarSearchRequestSeqRef.current = requestId;
setRadarSearchDraft(RADAR_SEARCH_DEFAULTS);
setRadarSearchApplied(RADAR_SEARCH_DEFAULTS);
setRadarSearchAoiMode('none');
setRadarSearchAppliedAoiMode('none');
setRadarSearchFiles(null);
setRadarSearchRegionOptions({ provinces: [], cities: [] });
setRadarSearchRegionSelection({ province: '', city: '' });
setRadarSearchAppliedRegionTreeId('');
setRadarSearchRegionError('');
setRadarSearchAoiToken('');
setSelectedSatelliteGroup('all');
fetchRadarSearchOptions();
setHasRadarSearched(true);
setIsLoading(true);
addLog('info', '开始执行无条件检索(搜索全部源数据)...');
clearRadarSearchResults({ limit: radarPagination.limit });
try {
await fetchAllData({
limit: radarPagination.limit,
offset: 0,
criteria: RADAR_SEARCH_DEFAULTS,
aoiMode: 'none',
regionTreeId: '',
files: null,
aoiToken: '',
requestId,
});
} finally {
setIsLoading(false);
}
}, [
radarPagination.limit, radarSearchRequestSeqRef,
addLog, setIsLoading, setRadarSearchDraft, setRadarSearchApplied,
setRadarSearchAoiMode, setRadarSearchAppliedAoiMode,
setRadarSearchFiles, setRadarSearchRegionOptions,
setRadarSearchRegionSelection, setRadarSearchAppliedRegionTreeId,
setRadarSearchRegionError, setRadarSearchAoiToken,
setSelectedSatelliteGroup, setHasRadarSearched,
clearRadarSearchResults, fetchAllData, fetchRadarSearchOptions,
]);
const refreshCurrentRadarSearch = useCallback(async () => {
if (!hasRadarSearched) {
addLog('warn', '请先执行一次源数据检索。');
return;
}
const requestId = radarSearchRequestSeqRef.current + 1;
radarSearchRequestSeqRef.current = requestId;
setIsLoading(true);
addLog('info', '正在刷新当前源数据检索结果...');
try {
await fetchAllData({
limit: radarPagination.limit,
offset: radarPagination.offset,
criteria: radarSearchApplied,
aoiMode: radarSearchAppliedAoiMode,
regionTreeId: radarSearchAppliedRegionTreeId,
files: null,
aoiToken: radarSearchAoiToken,
requestId,
});
} finally {
setIsLoading(false);
}
}, [
hasRadarSearched, radarPagination.limit, radarPagination.offset,
radarSearchApplied, radarSearchAppliedAoiMode,
radarSearchAppliedRegionTreeId, radarSearchAoiToken,
radarSearchRequestSeqRef,
addLog, setIsLoading, fetchAllData,
]);
return {
fetchRadarImagingDates,
fetchRadarSearchOptions,
fetchAllData,
applyRadarSearch,
resetRadarSearch,
searchAllRadarData,
refreshCurrentRadarSearch,
processAndSetAllData,
changeSatelliteGroup,
};
}
+388
View File
@@ -0,0 +1,388 @@
import { useCallback } from 'react';
import L from 'leaflet';
import apiClient from '../api/client';
import { getRegionDisplayName, getSelectedRegionTreeId } from '../utils/appUiHelpers';
export default function useRegionAoiHandlers({
setPairingRegionLoading,
setPairingRegionError,
setPairingRegionOptions,
setPsRegionLoading,
setPsRegionError,
setPsRegionOptions,
setPairingAoiMode,
pairingRegionOptions,
setPsAoiMode,
psRegionOptions,
setPairingRegionSelection,
setPsRegionSelection,
setMapRegionLoading,
setMapRegionError,
setMapRegionOptions,
showMapRegionLocator,
setShowMapRegionLocator,
mapRegionOptions,
setMapRegionSelection,
setMapRegionLocatedName,
setMapRegionLocating,
setRadarSearchRegionLoading,
setRadarSearchRegionError,
setRadarSearchRegionOptions,
setRadarSearchAoiMode,
setRadarSearchFiles,
setRadarSearchRegionSelection,
radarSearchRegionOptions,
setRadarSearchDraft,
mapRef,
mapRegionLayerRef,
mapRegionSelection,
addLog,
pairingAoiMode,
setShowPairingModal,
psAoiMode,
setShowPsModal,
}) {
const fetchRegionChildren = useCallback(async (parentTreeId = '1') => {
const response = await apiClient.get('/aoi/regions/children', {
params: { parent_tree_id: parentTreeId },
});
return response.data?.children || [];
}, []);
const fetchRegionGeometry = useCallback(async (treeId) => {
const response = await apiClient.get(`/aoi/regions/${treeId}/geometry`);
return response.data?.aoi_geojson || null;
}, []);
const loadPairingProvinces = useCallback(async () => {
setPairingRegionLoading(true);
setPairingRegionError('');
try {
const provinces = await fetchRegionChildren('1');
setPairingRegionOptions({ provinces, cities: [] });
} catch (error) {
setPairingRegionError(error.response?.data?.detail || error.message || '行政区加载失败');
setPairingRegionOptions({ provinces: [], cities: [] });
} finally {
setPairingRegionLoading(false);
}
}, [fetchRegionChildren, setPairingRegionLoading, setPairingRegionError, setPairingRegionOptions]);
const loadPsProvinces = useCallback(async () => {
setPsRegionLoading(true);
setPsRegionError('');
try {
const provinces = await fetchRegionChildren('1');
setPsRegionOptions({ provinces, cities: [] });
} catch (error) {
setPsRegionError(error.response?.data?.detail || error.message || '行政区加载失败');
setPsRegionOptions({ provinces: [], cities: [] });
} finally {
setPsRegionLoading(false);
}
}, [fetchRegionChildren, setPsRegionLoading, setPsRegionError, setPsRegionOptions]);
const handlePairingAoiModeChange = useCallback(async (nextMode) => {
setPairingAoiMode(nextMode);
if (nextMode === 'region' && pairingRegionOptions.provinces.length === 0) {
await loadPairingProvinces();
}
}, [setPairingAoiMode, pairingRegionOptions.provinces.length, loadPairingProvinces]);
const handlePsAoiModeChange = useCallback(async (nextMode) => {
setPsAoiMode(nextMode);
if (nextMode === 'region' && psRegionOptions.provinces.length === 0) {
await loadPsProvinces();
}
}, [setPsAoiMode, psRegionOptions.provinces.length, loadPsProvinces]);
const handlePairingProvinceChange = useCallback(async (provinceId) => {
setPairingRegionSelection({ province: provinceId, city: '' });
setPairingRegionOptions((prev) => ({ ...prev, cities: [] }));
if (!provinceId) return;
setPairingRegionLoading(true);
setPairingRegionError('');
try {
const cities = await fetchRegionChildren(provinceId);
setPairingRegionOptions((prev) => ({ ...prev, cities }));
} catch (error) {
setPairingRegionError(error.response?.data?.detail || error.message || '地市加载失败');
} finally {
setPairingRegionLoading(false);
}
}, [
fetchRegionChildren,
setPairingRegionSelection,
setPairingRegionOptions,
setPairingRegionLoading,
setPairingRegionError,
]);
const handlePairingCityChange = useCallback((cityId) => {
setPairingRegionSelection((prev) => ({ ...prev, city: cityId }));
}, [setPairingRegionSelection]);
const handlePsProvinceChange = useCallback(async (provinceId) => {
setPsRegionSelection({ province: provinceId, city: '' });
setPsRegionOptions((prev) => ({ ...prev, cities: [] }));
if (!provinceId) return;
setPsRegionLoading(true);
setPsRegionError('');
try {
const cities = await fetchRegionChildren(provinceId);
setPsRegionOptions((prev) => ({ ...prev, cities }));
} catch (error) {
setPsRegionError(error.response?.data?.detail || error.message || '地市加载失败');
} finally {
setPsRegionLoading(false);
}
}, [
fetchRegionChildren,
setPsRegionSelection,
setPsRegionOptions,
setPsRegionLoading,
setPsRegionError,
]);
const handlePsCityChange = useCallback((cityId) => {
setPsRegionSelection((prev) => ({ ...prev, city: cityId }));
}, [setPsRegionSelection]);
const loadMapRegionProvinces = useCallback(async () => {
setMapRegionLoading(true);
setMapRegionError('');
try {
const provinces = await fetchRegionChildren('1');
setMapRegionOptions({ provinces, cities: [] });
} catch (error) {
setMapRegionError(error.response?.data?.detail || error.message || '行政区加载失败');
setMapRegionOptions({ provinces: [], cities: [] });
} finally {
setMapRegionLoading(false);
}
}, [fetchRegionChildren, setMapRegionLoading, setMapRegionError, setMapRegionOptions]);
const toggleMapRegionLocator = useCallback(async () => {
const nextVisible = !showMapRegionLocator;
setShowMapRegionLocator(nextVisible);
setMapRegionError('');
if (nextVisible && mapRegionOptions.provinces.length === 0) {
await loadMapRegionProvinces();
}
}, [
showMapRegionLocator,
setShowMapRegionLocator,
setMapRegionError,
mapRegionOptions.provinces.length,
loadMapRegionProvinces,
]);
const handleMapRegionProvinceChange = useCallback(async (provinceId) => {
setMapRegionSelection({ province: provinceId, city: '' });
setMapRegionOptions((prev) => ({ ...prev, cities: [] }));
setMapRegionLocatedName('');
if (!provinceId) return;
setMapRegionLoading(true);
setMapRegionError('');
try {
const cities = await fetchRegionChildren(provinceId);
setMapRegionOptions((prev) => ({ ...prev, cities }));
} catch (error) {
setMapRegionError(error.response?.data?.detail || error.message || '地市加载失败');
} finally {
setMapRegionLoading(false);
}
}, [
fetchRegionChildren,
setMapRegionSelection,
setMapRegionOptions,
setMapRegionLocatedName,
setMapRegionLoading,
setMapRegionError,
]);
const handleMapRegionCityChange = useCallback((cityId) => {
setMapRegionSelection((prev) => ({ ...prev, city: cityId }));
setMapRegionLocatedName('');
}, [setMapRegionSelection, setMapRegionLocatedName]);
const loadRadarSearchProvinces = useCallback(async () => {
setRadarSearchRegionLoading(true);
setRadarSearchRegionError('');
try {
const provinces = await fetchRegionChildren('1');
setRadarSearchRegionOptions({ provinces, cities: [] });
} catch (error) {
setRadarSearchRegionError(error.response?.data?.detail || error.message || '行政区加载失败');
setRadarSearchRegionOptions({ provinces: [], cities: [] });
} finally {
setRadarSearchRegionLoading(false);
}
}, [
fetchRegionChildren,
setRadarSearchRegionLoading,
setRadarSearchRegionError,
setRadarSearchRegionOptions,
]);
const handleRadarSearchAoiModeChange = useCallback(async (nextMode) => {
setRadarSearchAoiMode(nextMode);
setRadarSearchRegionError('');
if (nextMode !== 'shp') {
setRadarSearchFiles(null);
}
if (nextMode !== 'region') {
setRadarSearchRegionSelection({ province: '', city: '' });
setRadarSearchRegionOptions({ provinces: [], cities: [] });
} else if (radarSearchRegionOptions.provinces.length === 0) {
await loadRadarSearchProvinces();
}
}, [
setRadarSearchAoiMode,
setRadarSearchRegionError,
setRadarSearchFiles,
setRadarSearchRegionSelection,
setRadarSearchRegionOptions,
radarSearchRegionOptions.provinces.length,
loadRadarSearchProvinces,
]);
const handleRadarSearchProvinceChange = useCallback(async (provinceId) => {
setRadarSearchRegionSelection({ province: provinceId, city: '' });
setRadarSearchRegionOptions((prev) => ({ ...prev, cities: [] }));
if (!provinceId) return;
setRadarSearchRegionLoading(true);
setRadarSearchRegionError('');
try {
const cities = await fetchRegionChildren(provinceId);
setRadarSearchRegionOptions((prev) => ({ ...prev, cities }));
} catch (error) {
setRadarSearchRegionError(error.response?.data?.detail || error.message || '地市加载失败');
} finally {
setRadarSearchRegionLoading(false);
}
}, [
fetchRegionChildren,
setRadarSearchRegionSelection,
setRadarSearchRegionOptions,
setRadarSearchRegionLoading,
setRadarSearchRegionError,
]);
const handleRadarSearchCityChange = useCallback((cityId) => {
setRadarSearchRegionSelection((prev) => ({ ...prev, city: cityId }));
}, [setRadarSearchRegionSelection]);
const updateRadarSearchDraft = useCallback((field, value) => {
setRadarSearchDraft((prev) => ({ ...prev, [field]: value }));
}, [setRadarSearchDraft]);
const locateSelectedRegionOnMap = useCallback(async () => {
const selectedRegionTreeId = getSelectedRegionTreeId(mapRegionSelection);
if (!selectedRegionTreeId) {
setMapRegionError('请先选择要定位的行政区。');
return;
}
setMapRegionLocating(true);
setMapRegionError('');
try {
if (!mapRef.current) {
throw new Error('地图未初始化');
}
const selectedAoiGeoJson = await fetchRegionGeometry(selectedRegionTreeId);
if (!selectedAoiGeoJson) {
throw new Error('未获取到行政区边界,请检查后端行政区边界数据。');
}
if (mapRegionLayerRef.current) {
mapRegionLayerRef.current.remove();
mapRegionLayerRef.current = null;
}
const regionLayer = L.geoJSON(selectedAoiGeoJson, {
style: {
color: '#2563eb',
weight: 2,
opacity: 0.95,
fillColor: '#60a5fa',
fillOpacity: 0.08,
},
}).addTo(mapRef.current);
mapRegionLayerRef.current = regionLayer;
const bounds = regionLayer.getBounds();
if (bounds?.isValid()) {
mapRef.current.fitBounds(bounds, { padding: [40, 40], maxZoom: 11 });
}
const selectedRegionName = getRegionDisplayName(mapRegionSelection, mapRegionOptions) || selectedRegionTreeId;
setMapRegionLocatedName(selectedRegionName);
addLog('info', `地图区域定位成功: ${selectedRegionName}`);
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message || '地图定位失败';
setMapRegionError(errorMessage);
addLog('error', `地图定位失败: ${errorMessage}`);
} finally {
setMapRegionLocating(false);
}
}, [
mapRegionSelection,
setMapRegionError,
setMapRegionLocating,
fetchRegionGeometry,
mapRef,
mapRegionLayerRef,
mapRegionOptions,
setMapRegionLocatedName,
addLog,
]);
const clearMapRegionHighlight = useCallback(() => {
if (mapRegionLayerRef.current) {
mapRegionLayerRef.current.remove();
mapRegionLayerRef.current = null;
}
setMapRegionLocatedName('');
setMapRegionError('');
addLog('info', '已清除地图定位高亮。');
}, [mapRegionLayerRef, setMapRegionLocatedName, setMapRegionError, addLog]);
const openPairingModal = useCallback(async () => {
setShowPairingModal(true);
if (pairingAoiMode === 'region' && pairingRegionOptions.provinces.length === 0) {
await loadPairingProvinces();
}
}, [setShowPairingModal, pairingAoiMode, pairingRegionOptions.provinces.length, loadPairingProvinces]);
const openPsModal = useCallback(async () => {
setShowPsModal(true);
if (psAoiMode === 'region' && psRegionOptions.provinces.length === 0) {
await loadPsProvinces();
}
}, [setShowPsModal, psAoiMode, psRegionOptions.provinces.length, loadPsProvinces]);
return {
fetchRegionGeometry,
handlePairingAoiModeChange,
handlePsAoiModeChange,
handlePairingProvinceChange,
handlePairingCityChange,
handlePsProvinceChange,
handlePsCityChange,
toggleMapRegionLocator,
handleMapRegionProvinceChange,
handleMapRegionCityChange,
handleRadarSearchAoiModeChange,
handleRadarSearchProvinceChange,
handleRadarSearchCityChange,
updateRadarSearchDraft,
locateSelectedRegionOnMap,
clearMapRegionHighlight,
openPairingModal,
openPsModal,
};
}