IAQ页面接口

parent b16d534d
......@@ -7,6 +7,11 @@ import { addData } from "../data/addData";
import { updateManyData } from "../data/updateData";
import { controlAcByEnvironment } from "./running";
// ======= 模块级缓存(低频变化的数据) =======
let deviceTreeCache: { data: any; timestamp: number } | null = null;
const CACHE_TTL = 5 * 60 * 1000; // 5 分钟
const RECORD_LIMIT = 200; // 每天每设备最多取最近 200 条
/**
* 注册或更新设备信息
......@@ -73,13 +78,13 @@ export async function handleDevicePush(deviceId: string, data: any, deviceTime?:
}
let nowMs = getMySqlMs();
let receivedTime = new Date(nowMs).toISOString().slice(0, 19).replace('T', ' ');
let receivedTime = formatTime(new Date(nowMs));
await addData(TABLENAME.设备数据表, {
device_id: deviceId,
data: data,
received_time: receivedTime,
device_time: deviceTime ? new Date(deviceTime).toISOString().slice(0, 19).replace('T', ' ') : null,
device_time: deviceTime ? formatTime(deviceTime) : null,
created_at: receivedTime,
});
......@@ -163,23 +168,110 @@ const INDICATOR_CONFIG = [
];
/**
* 根据数据值判断质量等级
* 质量等级阈值(基于 GB/T 18883-2022 / RESET / WELL 标准)
* 优/良/中/差 四档,区间均为闭区间 [min, max]
*/
const QUALITY_THRESHOLDS: { [key: string]: { you: [number, number]; liang: [number, number]; zhong: [number, number] } } = {
co2: { you: [0, 800], liang: [800, 1000], zhong: [1000, 1500] }, // ppm
pm10: { you: [0, 50], liang: [50, 150], zhong: [150, 250] }, // μg/m³
pm2_5: { you: [0, 35], liang: [35, 75], zhong: [75, 150] }, // μg/m³
hcho: { you: [0, 0.03], liang: [0.03, 0.08], zhong: [0.08, 0.10] }, // mg/m³
tvoc: { you: [0, 0.3], liang: [0.3, 0.6], zhong: [0.6, 1.0] }, // mg/m³
// humidity 不在此表中,由 getQuality() 内联双向判断处理(偏低/偏高各方向独立降级)
o3: { you: [0, 0.05], liang: [0.05, 0.1], zhong: [0.1, 0.16] }, // mg/m³
light_level: { you: [300, 500], liang: [100, 300], zhong: [50, 100] }, // lux — 通用办公/室内场景,>500 过于明亮也会判差
};
/**
* 根据数据值判断质量等级(优/良/中/差)
* - 温度:根据当前月份自动判定夏/冬季模式
* - 湿度:双向判断(低于 40 或高于 60 降级)
* - 气压:双向判断(以 1013hPa 为中心,优:1010~1020, 良:1000~1010|1020~1030, 中:990~1000|1030~1040, 差:<990|>1040)
* - 光照度:通用室内办公标准,查阈值表(优:300~500, 良:100~300, 中:50~100, 差:<50 或 >500)
*/
function getQuality(key: string, value: number): string | null {
if (value === null || value === undefined) return null;
// 人体感应:特殊处理
if (key === "pir") return value === 1 ? "有人" : "无感应";
const config = INDICATOR_CONFIG.find(c => c.key === key);
if (!config || config.min === null || config.max === null) return null;
if (value >= config.min && value <= config.max) return "优秀";
return "超标";
// 温度:季节模式
if (key === "temperature") return getTemperatureQuality(value);
// 湿度:双向判断
if (key === "humidity") {
if (value >= 40 && value <= 60) return "优";
if ((value >= 30 && value < 40) || (value > 60 && value <= 70)) return "良";
if ((value >= 20 && value < 30) || (value > 70 && value <= 80)) return "中";
return "差"; // <20 或 >80
}
// 气压:双向判断(以 1013hPa 标准大气压为中心,偏低/偏高各方向独立降级)
if (key === "pressure") {
if (value >= 1010 && value <= 1020) return "优";
if ((value >= 1000 && value < 1010) || (value > 1020 && value <= 1030)) return "良";
if ((value >= 990 && value < 1000) || (value > 1030 && value <= 1040)) return "中";
return "差"; // <990 或 >1040
}
// 其他指标:查阈值表
const t = QUALITY_THRESHOLDS[key];
if (!t) return null;
if (value >= t.you[0] && value <= t.you[1]) return "优";
if (value >= t.liang[0] && value <= t.liang[1]) return "良";
if (value >= t.zhong[0] && value <= t.zhong[1]) return "中";
return "差"; // 超出 zhong 区间上限
}
/**
* 温度质量等级(夏/冬季模式,根据当前月份自动判定)
* 设计逻辑:温度控制是一个收敛系统,20℃/24℃ 是收敛目标。
* 良为收敛带(可接受偏移),中为漂移区间(需关注),差为失控(需告警)。
*
* 夏季(3-11月):
* 优 = 24℃(精确收敛点)
* 良 = 22~26℃(收敛带)
* 中 = 18~22℃ 或 26~32℃(漂移区间)
* 差 = <18℃ 或 >32℃(失控,可能设备故障或极端天气)
*
* 冬季(12-2月):
* 优 = 20℃(精确收敛点)
* 良 = 18~21℃(收敛带)
* 中 = 14~18℃ 或 21~27℃(漂移区间)
* 差 = <14℃ 或 >27℃(失控)
*/
function getTemperatureQuality(value: number): string {
const month = new Date().getMonth() + 1; // 1-12
const isWinter = month === 12 || month <= 2;
if (isWinter) {
if (value === 20) return "优";
if (value >= 18 && value <= 21) return "良";
if (value >= 14 && value <= 27) return "中"; // 14~18 和 21~27(优/良已在前两步返回)
return "差"; // <14 或 >27
} else {
// 夏季(3-11月)
if (value === 24) return "优";
if (value >= 22 && value <= 26) return "良";
if (value >= 18 && value <= 32) return "中"; // 18~22 和 26~32(优/良已在前两步返回)
return "差"; // <18 或 >32
}
}
/**
* 格式化时间为 yyyy-MM-dd HH:mm:ss
* 格式化为本地时间:yyyy-MM-dd HH:mm:ss(替代 toISOString,避免 UTC 时区偏移)
*/
function formatTime(date: Date): string {
function formatTime(date: Date | string): string {
if (!date) return null;
return new Date(date).toISOString().slice(0, 19).replace("T", " ");
const d = new Date(date);
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const h = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
const s = String(d.getSeconds()).padStart(2, '0');
return `${y}-${mo}-${day} ${h}:${mi}:${s}`;
}
/**
......@@ -192,15 +284,17 @@ function getTodayStart(): string {
}
/**
* 获取当天的 device_data 记录
* 获取当天最近的 device_data 记录(按设备测量时间倒序,限量返回)
*/
async function getTodayDeviceData(deviceId: string): Promise<any[]> {
async function getTodayDeviceData(deviceId: string, limit: number = RECORD_LIMIT): Promise<any[]> {
const todayStart = getTodayStart();
const result = await selectDataListByParam(TABLENAME.设备数据表, {
const params: any = {
device_id: deviceId,
created_at: { "%gte%": todayStart },
"%orderAsc%": "created_at",
});
device_time: { "%gte%": todayStart },
"%orderDesc%": "device_time",
};
if (limit) params["%limit%"] = limit;
const result = await selectDataListByParam(TABLENAME.设备数据表, params);
return result.data || [];
}
......@@ -208,11 +302,32 @@ async function getTodayDeviceData(deviceId: string): Promise<any[]> {
* 构建 indicators 数组(基于当天最新数据)
*/
function buildIndicators(allLatestData: { [deviceId: string]: any }): any[] {
const sampleData = Object.values(allLatestData).find(d => d && d.data) as any;
const raw = sampleData?.data || {};
// 收集所有设备的最新数据
const allData = Object.values(allLatestData).filter(d => d && d.data);
if (allData.length === 0) {
return INDICATOR_CONFIG.map(config => ({
key: config.key,
name: config.name,
unit: config.unit,
current_value: 0,
min: config.min,
max: config.max,
quality: null,
}));
}
return INDICATOR_CONFIG.map(config => {
const currentValue = raw[config.key] !== undefined ? raw[config.key] : null;
// 收集所有设备该指标的数值
const values = allData
.map(d => d.data[config.key])
.filter(v => v !== undefined && v !== null && typeof v === 'number');
let currentValue: number = 0;
if (values.length > 0) {
const sum = values.reduce((acc, v) => acc + v, 0);
currentValue = Math.round((sum / values.length) * 100) / 100; // 保留两位小数
}
return {
key: config.key,
name: config.name,
......@@ -228,50 +343,134 @@ function buildIndicators(allLatestData: { [deviceId: string]: any }): any[] {
/**
* 构建 records 数据
*/
function buildRecords(deviceDataMap: { [deviceId: string]: any[] }, isMultiple: boolean) {
function buildRecords(deviceDataMap: { [deviceId: string]: any[] }) {
const allRows: any[] = [];
for (const deviceId in deviceDataMap) {
for (const record of deviceDataMap[deviceId]) {
const rawData = record.data || {};
const row: any = {
device_id: deviceId,
device_name: record.device_name || deviceId,
device_time: record.device_time ? formatTime(record.device_time) : formatTime(record.created_at),
};
if (isMultiple) {
row.device_id = deviceId;
row.device_name = record.device_name || deviceId;
}
INDICATOR_CONFIG.forEach(c => {
row[c.key] = rawData[c.key] !== undefined ? rawData[c.key] : null;
});
allRows.push(row);
}
}
// 按时间
allRows.sort((a, b) => (a.device_time || "").localeCompare(b.device_time || ""));
// 按时间
allRows.sort((a, b) => (b.device_time || "").localeCompare(a.device_time || ""));
// titleList
const titleList = isMultiple
? ["设备编号", "设备名", "时间", ...INDICATOR_CONFIG.map(c => c.name)]
: ["时间", ...INDICATOR_CONFIG.map(c => c.name)];
const titleList = ["设备编号", "设备名", "时间", ...INDICATOR_CONFIG.map(c => c.name)];
// dataList:将 row 转为数组按 titleList 顺序
const dataList = allRows.map(row => {
if (isMultiple) {
return [
row.device_id,
row.device_name,
row.device_time,
...INDICATOR_CONFIG.map(c => row[c.key]),
];
} else {
return [
row.device_time,
...INDICATOR_CONFIG.map(c => row[c.key]),
];
const dataList = allRows.map(row => [
row.device_id,
row.device_name,
row.device_time,
...INDICATOR_CONFIG.map(c => row[c.key]),
]);
return { titleList, dataList };
}
/**
* 获取环境设备信息
* @param regionGroup
* @param regionType
* @param regionKey
* @param deviceId
*/
/**
* 获取环境监测设备,按 馆 → 层 → 设备 三级树形结构返回
* 响应格式为:[{"key":"A馆","value":"A馆","sub":[{"key":"一层","value":"一层","sub":[{"key":"24E124710E467239","value":"沙盘厅_cgq5","sub":[]}]}]}]
*/
export async function getEnvironmentDevices() {
// 内存缓存:设备树结构变化低频,5 分钟内直接复用
if (deviceTreeCache && (Date.now() - deviceTreeCache.timestamp) < CACHE_TTL) {
return deviceTreeCache.data;
}
// 1. 查询所有环境监测设备
const deviceResult = await selectDataListByParam(TABLENAME.设备表, {
device_type: "环境监测"
}, ["device_id", "device_name", "region_key"]);
const devices = deviceResult.data || [];
if (devices.length === 0) {
return [];
}
// 2. 收集所有 region_key,查询对应区域信息
const regionKeys = [...new Set(devices.map((d: any) => d.region_key).filter(Boolean))];
if (regionKeys.length === 0) {
return [];
}
const regionResult = await selectDataListByParam(TABLENAME.区域表, {
id: { "%in%": regionKeys }
}, ["id", "name", "type", "groups"]);
const regions = regionResult.data || [];
// 构建 regionKey → region 的映射
const regionMap: { [id: number]: any } = {};
regions.forEach((r: any) => {
regionMap[r.id] = r;
});
// 3. 构建三级树:groups(馆) → type(层) → device
// 用于跟踪已创建的 group/type 节点,避免重复
const groupMap: { [group: string]: { key: string; value: string; typeMap: { [type: string]: { key: string; value: string; sub: any[] } } } } = {};
devices.forEach((dev: any) => {
const region = regionMap[dev.region_key];
if (!region) return;
const groupName = region.groups;
const typeName = region.type;
const roomName = region.name;
// 确保 group 节点存在
if (!groupMap[groupName]) {
groupMap[groupName] = { key: groupName, value: groupName, typeMap: {} };
}
// 确保 type 节点存在
if (!groupMap[groupName].typeMap[typeName]) {
groupMap[groupName].typeMap[typeName] = { key: typeName, value: typeName, sub: [] };
}
// 添加设备到 type 下
groupMap[groupName].typeMap[typeName].sub.push({
key: dev.device_id,
value: roomName+"_"+dev.device_name,
sub: []
});
});
return { titleList, dataList };
// 4. 组装最终的树形数组
const result: any[] = [];
// 按 group 名称排序,保证稳定顺序
const sortedGroups = Object.keys(groupMap).sort();
sortedGroups.forEach(groupName => {
const group = groupMap[groupName];
const subArr: any[] = [];
// 按 type 名称排序
const sortedTypes = Object.keys(group.typeMap).sort();
sortedTypes.forEach(typeName => {
subArr.push(group.typeMap[typeName]);
});
result.push({
key: group.key,
value: group.value,
sub: subArr
});
});
deviceTreeCache = { data: result, timestamp: Date.now() };
return result;
}
/**
......@@ -282,10 +481,10 @@ function buildRecords(deviceDataMap: { [deviceId: string]: any[] }, isMultiple:
* @param regionGroup 楼栋(如 "A馆")
* @param regionType 楼层(如 "1F")
* @param regionKey 区域ID
* @param deviceId 设备ID,精确查询单个设备
* @param deviceIds 设备ID数组,精确查询指定设备
* @returns
*/
export async function getEnvironmentData(regionGroup: string, regionType: string, regionKey: number, deviceId: string) {
export async function getEnvironmentData(regionGroup: string, regionType: string, regionKey: number, deviceId: string[]) {
// ====== 假数据(调试用) ======
// return {
// indicators: [
......@@ -302,10 +501,10 @@ export async function getEnvironmentData(regionGroup: string, regionType: string
// { key: "o3", name: "臭氧", unit: "mg/m³", current_value: 0.04, min: 0, max: 0.1, quality: "优秀" },
// ],
// records: {
// titleList: ["时间", "二氧化碳", "PM10", "PM2.5", "甲醛", "TVOC", "温度", "湿度", "气压", "光照度", "PIR", "臭氧"],
// titleList: ["设备", "时间", "二氧化碳", "PM10", "PM2.5", "甲醛", "TVOC", "温度", "湿度", "气压", "光照度", "PIR", "臭氧"],
// dataList: [
// ["2025-06-18 10:00:00", 450, 30, 15, 0.02, 0.3, 25, 60, 1013, 300, 0, 0.04],
// ["2025-06-18 10:30:00", 460, 32, 16, 0.02, 0.28, 25.5, 58, 1012, 320, 1, 0.04],
// ["沙盘厅_cgq2", "2025-06-18 10:00:00", 450, 30, 15, 0.02, 0.3, 25, 60, 1013, 300, 0, 0.04],
// ["辉煌厅_cgq5", "2025-06-18 10:30:00", 460, 32, 16, 0.02, 0.28, 25.5, 58, 1012, 320, 1, 0.04],
// ],
// },
// areaInfo: {
......@@ -318,10 +517,10 @@ export async function getEnvironmentData(regionGroup: string, regionType: string
let devices: any[] = [];
// 1. 如果传了 deviceId,直接查设备
if (deviceId) {
// 1. 如果传了 deviceId,直接查这些设备
if (deviceId && deviceId.length > 0) {
const deviceResult = await selectDataListByParam(TABLENAME.设备表, {
device_id: deviceId,
device_id: {'%in%': deviceId},
device_type: "环境监测",
});
devices = deviceResult.data || [];
......@@ -355,46 +554,54 @@ export async function getEnvironmentData(regionGroup: string, regionType: string
return { indicators: [], records: { titleList: [], dataList: [] }, areaInfo: null };
}
const isMultiple = devices.length > 1;
// 查询设备所属区域,构建 regionKey -> 区域信息 的映射
const deviceRegionKeys = [...new Set(devices.map((d: any) => d.region_key).filter(Boolean))];
const regionNameMap: { [key: number]: string } = {};
const regionInfoMap: { [key: number]: any } = {};
if (deviceRegionKeys.length > 0) {
const regionResult = await selectDataListByParam(TABLENAME.区域表, {
id: { "%in%": deviceRegionKeys },
}, ["id", "name", "type", "groups"]);
(regionResult.data || []).forEach((r: any) => {
regionNameMap[r.id] = r.name || '';
regionInfoMap[r.id] = r;
});
}
// 5. 获取每个设备当天的数据
// 5. 并行获取每个设备当天的数据(取代串行 for...of,N 个设备只需 1 次 DB 往返时间)
const deviceDataMap: { [deviceId: string]: any[] } = {};
const allLatestData: { [deviceId: string]: any } = {};
for (const device of devices) {
await Promise.all(devices.map(async (device) => {
const devId = device.device_id;
const records = await getTodayDeviceData(devId);
const regionName = regionNameMap[device.region_key] || '';
records.forEach((r: any) => {
r.device_name = device.device_name || devId;
r.device_name = regionName + '_' + (device.device_name || devId);
});
deviceDataMap[devId] = records;
// 降序查询,第一条即为最新
if (records.length > 0) {
allLatestData[devId] = records[records.length - 1];
allLatestData[devId] = records[0];
}
}
}));
// 6. 构建 indicators
const indicators = buildIndicators(allLatestData);
// 7. 构建 records
const records = buildRecords(deviceDataMap, isMultiple);
const records = buildRecords(deviceDataMap);
// 8. 查询设备所属区域,构建 areaInfo
const deviceRegionKeys = [...new Set(devices.map((d: any) => d.region_key).filter(Boolean))];
// 8. 构建 areaInfo(复用已查询的区域信息)
let building = "";
let floor = "";
let room = "";
if (deviceRegionKeys.length > 0) {
const regionInfoResult = await selectDataListByParam(TABLENAME.区域表, {
id: { "%in%": deviceRegionKeys },
}, ["id", "name", "type", "groups"]);
const regionList = regionInfoResult.data || [];
if (regionList.length > 0) {
// 取第一个区域的信息填充
building = regionList[0].groups || "";
floor = regionList[0].type || "";
room = regionList[0].name || "";
}
const regionValues = Object.values(regionInfoMap);
if (regionValues.length > 0) {
const firstRegion: any = regionValues[0];
building = firstRegion.groups || "";
floor = firstRegion.type || "";
room = firstRegion.name || "";
}
const areaInfo = {
building,
......
......@@ -38,6 +38,8 @@ export function setRouter(httpServer) {
/** 停止数据集成 */
httpServer.post('/api/qdm/run/stop/schedule', asyncHandler(stopSchedule));
/** 获取环境监测设备 */
httpServer.post('/api/qdm/device/envDevice', asyncHandler(getEnvironmentDevices));
/** 获取环境监测数据 */
httpServer.post('/api/qdm/device/environment', asyncHandler(getEnvironmentData));
}
......@@ -160,10 +162,21 @@ async function stopSchedule(req, res) {
}
/**
* 获取环境设备
*/
async function getEnvironmentDevices(req, res) {
let reqConf = {};
let { } = eccReqParamater(reqConf, req.body);
const result = await deviceBiz.getEnvironmentDevices();
res.success(result);
}
/**
* 获取环境监测数据
*/
async function getEnvironmentData(req, res) {
let reqConf = {regionGroup:'String', regionType:'String', regionKey:'Number', deviceId:'String'};
let reqConf = {regionGroup:'String', regionType:'String', regionKey:'Number', deviceId:'Array'};
const NotMustHaveKeys = ["regionGroup", "regionType", "regionKey", "deviceId"];
let { regionGroup, regionType, regionKey, deviceId } = eccReqParamater(reqConf, req.body, NotMustHaveKeys);
......
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