diff --git a/employee-app/health-monitor/doc/CHANGELOG.md b/employee-app/health-monitor/doc/CHANGELOG.md
index a8680bb..44ea667 100644
--- a/employee-app/health-monitor/doc/CHANGELOG.md
+++ b/employee-app/health-monitor/doc/CHANGELOG.md
@@ -4,18 +4,18 @@
---
-## [2026-06-04] feat: 设备详情页变化趋势改造为折线图+新增空气质量指数
+## [2026-06-04] feat: 设备详情页变化趋势改造+新增空气质量指数
**涉及文件(1个修改):**
-- `environment/device-detail.html` — 变化趋势图表从柱状图改为 SVG 折线图,下拉框新增"空气质量指数"选项并默认选中
+- `environment/device-detail.html` — 变化趋势图表全面重构,参考 web-admin 端 indoor-data-analysis.html 样式
**变更说明:**
-- 变化趋势图表从柱状图改为 SVG 折线图,参考 web-admin 端 indoor-data-analysis.html 样式
-- 折线图支持渐变面积填充、数据点交互提示(Tooltip)
- 下拉选择框新增"空气质量指数"虚拟指标,并设为默认选中项
-- 所有原有指标(温度、湿度、PM2.5 等)均改为折线图方式展示
-- 支持近24小时(12个数据点)和近7日(7个数据点)两种时间维度
-- 各指标拥有独立的数值范围配置,确保折线图 Y 轴比例合理
+- **空气质量指数 - 近24小时**:SVG 折线图 + 渐变面积填充 + AQI 等级背景区域(清洁/未污染/轻污染/中污染/重污染)+ 数据点颜色随 AQI 等级动态变化 + 超标限制虚线
+- **空气质量指数 - 近7日/近30日**:柱状堆积图,每根柱子按5个AQI等级分段堆积,Y 轴为 0-24 小时分布,底部带图例
+- **其他指标**:统一改为 SVG 折线图,支持上下限超标限制区域和虚线
+- **交互提示**:折线图 hover 数据点显示日期+数值+彩色圆点;柱状图 hover 显示日期+各等级占比百分比
+- 各指标拥有独立的数值范围配置,确保 Y 轴比例合理
---
diff --git a/employee-app/health-monitor/environment/device-detail.html b/employee-app/health-monitor/environment/device-detail.html
index b6956e9..b60206b 100644
--- a/employee-app/health-monitor/environment/device-detail.html
+++ b/employee-app/health-monitor/environment/device-detail.html
@@ -44,20 +44,26 @@ 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; }
-/* 折线图样式 */
+/* 折线图/柱状图样式 */
.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-dot { stroke: #fff; stroke-width: 1.5; }
.chart-x-axis { stroke: #e8e8e8; stroke-width: 1; }
+.chart-limit-line { stroke: #ff4d4f; stroke-width: 1.5; stroke-dasharray: 6,3; opacity: .7; }
+.chart-limit-exceed { fill: #ff4d4f; opacity: .08; }
+.chart-limit-label { fill: #ff4d4f; font-size: 9px; font-weight: 500; }
/* 数据点 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 { 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: 120px; }
.chart-tooltip.show { display: block; }
-.chart-tooltip__label { color: #999; font-size: 11px; }
-.chart-tooltip__val { font-size: 14px; color: #1a1a1a; font-weight: 700; }
+.chart-tooltip__date { font-size: 10px; color: #999; margin-bottom: 4px; padding-bottom: 4px; border-bottom: 1px solid #f0f0f0; }
+.chart-tooltip__row { display: flex; align-items: center; gap: 4px; padding: 2px 0; }
+.chart-tooltip__dot { display: inline-block; width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }
+.chart-tooltip__label { color: #999; font-size: 11px; flex: 1; }
+.chart-tooltip__val { font-size: 13px; 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); }
@@ -235,127 +241,298 @@ const ALL_DATA = {
}
};
-// 趋势模拟数据生成
-function genTrend(metricName, baseVal, period) {
- // 根据指标生成不同的基础值范围
+/* AQI 等级配置 */
+var aqiLevels = [
+ {name:'清洁', color:'#13C2C2', range:[0,20]},
+ {name:'未污染', color:'#52C41A', range:[21,50]},
+ {name:'轻污染', color:'#FAAD14', range:[51,100]},
+ {name:'中污染', color:'#FA541C', range:[101,150]},
+ {name:'重污染', color:'#FF4D4F', range:[151,200]}
+];
+
+/* 获取 AQI 颜色 */
+function getAqiColor(val) {
+ if (val >= 151) return '#FF4D4F';
+ if (val >= 101) return '#FA541C';
+ if (val >= 51) return '#FAAD14';
+ if (val >= 21) return '#52C41A';
+ return '#13C2C2';
+}
+
+/* 根据数值映射到 SVG Y 坐标 */
+function valToY(val, yMax) {
+ return chartBottom - (val / yMax) * chartHeight;
+}
+
+/* 图表常量(供 renderTrend 内部使用) */
+var chartLeft = 36, chartRight = 330, chartWidth = chartRight - chartLeft;
+var chartTop = 15, chartBottom = 140, chartHeight = chartBottom - chartTop;
+var chartLine = '#1aad8d';
+
+/* 趋势数据模拟 */
+function genTrend(metricName, 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' },
+ '空气质量指数': {
+ isAQI: true, yMax: 200, yTicks: [200,150,100,50,0],
+ limitUpper: null, limitLower: null,
+ levelBands: [
+ {yMin:15,yMax:55,label:'清洁',color:'#13C2C2',opacity:.1},
+ {yMin:55,yMax:105,label:'未污染',color:'#52C41A',opacity:.1},
+ {yMin:105,yMax:135,label:'轻污染',color:'#FAAD14',opacity:.1},
+ {yMin:135,yMax:165,label:'中污染',color:'#FA541C',opacity:.1},
+ {yMin:165,yMax:chartBottom,label:'重污染',color:'#FF4D4F',opacity:.1}
+ ],
+ '24h': {points:[55,42,35,48,65,78,82,75,62,58,52,48]},
+ '7d': {barData:[
+ {clean:6,good:8,light:6,moderate:4,heavy:0},
+ {clean:8,good:7,light:5,moderate:4,heavy:0},
+ {clean:4,good:6,light:8,moderate:5,heavy:1},
+ {clean:2,good:5,light:7,moderate:8,heavy:2},
+ {clean:7,good:8,light:5,moderate:4,heavy:0},
+ {clean:9,good:7,light:4,moderate:4,heavy:0},
+ {clean:6,good:9,light:5,moderate:4,heavy:0}
+ ]},
+ '30d': {barData:(function(){var r=[];for(var i=0;i<30;i++){r.push({clean:Math.floor(Math.random()*8)+2,good:Math.floor(Math.random()*8)+2,light:Math.floor(Math.random()*6)+2,moderate:Math.floor(Math.random()*6)+2,heavy:Math.floor(Math.random()*3)})}return r})()}
+ }
};
- 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);
+ // 普通指标
+ var ranges = {
+ '温度':{range:[18,35],unit:'℃',limitUpper:28,limitLower:16},'湿度':{range:[30,80],unit:'%',limitUpper:70,limitLower:30},
+ 'CO₂':{range:[350,1100],unit:'ppm',limitUpper:1000,limitLower:350},'甲醛':{range:[0.02,0.12],unit:'mg/m³',limitUpper:0.1,limitLower:0.02},
+ 'PM2.5':{range:[20,150],unit:'μg/m³',limitUpper:75,limitLower:0},'PM10':{range:[40,200],unit:'μg/m³',limitUpper:150,limitLower:0},
+ '太阳辐射':{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'}
+ };
+
+ if (configs[metricName]) return { metricName: metricName, cfg: configs[metricName], period: period };
+
+ var r = ranges[metricName];
+ if (!r) r = { range: [10, 100], unit: '' };
+
+ var genPoints = function() {
+ var pts = [];
+ for (var i = 0; i < 12; i++) {
+ pts.push(+(r.range[0] + Math.random() * (r.range[1] - r.range[0])).toFixed(2));
}
- 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 pts;
+ };
+ var genPoints7 = function() {
+ var pts = [];
+ for (var i = 0; i < 7; i++) {
+ pts.push(+(r.range[0] + Math.random() * (r.range[1] - r.range[0])).toFixed(2));
}
- 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 轴刻度
+ return pts;
+ };
+ var genPoints30 = function() {
+ var pts = [];
+ for (var i = 0; i < 30; i++) {
+ pts.push(+(r.range[0] + Math.random() * (r.range[1] - r.range[0])).toFixed(2));
+ }
+ return pts;
+ };
+ var maxVal = Math.max(r.range[1]);
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 += '