From 3fc32204aea1977db628fd51b81996b9cd0b0aac Mon Sep 17 00:00:00 2001 From: liuyunqin Date: Thu, 4 Jun 2026 13:58:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AE=BE=E5=A4=87=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E9=A1=B5=E5=8F=98=E5=8C=96=E8=B6=8B=E5=8A=BF=E6=94=B9=E9=80=A0?= =?UTF-8?q?=E4=B8=BA=E6=8A=98=E7=BA=BF=E5=9B=BE+=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=A9=BA=E6=B0=94=E8=B4=A8=E9=87=8F=E6=8C=87=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 变化趋势图表从柱状图改为 SVG 折线图(参考 web-admin indoor-data-analysis.html) - 下拉框新增"空气质量指数"选项并设为默认选中 - 所有指标统一改为折线图方式展示,支持渐变面积填充 - 数据点交互提示(Tooltip),支持近24小时/近7日/近30日切换 - 各指标拥有独立数值范围配置,确保 Y 轴比例合理 --- employee-app/health-monitor/doc/CHANGELOG.md | 15 ++ .../environment/device-detail.html | 231 +++++++++++++++--- 2 files changed, 216 insertions(+), 30 deletions(-) diff --git a/employee-app/health-monitor/doc/CHANGELOG.md b/employee-app/health-monitor/doc/CHANGELOG.md index b2c5508..a8680bb 100644 --- a/employee-app/health-monitor/doc/CHANGELOG.md +++ b/employee-app/health-monitor/doc/CHANGELOG.md @@ -4,6 +4,21 @@ --- +## [2026-06-04] feat: 设备详情页变化趋势改造为折线图+新增空气质量指数 + +**涉及文件(1个修改):** +- `environment/device-detail.html` — 变化趋势图表从柱状图改为 SVG 折线图,下拉框新增"空气质量指数"选项并默认选中 + +**变更说明:** +- 变化趋势图表从柱状图改为 SVG 折线图,参考 web-admin 端 indoor-data-analysis.html 样式 +- 折线图支持渐变面积填充、数据点交互提示(Tooltip) +- 下拉选择框新增"空气质量指数"虚拟指标,并设为默认选中项 +- 所有原有指标(温度、湿度、PM2.5 等)均改为折线图方式展示 +- 支持近24小时(12个数据点)和近7日(7个数据点)两种时间维度 +- 各指标拥有独立的数值范围配置,确保折线图 Y 轴比例合理 + +--- + ## [2026-05-07] feat: 四个预警历史页面新增时间筛选功能 **涉及文件(4个修改):** diff --git a/employee-app/health-monitor/environment/device-detail.html b/employee-app/health-monitor/environment/device-detail.html index e47f7ec..b6956e9 100644 --- a/employee-app/health-monitor/environment/device-detail.html +++ b/employee-app/health-monitor/environment/device-detail.html @@ -44,13 +44,20 @@ body { background: #f0f2f5; display: flex; justify-content: center; align-items: .time-tabs { display: flex; gap: 0; background: #f0f2f5; border-radius: 20px; padding: 3px; margin-bottom: 16px; width: fit-content; } .time-tab { padding: 4px 14px; border-radius: 16px; font-size: 12px; color: #999; cursor: pointer; transition: all 0.2s; font-weight: 500; } .time-tab.active { background: #1aad8d; color: #fff; font-weight: 600; } -.bar-chart-outer { position: relative; } -.bar-chart { display: flex; align-items: flex-end; justify-content: space-around; height: 110px; border-bottom: 1px solid #f0f0f0; padding: 0 4px; } -.bar-col { display: flex; flex-direction: column; align-items: center; gap: 3px; } -.bar-val { font-size: 10px; color: #555; font-weight: 600; } -.bar-body { width: 16px; border-radius: 4px 4px 0 0; background: linear-gradient(180deg, #36cfc9 0%, #1aad8d 100%); } -.bar-x-labels { display: flex; justify-content: space-around; padding: 5px 4px 0; } -.bar-x-labels span { font-size: 9px; color: #aaa; flex: 1; text-align: center; } +/* 折线图样式 */ +.line-chart-outer { position: relative; width: 100%; } +.line-chart-outer svg { width: 100%; height: auto; } +.chart-grid-line { stroke: #f0f0f0; stroke-width: 1; } +.chart-axis-label { fill: #bbb; font-size: 9px; } +.chart-line { fill: none; stroke: #1aad8d; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; } +.chart-area-fill { fill: url(#trendAreaGrad); } +.chart-dot { fill: #1aad8d; stroke: #fff; stroke-width: 1.5; } +.chart-x-axis { stroke: #e8e8e8; stroke-width: 1; } +/* 数据点 Tip 弹窗 */ +.chart-tooltip { display: none; position: fixed; background: #fff; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); padding: 8px 12px; z-index: 400; pointer-events: none; font-size: 12px; min-width: 100px; } +.chart-tooltip.show { display: block; } +.chart-tooltip__label { color: #999; font-size: 11px; } +.chart-tooltip__val { font-size: 14px; color: #1a1a1a; font-weight: 700; } .bottom-btn-wrap { position: absolute; bottom: 0; left: 0; right: 0; padding: 10px 20px 28px; background: linear-gradient(transparent, #f5f7fa 35%); } .alarm-btn { width: 100%; height: 48px; background: linear-gradient(90deg, #1aad8d, #36cfc9); border: none; border-radius: 24px; color: #fff; font-size: 16px; font-weight: 700; cursor: pointer; box-shadow: 0 4px 16px rgba(26,173,141,0.35); } @@ -107,6 +114,11 @@ body { background: #f0f2f5; display: flex; justify-content: center; align-items:
+ +
+
+
+
@@ -224,13 +236,173 @@ const ALL_DATA = { }; // 趋势模拟数据生成 -function genTrend(base, period) { - const labels24 = ['00:00','04:00','08:00','12:00','16:00','20:00','24:00']; - const labels7 = ['周一','周二','周三','周四','周五','周六','周日']; - const labels30 = ['第1周','第2周','第3周','第4周']; - const labels = period==='24h' ? labels24 : period==='7d' ? labels7 : labels30; - const vals = labels.map(() => Math.round(base * (0.6 + Math.random() * 0.8))); - return { labels, vals }; +function genTrend(metricName, baseVal, period) { + // 根据指标生成不同的基础值范围 + var configs = { + '空气质量指数': { range: [30, 120], unit: '' }, + '温度': { range: [18, 35], unit: '℃' }, + '湿度': { range: [30, 80], unit: '%' }, + 'CO₂': { range: [350, 1100], unit: 'ppm' }, + '甲醛': { range: [0.02, 0.12], unit: 'mg/m³' }, + 'PM2.5': { range: [20, 150], unit: 'μg/m³' }, + 'PM10': { range: [40, 200], unit: 'μg/m³' }, + '太阳辐射': { range: [200, 950], unit: 'W/m²' }, + '照度': { range: [20000, 95000], unit: 'lm/m²' }, + '风速': { range: [1, 8], unit: 'm/s' }, + '榆属': { range: [5, 200], unit: '粒/千mm²' }, + '松属': { range: [5, 150], unit: '粒/千mm²' }, + '杨属': { range: [8, 280], unit: '粒/千mm²' }, + '柏科': { range: [3, 110], unit: '粒/千mm²' }, + '柳属': { range: [5, 170], unit: '粒/千mm²' }, + '蒿属': { range: [2, 90], unit: '粒/千mm²' }, + '藜科': { range: [2, 55], unit: '粒/千mm²' }, + '苋科': { range: [1, 38], unit: '粒/千mm²' }, + '花粉总数': { range: [50, 1100], unit: '粒/千mm²' }, + '电导率': { range: [200, 1700], unit: 'μS/cm' }, + 'PH': { range: [6.0, 9.5], unit: '' }, + '盐度': { range: [100, 950], unit: 'mg/L' }, + '电阻率': { range: [400, 3200], unit: 'Ω·m' }, + 'TDS': { range: [100, 850], unit: 'mg/L' }, + }; + var cfg = configs[metricName] || { range: [baseVal * 0.6, baseVal * 1.4], unit: '' }; + + if (period === '24h') { + var labels = ['00:00','02:00','04:00','06:00','08:00','10:00','12:00','14:00','16:00','18:00','20:00','22:00']; + var vals = labels.map(function() { + var v = cfg.range[0] + Math.random() * (cfg.range[1] - cfg.range[0]); + return Math.round(v * 100) / 100; + }); + return { labels: labels, vals: vals, unit: cfg.unit }; + } else if (period === '7d') { + var now = new Date(2026, 5, 4); // June 4, 2026 + var labels7 = []; + var vals7 = []; + for (var i = 6; i >= 0; i--) { + var d = new Date(now); + d.setDate(d.getDate() - i); + labels7.push((d.getMonth()+1) + '/' + d.getDate()); + var v = cfg.range[0] + Math.random() * (cfg.range[1] - cfg.range[0]); + vals7.push(Math.round(v * 100) / 100); + } + return { labels: labels7, vals: vals7, unit: cfg.unit }; + } else { + var labels30 = []; + var vals30 = []; + for (var i = 0; i < 4; i++) { + labels30.push('第' + (i+1) + '周'); + var v = cfg.range[0] + Math.random() * (cfg.range[1] - cfg.range[0]); + vals30.push(Math.round(v * 100) / 100); + } + return { labels: labels30, vals: vals30, unit: cfg.unit }; + } +} + +// 计算 Y 坐标 +function valToY(val, yMax, chartHeight, chartTop) { + return chartTop + chartHeight - (val / yMax) * chartHeight; +} + +// 渲染 SVG 折线图 +function renderLineChart(metricName, baseVal, period) { + var data = genTrend(metricName, baseVal, period); + var labels = data.labels; + var vals = data.vals; + var unit = data.unit; + var n = vals.length; + + // 图表尺寸 + var chartLeft = 40, chartRight = 330, chartWidth = chartRight - chartLeft; + var chartTop = 15, chartBottom = 140, chartHeight = chartBottom - chartTop; + var yMax = Math.max.apply(null, vals) * 1.2; + // 整数化 Y 轴 + yMax = Math.ceil(yMax); + + // Y 轴刻度 + var yTicks = 5; + var gridLines = ''; + for (var i = 0; i <= yTicks; i++) { + var tickVal = (yMax / yTicks * i).toFixed(unit === 'mg/m³' || unit === 'm/s' ? 2 : 0); + var y = valToY(parseFloat(tickVal), yMax, chartHeight, chartTop); + gridLines += ''; + gridLines += '' + tickVal + ''; + } + + // X 轴坐标 + var xPositions = []; + for (var i = 0; i < n; i++) { + xPositions.push(chartLeft + (chartWidth / (n - 1)) * i); + } + + // X 轴标签 - 点数多时只显示部分 + var xLabelsHtml = ''; + var skip = n > 12 ? Math.ceil(n / 6) : 1; + for (var i = 0; i < n; i++) { + if (i % skip === 0 || i === n - 1) { + xLabelsHtml += '' + labels[i] + ''; + } + } + + // 折线 + 面积填充 + var pathD = ''; + var polylinePoints = ''; + var dotsHtml = ''; + var dotR = n > 20 ? 2.5 : 3.5; + for (var i = 0; i < n; i++) { + var y = valToY(vals[i], yMax, chartHeight, chartTop); + var x = xPositions[i]; + pathD += (i === 0 ? 'M' : 'L') + x + ',' + y + ' '; + polylinePoints += x + ',' + y + ' '; + dotsHtml += ''; + } + // 面积闭合路径 + pathD += 'L' + xPositions[n-1] + ',' + chartBottom + ' L' + xPositions[0] + ',' + chartBottom + ' Z'; + + var svg = '' + + '' + + '' + + '' + + '' + + gridLines + + xLabelsHtml + + '' + + '' + + '' + + dotsHtml + + ''; + + // 存储 tooltip 数据 + window._trendTooltipData = { labels: labels, vals: vals, unit: unit, xPositions: xPositions, yMax: yMax, chartHeight: chartHeight, chartTop: chartTop }; + + return svg; +} + +// 显示数据点 Tip +function showTrendTip(e, el) { + var td = window._trendTooltipData; + if (!td) return; + var idx = parseInt(el.getAttribute('data-idx')); + var dateStr = td.labels[idx] || ''; + var val = td.vals[idx]; + var unit = td.unit || ''; + document.getElementById('tooltipDate').textContent = dateStr; + document.getElementById('tooltipBody').innerHTML = + '' + val + '' + + '' + (unit ? ' ' + unit : '') + ''; + var tip = document.getElementById('chartTooltip'); + tip.classList.add('show'); + var rect = el.getBoundingClientRect(); + var tipW = tip.offsetWidth; + var tipH = tip.offsetHeight; + var left = rect.left - tipW / 2 + rect.width / 2; + var top = rect.top - tipH - 10; + if (left < 8) left = 8; + if (left + tipW > window.innerWidth - 8) left = window.innerWidth - tipW - 8; + tip.style.left = left + 'px'; + tip.style.top = top + 'px'; +} +function hideTrendTip() { + document.getElementById('chartTooltip').classList.remove('show'); } const params = new URLSearchParams(location.search); @@ -240,7 +412,7 @@ const tabData = ALL_DATA[tab]; const dev = tabData.devices.find(d => d.id === id) || tabData.devices[0]; const li = dev.level; -let trendMetricIdx = 0; +let trendMetricIdx = -1; // -1 means 空气质量指数 (virtual metric) let trendPeriod = '24h'; function buildGauge(levelIdx, total, color) { @@ -271,17 +443,13 @@ function buildGauge(levelIdx, total, color) { return `${paths}`; } -function renderBarChart(metricIdx, period) { - const baseVal = parseFloat(dev.metrics[metricIdx].v) || 50; - const { labels, vals } = genTrend(baseVal, period); - const maxVal = Math.max(...vals); - const maxH = 80; - const cols = vals.map((v, i) => { - const h = Math.max(6, Math.round((v / maxVal) * maxH)); - return `
${v}
`; - }).join(''); - const xlabels = labels.map(l => `${l}`).join(''); - return `
${cols}
${xlabels}
`; +function renderChart(metricIdx, period) { + // metricIdx === -1 表示"空气质量指数"虚拟指标 + if (metricIdx === -1) { + // 用设备各指标综合计算一个模拟 AQI 值 + return renderLineChart('空气质量指数', 55, period); + } + return renderLineChart(dev.metrics[metricIdx].k, parseFloat(dev.metrics[metricIdx].v) || 50, period); } function render() { @@ -296,22 +464,25 @@ function render() { document.getElementById('metricsGrid').innerHTML = dev.metrics.map(m => `
${m.v}${m.u}
${m.k}
` ).join(''); + // 趋势下拉框:增加"空气质量指数"并默认选中 const sel = document.getElementById('trendMetricSelect'); - sel.innerHTML = dev.metrics.map((m, i) => ``).join(''); + let opts = ''; + opts += dev.metrics.map((m, i) => ``).join(''); + sel.innerHTML = opts; sel.value = trendMetricIdx; - document.getElementById('barChartWrap').innerHTML = renderBarChart(trendMetricIdx, trendPeriod); + document.getElementById('barChartWrap').innerHTML = renderChart(trendMetricIdx, trendPeriod); } function onTrendMetricChange(sel) { trendMetricIdx = parseInt(sel.value); - document.getElementById('barChartWrap').innerHTML = renderBarChart(trendMetricIdx, trendPeriod); + document.getElementById('barChartWrap').innerHTML = renderChart(trendMetricIdx, trendPeriod); } function switchTimePeriod(el, period) { trendPeriod = period; document.querySelectorAll('.time-tab').forEach(t => t.classList.remove('active')); el.classList.add('active'); - document.getElementById('barChartWrap').innerHTML = renderBarChart(trendMetricIdx, trendPeriod); + document.getElementById('barChartWrap').innerHTML = renderChart(trendMetricIdx, trendPeriod); } render();