ThingsBoard HTML Container 组件实战:用纯 HTML/CSS/JS 打造访客分析仪表板

原创2026-10-03 20:44:491,642 阅读
文章标签:物联网后端数据可视化微服务消息队列

ThingsBoard HTML Container 组件实战:用纯 HTML/CSS/JS 打造访客分析仪表板

本篇技术指南围绕 ThingsBoard 的 HTML Container 组件(Plain 纯模式) 展开,以官方帮助文档中的 “Visitor analytics(访客分析)” 完整示例为骨架,逐步拆解其 HTML 结构、CSS 样式、JavaScript 驱动函数与 ECharts 资源配置,并结合仓库源码讲解 ctx/container 函数签名、subscriptionApi 数据订阅与销毁回调等底层机制。读完本文,你将能够独立在 ThingsBoard 仪表板中搭建一个支持时间范围筛选、营业时段过滤、按设备堆叠统计与日/小时下钻的纯前端分析型 HTML 组件。


一、什么是 HTML Container 组件

在 ThingsBoard 中,html_container 是一种特殊类型的自定义 widget。它不再像普通 widget 那样由系统自动绑定数据源,而是把 HTML 模板 + CSS + JavaScript 函数 + 外部资源 四者完全交给你来控制,适合构建高度定制的可视化容器。

从源码看,该组件位于 html-container-widget.component.ts,它的模板非常简单:

<div #container class="tb-absolute-fill"><tb-anchor #angularContainer></tb-anchor></div>

即一个占满 widget 内容的 <div class="tb-absolute-fill"> 容器节点。它支持两种模式(定义于 html-container-widget.models.ts,由 html-container-settings.component.html 中的 tb-toggle-select 切换):

  • Plain(纯)模式:JS 函数签名为 function (ctx, container),直接操纵原生 DOM,适合引入 CDN 的第三方库(如 ECharts);
  • Angular 模式:JS 函数签名为 function (ctx),可以使用 Angular 模板语法与组件。

本文的 Visitor analytics 示例采用 Plain 模式,通过 CDN 加载 ECharts,是这一模式最典型的应用。


二、示例整体结构与配套文件

官方帮助文档把“访客分析”示例拆成了四个部分(均位于 examples 目录):

文件 作用
plain_analytics_html.md 组件 DOM 结构(标题、时间/时段筛选控件、KPI 卡片、图表容器)
plain_analytics_css.md 全部样式,完成布局、配色与交互态
plain_analytics_js.md 驱动函数:ECharts 初始化、数据订阅、KPI 计算、图表渲染与下钻
plain_analytics_resources.md 引入的 ECharts CDN 资源地址

示例的业务场景是:对一类名为 peopleCount 的设备做访客统计——统计指定日期范围内(可自定义起止日期与每日营业/打烊时段)的总访客数、最忙的一天、高峰时段与日均值,并按设备堆叠展示每日/每小时的访客柱状图,点击某天还能下钻到小时粒度。


三、HTML 模板详解

以下是该示例的完整 HTML 代码(原文位于 plain_analytics_html.md):

<div class="vc">
  <header class="vc__head">
    <div>
      <h3 class="vc__title">Visitor analytics</h3>
      <span class="vc__sub">all peopleCount devices</span>
    </div>
    <div class="vc__controls">
      <label class="vc__field"><span>From</span><input type="date" id="vcStart" /></label>
      <label class="vc__field"><span>To</span><input type="date" id="vcEnd" /></label>
      <label class="vc__field"><span>Open</span><input type="time" id="vcOpen" value="09:00" /></label>
      <label class="vc__field"><span>Close</span><input type="time" id="vcClose" value="18:00" /></label>
    </div>
  </header>
  <div class="vc__kpis">
    <div class="vc-kpi"><span class="vc-kpi__label">Total visitors</span><span class="vc-kpi__value" id="vcTotal">—</span></div>
    <div class="vc-kpi"><span class="vc-kpi__label">Busiest day</span><span class="vc-kpi__value" id="vcPeakDay">—</span></div>
    <div class="vc-kpi"><span class="vc-kpi__label">Peak hour</span><span class="vc-kpi__value" id="vcPeakHour">—</span></div>
    <div class="vc-kpi"><span class="vc-kpi__label">Daily average</span><span class="vc-kpi__value" id="vcAvg">—</span></div>
  </div>
  <div class="vc__card">
    <div class="vc__card-head">
      <span class="vc__card-title" id="vcChartTitle">Visitors by day</span>
      <button class="vc__back" id="vcBack" hidden>← Back</button>
    </div>
    <div class="vc__chart" id="vcChart"></div>
  </div>
