chore: initialize insar management system v2
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user