chore: initialize insar management system v2

This commit is contained in:
2026-04-14 13:16:01 +08:00
commit ecc72ec9cd
361 changed files with 2142522 additions and 0 deletions
+737
View File
@@ -0,0 +1,737 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title } from 'chart.js';
import { Doughnut, Bar } from 'react-chartjs-2';
import {
getEnviStatus,
inspectImport,
inspectDinsar,
queueImportJob,
queueDinsarJob,
getRecentRuns,
getActiveTasks,
forceCancelTask,
extractDispResults,
getTaskOverview,
getJobLog,
deleteRun,
} from './api/idl';
import { scanDinsarResults } from './api/dinsar';
import { getStatistics } from './api/stats';
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title);
const cardStyle = {
background: '#fff',
padding: '12px',
borderRadius: '8px',
border: '1px solid #e2e8f0',
marginBottom: '12px',
};
function IDLAutomationPanel({ readOnly = false, onJobQueued }) {
const [status, setStatus] = useState(null);
const [recentRuns, setRecentRuns] = useState([]);
const [isBusy, setIsBusy] = useState(false);
const [message, setMessage] = useState('');
const [runningTask, setRunningTask] = useState(null); // active IDL task from backend
const [showUnlockInput, setShowUnlockInput] = useState(false);
const [unlockPassword, setUnlockPassword] = useState('');
const [importRootDir, setImportRootDir] = useState('');
const [importNumToProcess, setImportNumToProcess] = useState(0);
const [dinsarRootDir, setDinsarRootDir] = useState('');
const [dinsarNumToProcess, setDinsarNumToProcess] = useState(0);
const [extractRootDir, setExtractRootDir] = useState('');
const [extractDestDir, setExtractDestDir] = useState('');
const [extractResult, setExtractResult] = useState(null);
const [overview, setOverview] = useState(null);
const [overviewLoading, setOverviewLoading] = useState(false);
const [logModal, setLogModal] = useState({ open: false, runId: '', content: '', loading: false, truncated: false, sizeBytes: 0 });
const [chartStats, setChartStats] = useState(null);
useEffect(() => {
getStatistics().then(setChartStats).catch(() => {});
}, []);
const [importInspect, setImportInspect] = useState(null);
const [dinsarInspect, setDinsarInspect] = useState(null);
const refreshData = useCallback(async () => {
const [s, runs, tasks] = await Promise.all([
getEnviStatus(),
getRecentRuns(20),
getActiveTasks().catch(() => []),
]);
setStatus(s);
setRecentRuns(Array.isArray(runs?.runs) ? runs.runs : []);
// Find any running IDL task (IDL_IMPORT or IDL_DINSAR)
const taskList = Array.isArray(tasks) ? tasks : [];
const active = taskList.find(
(t) => ['IDL_IMPORT', 'IDL_DINSAR'].includes(t.task_type) &&
['PENDING', 'RUNNING'].includes(t.status)
);
setRunningTask(active || null);
}, []);
useEffect(() => {
refreshData().catch(() => {});
const timer = setInterval(() => refreshData().catch(() => {}), 10000);
return () => clearInterval(timer);
}, [refreshData]);
const runAction = async (action) => {
setIsBusy(true);
setMessage('');
try {
await action();
await refreshData();
} catch (error) {
const detail = error?.response?.data?.detail || error?.message || '未知错误';
setMessage(`执行失败: ${detail}`);
} finally {
setIsBusy(false);
}
};
const handleInspectImport = () =>
runAction(async () => {
const r = await inspectImport(importRootDir.trim());
setImportInspect(r);
setMessage(`Import 预检查: ${r?.ready ? '就绪' : '未就绪'}`);
});
const handleInspectDinsar = () =>
runAction(async () => {
const r = await inspectDinsar(dinsarRootDir.trim());
setDinsarInspect(r);
setMessage(`D-InSAR 预检查: ${r?.ready ? '就绪' : '未就绪'}`);
});
const handleQueueImport = () => {
if (readOnly) return setMessage('只读账号无法提交任务。');
const root = importRootDir.trim();
if (!root) return setMessage('请填写数据目录。');
runAction(async () => {
const r = await queueImportJob({
root_dir: root,
num_to_process: Number(importNumToProcess) || 0,
});
setMessage(`Import 任务已入队。task_id=${r?.task_id || '-'}`);
if (r?.task_id) onJobQueued?.(r.task_id);
});
};
const handleQueueDinsar = (mode) => {
if (readOnly) return setMessage('只读账号无法提交任务。');
const root = dinsarRootDir.trim();
if (!root) return setMessage('请填写数据目录。');
const modeLabel = mode === 'custom' ? '自定义' : '默认';
runAction(async () => {
const r = await queueDinsarJob({
root_dir: root,
num_to_process: Number(dinsarNumToProcess) || 0,
mode,
});
setMessage(`D-InSAR (${modeLabel}) 任务已入队。task_id=${r?.task_id || '-'}`);
if (r?.task_id) onJobQueued?.(r.task_id);
});
};
const handleExtractDisp = () => {
if (readOnly) return setMessage('只读账号无法执行提取。');
const root = extractRootDir.trim();
if (!root) return setMessage('请填写数据目录。');
const dest = extractDestDir.trim() || null;
runAction(async () => {
const r = await extractDispResults(root, dest);
setExtractResult(r);
// 提取完成后自动触发扫描
let scanMsg = '';
if (r.copied > 0 || r.overwritten > 0) {
try {
await scanDinsarResults({ results_directories: [r.target_dir] });
scanMsg = ',已自动触发结果扫描入库';
} catch (e) {
scanMsg = ',扫描入库触发失败: ' + (e?.response?.data?.detail || e?.message);
}
}
setMessage(`提取完成: ${r.copied} 新增, ${r.overwritten} 更新, ${r.skipped} 跳过${scanMsg}`);
});
};
const handleLoadOverview = async () => {
const root = dinsarRootDir.trim();
if (!root) return;
setOverviewLoading(true);
try {
const r = await getTaskOverview(root);
setOverview(r);
} catch (e) {
setOverview(null);
setMessage(`总览加载失败: ${e?.response?.data?.detail || e?.message}`);
} finally {
setOverviewLoading(false);
}
};
const handleOpenLog = async (runId) => {
setLogModal({ open: true, runId, content: '', loading: true, truncated: false, sizeBytes: 0 });
try {
const r = await getJobLog(runId);
setLogModal({ open: true, runId, content: r.content, loading: false, truncated: r.truncated, sizeBytes: r.size_bytes });
} catch (e) {
const msg = e?.response?.data?.detail || e?.message || '加载失败';
setLogModal({ open: true, runId, content: `错误: ${msg}`, loading: false, truncated: false, sizeBytes: 0 });
}
};
const handleForceUnlock = async () => {
if (!runningTask || !unlockPassword) return;
try {
await forceCancelTask(runningTask.task_id, unlockPassword);
setMessage('任务已强制取消,前端已解锁。');
setShowUnlockInput(false);
setUnlockPassword('');
await refreshData();
} catch (error) {
const detail = error?.response?.data?.detail || error?.message || '解锁失败';
setMessage(`强制解锁失败: ${detail}`);
}
};
const renderInspect = (title, result) => {
if (!result) return null;
return (
<div style={{ marginTop: '8px', padding: '10px', background: '#f8fafc', borderRadius: '6px', border: '1px solid #e2e8f0' }}>
<div style={{ fontWeight: 600, marginBottom: '4px' }}>{title}</div>
<div style={{ fontSize: '12px', color: '#334155' }}>
状态: <strong style={{ color: result?.ready ? '#16a34a' : '#dc2626' }}>{result?.ready ? '就绪' : '未就绪'}</strong>
</div>
{result?.warnings?.length > 0 && (
<div style={{ fontSize: '12px', color: '#b45309', marginTop: '4px' }}>
{result.warnings.map((w, i) => <div key={i}>⚠ {w}</div>)}
</div>
)}
<pre style={{ margin: '8px 0 0', whiteSpace: 'pre-wrap', fontSize: '12px', color: '#475569' }}>
{JSON.stringify(result?.summary || {}, null, 2)}
</pre>
</div>
);
};
// Buttons are locked when: submitting API call, readOnly user, or a backend task is running
const isLocked = isBusy || !!runningTask;
const demDisplay = status?.dem_base_file || '-';
const demOk = status?.dem_exists;
return (
<div className="idl-automation-panel" style={{ padding: '15px', height: '100%', overflowY: 'auto' }}>
<h3 style={{ marginTop: 0 }}>SARscape 处理中心</h3>
{/* Status bar */}
<div style={{ ...cardStyle, background: '#f8fafc' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '12px', flexWrap: 'wrap', fontSize: '13px' }}>
<span>
ENVI: <strong>{status?.idl_installed ? '已安装' : '未检测到'}</strong>
{status?.idl_running && ' (运行中)'}
</span>
<span>引擎: <strong>{status?.engine || '-'}</strong></span>
<span style={{ color: demOk ? '#16a34a' : '#dc2626' }}>
DEM: <code style={{ fontSize: '11px' }}>{demDisplay.length > 40 ? '...' + demDisplay.slice(-37) : demDisplay}</code>
{demOk ? ' ✓' : ' ✗'}
</span>
</div>
</div>
{/* Running task indicator */}
{runningTask && (
<div style={{
...cardStyle,
background: '#fffbeb',
borderColor: '#f59e0b',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ fontSize: '16px' }}>&#9881;</span>
<div style={{ flex: 1, fontSize: '13px' }}>
<strong style={{ color: '#b45309' }}>
{runningTask.task_type === 'IDL_IMPORT' ? 'Import' : 'D-InSAR'} 任务运行中
</strong>
{runningTask.progress > 0 && (
<span style={{ color: '#92400e', marginLeft: '8px', fontVariantNumeric: 'tabular-nums' }}>
{runningTask.progress}%
</span>
)}
<div style={{ color: '#92400e', marginTop: '3px', fontSize: '12px', wordBreak: 'break-all' }}>
{runningTask.message || runningTask.status}
</div>
{runningTask.progress > 0 && (
<div style={{ marginTop: '5px', height: '6px', background: '#fde68a', borderRadius: '3px', overflow: 'hidden' }}>
<div style={{
height: '100%',
width: `${runningTask.progress}%`,
background: '#f59e0b',
borderRadius: '3px',
transition: 'width 0.5s ease',
}} />
</div>
)}
</div>
{!readOnly && !showUnlockInput && (
<button
type="button"
onClick={() => setShowUnlockInput(true)}
style={{
padding: '3px 10px',
borderRadius: '4px',
border: '1px solid #dc2626',
background: '#fef2f2',
color: '#dc2626',
fontSize: '12px',
cursor: 'pointer',
}}
>
强制解锁
</button>
)}
</div>
{showUnlockInput && (
<div style={{ marginTop: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="password"
placeholder="输入管理员密码"
value={unlockPassword}
onChange={(e) => setUnlockPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleForceUnlock()}
style={{ padding: '4px 8px', fontSize: '12px', borderRadius: '4px', border: '1px solid #d1d5db', width: '160px' }}
/>
<button
type="button"
onClick={handleForceUnlock}
disabled={!unlockPassword}
style={{
padding: '4px 10px',
borderRadius: '4px',
border: '1px solid #dc2626',
background: '#dc2626',
color: '#fff',
fontSize: '12px',
cursor: unlockPassword ? 'pointer' : 'not-allowed',
opacity: unlockPassword ? 1 : 0.5,
}}
>
确认取消
</button>
<button
type="button"
onClick={() => { setShowUnlockInput(false); setUnlockPassword(''); }}
style={{ padding: '4px 10px', borderRadius: '4px', border: '1px solid #d1d5db', background: '#fff', fontSize: '12px', cursor: 'pointer' }}
>
取消
</button>
</div>
)}
{!showUnlockInput && (
<div style={{ fontSize: '11px', color: '#92400e', marginTop: '4px', marginLeft: '26px' }}>
按钮已锁定,等待任务完成
</div>
)}
</div>
)}
{/* Task 状态总览 */}
<div style={cardStyle}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
<h4 style={{ margin: 0 }}>Task 状态总览</h4>
<button
type="button"
className="secondary-btn"
onClick={handleLoadOverview}
disabled={overviewLoading || !dinsarRootDir.trim()}
style={{ fontSize: '12px', padding: '3px 10px' }}
>
{overviewLoading ? '加载中...' : '刷新'}
</button>
</div>
{!overview && !overviewLoading && (
<div style={{ fontSize: '12px', color: '#94a3b8' }}>填写下方数据目录后点击刷新</div>
)}
{overview && (
<>
<div style={{ fontSize: '12px', color: '#475569', marginBottom: '8px' }}>
共 <strong>{overview.summary.total}</strong> 个 Task &nbsp;·&nbsp;
已导入 <strong style={{ color: '#2563eb' }}>{overview.summary.imported}</strong> &nbsp;·&nbsp;
已处理 <strong style={{ color: '#7c3aed' }}>{overview.summary.dinsar_done}</strong> &nbsp;·&nbsp;
已提取 <strong style={{ color: '#16a34a' }}>{overview.summary.extracted}</strong>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '12px' }}>
<thead>
<tr style={{ background: '#f1f5f9' }}>
<th style={{ textAlign: 'left', padding: '4px 8px', fontWeight: 600 }}>Task</th>
<th style={{ textAlign: 'center', padding: '4px 8px', fontWeight: 600 }}>结构</th>
<th style={{ textAlign: 'center', padding: '4px 8px', fontWeight: 600 }}>已导入</th>
<th style={{ textAlign: 'center', padding: '4px 8px', fontWeight: 600 }}>已处理</th>
<th style={{ textAlign: 'center', padding: '4px 8px', fontWeight: 600 }}>已提取</th>
<th style={{ textAlign: 'right', padding: '4px 8px', fontWeight: 600, color: '#94a3b8' }}>修改时间</th>
</tr>
</thead>
<tbody>
{overview.tasks.map((t) => (
<tr key={t.task_name} style={{ borderTop: '1px solid #e2e8f0' }}>
<td style={{ padding: '4px 8px', fontFamily: 'monospace', fontSize: '11px' }}>{t.task_name}</td>
{[t.has_structure, t.imported, t.dinsar_done, t.extracted].map((v, i) => (
<td key={i} style={{ textAlign: 'center', padding: '4px 8px' }}>
<span style={{ color: v ? '#16a34a' : '#dc2626', fontWeight: 700 }}>{v ? '✓' : '✗'}</span>
</td>
))}
<td style={{ textAlign: 'right', padding: '4px 8px', color: '#94a3b8', fontSize: '11px' }}>{t.last_modified}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
{/* Step 1: Import */}
<div style={cardStyle}>
<h4 style={{ marginTop: 0 }}>Step 1: 数据导入 (Import)</h4>
<p style={{ fontSize: '12px', color: 'var(--color-text-secondary)', margin: '0 0 10px' }}>
扫描目录下的 LuTan-1 原始数据 (*.meta.xml),转换为 ENVI SARscape 格式 (*.sml)。
支持 Task_*/master|slave 结构和平铺目录。
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 140px', gap: '10px', alignItems: 'start' }}>
<label className="user-admin-field">
<span>数据目录 (root_dir)</span>
<input
type="text"
value={importRootDir}
onChange={(e) => setImportRootDir(e.target.value)}
placeholder="例如: Z:/Test_data/Test_IDL_1"
disabled={isLocked}
/>
</label>
<label className="user-admin-field">
<span>最大处理数</span>
<input
type="number"
min={0}
value={importNumToProcess}
onChange={(e) => setImportNumToProcess(e.target.value)}
placeholder="0"
disabled={isLocked}
/>
<span style={{ fontSize: '11px', color: 'var(--color-text-muted, #94a3b8)' }}>0 = 不限制</span>
</label>
</div>
<div style={{ marginTop: '10px', display: 'flex', gap: '8px' }}>
<button type="button" className="secondary-btn" disabled={isLocked || readOnly} onClick={handleInspectImport}>
预检查
</button>
<button type="button" className="primary-btn" disabled={isLocked || readOnly} onClick={handleQueueImport}>
开始导入
</button>
</div>
{renderInspect('Import 检查结果', importInspect)}
</div>
{/* Step 2: D-InSAR */}
<div style={cardStyle}>
<h4 style={{ marginTop: 0 }}>Step 2: D-InSAR 生产</h4>
<p style={{ fontSize: '12px', color: 'var(--color-text-secondary)', margin: '0 0 10px' }}>
对 Task_* 目录执行 D-InSAR 位移生成。
未导入的数据将自动先执行 Import,再进行 D-InSAR 处理。
DEM 路径由系统配置管理,无需手动指定。
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 140px', gap: '10px', alignItems: 'start' }}>
<label className="user-admin-field">
<span>数据目录 (root_dir)</span>
<input
type="text"
value={dinsarRootDir}
onChange={(e) => setDinsarRootDir(e.target.value)}
placeholder="例如: Z:/Test_data/Test_IDL_1"
disabled={isLocked}
/>
</label>
<label className="user-admin-field">
<span>最大处理数</span>
<input
type="number"
min={0}
value={dinsarNumToProcess}
onChange={(e) => setDinsarNumToProcess(e.target.value)}
placeholder="0"
disabled={isLocked}
/>
<span style={{ fontSize: '11px', color: 'var(--color-text-muted, #94a3b8)' }}>0 = 不限制</span>
</label>
</div>
<div style={{ marginTop: '10px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
{/* 预检查 */}
<div>
<button type="button" className="secondary-btn" disabled={isLocked || readOnly} onClick={handleInspectDinsar}>
预检查
</button>
<div style={{ marginTop: '3px', fontSize: '11px', color: '#64748b' }}>
扫描目录,统计待处理对数,检查 DEM 和 SML 文件是否就绪,不执行任何处理。
</div>
</div>
{/* 默认处理 */}
<div>
<button type="button" className="primary-btn" disabled={isLocked || readOnly} onClick={() => handleQueueDinsar('metatask')}>
默认处理
</button>
<div style={{ marginTop: '3px', fontSize: '11px', color: '#64748b' }}>
调用 ENVI SARscape metatask 一键执行全流程。参数:3×3 多视、默认自适应滤波、自动 GCP 精配准。
</div>
</div>
{/* 自定义处理 */}
<div>
<button
type="button"
disabled={isLocked || readOnly}
onClick={() => handleQueueDinsar('custom')}
style={{
padding: '6px 14px',
borderRadius: '6px',
border: '1px solid #818cf8',
background: '#eef2ff',
color: '#4338ca',
fontWeight: 500,
fontSize: '13px',
cursor: isLocked || readOnly ? 'not-allowed' : 'pointer',
opacity: isLocked || readOnly ? 0.5 : 1,
}}
>
自定义处理
</button>
<div style={{ marginTop: '3px', fontSize: '11px', color: '#64748b' }}>
逐步调用各处理模块,参数可控。多视:动态计算、GOLDSTEIN 滤波、自动 GCP、相干阈值 coh=0.05。
</div>
</div>
</div>
{renderInspect('D-InSAR 检查结果', dinsarInspect)}
</div>
{/* Step 3: Extract Disp */}
<div style={cardStyle}>
<h4 style={{ marginTop: 0 }}>Step 3: 结果提取</h4>
<p style={{ fontSize: '12px', color: 'var(--color-text-secondary)', margin: '0 0 10px' }}>
将 D-InSAR 生产结果中的 disp 文件提取到目标目录,以便系统识别。
</p>
<label className="user-admin-field">
<span>数据目录 (root_dir)</span>
<input
type="text"
value={extractRootDir}
onChange={(e) => setExtractRootDir(e.target.value)}
placeholder="例如: Z:/Test_data/Test_IDL_1"
disabled={isLocked}
/>
</label>
<label className="user-admin-field" style={{ marginTop: '8px' }}>
<span>目标目录 (可选)</span>
<input
type="text"
value={extractDestDir}
onChange={(e) => setExtractDestDir(e.target.value)}
placeholder="留空则使用默认路径 (MONITOR_DINSAR_DIRS[0])"
disabled={isLocked}
/>
</label>
<div style={{ marginTop: '10px' }}>
<button
type="button"
className="primary-btn"
disabled={isLocked || readOnly || isBusy}
onClick={handleExtractDisp}
>
提取 Disp 结果
</button>
</div>
{extractResult && (
<div style={{ marginTop: '8px', padding: '10px', background: '#f0fdf4', borderRadius: '6px', border: '1px solid #bbf7d0', fontSize: '12px', color: '#166534' }}>
<div>目标目录: <code style={{ fontSize: '11px' }}>{extractResult.target_dir}</code></div>
<div style={{ marginTop: '4px' }}>
处理 {extractResult.processed} 个 Task &nbsp;·&nbsp;
新增 {extractResult.copied} &nbsp;·&nbsp;
更新 {extractResult.overwritten} &nbsp;·&nbsp;
跳过 {extractResult.skipped}
{extractResult.failed > 0 && (
<span style={{ color: '#dc2626' }}> &nbsp;·&nbsp; 失败 {extractResult.failed}</span>
)}
</div>
</div>
)}
</div>
{/* Run history */}
<div style={cardStyle}>
<h4 style={{ marginTop: 0 }}>任务历史</h4>
{recentRuns.length === 0 ? (
<div style={{ color: '#64748b', fontSize: '13px' }}>暂无运行记录</div>
) : (
<div style={{ display: 'grid', gap: '6px' }}>
{recentRuns.map((run) => {
const s = run.summary || {};
const counts = s.processed != null
? `成功 ${s.processed} / 失败 ${s.failed || 0} / 跳过 ${s.skipped || 0}`
: '';
const autoImported = s.auto_imported > 0 ? ` (自动导入 ${s.auto_imported})` : '';
const statusLabel =
run.status === 'success' ? '成功' :
run.status === 'failed' ? '失败' : run.status;
const statusColor =
run.status === 'success' ? '#16a34a' :
run.status === 'failed' ? '#dc2626' : '#d97706';
return (
<div key={run.run_id} style={{ border: '1px solid #e2e8f0', borderRadius: '6px', padding: '8px', fontSize: '13px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
<strong>{run.workflow === 'dinsar' ? 'D-InSAR' : run.workflow === 'dinsar_custom' ? 'D-InSAR (自定义)' : 'Import'}</strong>
<span style={{ color: statusColor, fontWeight: 500 }}>{statusLabel}</span>
<span style={{ color: '#475569' }}>{run.duration_seconds}s</span>
<button
type="button"
onClick={() => handleOpenLog(run.run_id)}
style={{ padding: '2px 8px', borderRadius: '4px', border: '1px solid #cbd5e1', background: 'transparent', color: '#64748b', fontSize: '11px', cursor: 'pointer' }}
title="查看日志"
>
日志
</button>
{!readOnly && (
<button
type="button"
onClick={async () => {
try {
await deleteRun(run.run_id);
setRecentRuns((prev) => prev.filter((r) => r.run_id !== run.run_id));
} catch (err) {
setMessage(`删除失败: ${err.response?.data?.detail || err.message}`);
}
}}
style={{ marginLeft: 'auto', padding: '2px 8px', borderRadius: '4px', border: '1px solid #e2e8f0', background: 'transparent', color: '#94a3b8', fontSize: '11px', cursor: 'pointer' }}
title="删除运行记录"
>
✕
</button>
)}
</div>
{counts && (
<div style={{ fontSize: '12px', color: '#475569', marginTop: '2px' }}>
{counts}{autoImported}
</div>
)}
<div style={{ fontSize: '11px', color: '#94a3b8', marginTop: '2px' }}>
{run.finished_at || run.started_at || '-'}
</div>
</div>
);
})}
</div>
)}
</div>
{message && (
<div style={{ ...cardStyle, borderColor: '#cbd5e1', background: '#f8fafc', marginBottom: 0 }}>
<strong>消息:</strong> {message}
</div>
)}
{/* 统计图表 */}
{(recentRuns.length > 0 || chartStats) && (() => {
// 图表1:近期运行成功/失败(来自 recentRuns)
const successCount = recentRuns.filter(r => r.status === 'success').length;
const failedCount = recentRuns.filter(r => r.status === 'failed').length;
const otherCount = recentRuns.length - successCount - failedCount;
const doughnutData = {
labels: ['成功', '失败', '其他'],
datasets: [{ data: [successCount, failedCount, otherCount], backgroundColor: ['#16a34a', '#dc2626', '#94a3b8'] }],
};
// 图表2:各工作流平均耗时
const avgDur = chartStats?.idl_processing_stats?.avg_duration_by_workflow || {};
const wfLabels = { import: 'Import', dinsar: 'D-InSAR', dinsar_custom: 'D-InSAR 自定义' };
const durKeys = Object.keys(avgDur);
const barDurData = {
labels: durKeys.map(k => wfLabels[k] || k),
datasets: [{ label: '平均耗时 (s)', data: durKeys.map(k => avgDur[k]), backgroundColor: '#818cf8' }],
};
// 图表3:D-InSAR 结果按月入库
const byMonth = chartStats?.dinsar_by_month || [];
const barMonthData = {
labels: byMonth.map(d => d.month),
datasets: [{ label: '入库数量', data: byMonth.map(d => d.count), backgroundColor: '#34d399' }],
};
const miniOpts = { responsive: true, plugins: { legend: { display: false }, title: { display: false } }, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } };
const doughnutOpts = { responsive: true, plugins: { legend: { position: 'bottom', labels: { font: { size: 11 } } } } };
return (
<div style={cardStyle}>
<h4 style={{ marginTop: 0, marginBottom: '12px' }}>处理统计</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '16px', alignItems: 'start' }}>
<div>
<div style={{ fontSize: '12px', color: '#64748b', marginBottom: '6px', textAlign: 'center' }}>近期运行结果</div>
<Doughnut data={doughnutData} options={doughnutOpts} />
</div>
{durKeys.length > 0 && (
<div>
<div style={{ fontSize: '12px', color: '#64748b', marginBottom: '6px', textAlign: 'center' }}>平均耗时 (秒)</div>
<Bar data={barDurData} options={miniOpts} />
</div>
)}
{byMonth.length > 0 && (
<div>
<div style={{ fontSize: '12px', color: '#64748b', marginBottom: '6px', textAlign: 'center' }}>结果入库趋势</div>
<Bar data={barMonthData} options={miniOpts} />
</div>
)}
</div>
</div>
);
})()}
{/* 日志 Modal */}
{logModal.open && (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={() => setLogModal((s) => ({ ...s, open: false }))}
>
<div
style={{ background: '#1e293b', borderRadius: '8px', width: '700px', maxWidth: '95vw', maxHeight: '75vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
onClick={(e) => e.stopPropagation()}
>
<div style={{ padding: '12px 16px', borderBottom: '1px solid #334155', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: '#e2e8f0', fontWeight: 600, fontSize: '13px', fontFamily: 'monospace' }}>{logModal.runId}.log</span>
<button
type="button"
onClick={() => setLogModal((s) => ({ ...s, open: false }))}
style={{ background: 'transparent', border: 'none', color: '#94a3b8', fontSize: '16px', cursor: 'pointer', lineHeight: 1 }}
>✕</button>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px' }}>
{logModal.loading ? (
<div style={{ color: '#94a3b8', fontSize: '13px' }}>加载中...</div>
) : (
<pre style={{ margin: 0, color: '#cbd5e1', fontSize: '11px', lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{logModal.content}
</pre>
)}
</div>
<div style={{ padding: '8px 16px', borderTop: '1px solid #334155', fontSize: '11px', color: '#64748b' }}>
{logModal.sizeBytes > 0 && `文件大小: ${(logModal.sizeBytes / 1024).toFixed(1)} KB`}
{logModal.truncated && ' · 已截断,仅显示末尾 200KB'}
</div>
</div>
</div>
)}
</div>
);
}
export default IDLAutomationPanel;