初始化系统融合软件代码仓库

This commit is contained in:
2026-10-20 08:34:12 +08:00
commit 587ae6f088
96 changed files with 10230 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
# 系统融合软件原生页面验收
final result: passed
日期:2026-10-16。
## 视觉依据与状态
设计依据为 `E:\Codex\xtrh\output\system-fusion-design-v2\01-数据接收.png` 至 `06-主从控制.png` 六张确认稿,像素均为 1586×992。当前实现遵循用户新增要求:数据接收接真实采集,其他功能先保留查看框架,不使用演示数据。因此当前无数据时的空列表、空画面、空曲线与 `—` 是预期状态,不能以设计稿内的示例值补齐。
原生页面随 Java JAR 运行。最终修正版浏览器截图在 `E:\Codex\xtrh\runtime\console-ui-qa\revised\`。CSS 视口为 1586×992,deviceScaleFactor=1;截图为完整页面,不拉伸。超出一屏的空状态页面保留原始高度。另逐页检查 1024×768 视口,无整页横向溢出。
## 对比与修正
六页设计图与实现已经放在同一张比较图中,路径为 `runtime/console-ui-qa/comparison/*-compare.png`;另有 `receive-focus.png` 和 `nodes-focus.png` 检查品牌、字体、搜索和密集表头。
第一轮发现 [P2] 侧页的实装列表列宽与确认稿不一致、部分面板内容贴边、品牌图标缺少蓝色底面。已统一桌面列宽、补齐 20px 内边距并使用原品牌 SVG 的蓝色容器。最终修正截图已重拍并重新对比;未发现待修复 P0/P1/P2。
## 五项视觉检查
- 字体:Microsoft YaHei / Segoe UI 回退,中文标题、表头、状态与时间可读;使用界面层级而非图片文字。
- 布局:深蓝侧栏、白色顶栏、浅灰工作区及六项导航保持一致;装备选择与详情分区明确,指标列表为整表。
- 色彩:使用 #172c46、#276cc0、#f4f6fa 和语义状态色;未知或未接入信息使用灰色,不显示为正常。
- 图像:使用已提供品牌资源与图标库;无真实融合视频源时为空,不把原图、DEMO 或生成画面作为业务视频。
- 内容:品牌统一为系统融合软件;没有旧的接收统计、输出任务配置、模拟切换或下发演示操作。
## 浏览器和数据验证
实际浏览器逐页验证六页导航、空状态、1024px 布局及控制台;异常数为 0。独立测试集群上验证 13 项完整链路检查:HTTP 签名推送及响应验签、无签名拒绝、模拟采样过滤、真实零值和 false、过期标记、装备筛选/选择、参数详情、融合功能为空、全量空替换。测试数据只写入独立测试集群,已清空;没有写入正式总控或 PXHJ。
记录:`runtime/console-ui-qa/browser-report.json`、`revised/browser-report.json`、`interaction-report.json`。
Java 回归:XTRH 48/48 通过;PXHJ 445 项,失败 0、错误 0、跳过 30。前端数据规则 3/3 通过。静态资源与两个服务 JAR 均构建通过。
## 运行生效边界
已在用户明确授权后重启 PXHJ 后端和融合总控并加载新版本,正式签名同步状态为 CONNECTED。当前真实冻结上下文为 5 个试验、6 台装备、286 个参数;模拟采样数量为 0。正式页面已重新逐页检查。原有采集网关已启动,登记路由保持不变,处于 APPLIED/在线状态。当前未收到新的有效采集样本,参数质量为 MISSING,页面按原值显示为空。不能据此宣称完成真实设备采样验收。CPU、内存、网络速率与其他功能数据源仍为空。
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="same-origin" />
<link rel="icon" type="image/svg+xml" href="/console/assets/brand.svg" />
<title>系统融合软件</title>
</head>
<body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body>
</html>
+20
View File
@@ -0,0 +1,20 @@
{
"name": "xtrh-console",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "node --test tests/*.test.mjs"
},
"dependencies": {
"lucide-react": "1.57.0",
"react": "19.2.0",
"react-dom": "19.2.0"
},
"devDependencies": {
"@vitejs/plugin-react": "5.0.4",
"vite": "6.4.2"
}
}
+1124
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="none" focusable="false">
<circle cx="14" cy="14" r="10" stroke="#ffffff" stroke-width="1.5" opacity=".35"/>
<path d="M4 14h5l2.5-6 5 12 2.5-6h5" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="21.5" cy="6.5" r="2" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 372 B

+38
View File
@@ -0,0 +1,38 @@
import React,{useCallback,useEffect,useMemo,useState} from 'react';
import {Radio,Activity,Send,MonitorPlay,ChartNoAxesColumn,Network,ChevronRight,RefreshCw,PanelLeftClose,Gauge,AlertCircle} from 'lucide-react';
import {Status} from './ui';
import {realEquipment,collectionState,effectiveEquipment} from './data.mjs';
import {ReceivePage,SyncPage} from './ReceivePages';
import {VirtualPage,VideoPage,MetricsPage,NodesPage} from './SecondaryPages';
const PAGES=[
{id:'receive',name:'数据接收',description:'按装备查看接收数据与当前状态',icon:Radio},
{id:'sync',name:'同步质量监控',description:'按装备查看同步状态与质量指标',icon:Activity},
{id:'virtual',name:'虚拟数据下发',description:'查看虚拟装备的血量、位置与运行状态',icon:Send},
{id:'video',name:'融合视频展示',description:'按实装查看融合视频及当前融合指标',icon:MonitorPlay},
{id:'metrics',name:'融合指标展示',description:'以装备列表查看各实装的融合指标',icon:ChartNoAxesColumn},
{id:'nodes',name:'主从控制',description:'查看各节点的 CPU、内存和网络运行状态',icon:Network},
];
const emptySnapshot={collection:{state:'WAITING',contexts:[]},nodes:[]};
const currentRoute=()=>PAGES.find(item=>item.id===location.hash.slice(1))?.id||'receive';
export function App(){
const [pageId,setPageId]=useState(currentRoute),[snapshot,setSnapshot]=useState(emptySnapshot);
const [contextId,setContextId]=useState(''),[selectedId,setSelectedId]=useState('');
const [failed,setFailed]=useState(false),[loading,setLoading]=useState(true),[refreshKey,setRefreshKey]=useState(0),[collapsed,setCollapsed]=useState(false),[now,setNow]=useState(Date.now());
useEffect(()=>{const handler=()=>{setPageId(currentRoute());window.scrollTo(0,0);};window.addEventListener('hashchange',handler);return()=>window.removeEventListener('hashchange',handler);},[]);
useEffect(()=>{const timer=setInterval(()=>setNow(Date.now()),1000);return()=>clearInterval(timer);},[]);
useEffect(()=>{let closed=false,timer,controller;
const poll=async()=>{controller=new AbortController();const timeout=setTimeout(()=>controller.abort(),6000);try{const response=await fetch('/console/api/snapshot',{signal:controller.signal,credentials:'same-origin',cache:'no-store',headers:{Accept:'application/json'}});if(!response.ok)throw new Error('snapshot');const body=await response.json();if(!body||!Array.isArray(body.collection?.contexts)||!Array.isArray(body.nodes))throw new Error('shape');if(!closed){setSnapshot(body);setFailed(false);}}catch{if(!closed)setFailed(true);}finally{clearTimeout(timeout);if(!closed){setLoading(false);timer=setTimeout(poll,2000);}}};poll();return()=>{closed=true;clearTimeout(timer);controller?.abort();};
},[refreshKey]);
const state=collectionState(snapshot.collection,failed);
const contexts=snapshot.collection.contexts||[];
const context=contexts.find(item=>item.id===contextId)||contexts[0];
const equipments=useMemo(()=>realEquipment(context).map(item=>effectiveEquipment(item,state)),[context,state]);
const selected=equipments.find(item=>item.id===selectedId)?.id||equipments[0]?.id||'';
const page=PAGES.find(item=>item.id===pageId);
useEffect(()=>{document.title=`${page.name} · 系统融合软件`;},[page.name]);
const onSelect=useCallback(id=>setSelectedId(id),[]);
const props={equipments,selectedId:selected,onSelect};
const date=new Intl.DateTimeFormat('zh-CN',{timeZone:'Asia/Shanghai',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false}).format(new Date(now));
return <div className={`app ${collapsed?'nav-collapsed':''}`}><aside className="sidebar"><div className="brand"><img src="/console/assets/brand.svg" alt=""/><strong>系统融合软件</strong><button className="collapse-toggle" type="button" aria-label="切换侧栏" onClick={()=>setCollapsed(value=>!value)}><PanelLeftClose size={16}/></button></div><nav aria-label="功能导航">{PAGES.map(item=><a href={`#${item.id}`} className={`nav-link ${item.id===page.id?'active':''}`} key={item.id} title={item.name} aria-current={item.id===page.id?'page':undefined}><item.icon size={23} strokeWidth={1.6}/><span>{item.name}</span></a>)}</nav><div className="sidebar-foot"><Gauge size={22}/><span>运行监测</span></div></aside><div className="main-shell"><header className="topbar"><div className="breadcrumb"><span>系统融合软件</span><ChevronRight size={14}/><strong>{page.name}</strong></div><div className="service-state"><Status state={failed?'ERROR':loading?'WAITING':'CONNECTED'} label={failed?'服务连接中断':loading?'正在连接':'服务连接正常'}/><time>{date}</time></div></header><main><div className="page-heading"><div><h1>{page.name}</h1><p>{page.description}</p></div><div className="heading-actions">{contexts.length>0&&<label className="context-select"><span>试验</span><select aria-label="选择试验上下文" value={context?.id||''} onChange={event=>{setContextId(event.target.value);setSelectedId('');}}>{contexts.map(item=><option key={item.id} value={item.id}>{item.name||item.id}</option>)}</select></label>}<button className="refresh-button" type="button" onClick={()=>setRefreshKey(key=>key+1)} aria-label="刷新数据" title="刷新数据"><RefreshCw size={17}/><span>刷新</span></button></div></div>{(failed||state==='STALE')&&<div className="source-notice" role="status"><AlertCircle size={17}/>{failed?'服务暂时不可用,当前显示最后一次收到的数据。':'采集数据同步已超时,当前显示最后一次收到的数据。'}</div>}{page.id==='receive'&&<ReceivePage {...props}/ >}{page.id==='sync'&&<SyncPage {...props}/ >}{page.id==='virtual'&&<VirtualPage/>}{page.id==='video'&&<VideoPage {...props}/ >}{page.id==='metrics'&&<MetricsPage equipments={equipments}/ >}{page.id==='nodes'&&<NodesPage nodes={snapshot.nodes||[]}/ >}</main><footer className="app-footer"><span>系统融合软件</span><span>{state==='WAITING'?'等待采集数据接入':state==='CONNECTED'?'采集数据自动更新':'采集数据同步中断'}</span></footer></div></div>;
}
+18
View File
@@ -0,0 +1,18 @@
import React,{useEffect,useState} from 'react';
import {Activity,Radio} from 'lucide-react';
import {Panel,Empty,EquipmentList,EquipmentHeader,Status,formatTime} from './ui';
import {displayValue} from './data.mjs';
const typeNames={NUMBER:'数值数据',STATE:'状态数据',IMAGE:'图像数据'};
export function ReceivePage({equipments,selectedId,onSelect}) {
const equipment=equipments.find(item=>item.id===selectedId);
const items=equipment?.items||[];
const [itemKey,setItemKey]=useState('');
useEffect(()=>setItemKey(''),[selectedId]);
const selected=items.find(item=>item.key===itemKey)||items[0];
return <div className="equipment-layout"><EquipmentList equipments={equipments} selectedId={selectedId} onSelect={onSelect}/><div className="detail-stack">{equipment?<><EquipmentHeader equipment={equipment}/><Panel title="数据接收情况" extra={<span className="muted">{equipment.participantCode||''}</span>}><div className="table-scroll"><table className="data-table receive-table"><thead><tr><th>数据类型 / 参数</th><th>最新数据</th><th>接收时间</th><th>接收状态</th></tr></thead><tbody>{items.map((item,index)=><tr key={`${item.key}-${index}`} className={selected===item?'selected':''} onClick={()=>setItemKey(item.key)} tabIndex={0} aria-selected={selected===item} onKeyDown={event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();setItemKey(item.key);}}}><td><strong>{item.label||item.key}</strong><small>{typeNames[item.type]||item.type||'采集数据'}</small></td><td className="parameter-value">{displayValue(item.value,item.unit)}</td><td><time>{formatTime(item.receivedAt)}</time></td><td><Status state={item.quality}/></td></tr>)}</tbody></table></div>{!items.length&&<Empty title="尚未收到该装备的数据" description="采集数据到达后将在此显示。" icon={Radio}/>}</Panel><Panel title={selected?`${selected.label||selected.key}详情`:'数据详情'}>{selected?<dl className="value-details"><div><dt>当前值</dt><dd>{displayValue(selected.value,selected.unit)}</dd></div><div><dt>数据来源</dt><dd>{selected.sourceName||'—'}</dd></div><div><dt>采样时间</dt><dd>{formatTime(selected.sampleTime)}</dd></div><div><dt>接收时间</dt><dd>{formatTime(selected.receivedAt)}</dd></div><div><dt>数据质量</dt><dd><Status state={selected.quality}/></dd></div></dl>:<Empty title="暂无参数详情" description="选择已接收参数查看其来源和时间。"/>}</Panel></>:<Panel className="unselected-panel"><Empty title="等待装备采集数据" description="已绑定的实装及其采集数据同步后将在此显示。" icon={Radio}/></Panel>}</div></div>;
}
export function SyncPage({equipments,selectedId,onSelect}) {
const equipment=equipments.find(item=>item.id===selectedId);
return <div className="equipment-layout"><EquipmentList equipments={equipments} selectedId={selectedId} onSelect={onSelect}/><div className="detail-stack">{equipment&&<EquipmentHeader equipment={{...equipment,state:'NOT_RECEIVED'}} statusLabel="同步指标未接入"/>}<Panel title="当前同步指标"><div className="sync-values">{[['同步延迟','ms'],['数据丢包率','%'],['同步偏差','ms']].map(([name,unit])=><div key={name}><span>{name}</span><strong>— <small>{unit}</small></strong><p>未接入</p></div>)}</div></Panel><Panel title="同步质量趋势"><Empty title="暂无同步质量数据" description="同步质量数据接入后,可查看所选装备的指标与趋势。" icon={Activity}/></Panel><Panel title="数据同步状态"><div className="table-scroll"><table className="data-table"><thead><tr><th>数据类型</th><th>数据时间</th><th>同步偏差</th><th>同步状态</th></tr></thead></table></div><Empty title="暂无同步状态"/></Panel></div></div>;
}
+210
View File
@@ -0,0 +1,210 @@
import React, { useMemo, useState } from 'react';
import {
Activity, ArrowDownToLine, ArrowUpFromLine, Cpu, HeartPulse,
Maximize, MonitorPlay, Network, Pause, Search, Server, Volume2,
} from 'lucide-react';
import { Empty, EquipmentHeader, EquipmentIcon, EquipmentList, Panel } from './ui';
import './secondary.css';
const FUSION_METRICS = [
['帧率', 'fps'], ['码率', 'Mbps'], ['丢帧率', '%'],
['同步偏差', 'ms'], ['网络带宽', 'Mbps'], ['运行时长', 'min'],
];
function equipmentId(equipment) {
return String(equipment?.equipmentId ?? equipment?.id ?? equipment?.code ?? '');
}
function equipmentName(equipment) {
return equipment?.equipmentName || equipment?.name || equipment?.code || equipmentId(equipment) || '未命名装备';
}
function equipmentCode(equipment) {
return equipment?.equipmentCode || equipment?.code || equipmentId(equipment) || '—';
}
function SearchField({ value, onChange, placeholder, label }) {
return <label className="secondary-search">
<Search size={17} aria-hidden="true" />
<input
aria-label={label || placeholder}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
type="search"
/>
</label>;
}
function BlankValue({ unit }) {
return <span className="secondary-blank">—{unit && <small>{unit}</small>}</span>;
}
function UnavailableTag({ children }) {
return <span className="secondary-unavailable"><i />{children}</span>;
}
function TableEmpty({ colSpan, title, description, icon }) {
return <tr><td colSpan={colSpan} className="secondary-empty-cell">
<Empty title={title} description={description} icon={icon} />
</td></tr>;
}
export function VirtualPage() {
const [query, setQuery] = useState('');
return <div className="secondary-equipment-layout virtual-page">
<Panel title="虚拟装备列表" className="virtual-list-panel">
<div className="secondary-list-search">
<SearchField value={query} onChange={setQuery} placeholder="搜索虚拟装备名称或编号" />
</div>
<div className="virtual-list-empty">
<Empty title={query.trim() ? '未找到匹配的虚拟装备' : '暂无虚拟装备'} description="接入虚拟装备后,可在此选择查看。" icon={Activity} />
</div>
</Panel>
<div className="secondary-detail-stack">
<div className="virtual-unselected-header">
<span className="virtual-equipment-icon"><Activity size={32} aria-hidden="true" /></span>
<div><h2>未选择虚拟装备</h2><p>装备编号 <span>—</span></p></div>
<UnavailableTag>暂无状态</UnavailableTag>
</div>
<Panel title="当前装备状态" className="virtual-state-panel">
<div className="virtual-state-body">
<div className="virtual-health-row">
<HeartPulse size={31} aria-hidden="true" />
<span className="virtual-health-label">血量</span>
<strong>— / —</strong>
<div className="virtual-health-track" aria-label="暂无血量数据" />
<span className="secondary-muted">—</span>
</div>
<div className="virtual-position-grid">
{[
['经度', '°'], ['速度', 'm/s'],
['纬度', '°'], ['航向角', '°'],
['高程', 'm'], ['动作状态', ''],
].map(([label, unit]) => <div className="virtual-position-item" key={label}>
<span>{label}</span><BlankValue unit={unit} />
</div>)}
</div>
</div>
</Panel>
<Panel title="虚拟装备状态一览" className="virtual-overview-panel">
<div className="secondary-table-scroll">
<table className="secondary-table virtual-overview-table">
<thead><tr><th scope="col">虚拟装备</th><th scope="col">血量</th><th scope="col">位置(经度 / 纬度)</th><th scope="col">高程</th><th scope="col">状态</th></tr></thead>
<tbody><TableEmpty colSpan={5} title="暂无虚拟装备状态" description="装备接入后显示血量、位置与运行状态。" icon={HeartPulse} /></tbody>
</table>
</div>
</Panel>
</div>
</div>;
}
export function VideoPage({ equipments = [], selectedId, onSelect }) {
const selected = equipments.find((equipment) => equipmentId(equipment) === String(selectedId ?? ''));
return <div className="secondary-equipment-layout video-page">
<EquipmentList equipments={equipments} selectedId={selectedId} onSelect={onSelect} title="实装列表" emptyText="暂无已接入的实装" />
<div className="secondary-detail-stack video-main-column">
{selected
? <EquipmentHeader equipment={{ ...selected, state: 'NOT_RECEIVED' }} statusLabel="暂无融合视频" />
: <Panel><div className="video-unselected-header"><MonitorPlay size={30} aria-hidden="true" /><div><h2>未选择实装</h2><p>从实装列表选择装备查看融合视频。</p></div></div></Panel>}
<Panel className="video-main-panel">
<div className="video-stage">
<div className="video-empty-frame">
<MonitorPlay size={54} strokeWidth={1.5} aria-hidden="true" />
<h3>{selected ? '暂无融合视频' : '请选择实装'}</h3>
<p>{selected ? '当前装备尚无可用的融合视频源。' : '选择装备后查看对应的融合画面。'}</p>
</div>
<aside className="video-metrics-overlay" aria-label="当前装备融合指标">
<div className="video-overlay-heading"><strong>融合指标</strong><span>{selected ? equipmentCode(selected) : '—'}</span></div>
<dl>{FUSION_METRICS.map(([label, unit]) => <div key={label}><dt>{label}</dt><dd>—<small>{unit}</small></dd></div>)}</dl>
</aside>
<div className="video-control-bar" aria-label="视频播放控制">
<UnavailableTag>{selected ? '暂无画面' : '未选择装备'}</UnavailableTag>
<span className="video-control-divider" />
<button type="button" disabled aria-label="暂停视频" title="暂无可播放的融合视频"><Pause size={19} /></button>
<button type="button" disabled aria-label="视频音量" title="暂无可播放的融合视频"><Volume2 size={20} /></button>
<div className="video-volume-track" aria-hidden="true" />
<span className="video-resolution">分辨率 —</span>
<button type="button" disabled aria-label="视频全屏" title="暂无可播放的融合视频"><Maximize size={20} /></button>
</div>
</div>
</Panel>
</div>
</div>;
}
export function MetricsPage({ equipments = [] }) {
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
const value = query.trim().toLocaleLowerCase();
return value ? equipments.filter((equipment) => `${equipmentName(equipment)} ${equipmentCode(equipment)}`.toLocaleLowerCase().includes(value)) : equipments;
}, [equipments, query]);
return <div className="fusion-metrics-page">
<Panel title="装备融合指标" className="fusion-metrics-panel">
<div className="secondary-panel-toolbar">
<SearchField value={query} onChange={setQuery} placeholder="搜索装备名称或编号" />
</div>
<div className="secondary-table-scroll">
<table className="secondary-table fusion-metrics-table">
<thead><tr><th scope="col">装备名称</th>{FUSION_METRICS.map(([label, unit]) => <th scope="col" key={label}>{label}<span className="secondary-column-unit">({unit})</span></th>)}<th scope="col">状态</th></tr></thead>
<tbody>
{filtered.length ? filtered.map((equipment, index) => <tr key={`${equipmentId(equipment)}-${index}`}>
<td><div className="fusion-equipment-name"><EquipmentIcon equipment={equipment} size={26} /><div><strong>{equipmentName(equipment)}</strong><span>{equipmentCode(equipment)}</span></div></div></td>
{FUSION_METRICS.map(([label]) => <td key={label}><BlankValue /></td>)}
<td><UnavailableTag>暂无指标</UnavailableTag></td>
</tr>) : <TableEmpty colSpan={8} title={query.trim() ? '未找到匹配的装备' : '暂无实装'} description={query.trim() ? '请调整名称或编号后重试。' : '装备接入后,将在此显示对应的融合指标。'} icon={Activity} />}
</tbody>
</table>
</div>
<p className="secondary-panel-note">未接入融合指标的装备显示为 —。</p>
</Panel>
<p className="fusion-metrics-footer">可在融合视频展示页查看对应装备的画面与融合指标。</p>
</div>;
}
function NodeStatus({ node }) {
const state = node.online === true ? 'online' : node.online === false ? 'offline' : 'unknown';
return <span className={`node-state node-state-${state}`}><i />{state === 'online' ? '在线' : state === 'offline' ? '离线' : '状态未知'}</span>;
}
function nodeRole(node) {
return node.nodeRole === 'COORDINATOR' ? '主节点' : node.nodeRole === 'WORKER' ? '从节点' : '—';
}
export function NodesPage({ nodes = [] }) {
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
const value = query.trim().toLocaleLowerCase();
return value ? nodes.filter((node) => `${node.name || ''} ${node.nodeId || ''}`.toLocaleLowerCase().includes(value)) : nodes;
}, [nodes, query]);
return <Panel title="节点运行状态" className="node-page">
<div className="secondary-panel-toolbar">
<SearchField value={query} onChange={setQuery} placeholder="搜索节点名称或编号" />
</div>
<div className="secondary-table-scroll">
<table className="secondary-table node-status-table">
<thead><tr><th scope="col">节点名称</th><th scope="col">角色</th><th scope="col"><Cpu size={15} aria-hidden="true" />CPU 使用率</th><th scope="col">内存使用情况</th><th scope="col"><ArrowDownToLine size={15} aria-hidden="true" />网络接收</th><th scope="col"><ArrowUpFromLine size={15} aria-hidden="true" />网络发送</th><th scope="col">运行状态</th></tr></thead>
<tbody>{filtered.length ? filtered.map((node, index) => <tr key={node.nodeId || index}>
<td><div className="node-identity"><Server size={28} aria-hidden="true" /><div><strong>{node.name || node.nodeId || '未命名节点'}</strong><span title={node.nodeId || ''}>{node.nodeId || '—'}</span></div></div></td>
<td>{nodeRole(node)}</td>
<td><BlankValue /></td><td><BlankValue /></td><td><BlankValue /></td><td><BlankValue /></td>
<td><NodeStatus node={node} /></td>
</tr>) : <TableEmpty colSpan={7} title={query.trim() ? '未找到匹配的节点' : '暂无已注册节点'} description={query.trim() ? '请调整名称或编号后重试。' : '节点注册后,可在此查看运行状态。'} icon={Server} />}</tbody>
</table>
</div>
<section className="node-network-panel" aria-label="节点心跳连接状态">
<h3><Network size={18} aria-hidden="true" />节点心跳连接状态</h3>
{filtered.length ? <div className="node-network-grid">{filtered.map((node, index) => <div className="node-network-item" key={node.nodeId || index}>
<Server size={25} aria-hidden="true" /><div><strong>{node.name || node.nodeId || '未命名节点'}</strong><span>{node.online === true ? '心跳在线' : node.online === false ? '心跳中断' : '暂无心跳状态'}</span></div><NodeStatus node={node} />
</div>)}</div> : <p className="node-network-empty">暂无节点连接信息</p>}
</section>
<p className="secondary-panel-note">资源使用情况与网络速率未上报时显示为 —。</p>
</Panel>;
}
+28
View File
@@ -0,0 +1,28 @@
const simulated = item => item?.simulated === true || /SIMULATED|模拟|演示/i.test([item?.quality,item?.sourceType,item?.sourceName].join(' '));
export function realEquipment(context) {
return (Array.isArray(context?.equipment) ? context.equipment : []).filter(item => !simulated(item)).map(item => ({
...item,
id: String(item.id || item.equipmentId || ''),
items: (Array.isArray(item.items) ? item.items : []).filter(value => !simulated(value)),
})).filter(item => item.id);
}
export function displayValue(value, unit = '') {
if (value === null || value === undefined || value === '') return '—';
if (typeof value === 'object') return '—';
const text = typeof value === 'boolean' ? (value ? '是' : '否') : String(value);
return unit ? `${text} ${unit}` : text;
}
export function collectionState(collection, failed) {
if (failed) return 'ERROR';
return ['CONNECTED','STALE','WAITING'].includes(collection?.state) ? collection.state : 'WAITING';
}
export function effectiveEquipment(equipment, state) {
if (!equipment) return undefined;
if (state === 'CONNECTED') return equipment;
return {...equipment, state: equipment.lastReceivedAt ? 'STALE' : 'NOT_RECEIVED',
items:(equipment.items||[]).map(item=>['GOOD','VALID'].includes(item.quality)?{...item,quality:'STALE'}:item)};
}
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
import {createRoot} from 'react-dom/client';
import {App} from './App';
import './styles.css';
createRoot(document.getElementById('root')).render(<React.StrictMode><App /></React.StrictMode>);
+137
View File
@@ -0,0 +1,137 @@
.secondary-equipment-layout { display: grid; grid-template-columns: 296px minmax(0, 1fr); gap: 18px; align-items: stretch; }
.secondary-detail-stack { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
.secondary-search { display: flex; align-items: center; gap: 10px; width: 330px; max-width: 100%; min-height: 40px; padding: 0 12px; color: #647e9f; border: 1px solid #d6e2f0; border-radius: 6px; background: #fff; }
.secondary-search:focus-within { border-color: #3280d4; box-shadow: 0 0 0 3px #2b7bd316; }
.secondary-search input { width: 100%; min-width: 0; height: 38px; padding: 0; color: #203753; font: inherit; font-size: 13px; border: 0; outline: 0; background: transparent; }
.secondary-search input::placeholder { color: #7d91ab; }
.secondary-list-search { padding: 0 18px 18px; }
.secondary-list-search .secondary-search { width: auto; }
.secondary-panel-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 0 0 18px; }
.secondary-muted, .secondary-blank { color: #8294ac; }
.secondary-blank { white-space: nowrap; font-variant-numeric: tabular-nums; }
.secondary-blank small { margin-left: 8px; color: #8294ac; font-size: 12px; }
.secondary-unavailable { display: inline-flex; align-items: center; gap: 7px; color: #8393a9; white-space: nowrap; font-size: 13px; }
.secondary-unavailable i, .node-state i { width: 8px; height: 8px; flex: 0 0 8px; border-radius: 50%; background: currentColor; }
.secondary-table-scroll { width: 100%; overflow-x: auto; }
.secondary-table { width: 100%; border-spacing: 0; border-collapse: collapse; table-layout: fixed; color: #294360; font-size: 13px; }
.secondary-table th { height: 48px; padding: 12px 14px; color: #5c7798; background: #f4f8fc; border: 1px solid #e1eaf4; font-weight: 500; text-align: left; }
.secondary-table td { height: 83px; padding: 17px 14px; border: 1px solid #e1eaf4; vertical-align: middle; }
.secondary-table th svg { display: inline-block; margin: 0 6px -2px 0; }
.secondary-table tbody tr:hover { background: #f9fbfe; }
.secondary-table .secondary-empty-cell { height: 230px; padding: 30px 16px; text-align: center; }
.secondary-table .secondary-empty-cell:hover { background: #fff; }
.secondary-column-unit { white-space: nowrap; }
.secondary-panel-note { margin: 20px 0 0; color: #7f94ae; font-size: 12px; line-height: 1.6; }
.virtual-list-panel { min-height: 650px; }
.virtual-list-empty { display: grid; place-items: center; padding: 80px 18px 28px; }
.virtual-unselected-header { display: flex; align-items: center; gap: 18px; padding: 23px; min-height: 100px; border: 1px solid #dfe8f3; border-radius: 8px; background: #fff; }
.virtual-equipment-icon { display: grid; place-items: center; width: 48px; height: 48px; color: #a2b5cc; background: #f2f6fb; border-radius: 9px; }
.virtual-unselected-header h2, .video-unselected-header h2 { margin: 0 0 8px; font-size: 21px; font-weight: 600; color: #566d89; }
.virtual-unselected-header p, .video-unselected-header p { margin: 0; color: #8396ae; font-size: 13px; }
.virtual-unselected-header p span { margin-left: 12px; }
.virtual-unselected-header > .secondary-unavailable { margin-left: auto; }
.virtual-state-body { padding: 0; }
.virtual-health-row { display: flex; align-items: center; gap: 20px; padding: 20px 22px; background: #f9fbfe; border: 1px solid #e5edf6; border-radius: 5px; }
.virtual-health-row > svg { color: #9badc2; }
.virtual-health-label { color: #66809f; font-size: 14px; }
.virtual-health-row strong { color: #8195ad; font-size: 23px; font-weight: 500; white-space: nowrap; }
.virtual-health-track { flex: 1; height: 10px; margin-left: 8px; border-radius: 6px; background: #e7edf5; }
.virtual-position-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 19px 36px; padding: 23px 12px 4px; }
.virtual-position-item { display: grid; grid-template-columns: 84px 1fr; gap: 12px; font-size: 14px; }
.virtual-position-item > span:first-child { color: #6783a3; }
.virtual-overview-table { min-width: 620px; }
.virtual-overview-table th:first-child { width: 23%; }
.virtual-overview-table th:nth-child(3) { width: 33%; }
.video-main-panel { min-width: 0; overflow: hidden; }
.video-unselected-header { display: flex; align-items: center; gap: 18px; min-height: 91px; color: #91a6bf; }
.video-main-panel > .panel-header:empty { display: none; }
.video-stage { position: relative; display: flex; flex-direction: column; min-height: 480px; aspect-ratio: 16 / 10; overflow: hidden; background: #101d2c; border-radius: 6px; }
.video-empty-frame { display: flex; flex: 1; flex-direction: column; align-items: center; justify-content: center; padding: 180px 24px 36px; color: #5f7893; text-align: center; }
.video-empty-frame h3 { margin: 16px 0 9px; color: #c1cede; font-size: 19px; font-weight: 500; }
.video-empty-frame p { margin: 0; color: #7d92a9; font-size: 13px; line-height: 1.7; }
.video-metrics-overlay { position: absolute; top: 18px; right: 18px; width: 222px; padding: 15px 17px; color: #dfe7f0; border: 1px solid #52667c50; border-radius: 7px; background: #223246e8; }
.video-overlay-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 11px; border-bottom: 1px solid #a4b4c12b; }
.video-overlay-heading strong { font-size: 14px; }
.video-overlay-heading > span { max-width: 102px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #9fb2c7; font-size: 11px; }
.video-metrics-overlay dl { display: flex; flex-direction: column; gap: 10px; margin: 12px 0 0; font-size: 12px; }
.video-metrics-overlay dl > div { display: flex; justify-content: space-between; align-items: center; gap: 15px; }
.video-metrics-overlay dt { color: #c1cede; }
.video-metrics-overlay dd { margin: 0; color: #a9bbcf; white-space: nowrap; }
.video-metrics-overlay dd small { margin-left: 6px; color: #8098b3; font-size: 10px; }
.video-control-bar { display: flex; align-items: center; gap: 16px; min-height: 48px; padding: 0 16px; color: #8195ad; background: #1c2a3a; }
.video-control-bar .secondary-unavailable { color: #879bb2; font-size: 12px; }
.video-control-divider { height: 21px; width: 1px; background: #47596d; }
.video-control-bar button { display: grid; place-items: center; min-width: 25px; padding: 5px 0; border: 0; color: #768b9f; background: none; }
.video-control-bar button:disabled { cursor: not-allowed; opacity: .55; }
.video-volume-track { width: 96px; height: 3px; border-radius: 3px; background: #3f5369; }
.video-resolution { margin-left: auto; font-size: 11px; white-space: nowrap; }
.fusion-metrics-panel { min-height: 550px; }
.fusion-metrics-table { min-width: 1000px; }
.fusion-metrics-table th:first-child { width: 215px; }
.fusion-metrics-table th:last-child { width: 105px; }
.fusion-metrics-table td:not(:first-child), .fusion-metrics-table th:not(:first-child) { text-align: center; }
.fusion-metrics-table td { height: 93px; }
.fusion-equipment-name { display: flex; align-items: center; gap: 14px; }
.fusion-equipment-name > svg { color: #287dcb; flex-shrink: 0; }
.fusion-equipment-name > div { min-width: 0; }
.fusion-equipment-name strong { display: block; color: #1b395c; font-size: 15px; font-weight: 600; word-break: break-word; }
.fusion-equipment-name span { display: block; margin-top: 7px; color: #7e94b0; font-size: 12px; word-break: break-all; }
.fusion-metrics-footer { margin: 22px 0 0; color: #7b93b0; font-size: 13px; }
.node-status-table { min-width: 1020px; }
.node-status-table th:first-child { width: 250px; }
.node-status-table th:nth-child(2) { width: 90px; }
.node-status-table th:nth-child(4) { width: 180px; }
.node-status-table th:last-child { width: 115px; }
.node-status-table td { height: 96px; }
.node-identity { display: flex; align-items: center; gap: 15px; }
.node-identity > svg { flex-shrink: 0; color: #267bd1; }
.node-identity > div { min-width: 0; }
.node-identity strong { display: block; color: #193a5c; font-size: 15px; font-weight: 600; word-break: break-word; }
.node-identity span { display: block; margin-top: 7px; color: #7a91ad; font-size: 11px; overflow-wrap: anywhere; }
.node-state { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; white-space: nowrap; }
.node-state-online { color: #0da76a; }
.node-state-offline, .node-state-unknown { color: #8092aa; }
.node-network-panel { margin-top: 24px; padding: 18px; border: 1px solid #e0e9f4; border-radius: 6px; }
.node-network-panel h3 { display: flex; align-items: center; gap: 9px; margin: 0 0 18px; padding-bottom: 14px; border-bottom: 1px solid #e6edf6; font-size: 15px; font-weight: 600; color: #234363; }
.node-network-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }
.node-network-item { display: flex; align-items: center; gap: 11px; min-width: 0; padding: 7px 0; }
.node-network-item > svg { flex-shrink: 0; color: #287dc9; }
.node-network-item > div { min-width: 0; }
.node-network-item strong { display: block; color: #294664; font-size: 13px; font-weight: 600; word-break: break-word; }
.node-network-item > div > span { display: block; margin-top: 6px; color: #8294aa; font-size: 11px; }
.node-network-item > .node-state { margin-left: auto; }
.node-network-empty { margin: 28px 0; color: #8b9bb0; text-align: center; font-size: 13px; }
@media (max-width: 1220px) {
.secondary-equipment-layout { grid-template-columns: 260px minmax(0, 1fr); }
.video-stage { min-height: 490px; }
.video-empty-frame { padding-top: 230px; }
.node-network-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.virtual-health-row { gap: 13px; padding: 18px 14px; }
.virtual-health-row strong { font-size: 19px; }
}
@media (max-width: 950px) {
.secondary-equipment-layout { grid-template-columns: 1fr; }
.virtual-list-panel { min-height: 240px; }
.virtual-list-empty { padding: 25px 18px; }
.video-stage { aspect-ratio: 16 / 10; min-height: 440px; }
.video-empty-frame { padding-top: 180px; }
}
@media (max-width: 650px) {
.virtual-unselected-header { gap: 12px; padding: 17px; flex-wrap: wrap; }
.virtual-unselected-header > .secondary-unavailable { margin-left: 60px; }
.virtual-position-grid { grid-template-columns: 1fr; }
.virtual-health-row { flex-wrap: wrap; }
.virtual-health-track { min-width: 70px; }
.video-stage { min-height: 480px; }
.video-metrics-overlay { width: 200px; right: 12px; top: 12px; }
.video-empty-frame { padding-top: 250px; }
.video-control-bar { gap: 12px; padding: 0 12px; }
.video-volume-track, .video-resolution { display: none; }
.video-control-bar button:last-child { margin-left: auto; }
.node-network-grid { grid-template-columns: 1fr; }
}
File diff suppressed because one or more lines are too long
+39
View File
@@ -0,0 +1,39 @@
import React, {useMemo, useState} from 'react';
import {Search, Truck, Plane, Inbox} from 'lucide-react';
const labels = {
RECEIVING:'接收正常',GOOD:'正常',CONNECTED:'服务连接正常',STALE:'数据已过期',
NOT_RECEIVED:'未接收',MISSING:'未采到',ERROR:'数据异常',WAITING:'等待数据同步',
INVALID_TIME:'时间异常',INVALID_VALUE:'数值异常',AMBIGUOUS:'来源不明确',
online:'在线',offline:'离线',UNKNOWN:'未接入',
};
export function Status({state='UNKNOWN', label}) {
const tone = ['RECEIVING','GOOD','CONNECTED','online'].includes(state) ? 'good' : ['STALE','INVALID_TIME','AMBIGUOUS'].includes(state) ? 'warn' : ['ERROR','INVALID_VALUE'].includes(state) ? 'bad' : 'muted';
return <span className={`status status-${tone}`}><i />{label || labels[state] || labels.UNKNOWN}</span>;
}
export function Panel({title,children,extra,className=''}) {
return <section className={`panel ${className}`}>{(title||extra)&&<div className="panel-title"><h2>{title}</h2>{extra&&<div className="panel-extra">{extra}</div>}</div>}{children}</section>;
}
export function Empty({title='暂无数据',description,icon:Icon=Inbox}) {
return <div className="empty-state"><Icon size={35} strokeWidth={1.4}/><strong>{title}</strong>{description&&<p>{description}</p>}</div>;
}
export function EquipmentIcon({equipment,size=25}) {
const air=/无人机|航空|飞行|UAV/i.test(`${equipment?.name||''} ${equipment?.code||''} ${equipment?.type||''}`);
const Icon=air?Plane:Truck;
return <Icon size={size} strokeWidth={1.7}/>;
}
export function EquipmentList({equipments=[],selectedId,onSelect,title='实装列表',emptyText='暂无已绑定装备'}) {
const [search,setSearch]=useState('');
const filtered=useMemo(()=>equipments.filter(item=>`${item.name||''} ${item.code||''}`.toLowerCase().includes(search.trim().toLowerCase())),[equipments,search]);
return <Panel title={title} className="equipment-list"><label className="search-input"><Search size={17}/><input type="search" aria-label="搜索装备名称或编号" placeholder="搜索装备名称或编号" value={search} onChange={event=>setSearch(event.target.value)}/></label><div className="equipment-rows">{filtered.map(item=><button type="button" className={`equipment-row ${selectedId===item.id?'selected':''}`} key={item.id} onClick={()=>onSelect?.(item.id)} aria-pressed={selectedId===item.id}><EquipmentIcon equipment={item}/><span className="equipment-row-copy"><strong>{item.name||item.code||'未命名装备'}</strong><small>{item.code||item.participantCode||'—'}</small></span><Status state={item.state}/></button>)}</div>{!filtered.length&&<Empty title={search?'无匹配装备':emptyText} description={search?'请调整名称或编号。':'绑定装备后将在此显示。'}/>}</Panel>;
}
export function EquipmentHeader({equipment,statusLabel}) {
if(!equipment)return null;
return <Panel className="equipment-header"><div className="equipment-heading"><EquipmentIcon equipment={equipment} size={46}/><div><h2>{equipment.name||equipment.code||'未命名装备'}</h2><p>{equipment.code||'—'}<span>·</span>{equipment.type||'实装装备'}</p></div><Status state={equipment.state} label={statusLabel}/></div><div className="equipment-updated">最近接收 <time>{formatTime(equipment.lastReceivedAt)}</time></div></Panel>;
}
export function formatTime(value) {
if(value===null||value===undefined||value==='')return '—';
const date=new Date(value);
if(Number.isNaN(date.getTime()))return '—';
return new Intl.DateTimeFormat('zh-CN',{timeZone:'Asia/Shanghai',hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false}).format(date);
}
+21
View File
@@ -0,0 +1,21 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {realEquipment,displayValue,effectiveEquipment,collectionState} from '../src/data.mjs';
test('real zero is preserved and missing values never become zero',()=>{
assert.equal(displayValue(0,'m'),'0 m');
assert.equal(displayValue(null,'m'),'—');
assert.equal(displayValue(false),'否');
assert.equal(displayValue({value:15}),'—');
});
test('simulated samples are excluded without borrowing another equipment value',()=>{
const rows=realEquipment({equipment:[{id:'real',items:[{key:'a',value:0,quality:'GOOD'},{key:'b',value:15,quality:'SIMULATED'},{key:'c',value:7,sourceName:'模拟采集'},{key:'d',value:8,simulated:true}]},{id:'demo',sourceType:'SIMULATED',items:[]}]});
assert.equal(rows.length,1);
assert.deepEqual(rows[0].items.map(item=>item.key),['a']);
assert.equal(rows[0].items[0].value,0);
});
test('bridge failure stops showing last equipment data as fresh',()=>{
assert.equal(collectionState({state:'CONNECTED'},true),'ERROR');
assert.equal(effectiveEquipment({id:'e',state:'RECEIVING',lastReceivedAt:123},'ERROR').state,'STALE');
assert.equal(effectiveEquipment({id:'e',state:'RECEIVING',lastReceivedAt:null},'STALE').state,'NOT_RECEIVED');
});
+9
View File
@@ -0,0 +1,9 @@
import {defineConfig} from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
base: '/console/',
plugins: [react()],
build: {outDir: '../src/main/resources/static/console', emptyOutDir: true},
server: {host: '127.0.0.1', port: 4178, strictPort: true},
});