ThingsBoard HTML 容器控件看板实战:Plain 模式下用 CSS + SortableJS 打造可拖拽的设备状态看板
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 完整流程串起来
- 控件初始化 → Resources 加载 SortableJS;
- Plain 模式函数收到
(ctx, container),从container找到#board; createSubscription订阅machine类型设备的status属性;- 数据到达 →
onDataUpdated更新devices→render()重建三列卡片并挂上 Sortable; - 用户拖拽卡片到其他列 →
onAdd触发 → 本地状态更新 + 属性写回; - 控件销毁 → 销毁回调移除订阅。
七、配置到 HTML 容器控件的操作步骤
- 在仪表盘(Dashboard)中添加 HTML Container 类型控件(Widget),进入其编辑/配置界面;
- 在 HTML 页签粘贴
<div class="kanban" id="board"></div>; - 在 CSS 页签粘贴 plain_kanban_css.md 中的样式(不含
{:copy-code}修饰符行); - 在 JavaScript 页签粘贴 plain_kanban_js.md 中的完整函数代码;
- 在 Resources 页签添加
https://cdn.jsdelivr.net/npm/sortablejs@1/Sortable.min.js; - 保存并确认:仓库中应存在类型为
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。