const state = {
|
snapshot: null,
|
selectedDeviceId: '',
|
};
|
|
const metricGroups = [
|
{
|
title: '温度与治疗参数',
|
keys: [
|
['AF', '设定温度'],
|
['F', '当前温度'],
|
['A', '设定超滤总量'],
|
['C', '超滤率'],
|
['B', '超滤量'],
|
['K', '剩余时间'],
|
],
|
},
|
{
|
title: '流量与压力',
|
keys: [
|
['L', '透析液流量'],
|
['D', '有效血流量'],
|
['H', '静脉压'],
|
['o', '动脉压'],
|
['J', '跨膜压'],
|
['U', '累计血流量'],
|
],
|
},
|
{
|
title: '电解质与血液监测',
|
keys: [
|
['G', '电导率'],
|
['Na', '钠'],
|
['HCO3', '碳酸氢根'],
|
['O2Sat', '血氧饱和度'],
|
['Hct', '红细胞比容'],
|
['Hb', '血红蛋白'],
|
['Tblood', '血液温度'],
|
['ktv', 'Kt/V'],
|
],
|
},
|
{
|
title: '血压数据',
|
keys: [
|
['N', '收缩压'],
|
['O', '舒张压'],
|
['P', '心率'],
|
['M', '血压监测时间'],
|
],
|
},
|
];
|
|
function $(selector) {
|
return document.querySelector(selector);
|
}
|
|
function formatClock(date = new Date()) {
|
return date.toLocaleTimeString('zh-CN', { hour12: false });
|
}
|
|
function formatTime(value) {
|
if (!value) {
|
return '暂无';
|
}
|
|
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
}
|
|
function formatAge(ageMs) {
|
if (ageMs === null || ageMs === undefined) {
|
return '暂无数据';
|
}
|
|
const seconds = Math.max(0, Math.floor(ageMs / 1000));
|
if (seconds < 60) {
|
return `${seconds} 秒前`;
|
}
|
|
const minutes = Math.floor(seconds / 60);
|
if (minutes < 60) {
|
return `${minutes} 分钟前`;
|
}
|
|
return `${Math.floor(minutes / 60)} 小时前`;
|
}
|
|
function getDataStatusText(status) {
|
if (status === 'active') {
|
return '数据正常';
|
}
|
|
if (status === 'stale') {
|
return '数据超时';
|
}
|
|
return '等待数据';
|
}
|
|
function setText(id, value) {
|
const element = $(id);
|
if (element) {
|
element.textContent = value;
|
}
|
}
|
|
function render(snapshot) {
|
state.snapshot = snapshot;
|
|
if (!state.selectedDeviceId && snapshot.devices.length > 0) {
|
state.selectedDeviceId = snapshot.devices[0].deviceId;
|
}
|
|
if (state.selectedDeviceId && !snapshot.devices.some((device) => device.deviceId === state.selectedDeviceId)) {
|
state.selectedDeviceId = snapshot.devices[0] ? snapshot.devices[0].deviceId : '';
|
}
|
|
setText('#dashboard-title', snapshot.title || '设备中央监测大屏');
|
setText('#refresh-time', `最后刷新 ${formatTime(snapshot.generatedAt)}`);
|
setText('#online-count', snapshot.totals.online);
|
setText('#offline-count', snapshot.totals.offline);
|
setText('#active-count', snapshot.totals.active);
|
setText('#stale-count', snapshot.totals.waiting + snapshot.totals.stale);
|
setText('#device-total', `${snapshot.totals.devices} 台设备`);
|
|
renderDeviceList(snapshot.devices);
|
renderDetail(snapshot.devices.find((device) => device.deviceId === state.selectedDeviceId));
|
}
|
|
function renderDeviceList(devices) {
|
const container = $('#device-list');
|
|
container.innerHTML = '';
|
|
for (const device of devices) {
|
const button = document.createElement('button');
|
button.type = 'button';
|
button.className = `device-card ${device.deviceId === state.selectedDeviceId ? 'selected' : ''}`;
|
button.innerHTML = `
|
<div>
|
<div class="device-name">${escapeHtml(device.name)}</div>
|
<div class="device-meta">${escapeHtml(device.deviceId)} | ${escapeHtml(device.ip)} | ${formatAge(device.dataAgeMs)}</div>
|
</div>
|
<div class="badges">
|
<span class="badge ${device.online ? 'online' : 'offline'}">${device.online ? '在线' : '离线'}</span>
|
<span class="badge ${device.dataStatus}">${getDataStatusText(device.dataStatus)}</span>
|
</div>
|
`;
|
button.addEventListener('click', () => {
|
state.selectedDeviceId = device.deviceId;
|
render(state.snapshot);
|
});
|
container.appendChild(button);
|
}
|
}
|
|
function renderDetail(device) {
|
const body = $('#detail-body');
|
|
if (!device) {
|
$('#detail-title').textContent = '设备数据状态';
|
$('#detail-state').textContent = '未选择';
|
body.className = 'detail-body empty';
|
body.innerHTML = '<p>等待设备数据接入</p>';
|
return;
|
}
|
|
$('#detail-title').textContent = `${device.name} 数据状态`;
|
$('#detail-state').textContent = `${device.online ? '在线' : '离线'} / ${getDataStatusText(device.dataStatus)}`;
|
body.className = 'detail-body';
|
body.innerHTML = `
|
<div class="timeline">
|
${renderTimeBox('最近连接', device.connectedAt)}
|
${renderTimeBox('最近实时数据', device.lastRealtimeAt)}
|
${renderTimeBox('最近血压数据', device.lastBloodPressureAt)}
|
</div>
|
${metricGroups.map((group) => renderMetricGroup(group, device.payload || {})).join('')}
|
`;
|
}
|
|
function renderTimeBox(label, value) {
|
return `
|
<div class="timebox">
|
<span>${label}</span>
|
<strong>${formatTime(value)}</strong>
|
</div>
|
`;
|
}
|
|
function renderMetricGroup(group, payload) {
|
return `
|
<section class="metric-group">
|
<h3>${group.title}</h3>
|
<div class="metric-grid">
|
${group.keys.map(([key, label]) => renderMetric(key, label, payload[key])).join('')}
|
</div>
|
</section>
|
`;
|
}
|
|
function renderMetric(key, label, value) {
|
const text = value === undefined || value === null || value === '' ? '--' : value;
|
return `
|
<div class="metric">
|
<label>${label} (${key})</label>
|
<strong>${escapeHtml(String(text))}</strong>
|
</div>
|
`;
|
}
|
|
function escapeHtml(value) {
|
return value
|
.replace(/&/g, '&')
|
.replace(/</g, '<')
|
.replace(/>/g, '>')
|
.replace(/"/g, '"')
|
.replace(/'/g, ''');
|
}
|
|
async function fetchSnapshot() {
|
const response = await fetch('/api/snapshot', { cache: 'no-store' });
|
if (!response.ok) {
|
throw new Error(`HTTP ${response.status}`);
|
}
|
|
return response.json();
|
}
|
|
function startEvents() {
|
if (!window.EventSource) {
|
return false;
|
}
|
|
const events = new EventSource('/events');
|
events.addEventListener('snapshot', (event) => {
|
render(JSON.parse(event.data));
|
});
|
events.onerror = () => {
|
events.close();
|
startPolling();
|
};
|
return true;
|
}
|
|
function startPolling() {
|
const load = () => {
|
fetchSnapshot()
|
.then(render)
|
.catch(() => {
|
setText('#refresh-time', '连接大屏服务失败');
|
});
|
};
|
|
load();
|
setInterval(load, 3000);
|
}
|
|
setInterval(() => {
|
setText('#now-time', formatClock());
|
}, 1000);
|
setText('#now-time', formatClock());
|
|
if (!startEvents()) {
|
startPolling();
|
}
|