IAQ页面

parent 6350c715
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>智能环境态势感知系统</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
/* ============ CSS 自定义属性(主题色) ============ */
:root {
--color-primary: #948367;
--color-primary-rgb: 148, 131, 103;
--color-primary-light: #b09b7c;
--color-primary-light-rgb: 176, 155, 124;
--color-text: #c4b4a0;
--color-text-rgb: 196, 180, 160;
--color-text-light: #a89882;
--color-text-bright: #f0e6d8;
--color-status-green: #a8c97e;
--color-status-green-rgb: 168, 201, 126;
--color-status-red: #d1483a;
--color-status-orange: #d98c3a;
--color-bg-overlay: rgba(250, 245, 235, 0.97);
--color-bg-popup: rgba(36, 56, 40, 0.85);
--color-white: #fff;
--color-gray: #999;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background-size: cover;
background-attachment: fixed;
}
body {
display: flex;
flex-direction: column;
position: relative;
}
#app {
flex: 1;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 2px 2px 2px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-top: none;
border-bottom: none;
position: relative;
z-index: 1;
background: url('./img/IAQ_background.png') no-repeat center center;
background-size: 100% 100%;
}
/* ----- 顶部标题栏 ----- */
.header {
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px 30px;
background: url('./img/IAQ_top.png') no-repeat center center;
background-size: 100% 100%;
border-bottom: 1px solid rgba(var(--color-primary-rgb), 0.18);
flex-wrap: wrap;
gap: 8px 20px;
}
.title-group {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.main-title {
font-size: 22px;
font-weight: 700;
color: var(--color-primary);
letter-spacing: 1px;
display: flex;
align-items: center;
line-height: 25px;
gap: 8px;
}
.title-icon {
height: 30px;
object-fit: contain;
}
.min-title-text{
height: 20px;
}
.header-time {
font-size: 16px;
font-weight: 500;
color: rgba(240, 230, 216, 0.85);
letter-spacing: 2px;
padding-top: 10px;
white-space: nowrap;
}
.device-info {
display: flex;
align-items: center;
gap: 10px;
color: var(--color-text);
font-size: 13px;
}
.status-dot {
width: 10px;
height: 10px;
background: #a8c97e;
border-radius: 50%;
box-shadow: 0 0 12px rgba(168, 201, 126, 0.5);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.8); }
}
/* ----- 层级下拉选择器 ----- */
.cascade-select {
position: relative;
display: inline-flex;
align-items: center;
line-height: 20px;
margin-top: 5px;
}
.cascade-trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: rgba(var(--color-primary-rgb), 0.08);
border: 1px solid rgba(var(--color-primary-rgb), 0.25);
border-radius: 6px;
color: var(--color-text);
font-size: 13px;
cursor: pointer;
user-select: none;
transition: all 0.2s;
white-space: nowrap;
}
.cascade-trigger:hover {
background: rgba(var(--color-primary-rgb), 0.15);
border-color: rgba(var(--color-primary-rgb), 0.45);
color: var(--color-text-bright);
}
.cascade-trigger .arrow {
font-size: 10px;
transition: transform 0.2s;
}
.cascade-trigger.open .arrow {
transform: rotate(180deg);
}
/* 筛选标签 */
.filter-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-left: 12px;
}
.filter-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: rgba(var(--color-primary-rgb), 0.15);
border: 1px solid rgba(var(--color-primary-rgb), 0.3);
border-radius: 4px;
font-size: 11px;
color: var(--color-text);
cursor: default;
}
.filter-tag .tag-remove {
font-size: 13px;
cursor: pointer;
color: #a8c97e;
line-height: 1;
}
.filter-tag .tag-remove:hover {
color: #FF6B3A;
}
/* 下拉面板 */
.cascade-panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 520px;
background: var(--color-bg-popup);
border: 1px solid rgba(var(--color-primary-rgb), 0.25);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
z-index: 1000;
overflow: hidden;
}
.cascade-panel-header {
display: flex;
border-bottom: 1px solid rgba(var(--color-primary-rgb), 0.15);
padding: 8px 12px;
gap: 4px;
}
.cascade-panel .column-label {
flex: 1;
font-size: 11px;
color: var(--color-bg-overlay);
text-align: center;
}
.cascade-columns {
display: flex;
max-height: 280px;
overflow-y: auto;
}
.cascade-column {
flex: 1;
border-right: 1px solid rgba(var(--color-primary-rgb), 0.1);
min-width: 0;
overflow-y: auto;
max-height: 280px;
}
.cascade-column:last-child {
border-right: none;
}
.cascade-item {
display: flex;
align-items: center;
padding: 6px 10px;
cursor: pointer;
transition: background 0.15s;
font-size: 12px;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
gap: 6px;
}
.cascade-item:hover {
background: rgba(var(--color-primary-rgb), 0.12);
color: var(--color-white);
}
.cascade-item.selected {
background: rgba(var(--color-primary-rgb), 0.18);
color: var(--color-primary);
}
.cascade-item.active {
background: rgba(var(--color-primary-rgb), 0.15);
color: var(--color-white);
}
.cascade-item .check-box {
flex-shrink: 0;
width: 14px;
height: 14px;
border: 1.5px solid rgba(var(--color-primary-rgb), 0.35);
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: var(--color-status-green);
}
.cascade-item.checked .check-box {
background: rgba(var(--color-status-green-rgb), 0.25);
border-color: var(--color-status-green);
}
.cascade-item.partial .check-box {
background: rgba(var(--color-status-green-rgb), 0.12);
border-color: rgba(var(--color-status-green-rgb), 0.5);
}
.cascade-item .check-label {
overflow: hidden;
text-overflow: ellipsis;
}
.cascade-footer {
display: flex;
justify-content: flex-end;
padding: 8px 12px;
border-top: 1px solid rgba(var(--color-primary-rgb), 0.12);
gap: 8px;
}
.btn-ok {
padding: 4px 16px;
background: rgba(var(--color-primary-rgb), 0.2);
border: 1px solid rgba(var(--color-primary-rgb), 0.4);
border-radius: 4px;
color: var(--color-primary);
font-size: 12px;
cursor: pointer;
}
.btn-ok:hover {
background: rgba(var(--color-primary-rgb), 0.35);
color: var(--color-white);
}
.btn-reset {
padding: 4px 12px;
background: transparent;
border: 1px solid rgba(200, 200, 200, 0.2);
border-radius: 4px;
color: var(--color-text);
font-size: 12px;
cursor: pointer;
}
.btn-reset:hover {
border-color: rgba(200, 200, 200, 0.4);
color: var(--color-white);
}
/* ----- 仪表盘区 ----- */
.gauge-area {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 20px;
margin: 16px 0;
}
.gauge-large {
flex: 1;
min-width: 0;
}
.gauge-large .chart-container {
width: 100%;
height: 383px;
}
.gauge-right {
flex: 2;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 6px;
}
.gauge-small-item {
cursor: pointer;
transition: transform 0.2s;
}
.gauge-small-item:hover {
transform: scale(1.03);
}
.gauge-small-item.active {
outline: 2px solid rgba(var(--color-primary-rgb), 0.4);
outline-offset: 2px;
border-radius: 4px;
}
.gauge-small-item .chart-container {
width: 100%;
height: 240px;
}
/* ----- 滚动条 ----- */
.table-section::-webkit-scrollbar { width: 6px; }
.table-section::-webkit-scrollbar-track { background: transparent; }
.table-section::-webkit-scrollbar-thumb {
background: rgba(var(--color-primary-rgb), 0.35);
border-radius: 3px;
}
.table-section::-webkit-scrollbar-thumb:hover {
background: rgba(var(--color-primary-rgb), 0.55);
}
/* ----- 表格区 ----- */
.table-section {
flex: 1;
margin-top: 6px;
min-height: 0;
overflow-y: auto;
}
.table-scroll {
overflow-x: auto;
min-height: 100%;
}
table {
width: 100%;
min-width: 900px;
border-collapse: collapse;
font-size: 12px;
}
thead {
background: rgba(var(--color-primary-rgb), 0.06);
}
th {
padding: 10px 8px;
text-align: center;
font-weight: 500;
color: var(--color-primary-light);
font-size: 12px;
border-bottom: 1px solid rgba(var(--color-primary-rgb), 0.15);
white-space: nowrap;
}
td {
padding: 9px 8px;
text-align: center;
color: var(--color-text-light);
border-bottom: 1px solid rgba(var(--color-primary-rgb), 0.1);
white-space: nowrap;
}
tbody tr:hover {
background: rgba(var(--color-primary-rgb), 0.04);
}
.time-col {
color: var(--color-primary);
font-weight: 500;
}
.table-status {
color: #a8c97e;
font-size: 12px;
}
@media (max-width: 1100px) {
.gauge-area {
flex-direction: column;
}
.gauge-large .chart-container {
height: 264px;
}
.gauge-right {
grid-template-columns: repeat(4, 1fr);
}
.gauge-small-item .chart-container {
height: 165px;
}
}
@media (max-width: 700px) {
.gauge-right {
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
}
.gauge-small-item .chart-container {
height: 135px;
}
#app {
padding: 12px 8px 14px;
}
}
</style>
</head>
<body>
<div id="app">
<!-- 顶部 -->
<div class="header">
<div class="title-group">
<div class="main-title">
<img src="./img/IAQ_logo.png" alt="" class="title-icon" />
<div class="min-title-text">丨智能环境态势感知系统</div>
</div>
<!-- 层级多选下拉 -->
<div class="cascade-select" v-click-outside="closeDropdown">
<div class="cascade-trigger" :class="{ open: showDropdown }" @click.stop="toggleDropdown">
<span>设备筛选</span>
<span v-if="selectedFilters.length">({{ selectedFilters.length }})</span>
<span class="arrow"></span>
</div>
<div class="cascade-panel" v-if="showDropdown" @click.stop>
<div class="cascade-panel-header">
<span class="column-label">场馆</span>
<span class="column-label">楼层</span>
<span class="column-label">设备</span>
</div>
<div class="cascade-columns">
<!-- 第一列:场馆 -->
<div class="cascade-column">
<div v-for="g in deviceTree" :key="g.key"
class="cascade-item"
:class="{ active: activeGroup === g.key, checked: isGroupChecked(g), partial: isGroupPartial(g) }"
@click="selectGroup(g); toggleGroup(g)">
<span class="check-box">{{ isGroupChecked(g) ? '✓' : (isGroupPartial(g) ? '−' : '') }}</span>
<span class="check-label">{{ g.value }}</span>
</div>
</div>
<!-- 第二列:楼层 -->
<div class="cascade-column" v-if="currentFloors.length">
<div v-for="f in currentFloors" :key="f.key"
class="cascade-item"
:class="{ active: activeFloor === f.key, checked: isFloorChecked(f), partial: isFloorPartial(f) }"
@click="selectFloor(f); toggleFloor(f)">
<span class="check-box">{{ isFloorChecked(f) ? '✓' : (isFloorPartial(f) ? '−' : '') }}</span>
<span class="check-label">{{ f.value }}</span>
</div>
</div>
<!-- 第三列:设备 -->
<div class="cascade-column" v-if="currentDevices.length">
<div v-for="d in currentDevices" :key="d.key"
class="cascade-item"
:class="{ checked: isDeviceChecked(d) }"
@click="toggleDevice(d)">
<span class="check-box">{{ isDeviceChecked(d) ? '✓' : '' }}</span>
<span class="check-label">{{ d.value }}</span>
</div>
</div>
</div>
<div class="cascade-footer">
<span class="btn-reset" @click="resetFilters">重置</span>
<span class="btn-ok" @click="applyFilters">确认</span>
</div>
</div>
</div>
</div>
<div class="header-time">{{ currentTime }}</div>
</div>
<!-- 仪表盘 -->
<div class="gauge-area">
<div class="gauge-large">
<div id="gauge-large" class="chart-container"></div>
</div>
<div class="gauge-right">
<div v-for="ind in smallIndicators" :key="ind.key"
class="gauge-small-item"
:class="{ active: selectedKey === ind.key }"
@click="selectGauge(ind.key)">
<div :id="'gauge-' + ind.key" class="chart-container"></div>
</div>
</div>
</div>
<!-- 表格 -->
<div class="table-section">
<div class="table-scroll">
<table>
<thead>
<tr>
<th>设备</th>
<th>状态</th>
<th>更新时间</th>
<th>CO₂</th>
<th>PM2.5</th>
<th>PM10</th>
<th>TVOC</th>
<th>温度</th>
<th>湿度</th>
<th>甲醛</th>
<th>气压</th>
<th>光照</th>
</tr>
</thead>
<tbody>
<tr v-for="row in tableData" :key="row.time">
<td>{{ row.device_name ? row.device_name : (row.device_id || '--') }}</td>
<td><span class="table-status">{{ tableDeviceStatus }}</span></td>
<td class="time-col">{{ row.time }}</td>
<td>{{ row.co2 }}</td>
<td>{{ row.pm2_5 }}</td>
<td>{{ row.pm10 }}</td>
<td>{{ row.tvoc }}</td>
<td>{{ row.temperature }}</td>
<td>{{ row.humidity }}</td>
<td>{{ row.hcho }}</td>
<td>{{ row.pressure }}</td>
<td>{{ row.light_level }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<script>
const { createApp, ref, computed, onMounted, onUnmounted, nextTick, watch } = Vue;
const app = createApp({
directives: {
'click-outside': {
mounted(el, binding) {
el.__clickOutside__ = (e) => {
if (!el.contains(e.target)) {
binding.value(e);
}
};
document.addEventListener('mousedown', el.__clickOutside__);
},
unmounted(el) {
document.removeEventListener('mousedown', el.__clickOutside__);
}
}
},
setup() {
// ======== 指标配置(静态) ========
const INDICATOR_META = {
pm2_5: { label: 'PM2.5', unit: 'µg/m³', max: 100, decimals: 0 },
co2: { label: 'CO₂', unit: 'ppm', max: 2500, decimals: 0 },
tvoc: { label: 'TVOC', unit: 'mg/m³', max: 3.0, decimals: 2 },
temperature: { label: '温度', unit: '°C', max: 50, decimals: 1 },
humidity: { label: '湿度', unit: '%RH', max: 100, decimals: 1 },
hcho: { label: '甲醛', unit: 'mg/m³', max: 0.2, decimals: 2 },
pm10: { label: 'PM10', unit: 'µg/m³', max: 150, decimals: 0 },
pressure: { label: '气压', unit: 'hPa', max: 1100, decimals: 1 },
light_level: { label: '光照', unit: 'lux', max: 2000, decimals: 0 },
};
// 表格列映射:API dataList 索引 → 前端 key
const TABLE_KEY_MAP = ['co2', 'pm10', 'pm2_5', 'hcho', 'tvoc', 'temperature', 'humidity', 'pressure', 'light_level'];
// ======== 响应式数据 ========
const indicators = ref([]);
const tableData = ref([]);
const deviceName = ref('加载中...');
const deviceId = ref('---');
const apiDeviceNames = ref([]); // 从 /api/qdm/device/environment 返回的设备名列表
const loading = ref(true);
const selectedKey = ref('pm2_5');
const currentTime = ref('');
const deviceTree = ref([]); // 设备树 [{key,value,sub:[{key,value,sub:[{key,value,sub:[]}]}]}]
const selectedFilters = ref([]); // 已确认的筛选 [{label,regionGroup?,regionType?,deviceId?,nodeType,nodeKey}]
const pendingFilters = ref([]); // 下拉中勾选的待确认数据
const showDropdown = ref(false);
const activeGroup = ref(''); // 当前高亮的场馆 key
const activeFloor = ref(''); // 当前高亮的楼层 key
const isMounted = ref(false); // 组件挂载状态,防止卸载后定时器回调执行
const smallIndicators = computed(() =>
indicators.value.filter(i => i.key !== selectedKey.value)
);
// 表格中展示的设备名称(取 API 返回的设备名+编号)
const tableDeviceName = computed(() => {
if (apiDeviceNames.value.length) {
return apiDeviceNames.value.join('、');
}
return deviceName.value + (deviceId.value !== '---' ? ' ' + deviceId.value : '');
});
// 表格中展示的设备状态
const tableDeviceStatus = computed(() => {
return selectedFilters.value.length || deviceId.value !== '---' ? '在线' : '--';
});
// 当前选中场馆下的楼层列表
const currentFloors = computed(() => {
const g = deviceTree.value.find(n => n.key === activeGroup.value);
return g ? g.sub : [];
});
// 当前选中楼层下的设备列表
const currentDevices = computed(() => {
const g = deviceTree.value.find(n => n.key === activeGroup.value);
if (!g) return [];
const f = g.sub.find(n => n.key === activeFloor.value);
return f ? f.sub : [];
});
// ======== 业务函数 ========
async function fetchEnvironmentData() {
try {
// 如果有筛选条件,逐条查询后合并
const filters = selectedFilters.value;
let body = {};
if (filters.length > 0) {
// 收集所有勾选的设备编号,一次请求
const device_ids = filters.map(f => f.deviceId).filter(Boolean);
body = { deviceId: device_ids };
} else {
body = {};
}
const resp = await fetch('/api/qdm/device/environment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const json = await resp.json();
if (!json.success || !json.data) {
console.error('API返回异常', json);
return;
}
const { indicators: apiIndicators, records, areaInfo } = json.data;
renderIndicators(apiIndicators, areaInfo);
renderTable(records ? records.dataList || [] : [], areaInfo, records ? records.titleList : null);
} catch (e) {
console.error('获取环境数据失败', e);
} finally {
loading.value = false;
}
}
function renderIndicators(apiIndicators, areaInfo) {
const list = [];
const order = ['pm2_5', 'co2', 'tvoc', 'temperature', 'humidity', 'hcho', 'pm10', 'pressure', 'light_level'];
const valueMap = {};
const qualityMap = {};
(apiIndicators || []).forEach(api => {
valueMap[api.key] = api.current_value;
qualityMap[api.key] = api.quality;
});
order.forEach(key => {
const meta = INDICATOR_META[key];
if (!meta) return;
list.push({
key, label: meta.label, unit: meta.unit,
value: valueMap[key] != null ? valueMap[key] : 0,
max: meta.max, decimals: meta.decimals,
quality: qualityMap[key] || null,
});
});
indicators.value = list;
if (areaInfo) {
updateDeviceInfo(areaInfo);
}
}
function renderTable(dataList, areaInfo, titleList) {
if (dataList && dataList.length) {
// 判断是否为多设备格式:titleList[0] === "设备编号" 表示每行以 device_id, device_name, time 开头
const isMulti = titleList && titleList[0] === '设备编号';
tableData.value = dataList.map(row => {
if (isMulti) {
// 多设备格式: [device_id, device_name, time, indicators...]
const obj = {
device_id: row[0] || '',
device_name: row[1] || '',
time: row[2] || '',
};
TABLE_KEY_MAP.forEach((key, i) => {
obj[key] = row[i + 3] != null ? row[i + 3] : '';
});
return obj;
} else {
// 单设备格式: [time, indicators...]
const obj = { time: row[0] || '', device_id: '', device_name: '' };
TABLE_KEY_MAP.forEach((key, i) => {
obj[key] = row[i + 1] != null ? row[i + 1] : '';
});
return obj;
}
});
}
if (areaInfo) {
updateDeviceInfo(areaInfo);
}
}
function updateDeviceInfo(areaInfo) {
if (areaInfo && areaInfo.devices && areaInfo.devices.length > 0) {
const dev = areaInfo.devices[0];
deviceName.value = dev.device_name || dev.device_id;
deviceId.value = dev.device_id;
}
}
// ======== 设备树接口 ========
async function fetchDeviceTree() {
try {
const resp = await fetch('/api/qdm/device/envDevice', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const json = await resp.json();
if (json.success && json.data) {
deviceTree.value = json.data;
// 预计算每个 group/floor 的设备 filter 列表,避免重复遍历
rebuildFilterCache(json.data);
// 默认展开第一个场馆
if (json.data.length) {
activeGroup.value = json.data[0].key;
if (json.data[0].sub && json.data[0].sub.length) {
activeFloor.value = json.data[0].sub[0].key;
}
}
// 默认勾选所有设备
const allFilters = [];
json.data.forEach(g => {
(g.sub || []).forEach(f => {
(f.sub || []).forEach(d => {
allFilters.push({
label: g.value + ' / ' + f.value + ' / ' + d.value,
regionGroup: g.key, regionType: f.key, deviceId: d.key,
nodeType: 'device', nodeKey: d.key
});
});
});
});
selectedFilters.value = allFilters;
pendingFilters.value = JSON.parse(JSON.stringify(allFilters));
}
} catch (e) {
console.error('获取设备树失败', e);
}
}
// ======== 下拉面板交互 ========
function toggleDropdown() {
showDropdown.value = !showDropdown.value;
if (showDropdown.value) {
// 打开时把已确认的筛选同步到待确认
pendingFilters.value = JSON.parse(JSON.stringify(selectedFilters.value));
}
}
function closeDropdown() { showDropdown.value = false; }
function selectGroup(g) {
activeGroup.value = g.key;
if (g.sub && g.sub.length) {
activeFloor.value = g.sub[0].key;
} else {
activeFloor.value = '';
}
}
function selectFloor(f) {
activeFloor.value = f.key;
}
// ======== 勾选状态判断 ========
// 预计算的设备 filter 缓存:{ groupKey: { devices: [...], floors: { floorKey: [...] } } }
const filterCache = {};
function rebuildFilterCache(tree) {
Object.keys(filterCache).forEach(k => delete filterCache[k]);
tree.forEach(g => {
const entry = { devices: [], floors: {} };
(g.sub || []).forEach(f => {
const floorDevices = [];
(f.sub || []).forEach(d => {
const filter = {
label: g.value + ' / ' + f.value + ' / ' + d.value,
regionGroup: g.key, regionType: f.key, deviceId: d.key,
nodeType: 'device', nodeKey: d.key
};
entry.devices.push(filter);
floorDevices.push(filter);
});
entry.floors[f.key] = floorDevices;
});
filterCache[g.key] = entry;
});
}
// 获取 group 下的所有设备 filter
function getGroupDeviceFilters(g) {
const cached = filterCache[g.key];
return cached ? cached.devices : [];
}
// 获取 floor 下的所有设备 filter
function getFloorDeviceFilters(g, f) {
const cached = filterCache[g.key];
return (cached && cached.floors[f.key]) ? cached.floors[f.key] : [];
}
// 判断某个 filter 是否在 pendingFilters 中
function isFilterInPending(nodeType, nodeKey) {
return pendingFilters.value.some(f => f.nodeType === nodeType && f.nodeKey === nodeKey);
}
function addPendingFilter(f) {
if (!isFilterInPending(f.nodeType, f.nodeKey)) {
pendingFilters.value.push(f);
}
}
function removePendingFilter(nodeType, nodeKey) {
pendingFilters.value = pendingFilters.value.filter(f => !(f.nodeType === nodeType && f.nodeKey === nodeKey));
}
function isGroupChecked(g) {
const devices = getGroupDeviceFilters(g);
if (!devices.length) return false;
return devices.every(d => isFilterInPending('device', d.nodeKey));
}
function isGroupPartial(g) {
const devices = getGroupDeviceFilters(g);
if (!devices.length) return false;
const some = devices.some(d => isFilterInPending('device', d.nodeKey));
const all = devices.every(d => isFilterInPending('device', d.nodeKey));
return some && !all;
}
function isFloorChecked(f) {
const g = deviceTree.value.find(n => n.key === activeGroup.value);
if (!g) return false;
const devices = getFloorDeviceFilters(g, f);
if (!devices.length) return false;
return devices.every(d => isFilterInPending('device', d.nodeKey));
}
function isFloorPartial(f) {
const g = deviceTree.value.find(n => n.key === activeGroup.value);
if (!g) return false;
const devices = getFloorDeviceFilters(g, f);
if (!devices.length) return false;
const some = devices.some(d => isFilterInPending('device', d.nodeKey));
const all = devices.every(d => isFilterInPending('device', d.nodeKey));
return some && !all;
}
function isDeviceChecked(d) {
return isFilterInPending('device', d.key);
}
// ======== 勾选操作 ========
function toggleGroup(g) {
if (isGroupChecked(g)) {
// 取消:移除该 group 下所有设备
const devices = getGroupDeviceFilters(g);
devices.forEach(d => removePendingFilter('device', d.nodeKey));
} else {
// 全选:添加该 group 下所有设备
const devices = getGroupDeviceFilters(g);
devices.forEach(d => addPendingFilter(d));
}
}
function toggleFloor(f) {
const g = deviceTree.value.find(n => n.key === activeGroup.value);
if (!g) return;
if (isFloorChecked(f)) {
const devices = getFloorDeviceFilters(g, f);
devices.forEach(d => removePendingFilter('device', d.nodeKey));
} else {
const devices = getFloorDeviceFilters(g, f);
devices.forEach(d => addPendingFilter(d));
}
}
function toggleDevice(d) {
const g = deviceTree.value.find(n => n.key === activeGroup.value);
if (!g) return;
const f = (g.sub || []).find(n => n.key === activeFloor.value);
if (!f) return;
const label = g.value + ' / ' + f.value + ' / ' + d.value;
if (isDeviceChecked(d)) {
removePendingFilter('device', d.key);
} else {
addPendingFilter({
label, regionGroup: g.key, regionType: f.key, deviceId: d.key,
nodeType: 'device', nodeKey: d.key
});
}
}
function applyFilters() {
selectedFilters.value = JSON.parse(JSON.stringify(pendingFilters.value));
closeDropdown();
loading.value = true;
indicators.value = [];
tableData.value = [];
fetchEnvironmentData().then(() => {
nextTick(() => { initCharts(); });
});
}
function resetFilters() {
pendingFilters.value = [];
selectedFilters.value = [];
closeDropdown();
loading.value = true;
indicators.value = [];
tableData.value = [];
fetchEnvironmentData().then(() => {
nextTick(() => { initCharts(); });
});
}
function removeFilter(index) {
selectedFilters.value.splice(index, 1);
loading.value = true;
indicators.value = [];
tableData.value = [];
fetchEnvironmentData().then(() => {
nextTick(() => { initCharts(); });
});
}
// ======== 状态(颜色映射,评级由后端 getQuality() 统一计算) ========
const STATUS_GREEN = '#a8c97e'; // 暖调草绿
const STATUS_BLUE = '#cbb56d'; // 暖调金赭
const STATUS_ORANGE = '#d98c3a'; // 暖调铜橙
const STATUS_RED = '#d1483a'; // 暖调砖红
function getStatus(ind) {
const q = ind.quality;
if (!q) return ['—', '#999'];
switch (q) {
case '优': return ['优', STATUS_GREEN];
case '良': return ['良', STATUS_BLUE];
case '中': return ['中', STATUS_ORANGE];
case '差': return ['差', STATUS_RED];
case '有人': return ['有人', STATUS_GREEN];
case '无感应': return ['无感应', '#999'];
default: return [q, '#999'];
}
}
// ======== 渐变色:暖调草绿→暖调金赭→暖调铜橙→暖调砖红 ========
const PROGRESS_GRADIENT = [
{ offset: 0, color: '#a8c97e' }, // 暖调草绿
{ offset: 0.33, color: '#cbb56d' }, // 暖调金赭
{ offset: 0.66, color: '#d98c3a' }, // 暖调铜橙
{ offset: 1, color: '#d1483a' }, // 暖调砖红
];
function getColorStops(key) {
return PROGRESS_GRADIENT;
}
// ======== ECharts 配置 ========
function createGaugeOption(ind, isLarge) {
const [statusText, statusColor] = getStatus(ind);
const vSize = isLarge ? 58 : 45;
const uSize = isLarge ? 19 : 18;
const tSize = isLarge ? 42 : 27;
const radius = isLarge ? '88%' : '85%';
const barW = isLarge ? 13 : 11;
const centerY = isLarge ? '52%' : '50%';
return {
series: [{
type: 'gauge',
center: ['50%', centerY],
radius: radius,
startAngle: 240,
endAngle: -60,
min: 0,
max: ind.max,
progress: {
show: true,
width: barW,
roundCap: true,
itemStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 1, y2: 0,
colorStops: getColorStops(ind.key)
}
}
},
pointer: { show: false },
axisLine: {
lineStyle: {
width: barW,
color: [[1, 'rgba(80,70,55,0.5)']]
}
},
axisTick: {
show: true,
distance: 2,
length: 3,
lineStyle: { color: 'rgba(148,131,103,0.35)', width: 1 }
},
splitLine: {
show: true,
distance: 5,
length: 6,
lineStyle: { color: 'rgba(148,131,103,0.4)', width: 1 }
},
axisLabel: { show: false },
title: {
show: true,
offsetCenter: [0, isLarge ? '77%' : '79%'],
color: '#fff',
fontSize: tSize,
fontWeight: 400,
backgroundColor: statusColor,
borderRadius: isLarge ? 30 : 20,
padding: isLarge ? [10, 20, 10, 20] : [5, 10, 5, 10]
},
detail: {
formatter: `{value|${ind.value.toFixed(ind.decimals)}}\n{unit|${ind.label} ${ind.unit}}`,
rich: {
value: {
fontSize: vSize,
fontWeight: 700,
color: '#e8d5a3',
textShadow: '0 0 12px rgba(232,213,163,0.45)',
lineHeight: isLarge ? 64 : 48
},
unit: {
fontSize: uSize,
fontWeight: 400,
color: '#c4b4a0',
lineHeight: isLarge ? 26 : 24
}
},
offsetCenter: [0, isLarge ? '0%' : '-2%'],
valueAnimation: true
},
data: [{ value: ind.value, name: statusText }]
}]
};
}
// ======== 图表管理 ========
let largeChart = null;
const smallChartMap = {};
const allCharts = [];
let resizeHandler = null; // 防止重复绑定
function getInd(key) {
return indicators.value.find(i => i.key === key);
}
function renderLarge(key) {
if (!largeChart || largeChart.isDisposed()) return;
const ind = getInd(key);
if (!ind) return;
largeChart.setOption(createGaugeOption(ind, true), true);
}
function destroySmallCharts() {
Object.keys(smallChartMap).forEach(key => {
if (smallChartMap[key]) {
smallChartMap[key].dispose();
}
});
Object.keys(smallChartMap).forEach(k => delete smallChartMap[k]);
}
function destroyAllCharts() {
destroySmallCharts();
if (largeChart) {
largeChart.dispose();
largeChart = null;
}
allCharts.length = 0;
}
function selectGauge(key) {
if (selectedKey.value === key || loading.value) return;
selectedKey.value = key;
}
// 切换选中时刷新图表(增量更新,不销毁实例)
watch(selectedKey, async () => {
await nextTick();
initCharts();
});
function initCharts() {
if (indicators.value.length === 0) return;
// 大仪表盘:已存在则只重绘,不存在则创建
if (!largeChart || largeChart.isDisposed()) {
const largeDom = document.getElementById('gauge-large');
if (largeDom) {
largeChart = echarts.init(largeDom);
allCharts.push(largeChart);
}
}
renderLarge(selectedKey.value);
// 小仪表盘:增量更新,不销毁已有实例
const activeKeys = new Set(smallIndicators.value.map(i => i.key));
// 清理不再需要的小图表实例
Object.keys(smallChartMap).forEach(key => {
if (!activeKeys.has(key)) {
if (smallChartMap[key]) {
smallChartMap[key].dispose();
const idx = allCharts.indexOf(smallChartMap[key]);
if (idx > -1) allCharts.splice(idx, 1);
}
delete smallChartMap[key];
}
});
// 创建或更新小图表
smallIndicators.value.forEach(ind => {
const existing = smallChartMap[ind.key];
if (existing && !existing.isDisposed()) {
existing.setOption(createGaugeOption(ind, false), true);
} else {
const dom = document.getElementById('gauge-' + ind.key);
if (dom) {
const chart = echarts.init(dom);
smallChartMap[ind.key] = chart;
allCharts.push(chart);
chart.setOption(createGaugeOption(ind, false));
}
}
});
// 防泄漏:只绑定一次 resize,后续复用
if (!resizeHandler) {
resizeHandler = () => {
allCharts.forEach(c => { if (c && !c.isDisposed()) c.resize(); });
};
window.addEventListener('resize', resizeHandler);
}
}
// ======== 定时轮询 ========
const POLL_INTERVAL = 30 * 1000; // 30 秒刷新一次
let pollTimer = null;
// 轻量刷新:只 setOption 更新数据,不销毁重建图表
function refreshCharts() {
if (indicators.value.length === 0) return;
if (largeChart && !largeChart.isDisposed()) {
renderLarge(selectedKey.value);
}
smallIndicators.value.forEach(ind => {
const chart = smallChartMap[ind.key];
if (chart && !chart.isDisposed()) {
chart.setOption(createGaugeOption(ind, false), true);
}
});
}
function startPolling() {
stopPolling(); // 先清旧定时器,防止累积
pollTimer = setInterval(async () => {
if (!isMounted.value) return; // 组件已卸载,跳过
await fetchEnvironmentData();
await nextTick();
refreshCharts();
}, POLL_INTERVAL);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
// ======== 右上角时间 ========
let timeTimer = null;
function updateTime() {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth() + 1;
const d = now.getDate();
const days = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
const day = days[now.getDay()];
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
currentTime.value = `${y}${m}${d}${day} ${hh}:${mm}:${ss}`;
}
function startTimeClock() {
updateTime();
timeTimer = setInterval(() => {
if (!isMounted.value) return;
updateTime();
}, 1000);
}
function stopTimeClock() {
if (timeTimer) {
clearInterval(timeTimer);
timeTimer = null;
}
}
onMounted(async () => {
isMounted.value = true;
await fetchDeviceTree();
await fetchEnvironmentData();
await nextTick();
initCharts();
startPolling();
startTimeClock();
});
onUnmounted(() => {
isMounted.value = false; // 先标记卸载,防止回调继续执行
stopPolling();
stopTimeClock();
if (resizeHandler) {
window.removeEventListener('resize', resizeHandler);
resizeHandler = null;
}
destroyAllCharts(); // 卸载时彻底销毁所有图表
});
return {
indicators, selectedKey, smallIndicators, tableData, selectGauge, currentTime,
tableDeviceName, tableDeviceStatus, apiDeviceNames, deviceName, deviceId, loading,
deviceTree, selectedFilters, pendingFilters, showDropdown,
activeGroup, activeFloor, currentFloors, currentDevices,
toggleDropdown, closeDropdown, selectGroup, selectFloor,
isGroupChecked, isGroupPartial, isFloorChecked, isFloorPartial, isDeviceChecked,
toggleGroup, toggleFloor, toggleDevice,
applyFilters, resetFilters, removeFilter
};
}
});
app.mount('#app');
</script>
</body>
</html>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment