Fix D-InSAR export UI and task folder isolation

This commit is contained in:
2026-04-17 00:18:03 +08:00
parent bc5ed74d4d
commit 6e98bbe8b0
7 changed files with 335 additions and 97 deletions
+12 -4
View File
@@ -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))
+209 -1
View File
@@ -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
View File
@@ -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;
} }
+22 -26
View File
@@ -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
+21 -25
View File
@@ -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>
+44 -28
View File
@@ -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}>&times;</button> <button type="button" className="modal-close-btn" onClick={onClose} aria-label="关闭">
&times;
</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>