Document result delivery workbench design

This commit is contained in:
2026-07-08 10:20:14 +08:00
parent 36a406ae49
commit e4459180ad
7 changed files with 1199 additions and 0 deletions
+384
View File
@@ -1700,6 +1700,371 @@ input[type="checkbox"] {
word-break: break-all;
}
.manual-workspace {
min-height: 100%;
background: #eef2f7;
padding: 16px;
}
.manual-shell {
width: min(100%, 1480px);
margin: 0 auto;
display: grid;
gap: 12px;
}
.manual-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 360px);
gap: 18px;
align-items: end;
background: #ffffff;
border: 1px solid #d5dfeb;
border-radius: 8px;
padding: 16px 18px;
}
.manual-hero h1 {
margin: 0;
color: #0f172a;
font-size: 23px;
line-height: 1.25;
letter-spacing: 0;
}
.manual-hero p {
margin: 6px 0 0;
max-width: 76ch;
color: #475569;
font-size: 13px;
line-height: 1.6;
}
.manual-search {
display: grid;
gap: 6px;
}
.manual-search label {
color: #475569;
font-size: 12px;
font-weight: 800;
}
.manual-search input {
width: 100%;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #f8fafc;
color: #0f172a;
font-size: 13px;
padding: 9px 10px;
}
.manual-search input:focus {
outline: 2px solid rgba(37, 99, 235, 0.2);
border-color: #2563eb;
background: #ffffff;
}
.manual-layout {
display: grid;
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.manual-nav,
.manual-section,
.manual-faq {
background: #ffffff;
border: 1px solid #d5dfeb;
border-radius: 8px;
}
.manual-nav {
position: sticky;
top: 0;
overflow: hidden;
}
.manual-nav-title {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
padding: 12px;
border-bottom: 1px solid #e2e8f0;
}
.manual-nav-title strong {
color: #0f172a;
font-size: 14px;
}
.manual-nav-title span {
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.manual-nav-list {
display: grid;
gap: 4px;
max-height: calc(100vh - 210px);
overflow: auto;
padding: 8px;
}
.manual-nav-list button {
display: grid;
gap: 4px;
width: 100%;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: #334155;
cursor: pointer;
padding: 9px 10px;
text-align: left;
}
.manual-nav-list button:hover {
border-color: #d5dfeb;
background: #f8fafc;
}
.manual-nav-list button.is-active {
border-color: #bfdbfe;
background: #eff6ff;
color: #1d4ed8;
}
.manual-nav-list button strong {
font-size: 13px;
line-height: 1.35;
}
.manual-nav-list button span {
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.manual-nav-list button.is-active span {
color: #1e40af;
}
.manual-empty {
color: #64748b;
font-size: 13px;
padding: 12px;
text-align: center;
}
.manual-content {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 340px);
gap: 12px;
align-items: start;
}
.manual-section {
padding: 16px;
}
.manual-section-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
padding-bottom: 12px;
border-bottom: 1px solid #e2e8f0;
}
.manual-section-header h2,
.manual-faq-header h2 {
margin: 0;
color: #0f172a;
font-size: 18px;
line-height: 1.35;
}
.manual-section-header p {
margin: 5px 0 0;
color: #475569;
font-size: 13px;
line-height: 1.6;
max-width: 70ch;
}
.manual-tag-row {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
flex: 0 0 auto;
}
.manual-tag-row span {
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #f8fafc;
color: #334155;
font-size: 12px;
font-weight: 800;
padding: 5px 8px;
}
.manual-block {
margin-top: 14px;
}
.manual-block h3 {
margin: 0 0 8px;
color: #0f172a;
font-size: 14px;
line-height: 1.35;
}
.manual-check-list {
display: grid;
gap: 7px;
margin: 0;
padding: 0;
list-style: none;
}
.manual-check-list li {
position: relative;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
color: #334155;
font-size: 13px;
line-height: 1.55;
padding: 8px 10px 8px 28px;
}
.manual-check-list li::before {
content: "";
position: absolute;
left: 11px;
top: 15px;
width: 7px;
height: 7px;
border-radius: 999px;
background: #2563eb;
}
.manual-flow {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 8px;
}
.manual-flow-step {
display: grid;
grid-template-columns: 26px minmax(0, 1fr);
gap: 8px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #ffffff;
padding: 9px;
}
.manual-flow-step > span {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 999px;
background: #eff6ff;
color: #1d4ed8;
font-size: 12px;
font-weight: 900;
}
.manual-flow-step strong {
display: block;
color: #0f172a;
font-size: 13px;
line-height: 1.35;
}
.manual-flow-step p {
margin: 3px 0 0;
color: #475569;
font-size: 12px;
line-height: 1.5;
}
.manual-tip-list {
display: grid;
gap: 8px;
}
.manual-tip-list p {
margin: 0;
border: 1px solid #fde68a;
border-radius: 6px;
background: #fffbeb;
color: #78350f;
font-size: 13px;
line-height: 1.55;
padding: 8px 10px;
}
.manual-faq {
padding: 12px;
}
.manual-faq-header {
border-bottom: 1px solid #e2e8f0;
padding-bottom: 10px;
}
.manual-faq-header span {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.manual-faq-list {
display: grid;
gap: 8px;
margin-top: 10px;
}
.manual-faq-list details {
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
}
.manual-faq-list summary {
cursor: pointer;
color: #0f172a;
font-size: 13px;
font-weight: 800;
line-height: 1.45;
padding: 9px 10px;
}
.manual-faq-list details[open] summary {
border-bottom: 1px solid #e2e8f0;
}
.manual-faq-list p {
margin: 0;
color: #475569;
font-size: 12px;
line-height: 1.55;
padding: 9px 10px;
}
.statistics-workspace {
min-height: 100%;
background: #eef2f7;
@@ -2691,6 +3056,9 @@ input[type="checkbox"] {
}
@media (max-width: 980px) {
.manual-hero,
.manual-layout,
.manual-content,
.statistics-kpi-grid,
.statistics-command-grid,
.statistics-dashboard-grid,
@@ -2699,6 +3067,22 @@ input[type="checkbox"] {
grid-template-columns: 1fr;
}
.manual-nav {
position: static;
}
.manual-nav-list {
max-height: none;
}
.manual-section-header {
flex-direction: column;
}
.manual-tag-row {
justify-content: flex-start;
}
.statistics-section--orbit {
grid-column: auto;
}
+318
View File
@@ -0,0 +1,318 @@
import { useMemo, useState } from 'react';
const MANUAL_SECTIONS = [
{
id: 'start',
title: '快速入门',
summary: '按生产链路理解系统:先接入数据,再规划任务,最后生产、登记和交付成果。',
tags: ['流程', '新用户'],
highlights: [
'影像检索用于确认数据是否覆盖目标区域和时间。',
'资产台账用于确认源产品、精密轨道、元数据和几何画像是否可用于生产。',
'生产管理集中处理 D-InSAR、SBAS-InSAR 和 LT-1 单景生产。',
'结果提取面向成果登记和交付下载,不负责重新生产数据。',
],
workflow: [
{ name: '数据接入', detail: '扫描源数据、精密轨道和预览缓存,形成可查询资产。' },
{ name: '任务规划', detail: '在影像池中按时间、空间、AOI 和精轨条件生成候选任务。' },
{ name: '生产运行', detail: '按批次准备 Task_Pool,选择生产引擎并跟踪运行状态。' },
{ name: '结果管理', detail: '提取标准成果、登记 catalog,并为用户交付提供入口。' },
],
tips: [
'首次使用时先看“综合统计”,确认元数据、几何画像、精轨和预览是否完整。',
'生产前优先使用影像检索或省域覆盖配对检查空间覆盖,不要只看候选对数量。',
],
},
{
id: 'data',
title: '数据资产',
summary: '数据资产页负责把磁盘上的源数据和精密轨道变成系统可审计的生产资产。',
tags: ['扫描', '资产台账', '精轨'],
highlights: [
'源数据扫描是增量扫描:未变化的压缩包会在解析元数据前跳过。',
'精密轨道扫描也是增量扫描,LT-1 TXT 和 Sentinel-1 EOF 会登记为 orbit assets。',
'源数据扫描和精轨扫描结束后都会触发一次轨道绑定,因此先扫哪一类都能最终匹配。',
'预览缓存是辅助查看能力,不等同于生产就绪状态。',
],
workflow: [
{ name: '数据接入', detail: '选择源数据或精轨目录,启动资产索引任务。' },
{ name: '资产台账', detail: '查看源产品、精轨资产、绑定状态和开放问题。' },
{ name: '影像检索', detail: '按卫星、时间、轨道、AOI 和精轨状态筛选生产候选影像。' },
],
tips: [
'“缺精轨”按受影响影像景数统计,不等于缺失文件数量;统计页会展示实际缺失的卫星日期。',
'扫描日志中的 skipped 表示缓存命中,changed/new 表示进入解析队列的新增或变化文件。',
],
},
{
id: 'pairing',
title: '配对规划',
summary: 'D-InSAR 配对规划用于从影像池中生成标准候选对,并支持省域覆盖导向的贪心规划。',
tags: ['D-InSAR', '省域覆盖', '批次'],
highlights: [
'普通配对按时间基线、空间关系、重叠率、卫星模式、极化和精轨可用性筛选。',
'省域覆盖配对以 AOI 空间覆盖最大化为目标,时间范围是影像池约束。',
'保存后的批次可以分批生产,批次数量由用户指定,不需要一次性生产全部候选对。',
'已完成且已登记的结果应在候选列表中被识别,避免重复生产。',
],
workflow: [
{ name: '设置影像池', detail: '输入目标日期范围,必要时设置扩展天数补足覆盖。' },
{ name: '设置 AOI', detail: '选择行政区或上传 AOI 文件;省域任务应明确选择省级范围。' },
{ name: '生成候选对', detail: '检查覆盖、精轨、时间基线和质量指标。' },
{ name: '保存批次', detail: '按用户输入的数量分批保存,后续在生产准备中逐批处理。' },
],
tips: [
'省域覆盖的核心指标是空间覆盖,不是生成候选对数量。',
'如果候选很多但覆盖不足,优先检查 AOI、轨道方向、时间范围和精轨条件。',
],
},
{
id: 'production',
title: '生产管理',
summary: '生产管理把配对、数据准备、引擎运行和结果目录组织在同一个工作台内。',
tags: ['D-InSAR', 'SBAS', 'LT-1'],
highlights: [
'D-InSAR 工作台包含配对规划、候选对与批次、生产准备、生产运行和结果管理。',
'D-InSAR 当前支持 ENVI/SARscape、LandSAR 和 Gamma/PyINT 等生产路线。',
'LT-1 单景生产由服务器侧流水线输出 analysis_ready.tif,并登记为可交付正射成果。',
'SBAS-InSAR 当前以 Gamma 工作流为主,生产和成果目录与 D-InSAR 分开管理。',
],
workflow: [
{ name: '生产准备', detail: '把批次材料化到 Task_Pool,并复制源数据和精密轨道。' },
{ name: '生产运行', detail: '选择生产引擎,提交运行并观察成功、失败、部分成功状态。' },
{ name: '结果管理', detail: '从生产目录提取成果,登记标准 catalog 和预览资产。' },
],
tips: [
'“部分成功”表示批次内有成功结果也有失败任务;只有全部失败才应视为失败。',
'LandSAR 访问冲突、基线精估计失败、DEM/地理编码失败通常需要结合日志判断是数据质量、参数还是软件稳定性问题。',
],
},
{
id: 'results',
title: '结果管理与交付',
summary: '结果页面区分生产结果入库和用户成果交付,避免大文件下载阻塞页面请求。',
tags: ['成果', '下载', '登记'],
highlights: [
'D-InSAR 结果提取用于把生产目录中的位移结果登记为标准成果。',
'LT-1 正射和 GF3 正射属于可交付影像成果;Sentinel-1 正射仍是占位能力。',
'大体量成果建议通过后台交付任务生成目录或压缩包,再下载到本地。',
'服务器交付区只是临时缓存,正式成果仍以登记 catalog 和源生产目录为准。',
],
workflow: [
{ name: '登记成果', detail: '确认生产目录、主成果文件、预览和 manifest。' },
{ name: '选择交付内容', detail: '按 D-InSAR、LT-1 正射、GF3 正射等通道选择可交付成果。' },
{ name: '生成交付包', detail: '后台复制文件并生成 manifest 和校验信息。' },
{ name: '下载本地', detail: '用户从交付目录或下载链接获取成果。' },
],
tips: [
'结果提取失败时先看任务日志和结果目录,不要直接删除生产源目录。',
'SBAS 和 Sentinel-1 正射如果显示为规划中,表示页面保留入口但尚未接入真实交付。',
],
},
{
id: 'statistics',
title: '综合统计',
summary: '综合统计用于快速判断系统的数据、轨道、生产和成果健康状态。',
tags: ['统计', '健康状态'],
highlights: [
'元数据入库率只统计系统当前管理的 LT-1 和 Sentinel-1 源资产。',
'几何画像可用率表示可用于空间覆盖统计的影像画像是否准备好。',
'精轨绑定率按可生产影像场景计算,缺轨会进一步拆成受影响景数和实际缺失卫星日期。',
'待关注项是生产管理指标,不应把历史 XML 旧口径或重复问题混入统计。',
],
workflow: [
{ name: '看总览', detail: '先检查 KPI 是否有 warning 或 danger。' },
{ name: '看覆盖', detail: '查看源数据和成果在行政区上的覆盖密度。' },
{ name: '看精轨', detail: '确认缺口是文件日期缺失还是受影响景数较多。' },
{ name: '看问题闭环', detail: '按问题代码判断是否需要重新扫描、补轨或清理任务。' },
],
tips: [
'刷新统计会触发较重的聚合查询,生产高峰期不要频繁刷新。',
'精轨缺口显示 1000 多景时,实际可能只缺几十个卫星日期。',
],
},
{
id: 'ops',
title: '运行维护',
summary: '运行维护用于发现失败任务、残留目录、扫描异常和服务环境问题。',
tags: ['任务维护', '清理', '自检'],
highlights: [
'任务维护面板只负责诊断和清理,不提供自动重跑。',
'清理前会预览数据库记录和磁盘目录影响范围,禁止删除源数据、DEM、精轨和代码目录。',
'扫描任务如果因为服务器断电或进程退出失败,应先确认 worker 是否仍在运行。',
'配对基础重建、数据分发、生产运行等长任务都应通过任务中心和维护面板观察。',
],
workflow: [
{ name: '运行自检', detail: '查看数据库、目录、任务、缓存和生产环境状态。' },
{ name: '任务维护', detail: '筛选失败、部分成功、取消或疑似 stale 的任务。' },
{ name: '诊断详情', detail: '查看 job、run、item、execution、日志和磁盘路径。' },
{ name: '清理后重提', detail: '管理员清理失败记录和残留目录后,用户回业务页面重新提交。' },
],
tips: [
'正在运行的生产任务不要直接清理,应等待结束或先确认没有真实进程。',
'LandSAR_Work 当前只清理运行工作目录,不清理正式成果、源数据、DEM 或精轨。',
],
},
];
const FAQ_ITEMS = [
{
question: '为什么缺精轨数量看起来很多?',
answer: '统计首页的缺精轨数量是受影响影像景数。LT-1 一个缺失卫星日期可能影响几十景,因此要同时查看“缺失卫星日期”清单。',
},
{
question: '扫描日志里的 skipped 是不是还在解析?',
answer: '不是。skipped 表示缓存命中,文件在元数据解析前已经被过滤;changed/new 才表示进入解析队列。',
},
{
question: '生产任务部分成功是不是失败?',
answer: '不是。部分成功表示同一批任务中有成功也有失败,成功结果应保留和登记,失败项需要结合日志判断是否重跑。',
},
{
question: '为什么省域覆盖配对只看见少量绿色框?',
answer: '省域覆盖规划的目标是尽量覆盖 AOI 空间范围。需要确认 AOI 是否正确选择、候选是否全量显示,以及 max_pairs 是否限制过低。',
},
{
question: '结果提取和成果下载有什么区别?',
answer: '结果提取是把生产目录中的成果登记到系统 catalog;成果下载是用户从已登记成果中申请交付包并下载到本地。',
},
];
function filterSections(sections, query) {
const keyword = query.trim().toLowerCase();
if (!keyword) return sections;
return sections.filter((section) => {
const haystack = [
section.title,
section.summary,
...(section.tags || []),
...(section.highlights || []),
...(section.tips || []),
...(section.workflow || []).flatMap((item) => [item.name, item.detail]),
].join(' ').toLowerCase();
return haystack.includes(keyword);
});
}
export default function UserManualPanel() {
const [activeSectionId, setActiveSectionId] = useState(MANUAL_SECTIONS[0].id);
const [query, setQuery] = useState('');
const visibleSections = useMemo(() => filterSections(MANUAL_SECTIONS, query), [query]);
const activeSection = visibleSections.find((section) => section.id === activeSectionId)
|| visibleSections[0]
|| MANUAL_SECTIONS[0];
const handleSelectSection = (sectionId) => {
setActiveSectionId(sectionId);
};
return (
<div className="manual-workspace">
<div className="manual-shell">
<header className="manual-hero">
<div>
<h1>用户手册</h1>
<p>面向日常生产结果管理和运行维护的操作说明内容来自当前有效工程文档并按用户工作流重新整理</p>
</div>
<div className="manual-search">
<label htmlFor="manual-search-input">搜索手册</label>
<input
id="manual-search-input"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="输入:精轨、配对、交付、任务维护..."
/>
</div>
</header>
<div className="manual-layout">
<nav className="manual-nav" aria-label="用户手册目录">
<div className="manual-nav-title">
<strong>目录</strong>
<span>{visibleSections.length}/{MANUAL_SECTIONS.length}</span>
</div>
<div className="manual-nav-list">
{visibleSections.map((section) => (
<button
key={section.id}
type="button"
className={section.id === activeSection.id ? 'is-active' : ''}
onClick={() => handleSelectSection(section.id)}
>
<strong>{section.title}</strong>
<span>{section.summary}</span>
</button>
))}
{!visibleSections.length && (
<div className="manual-empty">没有匹配的章节</div>
)}
</div>
</nav>
<main className="manual-content">
<section className="manual-section">
<div className="manual-section-header">
<div>
<h2>{activeSection.title}</h2>
<p>{activeSection.summary}</p>
</div>
<div className="manual-tag-row">
{(activeSection.tags || []).map((tag) => <span key={tag}>{tag}</span>)}
</div>
</div>
<div className="manual-block">
<h3>关键判断</h3>
<ul className="manual-check-list">
{activeSection.highlights.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
<div className="manual-block">
<h3>推荐流程</h3>
<div className="manual-flow">
{activeSection.workflow.map((item, index) => (
<div key={item.name} className="manual-flow-step">
<span>{index + 1}</span>
<div>
<strong>{item.name}</strong>
<p>{item.detail}</p>
</div>
</div>
))}
</div>
</div>
<div className="manual-block">
<h3>操作提示</h3>
<div className="manual-tip-list">
{activeSection.tips.map((item) => (
<p key={item}>{item}</p>
))}
</div>
</div>
</section>
<aside className="manual-faq">
<div className="manual-faq-header">
<h2>常见问题</h2>
<span>生产现场高频问题</span>
</div>
<div className="manual-faq-list">
{FAQ_ITEMS.map((item) => (
<details key={item.question}>
<summary>{item.question}</summary>
<p>{item.answer}</p>
</details>
))}
</div>
</aside>
</main>
</div>
</div>
</div>
);
}
@@ -31,6 +31,7 @@ const LazySbasInsarMapAnalysisPanel = lazy(() => import('../../panels/SbasInsarM
const LazyProductionWorkspace = lazy(() => import('../../ProductionWorkspace'));
const LazyStatisticsDashboard = lazy(() => import('../../StatisticsDashboard'));
const LazyResultExtractionPanel = lazy(() => import('../../ResultExtractionPanel'));
const LazyUserManualPanel = lazy(() => import('../../UserManualPanel'));
export default function AppSidePanel({
leftPanelWidth,
@@ -229,6 +230,14 @@ export default function AppSidePanel({
</div>
)}
{leftPanelTab === 'user_manual' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载用户手册..." />}>
<LazyUserManualPanel />
</Suspense>
</div>
)}
{leftPanelTab === 'ingest' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载数据接入面板..." />}>
+3
View File
@@ -197,6 +197,7 @@ export const LEFT_GROUP_LABELS = {
statistics: '综合统计',
flood_analysis: '灾害分析',
ops: '运行维护',
help: '帮助文档',
};
export const LEFT_GROUP_SECTIONS = {
@@ -222,6 +223,7 @@ export const LEFT_GROUP_TABS = {
statistics: ['statistics'],
flood_analysis: ['flood_analysis'],
ops: ['health', 'ops_tasks', 'users', 'audit'],
help: ['user_manual'],
};
export const LEFT_TAB_GROUP = Object.entries(LEFT_GROUP_TABS).reduce((acc, [group, tabs]) => {
@@ -248,6 +250,7 @@ export const FULL_WIDTH_LEFT_TABS = new Set([
...PRODUCTION_WORKSPACE_ROUTE_TABS,
'statistics',
'result_extraction',
'user_manual',
]);
export const ADMIN_ONLY_TABS = new Set([
+4
View File
@@ -93,6 +93,8 @@ export const getLeftTabLabel = (tabKey, metrics = {}) => {
return '用户管理';
case 'audit':
return '审计日志';
case 'user_manual':
return '用户手册';
default:
return tabKey;
}
@@ -128,6 +130,8 @@ export const getLeftTabDescription = (tabKey) => {
return '维护系统用户、角色与访问权限。';
case 'audit':
return '查看关键操作和生产任务的审计记录。';
case 'user_manual':
return '查看系统主要工作流、生产口径、统计解释和常见问题,辅助用户快速定位正确操作入口。';
default:
return '当前模块用于支撑科研工程生产流程。';
}