</div>

结构上分为三层,每个元素都有明确职责:

  1. 头部(.vc__head):左侧标题 + 副标题,右侧四个表单控件——
    • #vcStart / #vcEnd:日期范围(type="date"),决定订阅哪段历史数据;
    • #vcOpen / #vcClose:营业时段(type="time",默认 09:00–18:00),只对数据做过滤,不重新订阅。
  2. KPI 区(.vc__kpis):四张卡片,初始值均为 —,等待 JS 计算后填充:
    • #vcTotal 总访客数、#vcPeakDay 最忙日期、#vcPeakHour 高峰时段、#vcAvg 日均访客。
  3. 图表卡片(.vc__card):#vcChartTitle 动态标题、#vcBack 返回按钮(默认 hidden,仅在小时下钻时显示)、#vcChart 作为 ECharts 挂载点。

注意一个细节:图表标题和返回按钮也被 id 化了,因为 JS 需要在下钻/返回时动态改写标题文本并切换按钮可见性,说明 Plain 模式下 DOM 完全是 JS 的“数据通道”。


四、CSS 样式详解

完整样式(原文位于 plain_analytics_css.md):

.vc {
  display: flex;
  flex-direction: column;
  gap: 16px;
  height: 100%;
  box-sizing: border-box;
  padding: 20px;
  background: #fff;
  font-family: 'Inter', 'Roboto', system-ui, sans-serif;
  color: #0f172a;
}
.vc__head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.vc__title { margin: 0; font-family: 'Roboto', sans-serif; font-size: 20px; font-weight: 500; }
.vc__sub { font-size: 12px; font-weight: 600; color: #94a3b8; }
.vc__controls { display: flex; gap: 10px; flex-wrap: wrap; }
.vc__field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: #64748b; }
.vc__field input { padding: 7px 10px; border: 1px solid #d8dee9; border-radius: 10px; font-size: 13px; color: #0f172a; }
.vc__field input:focus { outline: none; border-color: #2f6bff; box-shadow: 0 0 0 3px rgba(47, 107, 255, 0.15); }
.vc__kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.vc-kpi { display: flex; flex-direction: column; gap: 6px; padding: 14px 16px; border-radius: 14px; background: #f8fafc; border: 1px solid #e6ebf2; }
.vc-kpi__label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #94a3b8; }
.vc-kpi__value { font-size: 26px; font-weight: 800; line-height: 1; color: #1e293b; }
.vc__card { flex: 1; min-height: 240px; display: flex; flex-direction: column; padding: 16px; border: 1px solid #e6ebf2; border-radius: 14px; }
.vc__card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.vc__card-title { font-size: 13px; font-weight: 700; color: #334155; }
.vc__back { padding: 6px 12px; border: 1px solid #d8dee9; border-radius: 8px; background: #fff; color: #2f6bff; font-size: 12px; font-weight: 600; cursor: pointer; }
.vc__back[hidden] { display: none; }
.vc__back:hover { background: #f0f5ff; border-color: #2f6bff; }
.vc__chart { flex: 1; min-height: 200px; }

几点实战说明:

  • .vc 根节点使用 height: 100% 配合 flex-direction: column,保证 widget 内容区被完整撑满,这是 Plain 模式下自绘组件必做的“全屏适配”;
  • KPI 区用 grid-template-columns: repeat(4, 1fr) 四等分,在窄屏下仍可保持等宽;
  • :focus 状态与 .vc__back 的 hover 态定义了统一的品牌色 #2f6bff,让控件与仪表板主题保持一致;
  • .vc__back[hidden] { display: none; } 是必须的——原生 hidden 属性在部分场景下会被 CSS 覆盖,这里显式兜底,确保返回按钮默认不可见。

五、JavaScript 驱动函数详解

这是整个示例的灵魂。完整代码(原文位于 plain_analytics_js.md):

// Plain HTML mode: `container` is the widget's DOM; ECharts comes from the CDN.
const echarts = window.echarts;
const el = (sel) => container.querySelector(sel);
const pad = (n) => String(n).padStart(2, '0');
const isoDate = (d) => d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
const dayLabel = (d) => d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
const minutes = (v) => {
  const [h, m] = (v || '0:0').split(':').map(Number);
  return h * 60 + m;
};
const palette = ['#2563eb', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#0ea5e9'];

const chart = echarts.init(el('#vcChart'));
chart.setOption({
  tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
  legend: { top: 0 },
  grid: { left: 8, right: 16, top: 40, bottom: 8, containLabel: true },
  xAxis: { type: 'category', data: [], axisLabel: { hideOverlap: true } },
  yAxis: { type: 'value' },
  series: [],
});
const resizeObserver = new window.ResizeObserver(() => chart.resize());
resizeObserver.observe(el('#vcChart'));

let lastEntries = [];
let mode = 'daily';
let selectedDate = null;
let dailyDates = []; // iso date per daily x-index, for drill-down

// Click a day → drill into its hourly breakdown.
chart.on('click', (p) => {
  if (mode !== 'daily' || p.dataIndex == null || dailyDates[p.dataIndex] == null) return;
  selectedDate = dailyDates[p.dataIndex];
  mode = 'hourly';
  el('#vcBack').hidden = false;
  recompute();
});
el('#vcBack').addEventListener('click', () => {
  mode = 'daily';
  selectedDate = null;
  el('#vcBack').hidden = true;
  recompute();
});

// One stacked series per device, summing values into the given bucket index.
function buildSeries(labelCount, bucketOf) {
  return lastEntries.map((entry, k) => {
    const arr = new Array(labelCount).fill(0);
    (entry.data || []).forEach(([ts, value]) => {
      const i = bucketOf(new Date(Number(ts)));
      if (i != null && i >= 0) arr[i] += Number(value);
    });
    return { name: entry.datasource.entityName, type: 'bar', stack: 'v', data: arr, itemStyle: { color: palette[k % palette.length] } };
  });
}

function recompute() {
  const open = minutes(el('#vcOpen').value);
  const close = minutes(el('#vcClose').value);
  const inHours = (d) => {
    const m = d.getHours() * 60 + d.getMinutes();
    return m >= open && m < close;
  };

  // KPIs over the whole range (per day, summed across devices).
  const start = new Date(el('#vcStart').value + 'T00:00:00');
  const end = new Date(el('#vcEnd').value + 'T00:00:00');
  const dayIndex = {};
  const dayLabels = [];
  dailyDates = [];
  for (let t = new Date(start); t <= end; t.setDate(t.getDate() + 1)) {
    dayIndex[isoDate(t)] = dayLabels.length;
    dailyDates.push(isoDate(t));
    dayLabels.push(dayLabel(t));
  }
  const dayTotals = dayLabels.map(() => 0);
  const perHour = {};
  let total = 0;
  lastEntries.forEach((entry) =>
    (entry.data || []).forEach(([ts, value]) => {
      const d = new Date(Number(ts));
      if (!inHours(d)) return;
      const i = dayIndex[isoDate(d)];
      if (i === undefined) return;
      const v = Number(value);
      dayTotals[i] += v;
      perHour[d.getHours()] = (perHour[d.getHours()] || 0) + v;
      total += v;
    }),
  );
  el('#vcTotal').textContent = total;
  el('#vcPeakDay').textContent = total ? dayLabels[dayTotals.indexOf(Math.max(...dayTotals))] : '—';
  const ph = Object.keys(perHour).sort((a, b) => perHour[b] - perHour[a])[0];
  el('#vcPeakHour').textContent = ph != null ? ph + ':00' : '—';
  el('#vcAvg').textContent = Math.round(total / (dayTotals.filter((x) => x > 0).length || 1));

  // Chart: daily (stacked by device) or the hourly breakdown of the selected day.
  if (mode === 'daily') {
    el('#vcChartTitle').textContent = 'Visitors by day';
    const series = buildSeries(dayLabels.length, (d) => (inHours(d) ? dayIndex[isoDate(d)] : null));
    chart.setOption({ xAxis: { data: dayLabels }, series }, { replaceMerge: ['series'] });
  } else {
    const h0 = Math.floor(open / 60);
    const h1 = Math.ceil(close / 60);
    const labels = [];
    for (let h = h0; h < h1; h++) labels.push(pad(h) + ':00');
    el('#vcChartTitle').textContent = 'Visitors by hour — ' + dayLabel(new Date(selectedDate + 'T00:00:00'));
    const series = buildSeries(labels.length, (d) =>
      isoDate(d) === selectedDate && inHours(d) ? d.getHours() - h0 : null,
    );
    chart.setOption({ xAxis: { data: labels }, series }, { replaceMerge: ['series'] });
  }
}

// (Re)subscribe for the chosen date range (a fixed history window).
let subscriptionId = null;
function subscribe() {
  if (subscriptionId != null) {
    ctx.subscriptionApi.removeSubscription(subscriptionId);
    subscriptionId = null;
  }
  const startTimeMs = new Date(el('#vcStart').value + 'T00:00:00').getTime();
  const endTimeMs = new Date(el('#vcEnd').value + 'T23:59:59').getTime();
  const subOpts = {
    type: 'timeseries',
    useDashboardTimewindow: false,
    timeWindowConfig: {
      selectedTab: 1,
      history: { historyType: 1, fixedTimewindow: { startTimeMs, endTimeMs } },
      aggregation: { type: 'NONE', limit: 50000 },
    },
    datasources: [
      {
        type: 'entity',
        entityFilter: { type: 'deviceType', deviceTypes: ['peopleCount'], deviceNameFilter: '', resolveMultiple: true },
        dataKeys: [{ type: 'timeseries', name: 'peopleCount', settings: {} }],
      },
    ],
    callbacks: {
      onDataUpdated: (subscription) => {
        lastEntries = subscription.data || [];
        recompute();
      },
      onDataUpdateError: (subscription, e) => console.error(e),
    },
  };
  ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => {
    subscriptionId = subscription.id;
  });
}

// Changing the range resets the drill-down and re-subscribes; hours just re-filter.
function onRangeChange() {
  mode = 'daily';
  selectedDate = null;
  el('#vcBack').hidden = true;
  subscribe();
}

// Defaults: the current month (1st → today).
const now = new Date();
el('#vcEnd').value = isoDate(now);
el('#vcStart').value = isoDate(new Date(now.getFullYear(), now.getMonth(), 1));

el('#vcStart').addEventListener('change', onRangeChange);
el('#vcEnd').addEventListener('change', onRangeChange);
el('#vcOpen').addEventListener('change', recompute);
el('#vcClose').addEventListener('change', recompute);
subscribe();

// The JS re-runs on every reload — release the subscription, observer and chart.
ctx.registerDestroyCallback(() => {
  if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId);
  resizeObserver.disconnect();
  chart.dispose();
});

下面按逻辑分层解读。

5.1 工具函数与图表初始化

  • echarts 取自 window.echarts——因为资源是通过 CDN 以普通 <script> 方式加载的,会挂到 window 上(详见第六节);
  • el(sel) 是对 container.querySelector 的封装,之后所有 DOM 操作都通过它完成;
  • isoDate / dayLabel / minutes 分别用于格式化日期、生成短标签、把 "HH:mm" 转成分钟数;
  • palette 定义了 6 色序列,用于按设备区分堆叠柱状图的颜色;
  • 图表初始化后即注册 ResizeObserver 监听 #vcChart,保证 widget 尺寸变化(仪表板拉伸、全屏等)时自动 chart.resize()。

5.2 KPI 计算与营业时段过滤

recompute() 是核心计算函数:

  • 先读入 #vcOpen / #vcClose,构造 inHours(d) 判断时间戳是否落在营业时段内(闭区间 [open, close),即打烊那一刻不计入);
  • 遍历 lastEntries(即订阅回调里收到的所有设备数据),把每条 [ts, value] 数据点按日期归桶:
    • 累加出 dayTotals(按天、跨设备汇总)、perHour(按小时汇总)和 total(总访客);
    • 营业时段之外的记录直接 return 跳过;
  • 四个 KPI 依次写入 DOM:#vcTotal 直接显示总数;#vcPeakDay 取 dayTotals 最大值的索引对应日期标签;#vcPeakHour 取 perHour 中计数最高的整点;#vcAvg 用 总访客 / 有数据的天数 并四舍五入。当 total 为 0 时峰值显示 —,避免 Math.max(...[]) 出现 -Infinity。

5.3 堆叠柱状图与日→小时下钻

buildSeries(labelCount, bucketOf) 为每个设备生成一个 type: 'bar', stack: 'v' 的堆叠系列:

  • entry 对应订阅结果中的一条数据源,entry.datasource.entityName 即设备名,用作图例与系列名;
  • 每个系列先初始化为全 0 数组,再逐点调用 bucketOf(时间) 得到目标桶下标并累加;
  • 颜色按 palette[k % palette.length] 循环取色。

图表交互分为两种模式(变量 mode 控制):

  • daily(默认):X 轴为日期,柱按设备堆叠;点击任一柱子(chart.on('click'))后记录 selectedDate、切换到 hourly、显示 #vcBack 按钮并重算;
  • hourly:X 轴为 #vcOpen 到 #vcClose 覆盖的小时区间(h0 = floor(open/60) 到 h1 = ceil(close/60),例如 09:00–18:00 即 09:00…18:00),bucketOf 只接受 isoDate === selectedDate 且处于营业时段的点,用 d.getHours() - h0 作为桶下标;标题变为 “Visitors by hour — <日期>”。

返回按钮则把 mode 重置回 daily、隐藏自身并重算。setOption(..., { replaceMerge: ['series'] }) 是关键技巧——只替换系列数组而不重建坐标系,避免图例闪烁。

5.4 数据订阅:不依赖 widget 自动绑定

这是 Plain 模式最需要注意的地方。参考 container_plain_fn.md 的明确说明:此类组件不会自动绑定数据源,ctx.data 不会自动填充,必须通过 ctx.subscriptionApi.createSubscription(...) 手动创建订阅。

示例中的 subscribe() 做了几件事:

  1. 若已有 subscriptionId,先 ctx.subscriptionApi.removeSubscription(subscriptionId) 释放旧订阅;
  2. 构造订阅选项 subOpts:
    • type: 'timeseries' 时序数据;
    • useDashboardTimewindow: false——忽略仪表板右上角的时间窗口,改用自己定义的历史窗口(这正是日期选择控件能独立生效的原因);
    • timeWindowConfig.history.fixedTimewindow 指定 startTimeMs/endTimeMs(#vcEnd 那天取到 T23:59:59,保证当天完整);
    • aggregation: { type: 'NONE', limit: 50000 }——不聚合、最多取 50000 条原始数据点,确保 KPI 计算准确;
    • datasources 中 entityFilter 使用 deviceType: 'peopleCount' + resolveMultiple: true,即按设备类型筛选出所有 peopleCount 设备,dataKeys 订阅 peopleCount 时序键;
  3. callbacks.onDataUpdated 把 subscription.data 存到 lastEntries 并触发 recompute();onDataUpdateError 打印错误;
  4. createSubscription(...).subscribe(...) 异步返回订阅对象,回调里记住 subscriptionId 以便后续释放。

5.5 事件绑定、默认值与销毁

  • 默认范围是本月 1 号到今天,页面打开即回填两个日期输入框;
  • #vcStart / #vcEnd 的 change 触发 onRangeChange()——重置下钻状态并重新订阅;#vcOpen / #vcClose 的 change 只触发 recompute()——只过滤不重新订阅,这是对“历史数据固定、展示口径可变”场景的极佳优化;
  • 由于 JS 函数在每次 widget 重载时都会重新执行,最后通过 ctx.registerDestroyCallback 释放三样东西:数据订阅、ResizeObserver、ECharts 实例。凡是自行创建的订阅、定时器、全局事件监听器,都必须在销毁回调里清理,否则会造成内存泄漏或重复订阅。

六、Resources 配置:加载 ECharts

由于 Plain 模式不能使用 Angular 模块,第三方库通过 CDN 脚本引入(原文见 plain_analytics_resources.md),在 widget 编辑界面的 Resources(资源) 标签页添加以下 JavaScript URL:

https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js

添加后,组件会在你的 JS 函数执行之前加载该脚本并暴露 echarts 全局变量(即代码中的 window.echarts)。

从源码看,这一机制由 html-container-widget.component.ts 的 loadWidgetResources() 实现:组件在 ngOnInit 里先通过 ResourcesService.loadResource(url) 加载所有 isModule = false 的资源,全部成功后才进入 initPlain() 初始化流程,保证第三方库一定先于业务 JS 就绪。


七、容器函数签名与 WidgetContext

Plain 模式的容器函数签名为:

function (ctx, container): void

官方帮助文档(container_plain_fn.md)对两个参数的说明如下:

  • ctx:WidgetContext 类型,包含 widget 实例所需的全部 API 与数据,可发送 RPC、切换仪表板状态、创建数据订阅、读取设置并访问平台服务。类型定义可查看 widget-component.models.ts;
  • container:HTMLElement,即 widget 内容元素(源码模板中的 <div class="tb-absolute-fill">),需要用 container.querySelector(...) 查询内部节点。

函数不返回任何值。这也是它在编辑器里的形态——settings 面板中 JavaScript 标签页的 <a href="https://link.gitcode.com/i/a5d81a6fba07243ede4126d806f29aef" target="_blank">functionArgs] 就是 ['ctx', 'container'](见 [html-container-settings.component.html)。


八、底层执行流程(源码视角)

把整条链路串起来,Plain 模式组件从配置到渲染的实际流程如下(对应 html-container-widget.component.ts):

  1. ngOnInit 合并默认设置与 ctx.settings,先调用 loadWidgetResources() 加载 CDN 资源;
  2. 加载成功后进入 initPlain():
    • 若配置了 CSS,用 cssjs 解析器对 CSS 做 hash 命名空间化(html-container-<hash>),创建独立 <style> 元素并挂到容器上,避免样式污染仪表板其他部分;
    • 若配置了 HTML,通过 $(containerElmRef.nativeElement).html(settings.html) 注入模板;
  3. compileAndExecutePlainFunction() 用 parseTbFunction(ctx.http, settings.js, ['ctx', 'container']) 编译 JS 函数并执行——也就是本文第五节那段代码真正开始运行的地方;
  4. 执行过程中任何异常都会进入 handleWidgetException(),在 widget 区域显示错误信息而不是让整个仪表板崩溃。

九、实战要点总结

把官方示例与源码结合,可以得到几条可直接复用的经验:

  1. 显式订阅:Plain 模式不会自动注入 ctx.data,必须用 ctx.subscriptionApi.createSubscription() 手动订阅;想让日期控件独立于仪表板时间窗口,记得设 useDashboardTimewindow: false。
  2. 区分“数据口径”与“展示口径”:日期变化需要重新订阅(因为要拉不同区间的历史数据),营业时段变化只需重算图表——这种拆分能显著减少不必要的网络请求。
  3. 聚合与上限:示例对原始数据用 aggregation: { type: 'NONE', limit: 50000 },保证 KPI 精确;数据量大时可换用 AVG/SUM 等聚合类型以提升性能。
  4. 完整清理:订阅、ResizeObserver、图表实例以及任何自定义事件都要在 ctx.registerDestroyCallback 中释放。
  5. 样式隔离:Plain 模式 CSS 会被自动命名空间化,但仍建议使用 vc__ 这类带前缀的 BEM 式类名,避免与仪表板既有样式冲突。
  6. 资源先行:CDN 脚本务必填在 Resources 标签页,组件保证它们在业务 JS 之前加载完成。

这套 “HTML + CSS + JS + CDN 资源” 的组合,是 ThingsBoard 中构建定制分析面板、数据可视化看板与自助报表最灵活的路径之一。你可以在此基础上把 peopleCount 换成任意设备类型与数据键,把 ECharts 换成 D3、Three.js 等任何可通过 CDN 引入的库,快速扩展出属于自己的 HTML Container 组件。

登录后查看全文
thingsboard