Checkpoint production workflow updates
This commit is contained in:
+3
-3
@@ -1,11 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.tile.openstreetmap.org https://*.tile.opentopomap.org; connect-src 'self'; font-src 'self' data:; worker-src blob:;" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/png" href="/app-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<title>雷达数据生产管理系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
@@ -2589,6 +2589,96 @@ input[type="checkbox"] {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.task-runtime-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.task-runtime-summary > div {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-runtime-summary span {
|
||||
display: block;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.task-runtime-summary strong {
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.active-jobs-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.job-runtime-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) minmax(92px, 0.8fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 7px;
|
||||
background: #ffffff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.job-status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 58px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #cbd5e1;
|
||||
color: #475569;
|
||||
background: #f8fafc;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.job-status-chip.running {
|
||||
border-color: rgba(37, 99, 235, 0.22);
|
||||
color: #1d4ed8;
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
|
||||
.job-status-chip.retry {
|
||||
border-color: rgba(217, 119, 6, 0.24);
|
||||
color: #b45309;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
|
||||
.job-runtime-title,
|
||||
.job-runtime-worker {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.job-runtime-title {
|
||||
color: #334155;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.job-runtime-worker {
|
||||
color: #64748b;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.task-progress-item {
|
||||
background: #f8fafc;
|
||||
padding: 10px;
|
||||
@@ -2596,6 +2686,10 @@ input[type="checkbox"] {
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.task-progress-item--muted {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.task-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -3502,6 +3596,7 @@ input[type="checkbox"] {
|
||||
color: var(--color-text-secondary);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.status-task.has-active-tasks {
|
||||
@@ -3509,6 +3604,13 @@ input[type="checkbox"] {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.status-task small {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.86em;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-license {
|
||||
font-size: 0.75em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -5615,6 +5717,17 @@ input[type="checkbox"] {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.production-workspace-shell .dinsar-production-shell,
|
||||
.production-workspace-shell .dinsar-products-page {
|
||||
max-width: none;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.production-workspace-shell .dinsar-production-shell {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dinsar-production-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 1fr) minmax(420px, 0.95fr);
|
||||
@@ -6485,6 +6598,18 @@ input[type="checkbox"] {
|
||||
.dinsar-products-catalog-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dinsar-products-catalog-section .dinsar-catalog-shell {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dinsar-products-catalog-section .dinsar-catalog-workspace {
|
||||
grid-template-columns: minmax(360px, 420px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sbas-products-page {
|
||||
@@ -7170,6 +7295,7 @@ input[type="checkbox"] {
|
||||
.dinsar-filter-layout,
|
||||
.dinsar-catalog-summary,
|
||||
.dinsar-catalog-workspace,
|
||||
.dinsar-products-catalog-section .dinsar-catalog-workspace,
|
||||
.dinsar-catalog-manage,
|
||||
.dinsar-catalog-filter-bar,
|
||||
.dinsar-catalog-hero,
|
||||
|
||||
@@ -208,12 +208,14 @@ function App() {
|
||||
setHealthError: state.setHealthError,
|
||||
})));
|
||||
const {
|
||||
activeTasks, setActiveTasks,
|
||||
activeTasks, setActiveTasks, runtimeSummary, setRuntimeSummary,
|
||||
isCheckingTasks, setIsCheckingTasks,
|
||||
pendingTaskIds, setPendingTaskIds,
|
||||
} = useTaskStore(useShallow((state) => ({
|
||||
activeTasks: state.activeTasks,
|
||||
setActiveTasks: state.setActiveTasks,
|
||||
runtimeSummary: state.runtimeSummary,
|
||||
setRuntimeSummary: state.setRuntimeSummary,
|
||||
isCheckingTasks: state.isCheckingTasks,
|
||||
setIsCheckingTasks: state.setIsCheckingTasks,
|
||||
pendingTaskIds: state.pendingTaskIds,
|
||||
@@ -918,6 +920,7 @@ function App() {
|
||||
licenseOk: !!licenseStatus?.ok,
|
||||
activeTasks,
|
||||
setActiveTasks,
|
||||
setRuntimeSummary,
|
||||
pendingTaskIds,
|
||||
setPendingTaskIds,
|
||||
setIsCheckingTasks,
|
||||
@@ -2114,6 +2117,7 @@ function App() {
|
||||
isReadOnlyUser={isReadOnlyUser}
|
||||
activeTasks={activeTasks}
|
||||
avgTaskProgress={avgTaskProgress}
|
||||
runtimeSummary={runtimeSummary}
|
||||
licenseStatus={licenseStatus}
|
||||
healthStatus={healthStatus}
|
||||
healthLoading={healthLoading}
|
||||
@@ -2197,6 +2201,7 @@ function App() {
|
||||
licenseFileName={licenseFileName}
|
||||
licenseUploadStatus={licenseUploadStatus}
|
||||
activeTasks={activeTasks}
|
||||
runtimeSummary={runtimeSummary}
|
||||
showCancelTask={showCancelTask}
|
||||
cancelTaskPwd={cancelTaskPwd}
|
||||
onShowCancelTask={() => setShowCancelTask(true)}
|
||||
|
||||
@@ -28,7 +28,7 @@ const fmtBytes = (value) => {
|
||||
const StatusBadge = ({ value }) => {
|
||||
const text = String(value || '-');
|
||||
const status = text.toUpperCase();
|
||||
const tone = status === 'OK' || status === 'MATCHED' || status === 'SELECTED'
|
||||
const tone = status === 'OK' || status === 'MATCHED' || status === 'SELECTED' || text.startsWith('已生产')
|
||||
? 'ok'
|
||||
: status === 'WARNING' || status === 'OPEN' || status === 'MISSING'
|
||||
? 'warn'
|
||||
@@ -202,6 +202,7 @@ export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) {
|
||||
<th>产品</th>
|
||||
<th>轨道</th>
|
||||
<th>状态</th>
|
||||
<th>生产</th>
|
||||
<th>完整性</th>
|
||||
<th>动作</th>
|
||||
<th>文件</th>
|
||||
@@ -216,6 +217,7 @@ export default function AssetInventoryPanel({ readOnly = false, onTaskStart }) {
|
||||
<td>{item.source_format}<small>{item.imaging_mode} / {item.polarization}</small></td>
|
||||
<td>{item.relative_orbit || '-'}<small>abs {item.absolute_orbit || '-'}</small></td>
|
||||
<td><StatusBadge value={item.parse_status} /></td>
|
||||
<td><StatusBadge value={(item.lt1_image_produced || item.lt1_landsar_produced) ? '已生产 GeoTIFF' : '未生产'} /></td>
|
||||
<td title={item.archive_integrity_error || ''}>
|
||||
<StatusBadge value={item.archive_integrity_status || 'NOT_CHECKED'} />
|
||||
<small>{item.archive_integrity_member_count != null ? `${item.archive_integrity_member_count} files` : item.archive_integrity_method || '-'}</small>
|
||||
|
||||
@@ -63,6 +63,17 @@ const STATUS_LABEL = {
|
||||
pending: '等待中',
|
||||
};
|
||||
|
||||
const FAILURE_REASON_LABEL = {
|
||||
'LandSAR access violation during coherence mask/phase unwrapping': 'LandSAR 访问冲突(相干性掩膜/相位解缠)',
|
||||
'Insufficient tie/GCP points for DEM/geocoding': 'DEM / 地理编码控制点不足',
|
||||
'DEM/sub-terrain processing failed': 'DEM / 去地形阶段失败',
|
||||
'Insufficient GCPs for baseline/calibration': '基线精估计控制点不足',
|
||||
'Coherence mask/phase unwrapping failed': '相干性掩膜 / 相位解缠失败',
|
||||
'Processing timeout': '处理超时',
|
||||
'Result catalog publish failed': '结果目录发布失败',
|
||||
'Unclassified D-InSAR failure': '未分类失败',
|
||||
};
|
||||
|
||||
const PYINT_DEM_MODE_LABEL = {
|
||||
local_fabdem: '本地 FABDEM',
|
||||
opentopo: 'OpenTopography',
|
||||
@@ -267,6 +278,123 @@ function formatTaskRootUpdatedAt(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function safeCount(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function getRunCounts(run) {
|
||||
const completed = safeCount(run?.completed_items);
|
||||
const failed = safeCount(run?.failed_items);
|
||||
const skipped = safeCount(run?.skipped_items);
|
||||
const total = safeCount(run?.total_items);
|
||||
return { completed, failed, skipped, total };
|
||||
}
|
||||
|
||||
function hasRunCounts(run) {
|
||||
const { completed, failed, skipped, total } = getRunCounts(run);
|
||||
return [completed, failed, skipped, total].some(value => value != null);
|
||||
}
|
||||
|
||||
function formatRunCounts(run) {
|
||||
const { completed, failed, skipped, total } = getRunCounts(run);
|
||||
const parts = [];
|
||||
if (completed != null) parts.push(`成功 ${completed}`);
|
||||
if (failed != null) parts.push(`失败 ${failed}`);
|
||||
if (skipped != null && skipped > 0) parts.push(`跳过 ${skipped}`);
|
||||
if (total != null) parts.push(`总数 ${total}`);
|
||||
return parts.join(' / ');
|
||||
}
|
||||
|
||||
function statusToneClass(status) {
|
||||
const normalized = String(status || '').toLowerCase();
|
||||
if (normalized === 'success' || normalized === 'completed') return 'tone-ready';
|
||||
if (normalized === 'failed') return 'tone-error';
|
||||
if (normalized === 'running') return 'tone-info';
|
||||
if (normalized === 'cancelled' || normalized === 'canceled') return 'tone-neutral';
|
||||
return 'tone-warn';
|
||||
}
|
||||
|
||||
function formatFailureReason(reason) {
|
||||
return FAILURE_REASON_LABEL[reason] || reason || '未分类失败';
|
||||
}
|
||||
|
||||
function compactText(value, maxLength = 180) {
|
||||
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
||||
if (!text) return '';
|
||||
return text.length > maxLength ? `${text.slice(0, maxLength - 1).trim()}…` : text;
|
||||
}
|
||||
|
||||
function classifyFailureReason(errorMessage) {
|
||||
const text = String(errorMessage || '');
|
||||
const lower = text.toLowerCase();
|
||||
if (text.includes('3221225477') || lower.includes('status_access_violation')) {
|
||||
return 'LandSAR access violation during coherence mask/phase unwrapping';
|
||||
}
|
||||
if (lower.includes('not enough gcps') || lower.includes('space insar calibration failed')) {
|
||||
return 'Insufficient GCPs for baseline/calibration';
|
||||
}
|
||||
if (
|
||||
lower.includes('no enough points')
|
||||
|| lower.includes('not enough points')
|
||||
|| lower.includes('geo_extract_gcp')
|
||||
|| lower.includes('无满足snr')
|
||||
|| text.includes('离散采样点')
|
||||
) {
|
||||
return 'Insufficient tie/GCP points for DEM/geocoding';
|
||||
}
|
||||
if (lower.includes('dem/sub-terrain') || lower.includes('subterrain') || lower.includes('sub-terrain')) {
|
||||
return 'DEM/sub-terrain processing failed';
|
||||
}
|
||||
if (text.includes('相干性掩膜') && text.includes('相位解缠')) {
|
||||
return 'Coherence mask/phase unwrapping failed';
|
||||
}
|
||||
if (lower.includes('timeout') || lower.includes('timed out') || text.includes('超时')) {
|
||||
return 'Processing timeout';
|
||||
}
|
||||
if (lower.includes('publish')) {
|
||||
return 'Result catalog publish failed';
|
||||
}
|
||||
return 'Unclassified D-InSAR failure';
|
||||
}
|
||||
|
||||
function buildFailureSummaryFromItems(run) {
|
||||
const items = Array.isArray(run?.items) ? run.items : [];
|
||||
const failedItems = items.filter(item => String(item?.status || '').toUpperCase() === 'FAILED' || item?.last_error);
|
||||
if (failedItems.length === 0) return null;
|
||||
|
||||
const groupsByReason = new Map();
|
||||
const detailItems = failedItems.map(item => {
|
||||
const reason = classifyFailureReason(item?.last_error);
|
||||
const label = item?.task_alias || item?.task_name || item?.pair_key || '未命名任务';
|
||||
const group = groupsByReason.get(reason) || { reason, count: 0, items: [] };
|
||||
group.count += 1;
|
||||
group.items.push(label);
|
||||
groupsByReason.set(reason, group);
|
||||
return {
|
||||
task_alias: item?.task_alias,
|
||||
task_name: item?.task_name,
|
||||
reason,
|
||||
error: compactText(item?.last_error, 260),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
failed_count: failedItems.length,
|
||||
partial: true,
|
||||
groups: Array.from(groupsByReason.values()),
|
||||
items: detailItems,
|
||||
};
|
||||
}
|
||||
|
||||
function getRunFailureSummary(run) {
|
||||
const summary = run?.summary_json?.failure_summary;
|
||||
if (summary && Number(summary.failed_count || 0) > 0) {
|
||||
return summary;
|
||||
}
|
||||
return buildFailureSummaryFromItems(run);
|
||||
}
|
||||
|
||||
function RunPathBlock({ run }) {
|
||||
const items = Array.isArray(run?.items) ? run.items : [];
|
||||
const item = items.find(entry => entry?.status === 'RUNNING') || items[0] || null;
|
||||
@@ -299,6 +427,67 @@ function RunPathBlock({ run }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RunStatusBlock({ run }) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 4, minWidth: 120 }}>
|
||||
<span className={`dinsar-status-pill ${statusToneClass(run?.status)}`}>
|
||||
{formatStatus(run?.status)}
|
||||
</span>
|
||||
{hasRunCounts(run) && (
|
||||
<span style={{ color: '#475569', fontSize: 11, lineHeight: 1.35 }}>
|
||||
{formatRunCounts(run)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunSituationBlock({ run }) {
|
||||
const failureSummary = getRunFailureSummary(run);
|
||||
const countsText = hasRunCounts(run) ? formatRunCounts(run) : '';
|
||||
const message = compactText(run?.message, 220);
|
||||
if (!countsText && !failureSummary && !message) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #e2e8f0',
|
||||
background: '#f8fafc',
|
||||
color: '#334155',
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', marginBottom: failureSummary ? 6 : 0 }}>
|
||||
<strong style={{ fontSize: 12, color: '#0f172a' }}>运行情况</strong>
|
||||
{countsText && <span style={{ fontSize: 11, color: '#475569' }}>{countsText}</span>}
|
||||
</div>
|
||||
{failureSummary ? (
|
||||
<div style={{ display: 'grid', gap: 5 }}>
|
||||
{(failureSummary.groups || []).slice(0, 4).map((group, index) => (
|
||||
<div key={`${group.reason || 'reason'}-${index}`} style={{ fontSize: 11, color: '#7f1d1d' }}>
|
||||
<strong>{formatFailureReason(group.reason)}</strong>
|
||||
<span>:{Number(group.count || 0)} 项</span>
|
||||
{Array.isArray(group.items) && group.items.length > 0 && (
|
||||
<span style={{ color: '#475569' }}>({group.items.slice(0, 3).join('、')}{group.items.length > 3 ? ' 等' : ''})</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{failureSummary.partial && Number(run?.failed_items || 0) > Number(failureSummary.failed_count || 0) && (
|
||||
<div style={{ fontSize: 11, color: '#92400e' }}>
|
||||
当前接口仅返回部分失败明细,请查看日志获取完整失败项。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : message ? (
|
||||
<div style={{ marginTop: 6, fontSize: 11, color: '#475569' }}>{message}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewIssueList({ title, items, tone = 'warning' }) {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return null;
|
||||
@@ -1957,19 +2146,15 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
<td style={{ padding: '6px 8px', fontFamily: 'monospace', fontSize: 11 }}>{run.run_id}</td>
|
||||
<td style={{ padding: '6px 8px' }}>{formatEngineLabel(run.engine)}</td>
|
||||
<td style={{ padding: '6px 8px' }}>{formatSatelliteFamilyLabel(inferSatelliteFamilyFromResultLike(run))}</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
color: run.status === 'success' ? '#16a34a' : run.status === 'failed' ? '#ef4444' : '#64748b',
|
||||
}}
|
||||
>
|
||||
{formatStatus(run.status)}
|
||||
<td style={{ padding: '6px 8px', verticalAlign: 'top' }}>
|
||||
<RunStatusBlock run={run} />
|
||||
</td>
|
||||
<td style={{ padding: '6px 8px', color: '#94a3b8', whiteSpace: 'nowrap' }}>
|
||||
{run.started_at ? new Date(run.started_at * 1000).toLocaleString() : '-'}
|
||||
</td>
|
||||
<td style={{ padding: '6px 8px', maxWidth: 520, fontSize: 11 }}>
|
||||
<RunPathBlock run={run} />
|
||||
<RunSituationBlock run={run} />
|
||||
</td>
|
||||
<td style={{ padding: '6px 8px', whiteSpace: 'nowrap' }}>
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { scanDinsarResults } from './api/dinsar';
|
||||
import { listTaskRoots } from './api/dinsarProduction';
|
||||
import { extractDispResults } from './api/idl';
|
||||
import { clearTaskLogs, deleteTaskLog, getTaskLogs } from './api/tasks';
|
||||
@@ -8,10 +7,16 @@ import DinsarCatalogPanel from './components/DinsarCatalogPanel';
|
||||
import useTaskMonitor from './hooks/useTaskMonitor';
|
||||
|
||||
const PRODUCT_TASK_TYPES = [
|
||||
'EXTRACT_DINSAR_PRODUCTS',
|
||||
'SCAN_DINSAR',
|
||||
'PUBLISH_DINSAR_PRODUCTS',
|
||||
'REBUILD_DINSAR_CATALOG',
|
||||
];
|
||||
|
||||
const TASK_TYPE_LABEL = {
|
||||
EXTRACT_DINSAR_PRODUCTS: 'D-InSAR 结果提取与登记',
|
||||
PUBLISH_DINSAR_PRODUCTS: 'D-InSAR 结果发布',
|
||||
REBUILD_DINSAR_CATALOG: 'D-InSAR 结果目录重建',
|
||||
SCAN_DINSAR: 'D-InSAR 结果扫描',
|
||||
};
|
||||
|
||||
@@ -167,7 +172,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
try {
|
||||
const result = await extractDispResults(productionRoot.trim(), null);
|
||||
setExtractResult(result);
|
||||
const scanResult = await scanDinsarResults();
|
||||
const scanResult = result;
|
||||
setActionMessage(scanResult?.message || `D-InSAR 结果登记任务已提交:${scanResult?.task_id || '-'}`);
|
||||
if (scanResult?.task_id) {
|
||||
onJobQueued?.(scanResult.task_id);
|
||||
@@ -262,6 +267,8 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
<div className={`dinsar-products-result-card ${extractResult.error ? 'error' : 'success'}`}>
|
||||
{extractResult.error ? (
|
||||
<span>提取失败:{extractResult.error}</span>
|
||||
) : extractResult.queued ? (
|
||||
<span>D-InSAR 结果提取与登记任务已入队:{extractResult.task_id || '-'}</span>
|
||||
) : (
|
||||
<>
|
||||
<div>提取完成:复制 {extractResult.copied || 0} 个文件,覆盖 {extractResult.overwritten || 0} 个文件。</div>
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
getJobLog,
|
||||
deleteRun,
|
||||
} from './api/idl';
|
||||
import { scanDinsarResults } from './api/dinsar';
|
||||
import TaskStatusPanel from './components/tasks/TaskStatusPanel';
|
||||
import useTaskMonitor from './hooks/useTaskMonitor';
|
||||
|
||||
@@ -38,7 +37,7 @@ function IDLAutomationPanel({ readOnly = false, onJobQueued }) {
|
||||
const [showCancelInput, setShowCancelInput] = useState(false);
|
||||
const [cancelPassword, setCancelPassword] = useState('');
|
||||
const idlTaskMonitor = useTaskMonitor({
|
||||
taskTypes: ['IDL_IMPORT', 'IDL_DINSAR'],
|
||||
taskTypes: ['IDL_RUN_IMPORT', 'IDL_RUN_DINSAR', 'EXTRACT_DINSAR_PRODUCTS'],
|
||||
showRecent: true,
|
||||
recentLimit: 1,
|
||||
});
|
||||
@@ -147,17 +146,8 @@ function IDLAutomationPanel({ readOnly = false, onJobQueued }) {
|
||||
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}`);
|
||||
setMessage(`D-InSAR 结果提取与登记任务已入队。task_id=${r?.task_id || '-'}`);
|
||||
if (r?.task_id) onJobQueued?.(r.task_id);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -533,16 +523,22 @@ function IDLAutomationPanel({ readOnly = false, onJobQueued }) {
|
||||
</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 ·
|
||||
新增 {extractResult.copied} ·
|
||||
更新 {extractResult.overwritten} ·
|
||||
跳过 {extractResult.skipped}
|
||||
{extractResult.failed > 0 && (
|
||||
<span style={{ color: '#dc2626' }}> · 失败 {extractResult.failed}</span>
|
||||
)}
|
||||
</div>
|
||||
{extractResult.queued ? (
|
||||
<div>D-InSAR 结果提取与登记任务已入队。task_id={extractResult.task_id || '-'}</div>
|
||||
) : (
|
||||
<>
|
||||
<div>目标目录: <code style={{ fontSize: '11px' }}>{extractResult.target_dir}</code></div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
处理 {extractResult.processed} 个 Task ·
|
||||
新增 {extractResult.copied} ·
|
||||
更新 {extractResult.overwritten} ·
|
||||
跳过 {extractResult.skipped}
|
||||
{extractResult.failed > 0 && (
|
||||
<span style={{ color: '#dc2626' }}> · 失败 {extractResult.failed}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,816 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
getLandsarLt1Capabilities,
|
||||
listLandsarLt1Products,
|
||||
previewLandsarLt1Production,
|
||||
submitLandsarLt1Production,
|
||||
} from './api/landsarLt1Production';
|
||||
import { searchRadarData } from './api/radar';
|
||||
import { getRegionChildren } from './api/aoi';
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [50, 100, 200, 500];
|
||||
const DEFAULT_SEARCH = {
|
||||
imaging_date_from: '',
|
||||
imaging_date_to: '',
|
||||
imaging_mode: '',
|
||||
polarization: '',
|
||||
product_level: '',
|
||||
orbit_direction: '',
|
||||
relative_orbit: '',
|
||||
product_unique_id: '',
|
||||
};
|
||||
|
||||
const shellStyle = { display: 'grid', gap: 12 };
|
||||
const sectionStyle = {
|
||||
background: '#ffffff',
|
||||
border: '1px solid #d8dee8',
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
};
|
||||
const gridStyle = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
|
||||
gap: 12,
|
||||
};
|
||||
const labelStyle = {
|
||||
display: 'grid',
|
||||
gap: 5,
|
||||
color: '#475569',
|
||||
fontSize: 12,
|
||||
fontWeight: 650,
|
||||
};
|
||||
const inputStyle = {
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: 6,
|
||||
padding: '8px 9px',
|
||||
color: '#0f172a',
|
||||
background: '#ffffff',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.35,
|
||||
};
|
||||
const mutedStyle = { color: '#64748b', fontSize: 12, lineHeight: 1.55 };
|
||||
const buttonStyle = {
|
||||
border: '1px solid #2563eb',
|
||||
borderRadius: 6,
|
||||
background: '#2563eb',
|
||||
color: '#ffffff',
|
||||
padding: '8px 12px',
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
const ghostButtonStyle = {
|
||||
...buttonStyle,
|
||||
border: '1px solid #cbd5e1',
|
||||
background: '#ffffff',
|
||||
color: '#334155',
|
||||
};
|
||||
const disabledButtonStyle = {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
};
|
||||
const tableHeaderStyle = {
|
||||
textAlign: 'left',
|
||||
padding: 8,
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
};
|
||||
const tableCellStyle = {
|
||||
padding: 8,
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
};
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function formatYmd(value) {
|
||||
const text = String(value || '').trim();
|
||||
const compact = text.match(/^(\d{4})(\d{2})(\d{2})$/);
|
||||
if (compact) return `${compact[1]}-${compact[2]}-${compact[3]}`;
|
||||
return text || '-';
|
||||
}
|
||||
|
||||
function StatusPill({ ok, text }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
border: `1px solid ${ok ? '#86efac' : '#fecaca'}`,
|
||||
borderRadius: 999,
|
||||
padding: '3px 8px',
|
||||
color: ok ? '#166534' : '#991b1b',
|
||||
background: ok ? '#f0fdf4' : '#fef2f2',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function sceneProduced(scene) {
|
||||
return Boolean(scene.lt1_image_produced || scene.lt1_landsar_produced);
|
||||
}
|
||||
|
||||
function getSceneTitle(scene) {
|
||||
return scene.product_unique_id || scene.unique_id || scene.source_product_token || `radar:${scene.id}`;
|
||||
}
|
||||
|
||||
function getErrorMessage(error, fallback) {
|
||||
const detail = error?.response?.data?.detail;
|
||||
if (typeof detail === 'string') return detail;
|
||||
if (detail) return JSON.stringify(detail);
|
||||
return error?.message || fallback;
|
||||
}
|
||||
|
||||
function buildRadarSearchFormData(criteria, page, regionTreeId) {
|
||||
const formData = new FormData();
|
||||
formData.append('limit', String(page.limit));
|
||||
formData.append('offset', String(page.offset));
|
||||
formData.append('satellite_family', 'LT1');
|
||||
formData.append('source_format', 'LT1_ARCHIVE');
|
||||
Object.entries(criteria || {}).forEach(([key, rawValue]) => {
|
||||
const value = String(rawValue ?? '').trim();
|
||||
if (value) formData.append(key, value);
|
||||
});
|
||||
if (regionTreeId) {
|
||||
formData.append('region_tree_id', regionTreeId);
|
||||
}
|
||||
return formData;
|
||||
}
|
||||
|
||||
export default function LandsarLt1ProductionPanel({ readOnly, onJobQueued }) {
|
||||
const [capabilities, setCapabilities] = useState(null);
|
||||
const [products, setProducts] = useState([]);
|
||||
const [scenes, setScenes] = useState([]);
|
||||
const [scenePage, setScenePage] = useState({
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
const [searchDraft, setSearchDraft] = useState(DEFAULT_SEARCH);
|
||||
const [searchApplied, setSearchApplied] = useState(DEFAULT_SEARCH);
|
||||
const [regionMode, setRegionMode] = useState('none');
|
||||
const [regionSelection, setRegionSelection] = useState({ province: '', city: '' });
|
||||
const [regionOptions, setRegionOptions] = useState({ provinces: [], cities: [] });
|
||||
const [selectedRadarIds, setSelectedRadarIds] = useState(() => new Set());
|
||||
const [preview, setPreview] = useState(null);
|
||||
const [message, setMessage] = useState('');
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [regionLoading, setRegionLoading] = useState(false);
|
||||
const [form, setForm] = useState({ mode: 'scene', taskName: '' });
|
||||
|
||||
const selectedRegionTreeId = regionSelection.city || regionSelection.province || '';
|
||||
const producedRadarIds = useMemo(
|
||||
() => new Set(scenes.filter(sceneProduced).map(scene => Number(scene.id))),
|
||||
[scenes],
|
||||
);
|
||||
const selectedRadarIdList = useMemo(
|
||||
() => [...selectedRadarIds].filter(id => !producedRadarIds.has(Number(id))),
|
||||
[selectedRadarIds, producedRadarIds],
|
||||
);
|
||||
const selectableCurrentScenes = useMemo(
|
||||
() => scenes.filter(scene => !sceneProduced(scene) && scene.source_product_ref_id),
|
||||
[scenes],
|
||||
);
|
||||
const allCurrentSelectableSelected = selectableCurrentScenes.length > 0
|
||||
&& selectableCurrentScenes.every(scene => selectedRadarIds.has(scene.id));
|
||||
const sceneStart = scenePage.total === 0 ? 0 : scenePage.offset + 1;
|
||||
const sceneEnd = Math.min(scenePage.offset + scenes.length, scenePage.total || scenePage.offset + scenes.length);
|
||||
const payload = useMemo(() => ({
|
||||
radar_data_ids: selectedRadarIdList,
|
||||
mode: form.mode,
|
||||
task_name: form.taskName.trim() || undefined,
|
||||
}), [form, selectedRadarIdList]);
|
||||
|
||||
const refreshProducts = useCallback(async () => {
|
||||
const result = await listLandsarLt1Products({ limit: 20, offset: 0 });
|
||||
setProducts(Array.isArray(result?.items) ? result.items : []);
|
||||
}, []);
|
||||
|
||||
const refreshScenes = useCallback(async () => {
|
||||
setSearchLoading(true);
|
||||
try {
|
||||
const result = await searchRadarData(
|
||||
buildRadarSearchFormData(
|
||||
searchApplied,
|
||||
scenePage,
|
||||
regionMode === 'region' ? selectedRegionTreeId : '',
|
||||
),
|
||||
);
|
||||
const items = Array.isArray(result?.items) ? result.items : [];
|
||||
const total = Number(result?.total ?? items.length);
|
||||
const offset = Number(result?.offset ?? scenePage.offset);
|
||||
const limit = Number(result?.limit ?? scenePage.limit);
|
||||
setScenes(items);
|
||||
setScenePage(current => ({
|
||||
...current,
|
||||
limit,
|
||||
offset,
|
||||
total,
|
||||
hasMore: Boolean(result?.has_more ?? (offset + items.length < total)),
|
||||
}));
|
||||
} finally {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
}, [regionMode, scenePage.limit, scenePage.offset, searchApplied, selectedRegionTreeId]);
|
||||
|
||||
const refreshCapabilities = useCallback(async () => {
|
||||
const result = await getLandsarLt1Capabilities();
|
||||
setCapabilities(result);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshCapabilities().catch(error => setMessage(getErrorMessage(error, '读取 LT-1 生产能力失败')));
|
||||
refreshProducts().catch(() => {});
|
||||
}, [refreshCapabilities, refreshProducts]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshScenes().catch(error => setMessage(getErrorMessage(error, '检索 LT-1 影像失败')));
|
||||
}, [refreshScenes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!producedRadarIds.size) return;
|
||||
setSelectedRadarIds(current => {
|
||||
let changed = false;
|
||||
const next = new Set();
|
||||
current.forEach(id => {
|
||||
if (producedRadarIds.has(Number(id))) changed = true;
|
||||
else next.add(id);
|
||||
});
|
||||
return changed ? next : current;
|
||||
});
|
||||
}, [producedRadarIds]);
|
||||
|
||||
const loadProvinces = useCallback(async () => {
|
||||
setRegionLoading(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const result = await getRegionChildren('1');
|
||||
setRegionOptions({ provinces: Array.isArray(result?.children) ? result.children : [], cities: [] });
|
||||
} catch (error) {
|
||||
setMessage(getErrorMessage(error, '加载行政区失败'));
|
||||
} finally {
|
||||
setRegionLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadCities = useCallback(async provinceId => {
|
||||
if (!provinceId) {
|
||||
setRegionOptions(current => ({ ...current, cities: [] }));
|
||||
return;
|
||||
}
|
||||
setRegionLoading(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const result = await getRegionChildren(provinceId);
|
||||
setRegionOptions(current => ({ ...current, cities: Array.isArray(result?.children) ? result.children : [] }));
|
||||
} catch (error) {
|
||||
setMessage(getErrorMessage(error, '加载地市失败'));
|
||||
} finally {
|
||||
setRegionLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateField = (field, value) => {
|
||||
setForm(current => ({ ...current, [field]: value }));
|
||||
setPreview(null);
|
||||
setMessage('');
|
||||
};
|
||||
|
||||
const updateSearchDraft = (field, value) => {
|
||||
setSearchDraft(current => ({ ...current, [field]: value }));
|
||||
setPreview(null);
|
||||
setMessage('');
|
||||
};
|
||||
|
||||
const updateRegionMode = async value => {
|
||||
setRegionMode(value);
|
||||
setRegionSelection({ province: '', city: '' });
|
||||
setPreview(null);
|
||||
setMessage('');
|
||||
if (value === 'region' && regionOptions.provinces.length === 0) {
|
||||
await loadProvinces();
|
||||
}
|
||||
};
|
||||
|
||||
const updateProvince = async value => {
|
||||
setRegionSelection({ province: value, city: '' });
|
||||
setPreview(null);
|
||||
if (value) await loadCities(value);
|
||||
else setRegionOptions(current => ({ ...current, cities: [] }));
|
||||
};
|
||||
|
||||
const updateCity = value => {
|
||||
setRegionSelection(current => ({ ...current, city: value }));
|
||||
setPreview(null);
|
||||
};
|
||||
|
||||
const toggleScene = scene => {
|
||||
if (sceneProduced(scene) || !scene.source_product_ref_id) return;
|
||||
setPreview(null);
|
||||
setMessage('');
|
||||
setSelectedRadarIds(current => {
|
||||
const next = new Set(current);
|
||||
if (next.has(scene.id)) next.delete(scene.id);
|
||||
else next.add(scene.id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleCurrentPageSelection = () => {
|
||||
setPreview(null);
|
||||
setMessage('');
|
||||
setSelectedRadarIds(current => {
|
||||
const next = new Set(current);
|
||||
if (allCurrentSelectableSelected) {
|
||||
selectableCurrentScenes.forEach(scene => next.delete(scene.id));
|
||||
} else {
|
||||
selectableCurrentScenes.forEach(scene => next.add(scene.id));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updatePageSize = value => {
|
||||
const limit = Number(value);
|
||||
setScenePage(current => ({ ...current, limit, offset: 0 }));
|
||||
};
|
||||
|
||||
const goToScenePage = offset => {
|
||||
setScenePage(current => ({ ...current, offset: Math.max(0, offset) }));
|
||||
};
|
||||
|
||||
const applySearch = () => {
|
||||
if (regionMode === 'region' && !selectedRegionTreeId) {
|
||||
setMessage('请选择行政区。');
|
||||
return;
|
||||
}
|
||||
setSearchApplied(searchDraft);
|
||||
setScenePage(current => ({ ...current, offset: 0 }));
|
||||
setPreview(null);
|
||||
setMessage('');
|
||||
};
|
||||
|
||||
const resetSearch = () => {
|
||||
setSearchDraft(DEFAULT_SEARCH);
|
||||
setSearchApplied(DEFAULT_SEARCH);
|
||||
setRegionMode('none');
|
||||
setRegionSelection({ province: '', city: '' });
|
||||
setScenePage(current => ({ ...current, offset: 0 }));
|
||||
setPreview(null);
|
||||
setMessage('');
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setMessage('');
|
||||
try {
|
||||
await Promise.all([refreshProducts(), refreshScenes()]);
|
||||
} catch (error) {
|
||||
setMessage(getErrorMessage(error, '刷新失败'));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = async () => {
|
||||
setActionLoading(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const result = await previewLandsarLt1Production(payload);
|
||||
setPreview(result);
|
||||
setMessage(result.allow_submit ? '预览通过' : '预览未通过');
|
||||
} catch (error) {
|
||||
setPreview(null);
|
||||
setMessage(getErrorMessage(error, '预览失败'));
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setActionLoading(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const result = await submitLandsarLt1Production(payload);
|
||||
const queued = Array.isArray(result.queued) ? result.queued : [];
|
||||
setMessage(`已提交 ${queued.length || 1} 个地理编码 GeoTIFF 生产任务`);
|
||||
if (result.task_id) onJobQueued?.(result.task_id);
|
||||
else queued.forEach(item => item.task_id && onJobQueued?.(item.task_id));
|
||||
setPreview(null);
|
||||
setSelectedRadarIds(new Set());
|
||||
await Promise.all([refreshProducts(), refreshScenes()]);
|
||||
} catch (error) {
|
||||
setMessage(getErrorMessage(error, '提交失败'));
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const busy = actionLoading || searchLoading || regionLoading;
|
||||
const canSubmitSelection = !readOnly && selectedRadarIdList.length > 0 && !actionLoading;
|
||||
|
||||
return (
|
||||
<div style={shellStyle}>
|
||||
<section style={sectionStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 16 }}>LT-1 地理编码影像生产</h3>
|
||||
<div style={{ ...mutedStyle, marginTop: 5 }}>
|
||||
复用影像检索筛选 LT-1 场景,再提交 Gamma 单景流水线生成 analysis_ready.tif。
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill
|
||||
ok={capabilities?.engine === 'lt_gamma'}
|
||||
text={capabilities?.engine === 'lt_gamma' ? 'lt_gamma 已配置' : '未配置'}
|
||||
/>
|
||||
</div>
|
||||
{capabilities?.message && <div style={{ ...mutedStyle, marginTop: 8 }}>{capabilities.message}</div>}
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<div style={gridStyle}>
|
||||
<label style={labelStyle}>
|
||||
生产模式
|
||||
<select
|
||||
style={inputStyle}
|
||||
value={form.mode}
|
||||
onChange={event => updateField('mode', event.target.value)}
|
||||
disabled={actionLoading || readOnly}
|
||||
>
|
||||
<option value="scene">单景</option>
|
||||
<option value="batch">批量单景</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
任务名
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={form.taskName}
|
||||
onChange={event => updateField('taskName', event.target.value)}
|
||||
disabled={actionLoading || readOnly}
|
||||
placeholder="可选"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 14, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...ghostButtonStyle, ...((!canSubmitSelection || actionLoading) ? disabledButtonStyle : {}) }}
|
||||
onClick={handlePreview}
|
||||
disabled={!canSubmitSelection}
|
||||
>
|
||||
预览
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...buttonStyle, ...((!canSubmitSelection || preview?.allow_submit === false) ? disabledButtonStyle : {}) }}
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmitSelection || preview?.allow_submit === false}
|
||||
>
|
||||
提交生产
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...ghostButtonStyle, ...(busy ? disabledButtonStyle : {}) }}
|
||||
onClick={handleRefresh}
|
||||
disabled={busy}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
{message && <div style={{ ...mutedStyle, marginTop: 10 }}>{message}</div>}
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 16 }}>生产候选检索</h3>
|
||||
<div style={{ ...mutedStyle, marginTop: 4 }}>
|
||||
调用影像检索能力,按时间、行政区、轨道、极化等条件规划要生产的 LT-1 场景。
|
||||
</div>
|
||||
</div>
|
||||
<div style={mutedStyle}>已选 {selectedRadarIdList.length} 景</div>
|
||||
</div>
|
||||
|
||||
<div style={{ ...gridStyle, marginTop: 12 }}>
|
||||
<label style={labelStyle}>
|
||||
成像时间起
|
||||
<input
|
||||
type="date"
|
||||
style={inputStyle}
|
||||
value={searchDraft.imaging_date_from}
|
||||
onChange={event => updateSearchDraft('imaging_date_from', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
成像时间止
|
||||
<input
|
||||
type="date"
|
||||
style={inputStyle}
|
||||
value={searchDraft.imaging_date_to}
|
||||
onChange={event => updateSearchDraft('imaging_date_to', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
成像模式
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={searchDraft.imaging_mode}
|
||||
onChange={event => updateSearchDraft('imaging_mode', event.target.value)}
|
||||
placeholder="如 MONO / KSC"
|
||||
/>
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
极化
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={searchDraft.polarization}
|
||||
onChange={event => updateSearchDraft('polarization', event.target.value)}
|
||||
placeholder="如 HH"
|
||||
/>
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
相对轨道
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={searchDraft.relative_orbit}
|
||||
onChange={event => updateSearchDraft('relative_orbit', event.target.value)}
|
||||
placeholder="可选"
|
||||
/>
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
产品名
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={searchDraft.product_unique_id}
|
||||
onChange={event => updateSearchDraft('product_unique_id', event.target.value)}
|
||||
placeholder="模糊匹配"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ ...gridStyle, marginTop: 12 }}>
|
||||
<label style={labelStyle}>
|
||||
空间范围
|
||||
<select
|
||||
style={inputStyle}
|
||||
value={regionMode}
|
||||
onChange={event => updateRegionMode(event.target.value)}
|
||||
disabled={regionLoading}
|
||||
>
|
||||
<option value="none">不限</option>
|
||||
<option value="region">行政区</option>
|
||||
</select>
|
||||
</label>
|
||||
{regionMode === 'region' && (
|
||||
<>
|
||||
<label style={labelStyle}>
|
||||
省份
|
||||
<select
|
||||
style={inputStyle}
|
||||
value={regionSelection.province}
|
||||
onChange={event => updateProvince(event.target.value)}
|
||||
disabled={regionLoading}
|
||||
>
|
||||
<option value="">选择省份</option>
|
||||
{regionOptions.provinces.map(item => (
|
||||
<option key={item.tree_id} value={item.tree_id}>{item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label style={labelStyle}>
|
||||
地市
|
||||
<select
|
||||
style={inputStyle}
|
||||
value={regionSelection.city}
|
||||
onChange={event => updateCity(event.target.value)}
|
||||
disabled={regionLoading || !regionSelection.province}
|
||||
>
|
||||
<option value="">不限地市</option>
|
||||
{regionOptions.cities.map(item => (
|
||||
<option key={item.tree_id} value={item.tree_id}>{item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<label style={labelStyle}>
|
||||
每页数量
|
||||
<select
|
||||
style={inputStyle}
|
||||
value={scenePage.limit}
|
||||
onChange={event => updatePageSize(event.target.value)}
|
||||
disabled={searchLoading}
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map(size => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'center', marginTop: 12, flexWrap: 'wrap' }}>
|
||||
<div style={mutedStyle}>
|
||||
{searchLoading ? '正在检索影像...' : `第 ${sceneStart}-${sceneEnd} 景 / 共 ${scenePage.total} 景`}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...buttonStyle, ...(busy ? disabledButtonStyle : {}) }}
|
||||
onClick={applySearch}
|
||||
disabled={busy}
|
||||
>
|
||||
检索
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...ghostButtonStyle, ...(busy ? disabledButtonStyle : {}) }}
|
||||
onClick={resetSearch}
|
||||
disabled={busy}
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...ghostButtonStyle, ...((readOnly || searchLoading || selectableCurrentScenes.length === 0) ? disabledButtonStyle : {}) }}
|
||||
onClick={toggleCurrentPageSelection}
|
||||
disabled={readOnly || searchLoading || selectableCurrentScenes.length === 0}
|
||||
>
|
||||
{allCurrentSelectableSelected ? '取消本页选择' : '选择本页可生产'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...ghostButtonStyle, ...((searchLoading || scenePage.offset <= 0) ? disabledButtonStyle : {}) }}
|
||||
onClick={() => goToScenePage(scenePage.offset - scenePage.limit)}
|
||||
disabled={searchLoading || scenePage.offset <= 0}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...ghostButtonStyle, ...((searchLoading || !scenePage.hasMore) ? disabledButtonStyle : {}) }}
|
||||
onClick={() => goToScenePage(scenePage.offset + scenePage.limit)}
|
||||
disabled={searchLoading || !scenePage.hasMore}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto', marginTop: 10 }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<thead>
|
||||
<tr style={{ color: '#475569', background: '#f8fafc' }}>
|
||||
<th style={tableHeaderStyle}>选择</th>
|
||||
<th style={tableHeaderStyle}>产品</th>
|
||||
<th style={tableHeaderStyle}>日期</th>
|
||||
<th style={tableHeaderStyle}>模式</th>
|
||||
<th style={tableHeaderStyle}>轨道</th>
|
||||
<th style={tableHeaderStyle}>极化</th>
|
||||
<th style={tableHeaderStyle}>状态</th>
|
||||
<th style={tableHeaderStyle}>路径</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scenes.map(scene => {
|
||||
const produced = sceneProduced(scene);
|
||||
const selectable = !produced && Boolean(scene.source_product_ref_id);
|
||||
const selected = selectedRadarIds.has(scene.id);
|
||||
return (
|
||||
<tr key={scene.id} style={{ background: selected ? '#eff6ff' : '#ffffff', opacity: produced ? 0.62 : 1 }}>
|
||||
<td style={tableCellStyle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
disabled={readOnly || actionLoading || !selectable}
|
||||
onChange={() => toggleScene(scene)}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ ...tableCellStyle, color: '#0f172a', fontWeight: 650 }}>
|
||||
{getSceneTitle(scene)}
|
||||
</td>
|
||||
<td style={{ ...tableCellStyle, color: '#475569' }}>{formatYmd(scene.imaging_date)}</td>
|
||||
<td style={{ ...tableCellStyle, color: '#475569' }}>{scene.imaging_mode || '-'}</td>
|
||||
<td style={{ ...tableCellStyle, color: '#475569' }}>{scene.relative_orbit || scene.orbit_circle || '-'}</td>
|
||||
<td style={{ ...tableCellStyle, color: '#475569' }}>{scene.polarization || '-'}</td>
|
||||
<td style={{ ...tableCellStyle, color: produced ? '#166534' : '#475569', fontWeight: produced ? 700 : 500 }}>
|
||||
{produced ? '已生产 GeoTIFF' : (scene.source_product_ref_id ? '可生产' : '未关联源资产')}
|
||||
</td>
|
||||
<td
|
||||
title={scene.file_path}
|
||||
style={{
|
||||
...tableCellStyle,
|
||||
color: '#475569',
|
||||
maxWidth: 360,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{scene.file_path}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{scenes.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan="8" style={{ ...tableCellStyle, color: '#64748b' }}>
|
||||
暂无符合条件的 LT-1 影像
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{preview && (
|
||||
<section style={sectionStyle}>
|
||||
<h3 style={{ margin: 0, fontSize: 16 }}>预览结果</h3>
|
||||
<div style={{ ...gridStyle, marginTop: 10 }}>
|
||||
<div style={mutedStyle}>场景数: {preview.scene_count}</div>
|
||||
<div style={mutedStyle}>engine: {preview.engine}</div>
|
||||
<div style={mutedStyle}>profile: {preview.profile_code}</div>
|
||||
</div>
|
||||
{Array.isArray(preview.blockers) && preview.blockers.length > 0 && (
|
||||
<div style={{ marginTop: 10, display: 'grid', gap: 6 }}>
|
||||
{preview.blockers.map(item => (
|
||||
<div key={item} style={{ color: '#991b1b', fontSize: 12 }}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(preview.warnings) && preview.warnings.length > 0 && (
|
||||
<div style={{ marginTop: 10, display: 'grid', gap: 6 }}>
|
||||
{preview.warnings.map(item => (
|
||||
<div key={item} style={{ color: '#92400e', fontSize: 12 }}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0, fontSize: 16 }}>最近 LT-1 GeoTIFF 产品</h3>
|
||||
<div style={mutedStyle}>{products.length} 项</div>
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto', marginTop: 10 }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<thead>
|
||||
<tr style={{ color: '#475569', background: '#f8fafc' }}>
|
||||
<th style={tableHeaderStyle}>产品</th>
|
||||
<th style={tableHeaderStyle}>状态</th>
|
||||
<th style={tableHeaderStyle}>日期</th>
|
||||
<th style={tableHeaderStyle}>单位</th>
|
||||
<th style={tableHeaderStyle}>时间</th>
|
||||
<th style={tableHeaderStyle}>GeoTIFF</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map(product => (
|
||||
<tr key={product.id}>
|
||||
<td style={{ ...tableCellStyle, color: '#0f172a', fontWeight: 650 }}>
|
||||
{product.display_name || product.product_id}
|
||||
</td>
|
||||
<td style={{ ...tableCellStyle, color: '#475569' }}>{product.status}</td>
|
||||
<td style={{ ...tableCellStyle, color: '#475569' }}>{formatYmd(product.summary?.imaging_date)}</td>
|
||||
<td style={{ ...tableCellStyle, color: '#475569' }}>{product.summary?.backscatter_unit || '-'}</td>
|
||||
<td style={{ ...tableCellStyle, color: '#475569' }}>{formatTime(product.published_at)}</td>
|
||||
<td
|
||||
title={product.primary_asset_path}
|
||||
style={{
|
||||
...tableCellStyle,
|
||||
color: '#475569',
|
||||
maxWidth: 360,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{product.primary_asset_path || product.publish_dir || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{products.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan="6" style={{ ...tableCellStyle, color: '#64748b' }}>
|
||||
暂无产品
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const LazyDinsarProductionPanel = lazy(() => import('./DinsarProductionPanel'));
|
||||
const LazySbasInsarProductionPanel = lazy(() => import('./SbasInsarProductionPanel'));
|
||||
const LazySbasInsarProductsPanel = lazy(() => import('./SbasInsarProductsPanel'));
|
||||
const LazyDinsarProductsPanel = lazy(() => import('./DinsarProductsPanel'));
|
||||
const LazyLandsarLt1ProductionPanel = lazy(() => import('./LandsarLt1ProductionPanel'));
|
||||
const LazyPairPlanningPanel = lazy(() => import('./panels/PairPlanningPanel'));
|
||||
const LazyPairsListPanel = lazy(() => import('./panels/PairsListPanel'));
|
||||
const LazyBatchPanel = lazy(() => import('./panels/BatchPanel'));
|
||||
@@ -25,17 +26,6 @@ const WORKFLOW_STEPS = [
|
||||
];
|
||||
|
||||
const SENSOR_PRODUCTION_PLACEHOLDERS = {
|
||||
lt1_production: {
|
||||
title: '陆探一生产占位',
|
||||
note: '当前保留 LT-1 源压缩包本机登记与按需 materialize 入口。',
|
||||
rows: [
|
||||
['数据来源', '本机源压缩包 archive'],
|
||||
['精轨策略', '按生产任务关联 orbit 资产'],
|
||||
['准备方式', '按需 materialize 到 Task_Pool'],
|
||||
['生产边界', 'D-InSAR/SBAS 不走 UNC'],
|
||||
['结果管理', '进入统一产品 catalog'],
|
||||
],
|
||||
},
|
||||
sentinel1_production: {
|
||||
title: 'Sentinel-1 生产占位',
|
||||
note: '当前主要沉淀数据与精轨管理约束,SBAS 仅保留规划能力。',
|
||||
@@ -206,6 +196,13 @@ export default function ProductionWorkspace({
|
||||
});
|
||||
};
|
||||
|
||||
const handleLt1ImageQueued = taskId => {
|
||||
onTaskStart?.(taskId, 'LT-1 地理编码 GeoTIFF 生产任务已入队。', {
|
||||
taskType: 'SAR_SCENE_PREPROCESS',
|
||||
nonBlocking: true,
|
||||
});
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (activeView === 'dinsar_pairing') {
|
||||
return (
|
||||
@@ -215,11 +212,11 @@ export default function ProductionWorkspace({
|
||||
isLoading={isLoading}
|
||||
isReadOnlyUser={readOnly}
|
||||
hasEnoughRadarScenesForPlanning={hasEnoughRadarScenesForPlanning}
|
||||
onOpenPairingModal={pairingPanel?.openModal}
|
||||
onOpenPairingModal={pairingPanel?.onOpenPairingModal}
|
||||
hasRadarSearched={hasRadarSearched}
|
||||
onRefreshRadarSearch={radarPanel?.refresh}
|
||||
onSearchAll={radarPanel?.searchAll}
|
||||
onRefreshDinsar={pairsPanel?.refreshDinsar}
|
||||
onRefreshRadarSearch={pairingPanel?.onRefreshRadarSearch}
|
||||
onSearchAll={radarPanel?.onSearchAll}
|
||||
onRefreshDinsar={pairingPanel?.onRefreshDinsar}
|
||||
language={language}
|
||||
/>
|
||||
);
|
||||
@@ -228,8 +225,12 @@ export default function ProductionWorkspace({
|
||||
if (activeView === 'dinsar_pairs') {
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.1fr) minmax(360px, 0.9fr)', gap: 14 }}>
|
||||
<LazyPairsListPanel pairsPanel={pairsPanel} isReadOnlyUser={readOnly} language={language} />
|
||||
<LazyBatchPanel pairsPanel={pairsPanel} isReadOnlyUser={readOnly} language={language} />
|
||||
<LazyPairsListPanel
|
||||
onVisualizePair={pairsPanel?.onVisualizePair}
|
||||
onTogglePairVisibility={pairsPanel?.onTogglePairVisibility}
|
||||
onCreateDinsarBatch={pairsPanel?.onCreateDinsarBatch}
|
||||
/>
|
||||
<LazyBatchPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -272,11 +273,15 @@ export default function ProductionWorkspace({
|
||||
return <LazySbasInsarProductsPanel readOnly={readOnly} onJobQueued={handleSbasProductQueued} />;
|
||||
}
|
||||
|
||||
if (activeView === 'lt1_production') {
|
||||
return <LazyLandsarLt1ProductionPanel readOnly={readOnly} onJobQueued={handleLt1ImageQueued} />;
|
||||
}
|
||||
|
||||
return <PlaceholderView config={SENSOR_PRODUCTION_PLACEHOLDERS[activeView]} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={shellStyle}>
|
||||
<div className="production-workspace-shell" style={shellStyle}>
|
||||
<div style={headerStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
|
||||
@@ -17,3 +17,4 @@ export * as unpackApi from './unpack';
|
||||
export * as statsApi from './stats';
|
||||
export * as timeseriesProductionApi from './timeseriesProduction';
|
||||
export * as psinsarProductsApi from './psinsarProducts';
|
||||
export * as landsarLt1ProductionApi from './landsarLt1Production';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export const getLandsarLt1Capabilities = () =>
|
||||
apiClient.get('/landsar-lt1-production/capabilities').then(r => r.data);
|
||||
|
||||
export const previewLandsarLt1Production = payload =>
|
||||
apiClient.post('/landsar-lt1-production/preview', payload).then(r => r.data);
|
||||
|
||||
export const submitLandsarLt1Production = payload =>
|
||||
apiClient.post('/landsar-lt1-production/run', payload).then(r => r.data);
|
||||
|
||||
export const previewLandsarLt1Import = previewLandsarLt1Production;
|
||||
export const submitLandsarLt1Import = submitLandsarLt1Production;
|
||||
|
||||
export const listLandsarLt1Products = (params = {}) =>
|
||||
apiClient.get('/landsar-lt1-production/products', { params }).then(r => r.data);
|
||||
|
||||
export const getLandsarLt1Product = productId =>
|
||||
apiClient.get(`/landsar-lt1-production/products/${encodeURIComponent(productId)}`).then(r => r.data);
|
||||
|
||||
export const getLandsarLt1AssetUrl = (productId, assetId) =>
|
||||
`/api/landsar-lt1-production/products/${encodeURIComponent(productId)}/assets/${encodeURIComponent(assetId)}`;
|
||||
@@ -1,6 +1,7 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export const getActiveTasks = () => apiClient.get('/tasks/active').then(r => r.data);
|
||||
export const getTaskRuntimeSummary = () => apiClient.get('/tasks/runtime-summary').then(r => r.data);
|
||||
export const getRecentTasks = (taskTypes = [], statuses = [], limit = 20, offset = 0) =>
|
||||
apiClient.get('/tasks/recent', {
|
||||
params: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getTaskTypeLabel } from '../config/taskUiPolicies';
|
||||
export default function GlobalTaskCenter({
|
||||
isVisible,
|
||||
activeTasks,
|
||||
runtimeSummary,
|
||||
t,
|
||||
isAdmin,
|
||||
showCancelTask,
|
||||
@@ -14,14 +15,27 @@ export default function GlobalTaskCenter({
|
||||
onCloseCancelTask,
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
if (!isVisible || activeTasks.length === 0) {
|
||||
const jobs = runtimeSummary?.jobs || {};
|
||||
const worker = runtimeSummary?.worker || {};
|
||||
const scan = runtimeSummary?.scan || {};
|
||||
const activeJobs = Array.isArray(jobs.items) ? jobs.items : [];
|
||||
const activeCount = Math.max(
|
||||
activeTasks.length,
|
||||
Number(jobs.active_count) || 0,
|
||||
);
|
||||
const workerCount = Number(worker.worker_count) || 0;
|
||||
const queuedJobs = Number(jobs.queued_count) || 0;
|
||||
const runningJobs = Number(jobs.running_count) || 0;
|
||||
const scanJobCount = Number(scan.active_job_count) || 0;
|
||||
|
||||
if (!isVisible || activeCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeCount = activeTasks.length;
|
||||
const avgProgress = Math.round(
|
||||
activeTasks.reduce((sum, task) => sum + (Number(task.progress) || 0), 0) / Math.max(1, activeCount)
|
||||
);
|
||||
const visibleJobs = activeJobs.slice(0, 8);
|
||||
|
||||
return (
|
||||
<div className="global-task-overlay">
|
||||
@@ -29,7 +43,7 @@ export default function GlobalTaskCenter({
|
||||
<button className="task-center-button" onClick={() => setExpanded(true)}>
|
||||
<span className="task-center-dot" />
|
||||
<span>后台任务 {activeCount}</span>
|
||||
<strong>{avgProgress}%</strong>
|
||||
<strong>{runningJobs > 0 ? `执行 ${runningJobs}` : `${avgProgress}%`}</strong>
|
||||
</button>
|
||||
)}
|
||||
{expanded && (
|
||||
@@ -37,12 +51,45 @@ export default function GlobalTaskCenter({
|
||||
<div className="task-center-header">
|
||||
<div>
|
||||
<h3>后台任务</h3>
|
||||
<p>任务正在执行,你可以继续使用其他功能;同类重复提交由系统限制。</p>
|
||||
<p>展示 Worker、执行中 Job、排队 Job 和任务进度;同类重复提交由后端冲突检查处理。</p>
|
||||
</div>
|
||||
<button className="task-center-close" onClick={() => setExpanded(false)} aria-label="关闭任务中心">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="task-runtime-summary">
|
||||
<div>
|
||||
<span>Worker</span>
|
||||
<strong>{workerCount}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>执行中</span>
|
||||
<strong>{runningJobs}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>排队</span>
|
||||
<strong>{queuedJobs}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>扫描</span>
|
||||
<strong>{scanJobCount}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{visibleJobs.length > 0 && (
|
||||
<div className="active-jobs-container">
|
||||
{visibleJobs.map((job) => (
|
||||
<div key={job.job_id} className="job-runtime-row">
|
||||
<span className={`job-status-chip ${String(job.status || '').toLowerCase()}`}>{job.status || '-'}</span>
|
||||
<span className="job-runtime-title">
|
||||
{getTaskTypeLabel(job.task_type || job.job_type)}
|
||||
</span>
|
||||
<span className="job-runtime-worker" title={job.locked_by || ''}>
|
||||
{job.locked_by ? `Worker ${job.locked_by}` : (job.status === 'RETRY' ? '等待重试' : '等待领取')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="active-tasks-container">
|
||||
{(() => {
|
||||
const waterTasks = activeTasks.filter(task =>
|
||||
@@ -73,11 +120,16 @@ export default function GlobalTaskCenter({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{otherTasks.length === 0 && waterTasks.length === 0 && visibleJobs.length > 0 && (
|
||||
<div className="task-progress-item task-progress-item--muted">
|
||||
<p className="task-status-msg">当前只有 Job 运行态,任务进度尚未写入 system_tasks。</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<p className="overlay-footer-hint">任务中心只展示状态,不再锁定整个界面。需要互斥的操作由功能页按钮和后端任务冲突检查处理。</p>
|
||||
<p className="overlay-footer-hint">取消按钮只作用于可跟踪的 Task;纯 Job 取消需要在对应功能页或运维接口处理。</p>
|
||||
{isAdmin && (
|
||||
<div style={{ marginTop: '16px', textAlign: 'center' }}>
|
||||
{!showCancelTask ? (
|
||||
|
||||
@@ -26,6 +26,7 @@ export default function AppOverlays({
|
||||
licenseFileName,
|
||||
licenseUploadStatus,
|
||||
activeTasks,
|
||||
runtimeSummary,
|
||||
showCancelTask,
|
||||
cancelTaskPwd,
|
||||
onShowCancelTask,
|
||||
@@ -97,11 +98,12 @@ export default function AppOverlays({
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{activeTasks.length > 0 && (
|
||||
{(activeTasks.length > 0 || Number(runtimeSummary?.jobs?.active_count || 0) > 0) && (
|
||||
<Suspense fallback={<ModalLoadingFallback message="正在加载任务中心..." />}>
|
||||
<LazyGlobalTaskCenter
|
||||
isVisible={activeTasks.length > 0}
|
||||
isVisible={activeTasks.length > 0 || Number(runtimeSummary?.jobs?.active_count || 0) > 0}
|
||||
activeTasks={activeTasks}
|
||||
runtimeSummary={runtimeSummary}
|
||||
t={t}
|
||||
isAdmin={isAdmin}
|
||||
showCancelTask={showCancelTask}
|
||||
|
||||
@@ -3,7 +3,7 @@ import defaultLogoUrl from '../../logo.jpg';
|
||||
import { formatUtc } from '../../utils/appUiHelpers';
|
||||
|
||||
const ORGANIZATION_NAME = import.meta.env.VITE_APP_ORG_NAME || '黑龙江省自然资源卫星应用技术中心';
|
||||
const SYSTEM_NAME = import.meta.env.VITE_APP_SYSTEM_NAME || 'InSAR 自动化管理系统';
|
||||
const SYSTEM_NAME = import.meta.env.VITE_APP_SYSTEM_NAME || '雷达数据生产管理系统';
|
||||
const SYSTEM_TAGLINE = import.meta.env.VITE_APP_SYSTEM_TAGLINE || '科研工程生产平台';
|
||||
const LOGO_URL = import.meta.env.VITE_APP_LOGO_URL || defaultLogoUrl;
|
||||
|
||||
@@ -14,11 +14,40 @@ function AppStatusHeader({
|
||||
isReadOnlyUser,
|
||||
activeTasks,
|
||||
avgTaskProgress,
|
||||
runtimeSummary,
|
||||
licenseStatus,
|
||||
onLogout,
|
||||
}) {
|
||||
const licenseOk = !!licenseStatus?.ok;
|
||||
const hasActiveTasks = activeTasks.length > 0;
|
||||
const worker = runtimeSummary?.worker || {};
|
||||
const jobs = runtimeSummary?.jobs || {};
|
||||
const scan = runtimeSummary?.scan || {};
|
||||
const workerCount = Number(worker.worker_count) || 0;
|
||||
const runningJobs = Number(jobs.running_count) || 0;
|
||||
const queuedJobs = Number(jobs.queued_count) || 0;
|
||||
const scanJobs = Number(scan.active_job_count) || 0;
|
||||
const scanRunningJobs = Number(scan.running_job_count) || 0;
|
||||
const staleJobs = Number(worker.stale_running_job_count) || 0;
|
||||
const hasRuntimeActivity = activeTasks.length > 0 || runningJobs > 0 || queuedJobs > 0;
|
||||
const taskProgress = hasRuntimeActivity ? avgTaskProgress : 0;
|
||||
let runtimeLabel = 'Worker 未连接';
|
||||
if (!runtimeSummary && activeTasks.length > 0) {
|
||||
runtimeLabel = `运行中 ${activeTasks.length}`;
|
||||
} else if (runningJobs > 0 && staleJobs > 0) {
|
||||
runtimeLabel = `运行态待恢复 ${staleJobs}`;
|
||||
} else if (runningJobs > 0) {
|
||||
runtimeLabel = `执行中 ${runningJobs}`;
|
||||
} else if (queuedJobs > 0) {
|
||||
runtimeLabel = `排队 ${queuedJobs}`;
|
||||
} else if (workerCount > 0) {
|
||||
runtimeLabel = `Worker ${workerCount} 空闲`;
|
||||
}
|
||||
const runtimeDetail = !runtimeSummary && activeTasks.length > 0
|
||||
? '任务状态来自兼容接口'
|
||||
: workerCount > 0
|
||||
? `Worker ${workerCount}`
|
||||
: '无在线 worker';
|
||||
const staleDetail = staleJobs > 0 ? ` · 待恢复 ${staleJobs}` : '';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -50,11 +79,12 @@ function AppStatusHeader({
|
||||
</div>
|
||||
|
||||
<div className="status-actions">
|
||||
<div className={`status-task ${hasActiveTasks ? 'has-active-tasks' : ''}`}>
|
||||
<span>{hasActiveTasks ? `运行中 ${activeTasks.length}` : '任务空闲'}</span>
|
||||
{hasActiveTasks && (
|
||||
<div className={`status-task ${hasRuntimeActivity ? 'has-active-tasks' : ''}`}>
|
||||
<span>{runtimeLabel}</span>
|
||||
<small>{runtimeDetail}{staleDetail}{scanJobs > 0 ? ` · 扫描 ${scanRunningJobs}/${scanJobs}` : ''}</small>
|
||||
{hasRuntimeActivity && (
|
||||
<div className="status-task-bar" aria-hidden="true">
|
||||
<div className="status-task-fill" style={{ width: `${avgTaskProgress}%` }} />
|
||||
<div className="status-task-fill" style={{ width: `${taskProgress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,10 +12,16 @@ function RadarDataRow({
|
||||
onRebuildPreview,
|
||||
onToggleLayer,
|
||||
}) {
|
||||
const isProduced = Boolean(item.lt1_image_produced || item.lt1_landsar_produced);
|
||||
return (
|
||||
<li className="data-item radar-data-item" onClick={() => onFlyTo(item)}>
|
||||
<span className="data-item-name" title={item.displayName}>
|
||||
{item.displayName}
|
||||
{isProduced && (
|
||||
<small style={{ marginLeft: 8, color: '#166534', fontWeight: 700 }}>
|
||||
已生产 GeoTIFF
|
||||
</small>
|
||||
)}
|
||||
</span>
|
||||
<div className="data-item-controls">
|
||||
<span
|
||||
|
||||
@@ -123,6 +123,14 @@ export const PRODUCTION_WORKSPACE_SBAS_VIEWS = [
|
||||
},
|
||||
];
|
||||
|
||||
export const PRODUCTION_WORKSPACE_LT1_VIEWS = [
|
||||
{
|
||||
key: 'lt1_production',
|
||||
label: '陆探一 GeoTIFF 生产',
|
||||
description: '运行 LT-1 Gamma 单景流水线,输出多视、地理编码后的 analysis_ready.tif。',
|
||||
},
|
||||
];
|
||||
|
||||
export const PRODUCTION_WORKSPACE_WORKBENCHES = [
|
||||
{
|
||||
key: 'dinsar_workbench',
|
||||
@@ -138,16 +146,19 @@ export const PRODUCTION_WORKSPACE_WORKBENCHES = [
|
||||
defaultView: 'sbas_insar_planning',
|
||||
views: PRODUCTION_WORKSPACE_SBAS_VIEWS,
|
||||
},
|
||||
{
|
||||
key: 'lt1_workbench',
|
||||
label: '陆探一工作台',
|
||||
description: '面向 LT-1 非 D-InSAR 影像生产,组织源资产选择、单景地理编码和 GeoTIFF 产品登记。',
|
||||
defaultView: 'lt1_production',
|
||||
views: PRODUCTION_WORKSPACE_LT1_VIEWS,
|
||||
},
|
||||
];
|
||||
|
||||
export const PRODUCTION_WORKSPACE_VIEWS = [
|
||||
...PRODUCTION_WORKSPACE_DINSAR_VIEWS,
|
||||
...PRODUCTION_WORKSPACE_SBAS_VIEWS,
|
||||
{
|
||||
key: 'lt1_production',
|
||||
label: '陆探一生产占位',
|
||||
description: 'LT-1 源压缩包本机登记,按需 materialize 到 Task_Pool;D-InSAR/SBAS 生产不走 UNC。',
|
||||
},
|
||||
...PRODUCTION_WORKSPACE_LT1_VIEWS,
|
||||
{
|
||||
key: 'sentinel1_production',
|
||||
label: 'Sentinel-1 生产占位',
|
||||
|
||||
@@ -9,6 +9,7 @@ export default function useGlobalTaskControl({
|
||||
licenseOk,
|
||||
activeTasks,
|
||||
setActiveTasks,
|
||||
setRuntimeSummary,
|
||||
pendingTaskIds,
|
||||
setPendingTaskIds,
|
||||
setIsCheckingTasks,
|
||||
@@ -21,8 +22,11 @@ export default function useGlobalTaskControl({
|
||||
const pendingTaskIdsRef = useRef(pendingTaskIds);
|
||||
useEffect(() => { pendingTaskIdsRef.current = pendingTaskIds; }, [pendingTaskIds]);
|
||||
|
||||
const handleTasksUpdate = useCallback(async (tasks) => {
|
||||
const handleTasksUpdate = useCallback(async (tasks, runtimeSummary = null) => {
|
||||
setActiveTasks(tasks);
|
||||
if (setRuntimeSummary) {
|
||||
setRuntimeSummary(runtimeSummary);
|
||||
}
|
||||
const hasRunningTasks = tasks.length > 0;
|
||||
|
||||
// 首次检查完成,清除检查状态
|
||||
@@ -93,21 +97,45 @@ export default function useGlobalTaskControl({
|
||||
|
||||
}, [
|
||||
setActiveTasks,
|
||||
setRuntimeSummary,
|
||||
setIsCheckingTasks,
|
||||
handleTaskCompletionRef,
|
||||
setPendingTaskIds,
|
||||
]);
|
||||
|
||||
const normalizeRuntimeSummary = useCallback((payload) => {
|
||||
if (!payload || typeof payload !== 'object') return null;
|
||||
const items = payload.tasks?.items;
|
||||
return {
|
||||
...payload,
|
||||
tasks: {
|
||||
...(payload.tasks || {}),
|
||||
items: Array.isArray(items) ? items : [],
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fallback polling (used when SSE is unavailable)
|
||||
const syncActiveTasks = useCallback(async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/tasks/runtime-summary');
|
||||
const summary = normalizeRuntimeSummary(response.data);
|
||||
if (summary) {
|
||||
await handleTasksUpdate(summary.tasks.items, summary);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('同步任务运行概览失败:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get('/tasks/active');
|
||||
const tasks = Array.isArray(response.data) ? response.data : [];
|
||||
await handleTasksUpdate(tasks);
|
||||
await handleTasksUpdate(tasks, null);
|
||||
} catch (error) {
|
||||
console.error('同步任务状态失败:', error);
|
||||
}
|
||||
}, [handleTasksUpdate]);
|
||||
}, [handleTasksUpdate, normalizeRuntimeSummary]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser || !licenseOk) return;
|
||||
@@ -121,12 +149,14 @@ export default function useGlobalTaskControl({
|
||||
|
||||
const startSSE = () => {
|
||||
const baseURL = apiClient.defaults.baseURL || '';
|
||||
es = new EventSource(`${baseURL}/tasks/active/stream`);
|
||||
es = new EventSource(`${baseURL}/tasks/runtime-summary/stream`);
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const tasks = JSON.parse(event.data);
|
||||
handleTasksUpdate(Array.isArray(tasks) ? tasks : []);
|
||||
const summary = normalizeRuntimeSummary(JSON.parse(event.data));
|
||||
if (summary) {
|
||||
handleTasksUpdate(summary.tasks.items, summary);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('SSE parse error:', e);
|
||||
}
|
||||
|
||||
@@ -216,24 +216,46 @@ export default function usePairingLogic({
|
||||
}
|
||||
};
|
||||
|
||||
const createDinsarBatch = async () => {
|
||||
const createDinsarBatch = async (options = {}) => {
|
||||
if (!ensureCanOperate()) return;
|
||||
const chunkSize = Number(options?.chunkSize || 0);
|
||||
const foundPairs = usePairingStore.getState().foundPairs;
|
||||
const selectedPairs = foundPairs.filter(p => p.isSelected);
|
||||
if (selectedPairs.length === 0) {
|
||||
addLog('warn', '没有选中的配对可保存。');
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(chunkSize) || chunkSize <= 0) {
|
||||
addLog('warn', '每批条数必须是大于 0 的整数。');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const batchPairs = selectedPairs.map(compactDinsarBatchPair);
|
||||
const response = await apiClient.post('/task-batches/dinsar', {
|
||||
name: `DINSAR_${new Date().toISOString().slice(0, 10)}`,
|
||||
pairs: batchPairs,
|
||||
});
|
||||
const batchId = response.data?.batch_id || '';
|
||||
addLog('success', `已创建 D-InSAR 批次: ${batchId || 'OK'}`);
|
||||
if (batchId) {
|
||||
await focusBatchAfterCreate('dinsar', batchId);
|
||||
const createdBatchIds = [];
|
||||
const createdAt = new Date().toISOString().slice(0, 10);
|
||||
const totalChunks = Math.ceil(selectedPairs.length / chunkSize);
|
||||
for (let offset = 0; offset < selectedPairs.length; offset += chunkSize) {
|
||||
const chunkIndex = Math.floor(offset / chunkSize) + 1;
|
||||
const chunkPairs = selectedPairs
|
||||
.slice(offset, offset + chunkSize)
|
||||
.map(compactDinsarBatchPair);
|
||||
const response = await apiClient.post('/task-batches/dinsar', {
|
||||
name: totalChunks > 1
|
||||
? `DINSAR_${createdAt}_${String(chunkIndex).padStart(3, '0')}_of_${String(totalChunks).padStart(3, '0')}`
|
||||
: `DINSAR_${createdAt}`,
|
||||
pairs: chunkPairs,
|
||||
});
|
||||
const batchId = response.data?.batch_id || '';
|
||||
if (batchId) {
|
||||
createdBatchIds.push(batchId);
|
||||
}
|
||||
addLog(
|
||||
'success',
|
||||
`已创建 D-InSAR 批次 ${chunkIndex}/${totalChunks}: ${batchId || 'OK'} (${chunkPairs.length} 条)`
|
||||
);
|
||||
}
|
||||
if (createdBatchIds.length > 0) {
|
||||
addLog('info', `已按每批 ${chunkSize} 条拆分为 ${createdBatchIds.length} 个 D-InSAR 批次。`);
|
||||
await focusBatchAfterCreate('dinsar', createdBatchIds[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.detail || error.message || '未知错误';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const TRANSLATION_PAIRS = [
|
||||
{ zh: '正在检查登录状态...', en: 'Checking login status...' },
|
||||
{ zh: '请稍候,系统正在验证会话。', en: 'Please wait, verifying your session.' },
|
||||
{ zh: 'InSAR 自动化管理系统', en: 'InSAR Automation Management System' },
|
||||
{ zh: '雷达数据生产管理系统', en: 'Radar Data Production Management System' },
|
||||
{ zh: '科研工程模式', en: 'Research Engineering Mode' },
|
||||
{ zh: '已授权', en: 'Licensed' },
|
||||
{ zh: '未授权', en: 'Unlicensed' },
|
||||
|
||||
@@ -21,6 +21,8 @@ const formatActionMode = (mode, en = false) => {
|
||||
return en ? 'Full rebuild' : '全量重建';
|
||||
case 'incremental_reconcile':
|
||||
return en ? 'Incremental reconcile' : '增量修复';
|
||||
case 'auto_reconcile':
|
||||
return en ? 'Automatic repair queued' : '自动修复已提交';
|
||||
case 'noop':
|
||||
return en ? 'No-op reconcile' : '无需修复';
|
||||
default:
|
||||
@@ -244,6 +246,22 @@ export default function PairPlanningPanel({
|
||||
>
|
||||
{pairingActionResult.error ? (
|
||||
<div style={{ color: '#b91c1c' }}>{pairingActionResult.error}</div>
|
||||
) : pairingActionResult.queued ? (
|
||||
<>
|
||||
<div style={{ color: '#0f172a', fontWeight: 600 }}>
|
||||
{formatActionMode(pairingActionResult.mode, en)}
|
||||
</div>
|
||||
<div>
|
||||
{en
|
||||
? `Task queued: ${pairingActionResult.task_id || '-'}`
|
||||
: `任务已提交:${pairingActionResult.task_id || '-'}`}
|
||||
</div>
|
||||
<div>
|
||||
{en
|
||||
? 'Track progress in the task center. Refresh this status after the task completes.'
|
||||
: '请在任务中心查看进度,任务完成后刷新这里的状态。'}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ color: '#0f172a', fontWeight: 600 }}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { usePairingStore, useAuthStore } from '../store';
|
||||
import VirtualizedList from '../components/common/VirtualizedList';
|
||||
@@ -19,6 +19,7 @@ function PairsListPanel({
|
||||
})));
|
||||
const { currentUser } = useAuthStore();
|
||||
const isReadOnlyUser = !!currentUser && currentUser.role !== 'admin';
|
||||
const [batchSizeInput, setBatchSizeInput] = useState('100');
|
||||
|
||||
const handlePairSelectionChange = useCallback((index) => {
|
||||
setFoundPairs((prevPairs) => {
|
||||
@@ -45,6 +46,19 @@ function PairsListPanel({
|
||||
() => foundPairs.filter((pair) => pair.isSelected).length,
|
||||
[foundPairs]
|
||||
);
|
||||
const batchSize = useMemo(() => {
|
||||
const parsed = Number(batchSizeInput);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
|
||||
}, [batchSizeInput]);
|
||||
const plannedBatchCount = useMemo(() => (
|
||||
selectedPairsCount > 0 && batchSize > 0
|
||||
? Math.ceil(selectedPairsCount / batchSize)
|
||||
: 0
|
||||
), [batchSize, selectedPairsCount]);
|
||||
const handleCreateDinsarBatch = useCallback(() => {
|
||||
if (typeof onCreateDinsarBatch !== 'function') return;
|
||||
onCreateDinsarBatch({ chunkSize: batchSize });
|
||||
}, [batchSize, onCreateDinsarBatch]);
|
||||
const mapPreviewPairs = useMemo(() => {
|
||||
const visible = foundPairs.filter((pair) => pair.isVis);
|
||||
return visible.slice(0, 24);
|
||||
@@ -141,9 +155,27 @@ function PairsListPanel({
|
||||
</div>
|
||||
</div>
|
||||
<footer className="panel-footer">
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, marginRight: 10 }}>
|
||||
每批
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={Math.max(1, selectedPairsCount)}
|
||||
value={batchSizeInput}
|
||||
onChange={(event) => setBatchSizeInput(event.target.value)}
|
||||
style={{ width: 88 }}
|
||||
disabled={selectedPairsCount === 0 || isReadOnlyUser}
|
||||
/>
|
||||
条
|
||||
</label>
|
||||
{selectedPairsCount > 0 && batchSize > 0 && (
|
||||
<span style={{ marginRight: 10, color: '#64748b', fontSize: 12 }}>
|
||||
将创建 {plannedBatchCount} 个批次
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={onCreateDinsarBatch}
|
||||
disabled={selectedPairsCount === 0 || isReadOnlyUser}
|
||||
onClick={handleCreateDinsarBatch}
|
||||
disabled={selectedPairsCount === 0 || batchSize <= 0 || isReadOnlyUser}
|
||||
className="footer-button"
|
||||
title="保存选中的配对为任务批次"
|
||||
>
|
||||
|
||||
@@ -5,9 +5,11 @@ const s = (set, key) => (v) =>
|
||||
|
||||
export const useTaskStore = create((set) => ({
|
||||
activeTasks: [],
|
||||
runtimeSummary: null,
|
||||
isCheckingTasks: true, // 初始化时假设正在检查任务,避免闪烁
|
||||
pendingTaskIds: [],
|
||||
setActiveTasks: s(set, 'activeTasks'),
|
||||
setRuntimeSummary: s(set, 'runtimeSummary'),
|
||||
setIsCheckingTasks: s(set, 'isCheckingTasks'),
|
||||
setPendingTaskIds: s(set, 'pendingTaskIds'),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user