低代码平台在物联网可视化场景的落地:AI驱动的实时数据看板生成
物联网(IoT)场景的可视化需求具有高度定制化特点:不同设备、不同场景的数据指标完全不同,用户期望的展示形式也各异。传统开发模式下,每个数据看板需要前端工程师手工搭建,交付周期长且成本高。低代码平台与 AI 结合,为这一场景提供了新的解法。
一、IoT 可视化的工程痛点
物联网可视化不是简单的折线图+柱状图。实际项目中的典型需求包括:设备的实时状态拓扑图、传感器的时序数据曲线、告警事件的地理信息分布、以及基于设备树的层级钻取。
这些需求的共性是:组件类型有限但组合方式多变。以常见的 SCADA(监控与数据采集)场景为例,涉及的核心组件不超过 20 种(仪表盘、状态灯、曲线图、地图标记、设备树等),但不同项目之间的组件布局和交互逻辑差异巨大。
低代码的切入点在于:将组件标准化固化为可拖拽的物料,让配置人员通过拖拽+属性设置的方式搭建看板。AI 的切入点在于:根据设备元数据和数据特征,自动推荐合适的图表类型和布局方案,减少人工配置的试错成本。
二、系统整体架构
数据层:设备通过 MQTT 协议上报实时数据,时序数据存储在 TSDB(如 InfluxDB 或 TDengine)中,设备元数据(类型、位置、父子关系)存储在关系数据库中。
AI 推荐引擎:分析数据特征(数值范围、变化频率、维度数量),推荐合适的图表类型。例如,单值波动数据推荐仪表盘、多维度时序数据推荐多 Y 轴折线图、地理坐标数据推荐散点地图。
低代码编辑器:基于组件物料库提供拖拽搭建能力,同时接收 AI 推荐的初始布局方案,用户可以在推荐基础上微调。
三、AI 图卡推荐的核心实现
图卡推荐引擎的核心逻辑是:根据数据源的字段类型和统计特征,匹配最合适的可视化组件。以下是一个基于规则的推荐实现:
// ChartRecommender.ts — 图表类型推荐引擎 import { DataSourceMeta, ChartType, LayoutRecommendation } from './types'; /** 数据特征提取结果 */ interface DataProfile { hasTimestamp: boolean; // 是否包含时间字段 hasLocation: boolean; // 是否包含经纬度字段 numericFields: string[]; // 数值型字段列表 categoricalFields: string[];// 分类型字段列表 cardinality: Record<string, number>; // 各字段的基数(唯一值数量) valueRange: Record<string, { min: number; max: number }>; // 数值范围 updateFrequency: number; // 数据更新频率(次/秒) } /** 图表类型推荐规则 */ interface RecommendationRule { chartType: ChartType; condition: (profile: DataProfile) => boolean; priority: number; // 多个规则同时匹配时的优先级 } export class ChartRecommender { private rules: RecommendationRule[] = [ { chartType: 'gauge', condition: (p) => p.numericFields.length === 1 && !p.hasTimestamp && p.updateFrequency > 0.5, priority: 10, }, { chartType: 'line', condition: (p) => p.hasTimestamp && p.numericFields.length >= 1 && p.numericFields.length <= 3, priority: 9, }, { chartType: 'multiLine', condition: (p) => p.hasTimestamp && p.numericFields.length > 3, priority: 8, }, { chartType: 'bar', condition: (p) => !p.hasTimestamp && p.categoricalFields.length >= 1 && p.numericFields.length >= 1 && (p.cardinality[p.categoricalFields[0]] || 0) <= 20, priority: 7, }, { chartType: 'scatterMap', condition: (p) => p.hasLocation && p.numericFields.length >= 1, priority: 10, }, { chartType: 'pie', condition: (p) => p.categoricalFields.length === 1 && p.numericFields.length === 1 && (p.cardinality[p.categoricalFields[0]] || 0) <= 8, priority: 6, }, { chartType: 'table', // 回退规则:当以上规则都不匹配时,使用表格 condition: () => true, priority: 0, }, { chartType: 'statusIndicator', condition: (p) => p.numericFields.length === 1 && p.valueRange[p.numericFields[0]] && p.valueRange[p.numericFields[0]].max <= 1 && // 0-1 值认为是状态 p.updateFrequency > 0.1, priority: 8, }, ]; /** * 分析数据源特征 * @param meta - 数据源的元数据描述 * @param sampleData - 样本数据(前 100 行用于特征提取) */ analyzeProfile( meta: DataSourceMeta, sampleData: Record<string, any>[] ): DataProfile { if (sampleData.length === 0) { throw new Error('样本数据为空,无法完成特征分析'); } const fields = Object.keys(sampleData[0]); const profile: DataProfile = { hasTimestamp: false, hasLocation: false, numericFields: [], categoricalFields: [], cardinality: {}, valueRange: {}, updateFrequency: meta.updateFrequency || 0, }; const fieldTypes = new Map<string, 'number' | 'string' | 'boolean' | 'date'>(); // 推断字段类型 for (const field of fields) { const sampleValues = sampleData.slice(0, 50).map((r) => r[field]); if (sampleValues.every((v) => typeof v === 'number')) { fieldTypes.set(field, 'number'); profile.numericFields.push(field); } else if ( sampleValues.every( (v) => typeof v === 'string' && !isNaN(Date.parse(v)) ) ) { fieldTypes.set(field, 'date'); profile.hasTimestamp = true; // 日期字段也可能有数值含义 profile.numericFields.push(field); } else if (sampleValues.some((v) => typeof v === 'string')) { fieldTypes.set(field, 'string'); profile.categoricalFields.push(field); } } // 检查地理坐标字段 const latFields = fields.filter((f) => /^(lat|latitude|维度|y)$/i.test(f) ); const lngFields = fields.filter((f) => /^(lng|longitude|lon|经度|x)$/i.test(f) ); profile.hasLocation = latFields.length > 0 && lngFields.length > 0; // 计算基数 for (const field of profile.categoricalFields) { const uniqueValues = new Set(sampleData.map((r) => r[field])); profile.cardinality[field] = uniqueValues.size; } // 计算数值范围 for (const field of profile.numericFields) { const values = sampleData .map((r) => Number(r[field])) .filter((v) => !isNaN(v)); if (values.length > 0) { profile.valueRange[field] = { min: Math.min(...values), max: Math.max(...values), }; } } return profile; } /** 推荐图表类型 */ recommend(profile: DataProfile): ChartType[] { const matches = this.rules .filter((rule) => { try { return rule.condition(profile); } catch { return false; } }) .sort((a, b) => b.priority - a.priority) .map((rule) => rule.chartType); // 去重,返回前 3 个推荐 return [...new Set(matches)].slice(0, 3); } /** 生成完整的布局推荐 */ generateLayout( dataSources: Array<{ meta: DataSourceMeta; profile: DataProfile }> ): LayoutRecommendation { const cards = dataSources.map((ds, index) => { const [primaryType, ...alternatives] = this.recommend(ds.profile); return { id: `card_${index}`, title: ds.meta.name || `数据卡片 ${index + 1}`, chartType: primaryType, alternatives, dataSourceId: ds.meta.id, // 网格布局位置(简单按顺序排列,两列布局) position: { x: (index % 2) * 6, // 12 列栅格,每列占 6 y: Math.floor(index / 2) * 4, w: 6, h: 4, }, }; }); return { id: `layout_${Date.now()}`, name: 'AI 推荐布局', cards, generatedAt: new Date().toISOString(), }; } }四、运行时的实时数据绑定
看板的 JSON 配置需要被运行时引擎解析为可交互的页面。运行时引擎的核心职责是:解析配置 JSON → 实例化对应的 React/Vue 组件 → 建立组件与数据源的 WebSocket 连接 → 处理实时数据更新。
数据绑定采用"数据源-组件"的声明式映射:
// RuntimeBindingEngine.ts — 运行时数据绑定引擎 type DataUpdateCallback = (data: any) => void; export class RuntimeBindingEngine { private wsConnections = new Map<string, WebSocket>(); private dataListeners = new Map<string, Set<DataUpdateCallback>>(); private reconnectTimers = new Map<string, NodeJS.Timeout>(); /** * 绑定组件到数据源 * @param componentId - 组件实例 ID * @param dataSourceId - 数据源 ID * @param onData - 数据更新回调 */ bind( componentId: string, dataSourceId: string, onData: DataUpdateCallback ): void { const listenerKey = `${dataSourceId}:${componentId}`; // 注册监听器 if (!this.dataListeners.has(dataSourceId)) { this.dataListeners.set(dataSourceId, new Set()); } this.dataListeners.get(dataSourceId)!.add(onData); // 如果数据源连接不存在,则建立 this.ensureConnection(dataSourceId); console.log( `[RuntimeBinding] 组件 ${componentId} 绑定到数据源 ${dataSourceId}` ); } /** 确保 WebSocket 连接活跃 */ private ensureConnection(dataSourceId: string): void { if (this.wsConnections.has(dataSourceId)) return; const wsUrl = `wss://${process.env.IOT_WS_HOST}/stream/${dataSourceId}`; try { const ws = new WebSocket(wsUrl); ws.onopen = () => { console.log(`[RuntimeBinding] 数据源 ${dataSourceId} 连接成功`); // 清除重连定时器 const timer = this.reconnectTimers.get(dataSourceId); if (timer) { clearTimeout(timer); this.reconnectTimers.delete(dataSourceId); } }; ws.onmessage = (event) => { try { const data = JSON.parse(event.data); // 通知所有监听该数据源的组件 const listeners = this.dataListeners.get(dataSourceId); if (listeners) { listeners.forEach((callback) => { try { callback(data); } catch (error) { console.error( `[RuntimeBinding] 组件数据回调异常: ${dataSourceId}`, error ); } }); } } catch (error) { console.error( `[RuntimeBinding] 数据解析失败: ${dataSourceId}`, error ); } }; ws.onerror = (error) => { console.error( `[RuntimeBinding] 数据源 ${dataSourceId} 连接错误:`, error ); }; ws.onclose = (event) => { console.warn( `[RuntimeBinding] 数据源 ${dataSourceId} 连接关闭 (code: ${event.code})` ); this.wsConnections.delete(dataSourceId); // 自动重连(指数退避) if (event.code !== 1000) { // 非正常关闭时重连 const delay = Math.min( 1000 * Math.pow(2, this.getRetryCount(dataSourceId)), 30000 ); const timer = setTimeout(() => { this.ensureConnection(dataSourceId); }, delay); this.reconnectTimers.set(dataSourceId, timer); } }; this.wsConnections.set(dataSourceId, ws); } catch (error) { console.error( `[RuntimeBinding] 创建 WebSocket 连接失败: ${dataSourceId}`, error ); } } private retryCounts = new Map<string, number>(); private getRetryCount(dataSourceId: string): number { const count = (this.retryCounts.get(dataSourceId) || 0) + 1; this.retryCounts.set(dataSourceId, count); return count; } /** 解绑组件 */ unbind(componentId: string, dataSourceId: string): void { const listeners = this.dataListeners.get(dataSourceId); if (listeners) { // 清理该组件的所有监听(简化处理) listeners.clear(); } // 如果该数据源已无监听者,关闭连接 if (listeners && listeners.size === 0) { const ws = this.wsConnections.get(dataSourceId); if (ws && ws.readyState === WebSocket.OPEN) { ws.close(1000, '无活跃监听者'); } this.wsConnections.delete(dataSourceId); this.dataListeners.delete(dataSourceId); } } /** 关闭所有连接 */ destroy(): void { this.wsConnections.forEach((ws) => { if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { ws.close(1000, '引擎销毁'); } }); this.wsConnections.clear(); this.dataListeners.clear(); this.reconnectTimers.forEach((timer) => clearTimeout(timer)); this.reconnectTimers.clear(); this.retryCounts.clear(); } }五、总结
低代码平台在 IoT 可视化场景的落地,关键在于两点:一是 AI 推荐引擎降低了图卡配置的学习成本,让非前端人员也能快速搭建专业的数据看板;二是运行时的实时数据绑定机制保证了看板对设备数据的响应速度。
在一个实际的智慧园区项目中,通过低代码+AI 推荐的方式,交付一个包含 15 个图卡的设备监控看板,从传统的 3 个工作日缩短到 4 小时。核心收益不在于开发效率的提升,而在于将看板搭建能力从研发团队释放到运营团队,改变了交付流程中的瓶颈点。