ThingsBoard HTML 容器控件看板实战:Plain 模式下用 CSS + SortableJS 打造可拖拽的设备状态看板

原创2026-10-03 18:45:09476 阅读
文章标签:物联网后端数据可视化消息队列

ThingsBoard HTML 容器控件看板实战:Plain 模式下用 CSS + SortableJS 打造可拖拽的设备状态看板

导读

本文聚焦 ThingsBoard HTML 容器控件(HTML Container Widget)中的一组官方示例,以其中最有代表性的 “纯 HTML 看板(Plain Kanban Board)” 为例,完整讲解如何在 HTML 容器控件的 CSS、HTML、JavaScript 与 Resources 四个配置页签中编写代码,把一个设备列表渲染成“待机 / 运行 / 维护”三列可拖拽看板,并将拖拽后的新状态实时写回设备属性。读完本文你将掌握:HTML 容器控件的四段式配置结构、CSS 修饰器({:copy-code})与资源加载机制、基于 ctx.subscriptionApi 的实时数据订阅与销毁清理,以及利用 SortableJS 实现拖拽交互并持久化属性的完整实战方案。

一、示例文件在仓库中的位置与整体结构

本示例由四份相互配套的文档组成,位于 ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/ 目录下:

文件 对应控件配置页签 职责
plain_kanban_css.md CSS 看板的全部样式(Flex 布局、卡片、状态圆点、拖拽态)
plain_kanban_html.md HTML 看板挂载点 <div class="kanban" id="board"></div>
plain_kanban_js.md JavaScript 数据订阅、渲染逻辑与拖拽回调
plain_kanban_resources.md Resources 引入 SortableJS 的 CDN 地址

这些 .md 文件本身存放在仓库帮助资源目录下,作为 HTML 容器控件内置帮助面板的内容被加载展示;其内的代码块带有 {:copy-code} 修饰符,用户可以直接一键复制到控件配置中。之所以命名为 “plain”,对应的是 HTML 容器控件的 PLAIN 模式(非 Angular 模式),即由开发者直接书写原生 HTML/CSS/JS。

二、HTML 容器控件的运行机制(源码视角)

在动手配置之前,先理解该控件在前端是如何把“HTML + CSS + JS + Resources”四段代码组装起来的,这决定了我们写代码时的约束与可用能力。

从源码 html-container-widget.component.ts 可以看到:

  • 控件读取设置项后,根据 settings.type 分流:HtmlContainerWidgetType.PLAIN 走 initPlain(),ANGULAR 走 initAngular()(见 html-container-widget.models.ts 中 HtmlContainerWidgetType 枚举与 htmlContainerDefaultSettings 默认值,默认类型即 PLAIN)。
  • initPlain() 的加载顺序是:先通过 cssjs 解析 CSS 并注入 <style>(以 'html-container-' + hashCode(settings.css) 作为命名空间,避免样式污染),再把 HTML 写入容器 DOM,最后调用 compileAndExecutePlainFunction() 编译执行 JS。
  • Plain 模式的函数签名是 (ctx: WidgetContext, container: HTMLElement) => void(WidgetContainerPlainFunction,见 models 文件第 30 行),所以 JS 里可以直接拿到 ctx(控件上下文)和 container(控件容器 DOM 元素)两个入参——这正是示例代码中 ctx.attributeService、ctx.subscriptionApi 与 container.querySelector('#board') 的由来。
  • Resources 中的普通资源(!isModule)会在 JS 执行前由 loadWidgetResources() 先行加载完成(forkJoin 聚合,任一失败会抛出错误),因此示例中通过 Resources 加载的 SortableJS 才能在函数运行时以 window.Sortable 形式直接可用。

也就是说,本文要写的看板属于“原生 JS 应用 + 控件数据能力”的组合:渲染和交互交给开发者自己的代码,数据订阅与写回交给 ThingsBoard 的 ctx API。

三、Resources 页签:加载 SortableJS

看板拖拽依赖第三方库 SortableJS。在控件配置的 Resources 页签中添加以下 JavaScript 资源地址:

https://cdn.jsdelivr.net/npm/sortablejs@1/Sortable.min.js

该资源会在函数运行前被加载,加载成功后向 window 注入全局对象 Sortable,因此 JS 代码中可以直接使用 window.Sortable(或简写为 Sortable)。示例中这一句注释说明了其语义:

const Sortable = window.Sortable; // loaded from the CDN added under Resources

需要说明的是:资源加载本身发生在控件初始化阶段(见 loadWidgetResources()),若 URL 不可达,控件会抛出 Failed to load widget resource: '<url>' 之类的错误并中断初始化,因此请确保该 CDN 地址在目标运行环境中可访问。

