diff --git a/backend/app/routers/dinsar.py b/backend/app/routers/dinsar.py index 5ab4a34..1e6cfa8 100644 --- a/backend/app/routers/dinsar.py +++ b/backend/app/routers/dinsar.py @@ -371,7 +371,7 @@ async def export_dinsar_results_endpoint( if not records: raise HTTPException(status_code=404, detail="未找到任何匹配的结果记录") - file_paths = [] + export_items = [] for record in records: compat_row = record.compat_row file_path = ( @@ -380,15 +380,23 @@ async def export_dinsar_results_endpoint( or (str(compat_row.file_path).strip() if compat_row is not None and compat_row.file_path else "") ) if file_path: - file_paths.append(file_path) + export_items.append( + { + "source": file_path, + "task_alias": str(record.product.task_alias or "").strip(), + "task_name": str(record.product.task_name or "").strip(), + "display_name": str(record.display_name or "").strip(), + "product_id": str(record.product.product_id or "").strip(), + } + ) - if not file_paths: + if not export_items: raise HTTPException(status_code=400, detail="选中的结果没有关联的文件路径") try: import asyncio export_result = await asyncio.to_thread( - export_dinsar_results, file_paths, request.target_dir + export_dinsar_results, export_items, request.target_dir ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/app/services/dinsar_export.py b/backend/app/services/dinsar_export.py index e387188..c9b9c88 100644 --- a/backend/app/services/dinsar_export.py +++ b/backend/app/services/dinsar_export.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging import os +import re import shutil from typing import Any, Dict, List @@ -10,9 +11,34 @@ logger = logging.getLogger(__name__) # 允许的文件扩展名(主文件 + 附属文件) _ASSOCIATED_EXTENSIONS = ["", ".hdr", ".sml", ".xml", ".aux.xml", ".prj"] +_INVALID_SEGMENT_RE = re.compile(r'[<>:"/\\|?*\x00-\x1F]+') +_WINDOWS_RESERVED_NAMES = { + "CON", + "PRN", + "AUX", + "NUL", + "COM1", + "COM2", + "COM3", + "COM4", + "COM5", + "COM6", + "COM7", + "COM8", + "COM9", + "LPT1", + "LPT2", + "LPT3", + "LPT4", + "LPT5", + "LPT6", + "LPT7", + "LPT8", + "LPT9", +} -def export_dinsar_results( +def _legacy_export_dinsar_results_flat( file_paths: List[str], target_dir: str, ) -> Dict[str, Any]: @@ -100,3 +126,185 @@ def export_dinsar_results( "target_dir": target_dir, "details": details, } + + +def _derive_folder_hint(file_path: str) -> str: + base_name = os.path.basename(str(file_path or "").strip()) + stem, _ = os.path.splitext(base_name) + for suffix in ("_geo_disp", "_disp", "_coh"): + if stem.lower().endswith(suffix): + return stem[: -len(suffix)] or stem + return stem + + +def _sanitize_path_segment(value: Any, *, default: str) -> str: + text = str(value or "").strip() + text = _INVALID_SEGMENT_RE.sub("_", text).strip(" .") + if not text: + text = default + if text.upper() in _WINDOWS_RESERVED_NAMES: + text = f"_{text}" + return text[:120] + + +def _normalize_export_item(raw_item: Any, index: int) -> Dict[str, Any]: + if isinstance(raw_item, dict): + source_path = str( + raw_item.get("source") + or raw_item.get("file_path") + or raw_item.get("path") + or "" + ).strip() + folder_hint = ( + raw_item.get("task_alias") + or raw_item.get("task_name") + or raw_item.get("display_name") + or raw_item.get("name") + or "" + ) + else: + source_path = str(raw_item or "").strip() + folder_hint = "" + + base_name = os.path.basename(source_path) if source_path else "" + folder_name = _sanitize_path_segment( + folder_hint or _derive_folder_hint(source_path), + default=f"result_{index}", + ) + return { + "source_path": source_path, + "base_name": base_name, + "folder_name": folder_name, + } + + +def _same_file_size(left: str, right: str) -> bool: + try: + return os.path.getsize(left) == os.path.getsize(right) + except OSError: + return False + + +def _reserve_target_dir( + *, + target_root: str, + folder_name: str, + base_name: str, + source_path: str, + planned_primary_paths: Dict[str, str], +) -> str: + suffix = 1 + while True: + candidate_name = folder_name if suffix == 1 else f"{folder_name}__{suffix}" + candidate_dir = os.path.join(target_root, candidate_name) + candidate_primary = os.path.join(candidate_dir, base_name) + normalized_primary = os.path.normcase(os.path.abspath(candidate_primary)) + planned_source = planned_primary_paths.get(normalized_primary) + if planned_source and os.path.normcase(planned_source) != os.path.normcase(source_path): + suffix += 1 + continue + if os.path.isfile(candidate_primary) and not _same_file_size(source_path, candidate_primary): + suffix += 1 + continue + planned_primary_paths[normalized_primary] = source_path + return candidate_dir + + +def export_dinsar_results( + items: List[Any], + target_dir: str, +) -> Dict[str, Any]: + """Copy selected D-InSAR results into task-based subdirectories.""" + target_dir = target_dir.strip() + if not target_dir: + raise ValueError("目标路径不能为空") + + try: + os.makedirs(target_dir, exist_ok=True) + except OSError as exc: + raise ValueError(f"无法创建目标目录 '{target_dir}': {exc}") from exc + + copied = 0 + skipped = 0 + failed = 0 + details: List[Dict[str, Any]] = [] + planned_primary_paths: Dict[str, str] = {} + + for index, raw_item in enumerate(items, start=1): + item = _normalize_export_item(raw_item, index) + source_path = item["source_path"] + base_name = item["base_name"] + folder_name = item["folder_name"] + + if not source_path: + continue + + if not os.path.isfile(source_path): + failed += 1 + details.append( + { + "source": source_path, + "folder_name": folder_name, + "status": "error", + "message": "源文件不存在", + } + ) + continue + + target_item_dir = _reserve_target_dir( + target_root=target_dir, + folder_name=folder_name, + base_name=base_name, + source_path=source_path, + planned_primary_paths=planned_primary_paths, + ) + src_dir = os.path.dirname(source_path) + item_copied = 0 + item_skipped = 0 + item_failed = 0 + target_files: List[str] = [] + + for ext in _ASSOCIATED_EXTENSIONS: + src = os.path.join(src_dir, base_name + ext) if ext else source_path + if not os.path.isfile(src): + continue + + dst = os.path.join(target_item_dir, base_name + ext) + target_files.append(dst) + try: + os.makedirs(target_item_dir, exist_ok=True) + if os.path.isfile(dst) and _same_file_size(src, dst): + item_skipped += 1 + skipped += 1 + continue + shutil.copy2(src, dst) + item_copied += 1 + copied += 1 + except OSError as exc: + item_failed += 1 + failed += 1 + logger.warning("Failed to copy D-InSAR result %s -> %s: %s", src, dst, exc) + + status = "ok" if item_failed == 0 else "partial" + details.append( + { + "source": source_path, + "name": base_name, + "folder_name": os.path.basename(target_item_dir), + "target_dir": target_item_dir, + "target_files": target_files, + "status": status, + "copied": item_copied, + "skipped": item_skipped, + "failed": item_failed, + } + ) + + return { + "total_files": len(items), + "copied": copied, + "skipped": skipped, + "failed": failed, + "target_dir": target_dir, + "details": details, + } diff --git a/frontend/src/App.css b/frontend/src/App.css index 2520033..65f4b5b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -3709,6 +3709,12 @@ input[type="checkbox"] { gap: 6px; cursor: pointer; } +.export-select-hint { + margin-bottom: 8px; + font-size: 12px; + color: var(--color-text-secondary); + line-height: 1.5; +} .export-result-list { list-style: none; padding: 0; @@ -3780,15 +3786,28 @@ input[type="checkbox"] { padding-top: 12px; border-top: 1px solid var(--color-border); } -.result-export-modal .modal-footer .btn-primary { - background: var(--color-primary); - color: white; - border: none; +.result-export-modal .modal-footer button { + min-width: 108px; padding: 6px 16px; border-radius: 4px; + border: 1px solid var(--color-border); + background: #fff; + color: var(--color-text-primary); cursor: pointer; } -.result-export-modal .modal-footer .btn-primary:disabled { +.result-export-modal .modal-footer .btn-secondary:hover:not(:disabled) { + background: var(--color-panel-muted); +} +.result-export-modal .modal-footer .btn-primary { + background: var(--color-accent); + color: #fff; + border: none; + font-weight: 600; +} +.result-export-modal .modal-footer .btn-primary:hover:not(:disabled) { + background: var(--color-accent-strong); +} +.result-export-modal .modal-footer button:disabled { opacity: 0.5; cursor: not-allowed; } diff --git a/frontend/src/DinsarProductionPanel.jsx b/frontend/src/DinsarProductionPanel.jsx index 68312f7..2fbf1db 100644 --- a/frontend/src/DinsarProductionPanel.jsx +++ b/frontend/src/DinsarProductionPanel.jsx @@ -284,9 +284,12 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued }) setRunsLoading(true); try { const data = await listRuns(20); - setRuns(data.runs || []); + const nextRuns = data.runs || []; + setRuns(nextRuns); + return nextRuns; } catch { setRuns([]); + return []; } finally { setRunsLoading(false); } @@ -298,8 +301,10 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued }) const tasks = Array.isArray(data) ? data : (data?.tasks || []); const relevantTask = tasks.find(task => ['ISCE2_RUN', 'IDL_RUN_DINSAR'].includes(task.task_type)) || null; setActiveTask(relevantTask); + return relevantTask; } catch { setActiveTask(null); + return null; } }, []); @@ -355,27 +360,19 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued }) } }, [logTaskId, loadTaskLogs, taskLogActionLoading, taskLogs.length]); + const refreshMonitor = useCallback(async () => { + const [nextRuns, nextActiveTask] = await Promise.all([ + loadRuns(), + loadActiveTask(), + ]); + const fallbackTaskId = nextActiveTask?.task_id || nextRuns.find(run => run?.task_id)?.task_id || ''; + await loadTaskLogs(fallbackTaskId); + }, [loadActiveTask, loadRuns, loadTaskLogs]); + useEffect(() => { loadEngines(); - loadRuns(); - loadActiveTask(); - }, [loadActiveTask, loadEngines, loadRuns]); - - useEffect(() => { - const timer = setInterval(() => { - loadRuns(); - loadActiveTask(); - }, 5000); - return () => clearInterval(timer); - }, [loadActiveTask, loadRuns]); - - useEffect(() => { - const taskId = logTaskId; - loadTaskLogs(taskId); - if (!taskId) return undefined; - const timer = setInterval(() => loadTaskLogs(taskId), 5000); - return () => clearInterval(timer); - }, [logTaskId, loadTaskLogs]); + refreshMonitor(); + }, [loadEngines, refreshMonitor]); useEffect(() => { if (currentProfiles.length > 0) { @@ -418,8 +415,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued }) setSubmitError(false); setSubmitMsg(`任务已入队:${result.task_id}${taskCount}`); if (onJobQueued) onJobQueued(result.task_id); - loadRuns(); - loadActiveTask(); + await refreshMonitor(); } catch (err) { setSubmitError(true); setSubmitMsg(`提交失败:${err?.response?.data?.detail || err.message}`); @@ -672,10 +668,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
运行监控
+
+ 监控与日志改为手动刷新,避免界面持续轮询请求。 +
{monitoredTask && (
PRODUCT_TASK_TYPES.includes(task.task_type)) || null; setActiveTask(relevantTask); + return relevantTask; } catch { setActiveTask(null); + return null; } }, []); const loadRecentTask = useCallback(async () => { try { const tasks = await getRecentTasks(PRODUCT_TASK_TYPES, [], 1, 0); - setRecentTask(Array.isArray(tasks) ? (tasks[0] || null) : null); + const nextTask = Array.isArray(tasks) ? (tasks[0] || null) : null; + setRecentTask(nextTask); + return nextTask; } catch { setRecentTask(null); + return null; } }, []); @@ -133,26 +138,18 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) { } }, [logTaskId, loadTaskLogs, taskLogActionLoading, taskLogs.length]); - useEffect(() => { - loadActiveTask(); - loadRecentTask(); - }, [loadActiveTask, loadRecentTask]); + const refreshMonitor = useCallback(async () => { + const [nextActiveTask, nextRecentTask] = await Promise.all([ + loadActiveTask(), + loadRecentTask(), + ]); + const nextTaskId = nextActiveTask?.task_id || nextRecentTask?.task_id || ''; + await loadTaskLogs(nextTaskId); + }, [loadActiveTask, loadRecentTask, loadTaskLogs]); useEffect(() => { - const timer = setInterval(() => { - loadActiveTask(); - loadRecentTask(); - }, 5000); - return () => clearInterval(timer); - }, [loadActiveTask, loadRecentTask]); - - useEffect(() => { - const taskId = logTaskId; - loadTaskLogs(taskId); - if (!taskId) return undefined; - const timer = setInterval(() => loadTaskLogs(taskId), 5000); - return () => clearInterval(timer); - }, [logTaskId, loadTaskLogs]); + refreshMonitor(); + }, [refreshMonitor]); const handleExtract = async () => { if (!extractRootDir.trim()) return; @@ -181,8 +178,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) { if (result?.task_id) { onJobQueued?.(result.task_id); } - loadActiveTask(); - loadRecentTask(); + await refreshMonitor(); } catch (err) { setActionError(true); setActionMessage(err?.response?.data?.detail || err.message || 'D-InSAR结果扫描失败'); @@ -289,10 +285,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
产物任务监控
+
+ 任务状态与日志改为手动刷新,避免页面持续轮询。 +
{!monitoredTask ? (
当前没有正在执行的产物处理任务。
diff --git a/frontend/src/components/DinsarCatalogPanel.jsx b/frontend/src/components/DinsarCatalogPanel.jsx index 52dbc67..e2a154c 100644 --- a/frontend/src/components/DinsarCatalogPanel.jsx +++ b/frontend/src/components/DinsarCatalogPanel.jsx @@ -133,6 +133,7 @@ export default function DinsarCatalogPanel({ setSelectedProduct(null); return; } + setSelectedProduct(null); setDetailLoading(true); try { const detail = await getDinsarProductDetail(productId); @@ -148,13 +149,7 @@ export default function DinsarCatalogPanel({ useEffect(() => { loadCatalog(); - const timer = setInterval(() => { - if (!actionLoading) { - loadCatalog(); - } - }, 10000); - return () => clearInterval(timer); - }, [actionLoading, loadCatalog]); + }, [loadCatalog]); useEffect(() => { loadProductDetail(selectedProductId); @@ -408,7 +403,7 @@ export default function DinsarCatalogPanel({
{!selectedProductId ? (
请选择一个结果包查看详情。
- ) : detailLoading ? ( + ) : detailLoading || !selectedProduct ? (
正在加载详情...
) : selectedProduct?.error ? (
{selectedProduct.error}
diff --git a/frontend/src/components/ResultExportModal.jsx b/frontend/src/components/ResultExportModal.jsx index 06f4cbc..4fdd756 100644 --- a/frontend/src/components/ResultExportModal.jsx +++ b/frontend/src/components/ResultExportModal.jsx @@ -1,18 +1,23 @@ import { useState } from 'react'; import { exportDinsarResults } from '../api/dinsar'; -export default function ResultExportModal({ results, onClose }) { +const EXAMPLE_TARGET_DIR = String.raw`例如: D:\Export\Results 或 \\server\share\results`; + +export default function ResultExportModal({ results = [], onClose }) { const [targetDir, setTargetDir] = useState(''); - const [selectedIds, setSelectedIds] = useState(() => new Set(results.map(r => r.id))); + const [selectedIds, setSelectedIds] = useState(() => new Set(results.map((result) => result.id))); const [exporting, setExporting] = useState(false); const [exportResult, setExportResult] = useState(null); const [error, setError] = useState(''); const toggleSelect = (id) => { - setSelectedIds(prev => { + setSelectedIds((prev) => { const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } return next; }); }; @@ -20,29 +25,30 @@ export default function ResultExportModal({ results, onClose }) { const toggleAll = () => { if (selectedIds.size === results.length) { setSelectedIds(new Set()); - } else { - setSelectedIds(new Set(results.map(r => r.id))); + return; } + setSelectedIds(new Set(results.map((result) => result.id))); }; const handleExport = async () => { const dir = targetDir.trim(); if (!dir) { - setError('请输入目标路径'); + setError('请输入目标路径。'); return; } if (selectedIds.size === 0) { - setError('请至少选择一个结果'); + setError('请至少选择一个结果。'); return; } + setError(''); setExporting(true); setExportResult(null); try { - const res = await exportDinsarResults([...selectedIds], dir); - setExportResult(res); - } catch (e) { - setError(e.response?.data?.detail || e.message || '导出失败'); + const response = await exportDinsarResults([...selectedIds], dir); + setExportResult(response); + } catch (eventualError) { + setError(eventualError.response?.data?.detail || eventualError.message || '提取失败。'); } finally { setExporting(false); } @@ -50,20 +56,22 @@ export default function ResultExportModal({ results, onClose }) { return (
-
e.stopPropagation()}> +
event.stopPropagation()}>

提取 D-InSAR 结果

- +
- + setTargetDir(e.target.value)} - placeholder="例如: D:\Export\Results 或 \\server\share\results" + onChange={(event) => setTargetDir(event.target.value)} + placeholder={EXAMPLE_TARGET_DIR} disabled={exporting} className="export-path-input" /> @@ -76,23 +84,28 @@ export default function ResultExportModal({ results, onClose }) { type="checkbox" checked={selectedIds.size === results.length && results.length > 0} onChange={toggleAll} - disabled={exporting} + disabled={exporting || results.length === 0} /> 全选 ({selectedIds.size}/{results.length})
+ +
+ 导出时会优先按任务名创建子目录;如果同名结果已存在且内容不同,会自动追加后缀避免覆盖。 +
+
    - {results.map(r => ( -
  • + {results.map((result) => ( +
  • @@ -104,7 +117,7 @@ export default function ResultExportModal({ results, onClose }) { {exportResult && (
    -
    导出完成
    +
    提取完成
    复制: {exportResult.copied} 跳过: {exportResult.skipped} @@ -120,13 +133,16 @@ export default function ResultExportModal({ results, onClose }) {
    - +