Fix D-InSAR export UI and task folder isolation
This commit is contained in:
@@ -371,7 +371,7 @@ async def export_dinsar_results_endpoint(
|
|||||||
if not records:
|
if not records:
|
||||||
raise HTTPException(status_code=404, detail="未找到任何匹配的结果记录")
|
raise HTTPException(status_code=404, detail="未找到任何匹配的结果记录")
|
||||||
|
|
||||||
file_paths = []
|
export_items = []
|
||||||
for record in records:
|
for record in records:
|
||||||
compat_row = record.compat_row
|
compat_row = record.compat_row
|
||||||
file_path = (
|
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 "")
|
or (str(compat_row.file_path).strip() if compat_row is not None and compat_row.file_path else "")
|
||||||
)
|
)
|
||||||
if file_path:
|
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="选中的结果没有关联的文件路径")
|
raise HTTPException(status_code=400, detail="选中的结果没有关联的文件路径")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import asyncio
|
import asyncio
|
||||||
export_result = await asyncio.to_thread(
|
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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
@@ -10,9 +11,34 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# 允许的文件扩展名(主文件 + 附属文件)
|
# 允许的文件扩展名(主文件 + 附属文件)
|
||||||
_ASSOCIATED_EXTENSIONS = ["", ".hdr", ".sml", ".xml", ".aux.xml", ".prj"]
|
_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],
|
file_paths: List[str],
|
||||||
target_dir: str,
|
target_dir: str,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
@@ -100,3 +126,185 @@ def export_dinsar_results(
|
|||||||
"target_dir": target_dir,
|
"target_dir": target_dir,
|
||||||
"details": details,
|
"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,
|
||||||
|
}
|
||||||
|
|||||||
+24
-5
@@ -3709,6 +3709,12 @@ input[type="checkbox"] {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.export-select-hint {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
.export-result-list {
|
.export-result-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -3780,15 +3786,28 @@ input[type="checkbox"] {
|
|||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
.result-export-modal .modal-footer .btn-primary {
|
.result-export-modal .modal-footer button {
|
||||||
background: var(--color-primary);
|
min-width: 108px;
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 6px 16px;
|
padding: 6px 16px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--color-text-primary);
|
||||||
cursor: pointer;
|
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;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,9 +284,12 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
|||||||
setRunsLoading(true);
|
setRunsLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await listRuns(20);
|
const data = await listRuns(20);
|
||||||
setRuns(data.runs || []);
|
const nextRuns = data.runs || [];
|
||||||
|
setRuns(nextRuns);
|
||||||
|
return nextRuns;
|
||||||
} catch {
|
} catch {
|
||||||
setRuns([]);
|
setRuns([]);
|
||||||
|
return [];
|
||||||
} finally {
|
} finally {
|
||||||
setRunsLoading(false);
|
setRunsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -298,8 +301,10 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
|||||||
const tasks = Array.isArray(data) ? data : (data?.tasks || []);
|
const tasks = Array.isArray(data) ? data : (data?.tasks || []);
|
||||||
const relevantTask = tasks.find(task => ['ISCE2_RUN', 'IDL_RUN_DINSAR'].includes(task.task_type)) || null;
|
const relevantTask = tasks.find(task => ['ISCE2_RUN', 'IDL_RUN_DINSAR'].includes(task.task_type)) || null;
|
||||||
setActiveTask(relevantTask);
|
setActiveTask(relevantTask);
|
||||||
|
return relevantTask;
|
||||||
} catch {
|
} catch {
|
||||||
setActiveTask(null);
|
setActiveTask(null);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -355,27 +360,19 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
|||||||
}
|
}
|
||||||
}, [logTaskId, loadTaskLogs, taskLogActionLoading, taskLogs.length]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
loadEngines();
|
loadEngines();
|
||||||
loadRuns();
|
refreshMonitor();
|
||||||
loadActiveTask();
|
}, [loadEngines, refreshMonitor]);
|
||||||
}, [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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentProfiles.length > 0) {
|
if (currentProfiles.length > 0) {
|
||||||
@@ -418,8 +415,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
|||||||
setSubmitError(false);
|
setSubmitError(false);
|
||||||
setSubmitMsg(`任务已入队:${result.task_id}${taskCount}`);
|
setSubmitMsg(`任务已入队:${result.task_id}${taskCount}`);
|
||||||
if (onJobQueued) onJobQueued(result.task_id);
|
if (onJobQueued) onJobQueued(result.task_id);
|
||||||
loadRuns();
|
await refreshMonitor();
|
||||||
loadActiveTask();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSubmitError(true);
|
setSubmitError(true);
|
||||||
setSubmitMsg(`提交失败:${err?.response?.data?.detail || err.message}`);
|
setSubmitMsg(`提交失败:${err?.response?.data?.detail || err.message}`);
|
||||||
@@ -672,10 +668,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
|||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||||
<strong style={{ fontSize: 14 }}>运行监控</strong>
|
<strong style={{ fontSize: 14 }}>运行监控</strong>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={refreshMonitor}
|
||||||
loadRuns();
|
|
||||||
loadActiveTask();
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
padding: '3px 10px',
|
padding: '3px 10px',
|
||||||
@@ -688,6 +681,9 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
|||||||
刷新
|
刷新
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#94a3b8', marginBottom: 10 }}>
|
||||||
|
监控与日志改为手动刷新,避免界面持续轮询请求。
|
||||||
|
</div>
|
||||||
|
|
||||||
{monitoredTask && (
|
{monitoredTask && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -67,17 +67,22 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
|||||||
const tasks = Array.isArray(data) ? data : (data?.tasks || []);
|
const tasks = Array.isArray(data) ? data : (data?.tasks || []);
|
||||||
const relevantTask = tasks.find(task => PRODUCT_TASK_TYPES.includes(task.task_type)) || null;
|
const relevantTask = tasks.find(task => PRODUCT_TASK_TYPES.includes(task.task_type)) || null;
|
||||||
setActiveTask(relevantTask);
|
setActiveTask(relevantTask);
|
||||||
|
return relevantTask;
|
||||||
} catch {
|
} catch {
|
||||||
setActiveTask(null);
|
setActiveTask(null);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadRecentTask = useCallback(async () => {
|
const loadRecentTask = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const tasks = await getRecentTasks(PRODUCT_TASK_TYPES, [], 1, 0);
|
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 {
|
} catch {
|
||||||
setRecentTask(null);
|
setRecentTask(null);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -133,26 +138,18 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
|||||||
}
|
}
|
||||||
}, [logTaskId, loadTaskLogs, taskLogActionLoading, taskLogs.length]);
|
}, [logTaskId, loadTaskLogs, taskLogActionLoading, taskLogs.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
const refreshMonitor = useCallback(async () => {
|
||||||
loadActiveTask();
|
const [nextActiveTask, nextRecentTask] = await Promise.all([
|
||||||
loadRecentTask();
|
loadActiveTask(),
|
||||||
}, [loadActiveTask, loadRecentTask]);
|
loadRecentTask(),
|
||||||
|
]);
|
||||||
|
const nextTaskId = nextActiveTask?.task_id || nextRecentTask?.task_id || '';
|
||||||
|
await loadTaskLogs(nextTaskId);
|
||||||
|
}, [loadActiveTask, loadRecentTask, loadTaskLogs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
refreshMonitor();
|
||||||
loadActiveTask();
|
}, [refreshMonitor]);
|
||||||
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]);
|
|
||||||
|
|
||||||
const handleExtract = async () => {
|
const handleExtract = async () => {
|
||||||
if (!extractRootDir.trim()) return;
|
if (!extractRootDir.trim()) return;
|
||||||
@@ -181,8 +178,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
|||||||
if (result?.task_id) {
|
if (result?.task_id) {
|
||||||
onJobQueued?.(result.task_id);
|
onJobQueued?.(result.task_id);
|
||||||
}
|
}
|
||||||
loadActiveTask();
|
await refreshMonitor();
|
||||||
loadRecentTask();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setActionError(true);
|
setActionError(true);
|
||||||
setActionMessage(err?.response?.data?.detail || err.message || 'D-InSAR结果扫描失败');
|
setActionMessage(err?.response?.data?.detail || err.message || 'D-InSAR结果扫描失败');
|
||||||
@@ -289,10 +285,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
|||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||||
<strong style={{ fontSize: 14 }}>产物任务监控</strong>
|
<strong style={{ fontSize: 14 }}>产物任务监控</strong>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={refreshMonitor}
|
||||||
loadActiveTask();
|
|
||||||
loadRecentTask();
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
padding: '3px 10px',
|
padding: '3px 10px',
|
||||||
@@ -305,6 +298,9 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
|||||||
刷新
|
刷新
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#94a3b8', marginBottom: 10 }}>
|
||||||
|
任务状态与日志改为手动刷新,避免页面持续轮询。
|
||||||
|
</div>
|
||||||
|
|
||||||
{!monitoredTask ? (
|
{!monitoredTask ? (
|
||||||
<div style={{ fontSize: 12, color: '#94a3b8' }}>当前没有正在执行的产物处理任务。</div>
|
<div style={{ fontSize: 12, color: '#94a3b8' }}>当前没有正在执行的产物处理任务。</div>
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ export default function DinsarCatalogPanel({
|
|||||||
setSelectedProduct(null);
|
setSelectedProduct(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setSelectedProduct(null);
|
||||||
setDetailLoading(true);
|
setDetailLoading(true);
|
||||||
try {
|
try {
|
||||||
const detail = await getDinsarProductDetail(productId);
|
const detail = await getDinsarProductDetail(productId);
|
||||||
@@ -148,13 +149,7 @@ export default function DinsarCatalogPanel({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCatalog();
|
loadCatalog();
|
||||||
const timer = setInterval(() => {
|
}, [loadCatalog]);
|
||||||
if (!actionLoading) {
|
|
||||||
loadCatalog();
|
|
||||||
}
|
|
||||||
}, 10000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [actionLoading, loadCatalog]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProductDetail(selectedProductId);
|
loadProductDetail(selectedProductId);
|
||||||
@@ -408,7 +403,7 @@ export default function DinsarCatalogPanel({
|
|||||||
</div>
|
</div>
|
||||||
{!selectedProductId ? (
|
{!selectedProductId ? (
|
||||||
<div style={{ padding: '12px', fontSize: 12, color: '#94a3b8' }}>请选择一个结果包查看详情。</div>
|
<div style={{ padding: '12px', fontSize: 12, color: '#94a3b8' }}>请选择一个结果包查看详情。</div>
|
||||||
) : detailLoading ? (
|
) : detailLoading || !selectedProduct ? (
|
||||||
<div style={{ padding: '12px', fontSize: 12, color: '#94a3b8' }}>正在加载详情...</div>
|
<div style={{ padding: '12px', fontSize: 12, color: '#94a3b8' }}>正在加载详情...</div>
|
||||||
) : selectedProduct?.error ? (
|
) : selectedProduct?.error ? (
|
||||||
<div style={{ padding: '12px', fontSize: 12, color: '#dc2626' }}>{selectedProduct.error}</div>
|
<div style={{ padding: '12px', fontSize: 12, color: '#dc2626' }}>{selectedProduct.error}</div>
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { exportDinsarResults } from '../api/dinsar';
|
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 [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 [exporting, setExporting] = useState(false);
|
||||||
const [exportResult, setExportResult] = useState(null);
|
const [exportResult, setExportResult] = useState(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const toggleSelect = (id) => {
|
const toggleSelect = (id) => {
|
||||||
setSelectedIds(prev => {
|
setSelectedIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(id)) next.delete(id);
|
if (next.has(id)) {
|
||||||
else next.add(id);
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -20,29 +25,30 @@ export default function ResultExportModal({ results, onClose }) {
|
|||||||
const toggleAll = () => {
|
const toggleAll = () => {
|
||||||
if (selectedIds.size === results.length) {
|
if (selectedIds.size === results.length) {
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
} else {
|
return;
|
||||||
setSelectedIds(new Set(results.map(r => r.id)));
|
|
||||||
}
|
}
|
||||||
|
setSelectedIds(new Set(results.map((result) => result.id)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
const dir = targetDir.trim();
|
const dir = targetDir.trim();
|
||||||
if (!dir) {
|
if (!dir) {
|
||||||
setError('请输入目标路径');
|
setError('请输入目标路径。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedIds.size === 0) {
|
if (selectedIds.size === 0) {
|
||||||
setError('请至少选择一个结果');
|
setError('请至少选择一个结果。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setError('');
|
setError('');
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
setExportResult(null);
|
setExportResult(null);
|
||||||
try {
|
try {
|
||||||
const res = await exportDinsarResults([...selectedIds], dir);
|
const response = await exportDinsarResults([...selectedIds], dir);
|
||||||
setExportResult(res);
|
setExportResult(response);
|
||||||
} catch (e) {
|
} catch (eventualError) {
|
||||||
setError(e.response?.data?.detail || e.message || '导出失败');
|
setError(eventualError.response?.data?.detail || eventualError.message || '提取失败。');
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
@@ -50,20 +56,22 @@ export default function ResultExportModal({ results, onClose }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay visible" onClick={onClose}>
|
<div className="modal-overlay visible" onClick={onClose}>
|
||||||
<div className="modal-content result-export-modal" onClick={e => e.stopPropagation()}>
|
<div className="modal-content result-export-modal" onClick={(event) => event.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h3>提取 D-InSAR 结果</h3>
|
<h3>提取 D-InSAR 结果</h3>
|
||||||
<button className="modal-close-btn" onClick={onClose}>×</button>
|
<button type="button" className="modal-close-btn" onClick={onClose} aria-label="关闭">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
<div className="export-path-section">
|
<div className="export-path-section">
|
||||||
<label>目标路径(支持 UNC 路径,如 \\\\server\\share\\path)</label>
|
<label>目标路径,支持本地盘符或 UNC 路径,例如 `D:\Export\Results`。</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={targetDir}
|
value={targetDir}
|
||||||
onChange={e => setTargetDir(e.target.value)}
|
onChange={(event) => setTargetDir(event.target.value)}
|
||||||
placeholder="例如: D:\Export\Results 或 \\server\share\results"
|
placeholder={EXAMPLE_TARGET_DIR}
|
||||||
disabled={exporting}
|
disabled={exporting}
|
||||||
className="export-path-input"
|
className="export-path-input"
|
||||||
/>
|
/>
|
||||||
@@ -76,23 +84,28 @@ export default function ResultExportModal({ results, onClose }) {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedIds.size === results.length && results.length > 0}
|
checked={selectedIds.size === results.length && results.length > 0}
|
||||||
onChange={toggleAll}
|
onChange={toggleAll}
|
||||||
disabled={exporting}
|
disabled={exporting || results.length === 0}
|
||||||
/>
|
/>
|
||||||
全选 ({selectedIds.size}/{results.length})
|
全选 ({selectedIds.size}/{results.length})
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="export-select-hint">
|
||||||
|
导出时会优先按任务名创建子目录;如果同名结果已存在且内容不同,会自动追加后缀避免覆盖。
|
||||||
|
</div>
|
||||||
|
|
||||||
<ul className="export-result-list">
|
<ul className="export-result-list">
|
||||||
{results.map(r => (
|
{results.map((result) => (
|
||||||
<li key={r.id} className="export-result-item">
|
<li key={result.id} className="export-result-item">
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedIds.has(r.id)}
|
checked={selectedIds.has(result.id)}
|
||||||
onChange={() => toggleSelect(r.id)}
|
onChange={() => toggleSelect(result.id)}
|
||||||
disabled={exporting}
|
disabled={exporting}
|
||||||
/>
|
/>
|
||||||
<span className="export-result-name" title={r.file_path || r.name}>
|
<span className="export-result-name" title={result.file_path || result.name}>
|
||||||
{r.name}
|
{result.name}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
@@ -104,7 +117,7 @@ export default function ResultExportModal({ results, onClose }) {
|
|||||||
|
|
||||||
{exportResult && (
|
{exportResult && (
|
||||||
<div className="export-summary">
|
<div className="export-summary">
|
||||||
<div className="export-summary-title">导出完成</div>
|
<div className="export-summary-title">提取完成</div>
|
||||||
<div className="export-summary-stats">
|
<div className="export-summary-stats">
|
||||||
<span className="stat-ok">复制: {exportResult.copied}</span>
|
<span className="stat-ok">复制: {exportResult.copied}</span>
|
||||||
<span className="stat-skip">跳过: {exportResult.skipped}</span>
|
<span className="stat-skip">跳过: {exportResult.skipped}</span>
|
||||||
@@ -120,13 +133,16 @@ export default function ResultExportModal({ results, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button onClick={onClose} disabled={exporting}>关闭</button>
|
<button type="button" className="btn-secondary" onClick={onClose} disabled={exporting}>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
disabled={exporting || selectedIds.size === 0 || !targetDir.trim()}
|
disabled={exporting || selectedIds.size === 0 || !targetDir.trim()}
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
>
|
>
|
||||||
{exporting ? '导出中...' : `提取 ${selectedIds.size} 个结果`}
|
{exporting ? '提取中...' : `确定提取 ${selectedIds.size} 个结果`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user