四、HTML 页签:定义看板挂载点

HTML 页签只需提供一个空容器作为看板的挂载点:

<div class="kanban" id="board"></div>

所有列、卡片、状态圆点均由 JavaScript 动态生成并写入 #board 内部。之所以只留一个空壳,是因为看板结构依赖实时设备数据,静态 HTML 无法预知卡片数量与分布。

五、CSS 页签:完整看板样式与逐段拆解

CSS 页签是本文的绝对主体,源码见 plain_kanban_css.md。完整代码如下:

.kanban {
  display: flex;
  gap: 12px;
  height: 100%;
  box-sizing: border-box;
  padding: 16px;
  overflow-x: auto;
  background: #f9fafb;
  font-family: 'Inter', 'Roboto', system-ui, sans-serif;
  color: #111827;
  font-size: 13px;
}
.kanban__col { flex: 1 1 0; min-width: 220px; display: flex; flex-direction: column; background: #f3f4f6; border-radius: 10px; }
.kanban__head { display: flex; align-items: center; gap: 8px; padding: 12px 14px 8px; }
.kanban__title { font-size: 12px; font-weight: 600; color: #374151; }
.kanban__count { margin-left: auto; min-width: 20px; height: 20px; padding: 0 6px; display: inline-flex; align-items: center; justify-content: center; border-radius: 999px; background: #e5e7eb; color: #6b7280; font-size: 11px; font-weight: 600; }
.kanban__cards { display: flex; flex-direction: column; gap: 8px; padding: 4px 8px 12px; flex: 1; min-height: 24px; overflow-y: auto; }
.kanban__card {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 10px 12px;
  background: #fff;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  cursor: grab;
  transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.kanban__card:hover { border-color: #d1d5db; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); }
.kanban__name { font-weight: 500; color: #111827; }
.kanban__dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.kanban__dot--idle { background: #9ca3af; }
.kanban__dot--active { background: #22c55e; }
.kanban__dot--maintenance { background: #f59e0b; }
.kanban__card.sortable-ghost { opacity: 0; }
.kanban__card.sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); }

5.1 整体容器 .kanban

属性 取值 作用
display: flex — 三列横向排布
gap: 12px — 列与列之间的间距
height: 100% — 撑满控件可用高度
padding: 16px — 看板四周内边距
overflow-x: auto — 列数较多时横向滚动
background: #f9fafb 浅灰 看板底色
font-family Inter/Roboto/system-ui 字体栈,跟随平台回退
color: #111827 深灰 默认文字色
font-size: 13px — 基础字号

5.2 列、列头与计数徽章

  • .kanban__col:flex: 1 1 0 让三列等分宽度;min-width: 220px 保证列在窗口变窄时不至于挤压过小,配合父级 overflow-x: auto 触发横向滚动;flex-direction: column 让列头在上、卡片区在下;浅灰背景 #f3f4f6 与圆角 10px 构成列容器。
  • .kanban__head:列头采用 flex 水平布局,gap: 8px 分隔状态圆点、标题与计数;padding: 12px 14px 8px 上宽下窄。
  • .kanban__title:12px、font-weight: 600、颜色 #374151,用于列名(Idle / Active / Maintenance)。
  • .kanban__count:通过 margin-left: auto 推到列头最右端;border-radius: 999px 形成胶囊徽章;min-width: 20px + padding: 0 6px 保证个位/两位数都居中显示;底色 #e5e7eb、文字 #6b7280。

5.3 卡片列表区与卡片

  • .kanban__cards:纵向 flex 列表,gap: 8px 分隔卡片;flex: 1 撑满剩余高度;min-height: 24px 保证空列仍有可拖放的目标区域;overflow-y: auto 使列内卡片过多时可滚动。
  • .kanban__card:白底、1px solid #e5e7eb 边框、8px 圆角;cursor: grab 提示可拖拽;transition 让 hover 态变化平滑(0.12s 的 border-color 与 box-shadow)。
  • .kanban__card:hover:边框加深为 #d1d5db,并叠加轻量阴影 0 1px 2px rgba(0,0,0,0.05)。

5.4 状态圆点 .kanban__dot

8px 圆形指示符,三种状态三套配色,与 JS 中 statuses = ['idle', 'active', 'maintenance'] 一一对应:

修饰类 颜色 状态语义
.kanban__dot--idle #9ca3af(灰) 待机
.kanban__dot--active #22c55e(绿) 运行
.kanban__dot--maintenance #f59e0b(橙) 维护

5.5 SortableJS 拖拽态样式

  • .kanban__card.sortable-ghost:拖拽时原位置的“幽灵占位”设为 opacity: 0,视觉上直接从原列消失。
  • .kanban__card.sortable-chosen:被拖起的卡片叠加 0 4px 12px rgba(0,0,0,0.12) 阴影,形成“提起”的层级感。

5.6 代码块修饰符说明

原文档代码块使用了 ThingsBoard 帮助系统的两个修饰符:

  • {:code-style="max-height: 400px;"}:帮助面板中限制代码块最大高度为 400px,超出部分滚动,避免示例过长撑爆面板。
  • {:copy-code}:在代码块右上角渲染“复制”按钮,方便用户一键复制到控件配置。

这两个修饰符属于帮助资源的展示层语法,粘贴到控件 CSS 页签时不需要包含,只复制其中的纯 CSS 即可。

六、JavaScript 页签:数据订阅、渲染与拖拽持久化

JS 页签是看板的“大脑”,完整代码见 plain_kanban_js.md。其整体流程为:定义状态与标签 → 定义渲染函数 → 通过订阅 API 获取实时设备数据 → 拖拽回调中写回属性 → 注册销毁回调清理订阅。

6.1 状态定义与安全转义

const Sortable = window.Sortable; // loaded from the CDN added under Resources
const statuses = ['idle', 'active', 'maintenance'];
const labels = { idle: 'Idle', active: 'Active', maintenance: 'Maintenance' };

const board = container.querySelector('#board');
let devices = []; // { id, entityType, name, status }

// Escape untrusted strings (e.g. device names) before putting them in innerHTML.
const esc = (s) =>
  String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
  • window.Sortable:依赖 Resources 页签加载的 SortableJS。
  • statuses / labels:三状态枚举与展示名,贯穿渲染、圆点配色与拖拽写回。
  • esc():对设备名等不可信字符串做 HTML 转义后再拼接进 innerHTML,防止设备名称携带特殊字符破坏 DOM 或注入脚本,是原生 JS 渲染时必备的安全实践。

6.2 渲染函数 render()

function render() {
  board.innerHTML = statuses
    .map((s) => {
      const items = devices.filter((d) => d.status === s);
      const cards = items
        .map(
          (d) =>
            `<div class="kanban__card" data-id="${esc(d.id)}" data-type="${esc(d.entityType)}"><span class="kanban__dot kanban__dot--${s}"></span><span class="kanban__name">${esc(d.name)}</span></div>`,
        )
        .join('');
      return `<section class="kanban__col">
        <header class="kanban__head"><span class="kanban__dot kanban__dot--${s}"></span><span class="kanban__title">${labels[s]}</span><span class="kanban__count">${items.length}</span></header>
        <div class="kanban__cards" data-status="${s}">${cards}</div>
      </section>`;
    })
    .join('');

  // Make each column a Sortable list; cards share one group so they move between columns.
  board.querySelectorAll('.kanban__cards').forEach((listEl) => {
    new Sortable(listEl, {
      group: 'kanban',
      animation: 150,
      onAdd: (evt) => { ... },
    });
  });
}

要点:

  • 每次数据更新都全量重建 innerHTML,以 devices 数组为唯一数据源,保证 UI 与数据一致;
  • 卡片通过 data-id、data-type 携带实体标识,列容器通过 data-status 携带目标状态,供拖拽回调读取;
  • 每个列的 .kanban__cards 都实例化一个 Sortable,group: 'kanban' 让三列共享同一分组,从而支持跨列拖拽;animation: 150 提供 150ms 的平滑过渡动画。

6.3 实时数据订阅:subscriptionApi

const subOpts = {
  type: 'latest',
  datasources: [
    {
      type: 'entity',
      entityFilter: { type: 'deviceType', deviceTypes: ['machine'], deviceNameFilter: '', resolveMultiple: true },
      dataKeys: [{ type: 'attribute', name: 'status', settings: {} }],
    },
  ],
  callbacks: {
    onDataUpdated: (subscription) => {
      devices = (subscription.data || []).map((entry) => {
        const ds = entry.datasource;
        const last = entry.data && entry.data.length ? entry.data[entry.data.length - 1] : null;
        const status = last ? String(last[1]) : 'idle';
        return { id: ds.entityId, entityType: ds.entityType, name: ds.entityName, status: statuses.includes(status) ? status : 'idle' };
      });
      render();
    },
    onDataUpdateError: (subscription, e) => console.error(e),
  },
};
  • type: 'latest':订阅最新遥测/属性值,数据更新即回调;
  • entityFilter:deviceType 过滤,deviceTypes: ['machine'] 拉取所有类型为 machine 的设备,resolveMultiple: true 支持多实体返回;
  • dataKeys:只订阅 status 属性;
  • onDataUpdated:把订阅数据映射为 { id, entityType, name, status } 数组;取 entry.data 最后一组值(last[1])作为属性值;状态不在合法枚举内时兜底为 'idle';
  • onDataUpdateError:出错时打印到控制台,便于排查。

6.4 订阅生命周期与销毁清理

let subscriptionId = null;
ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => {
  subscriptionId = subscription.id;
});
ctx.registerDestroyCallback(() => {
  if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId);
});
  • JS 函数在控件每次重新加载(如刷新数据、重新渲染)时都会重新执行,因此不能重复叠加订阅;
  • createSubscription(subOpts, true) 创建订阅并拿到 subscription.id;
  • 通过 ctx.registerDestroyCallback() 注册销毁回调,在控件销毁时调用 removeSubscription 释放订阅,避免内存泄漏与无效请求。

6.5 拖拽写回:attributeService

onAdd: (evt) => {
  const id = evt.item.dataset.id;
  const entityType = evt.item.dataset.type;
  const status = evt.to.dataset.status; // destination column
  const dev = devices.find((d) => d.id === id);
  if (dev) dev.status = status;
  ctx.attributeService
    .saveEntityAttributes({ id, entityType }, 'SERVER_SCOPE', [{ key: 'status', value: status }])
    .subscribe();
},
  • evt.item 是被拖动的卡片,evt.to 是目标列容器;
  • 读取 data-id、data-type 与目标列 data-status,先同步更新本地 devices 中的状态,再通过 ctx.attributeService.saveEntityAttributes(entityId, 'SERVER_SCOPE', [{key: 'status', value: status}]) 把新状态持久化到设备属性(服务端作用域)。这样看板刷新后状态仍能保持,且其他订阅该属性的控件/界面会同步收到更新。

6.6 完整流程串起来

  1. 控件初始化 → Resources 加载 SortableJS;
  2. Plain 模式函数收到 (ctx, container),从 container 找到 #board;
  3. createSubscription 订阅 machine 类型设备的 status 属性;
  4. 数据到达 → onDataUpdated 更新 devices → render() 重建三列卡片并挂上 Sortable;
  5. 用户拖拽卡片到其他列 → onAdd 触发 → 本地状态更新 + 属性写回;
  6. 控件销毁 → 销毁回调移除订阅。

七、配置到 HTML 容器控件的操作步骤

  1. 在仪表盘(Dashboard)中添加 HTML Container 类型控件(Widget),进入其编辑/配置界面;
  2. 在 HTML 页签粘贴 <div class="kanban" id="board"></div>;
  3. 在 CSS 页签粘贴 plain_kanban_css.md 中的样式(不含 {:copy-code} 修饰符行);
  4. 在 JavaScript 页签粘贴 plain_kanban_js.md 中的完整函数代码;
  5. 在 Resources 页签添加 https://cdn.jsdelivr.net/npm/sortablejs@1/Sortable.min.js;
  6. 保存并确认:仓库中应存在类型为 machine、带 status 属性的设备,否则看板会呈现三个空列。

八、注意事项与延伸

  • 模式选择:以上代码基于 PLAIN 模式(默认)。若切换到 Angular 模式(HtmlContainerWidgetType.ANGULAR),函数签名变为仅 (ctx),且 HTML 会成为动态组件模板,写法与示例完全不同,不要混用两套代码。
  • 数据实体:订阅逻辑硬编码了 deviceTypes: ['machine'] 与属性名 status,实际使用时应改为自己场景中的设备类型与属性键。
  • 渲染策略:示例采用全量重建 innerHTML 的简单策略,适合卡片规模不大的场景;设备量大时可考虑增量 DOM 更新。
  • 安全性:所有插入 innerHTML 的外部字符串都应经过 esc() 转义;若需在卡片内展示更多字段,按同样方式扩展 render() 即可。
  • 依赖可用性:SortableJS 通过 CDN 加载,离线或内网环境需自行将资源部署到可访问的地址,并同步更新 Resources 中的 URL。
  • 进一步探索:同目录下另有 plain_analytics_*(分析类示例)与 angular_*(Angular 模式示例,含 tabs、drawer、split 等组件),可用于横向对比两种模式与更多自定义 HTML 控件的写法;控件运行机制的完整实现见 html-container-widget.component.ts。
登录后查看全文
thingsboard