ThingsBoard HTML Container 组件实战:用 Angular 模板与 Tab 导航构建复合仪表盘

原创2026-10-03 11:53:131,703 阅读
文章标签:物联网后端数据可视化微服务消息队列

ThingsBoard HTML Container 组件实战:用 Angular 模板与 Tab 导航构建复合仪表盘

本文围绕 ThingsBoard 官方 HTML Container 组件库中"带 Tab 导航的复合仪表盘"示例展开,该示例由三部分组成——HTML 模板(angular_tabs_html.md)、JavaScript 容器函数(angular_tabs_js.md)与 CSS(angular_tabs_css.md)。读完本文,你将掌握如何在 HTML Container 组件(Angular 模式)中编写自定义 Angular 模板、通过 tb-dashboard-state 嵌入其他仪表盘状态,并借助 ctx.stateController 实现跨状态切换,最终在单块仪表盘内拼装出多页签的"复合仪表盘"实战方案。

HTML Container 组件:Angular 模式与 Plain 模式

HTML Container 是 ThingsBoard 仪表盘组件库中用于承载"自定义 HTML/CSS/JavaScript"的容器型组件。从源码看,html-container-widget.component.ts 在初始化时会根据设置中的 type 字段分派两条渲染路径:

  • PLAIN 模式:将设置中的 HTML 直接写入容器,CSS 通过 cssjs 解析并注入带命名空间的 <style> 元素,JavaScript 函数以 function (ctx, container) 形式执行,container 为实际 DOM 根元素(对应 container_plain_fn.md)。
  • ANGULAR 模式:将设置中的 HTML 当作 Angular 模板动态编译,JavaScript 函数以 function (ctx) 形式执行,并通过 this 向模板暴露数据与事件处理器(对应 container_angular_fn.md)。

组件设置面板(html-container-settings.component.html)顶部提供了 PLAIN / ANGULAR 切换开关,下方依次是 Resources、CSS、HTML、JavaScript 四个编辑区。选择 Angular 模式后,HTML 标签页标题会变为 "Angular HTML template",且 JavaScript 函数的参数自动收敛为单个 ctx(见 html-container-settings.component.html)。

我们讨论的 Tab 导航示例正是基于 Angular 模式构建,这也是它能够直接使用 *ngFor、[class.is-active] 等 Angular 指令的前提。

示例全景:Tab 导航复合仪表盘的三块拼图

该示例在帮助中心以三个可独立复制的代码片段呈现(仓库中位于 ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/ 目录):

文件 内容 作用
angular_tabs_html.md HTML 模板 定义页签按钮、Toast 容器与内嵌仪表盘状态
angular_tabs_js.md JavaScript 函数 定义 tab 数据、维护 activeTab / stateId、响应状态变更
angular_tabs_css.md CSS 页签栏、激活态与内嵌区域的样式

三者通过 this 绑定相互衔接:JS 函数把 tabs、activeTab、selectTab 挂到 this 上,HTML 模板直接以无前缀形式引用(container_angular_fn.md 明确说明:"Reference them in the template without the this prefix")。CSS 则负责让这套结构呈现为视觉上的页签组件。

HTML 模板逐段解析(示例核心)

示例 HTML 模板完整代码如下(原样来自 angular_tabs_html.md):

<div class="composite">
  <nav class="tabs">
    <button *ngFor="let tab of tabs"
            class="tab"
            [class.is-active]="tab.id === activeTab"
            (click)="selectTab(tab.id)">
      {{ tab.label }}
    </button>
  </nav>
  <div tb-toast toastTarget="layout" class="container">
    <tb-dashboard-state class="state" [ctx]="ctx" [stateId]="stateId" [syncParentStateParams]="true"></tb-dashboard-state>
  </div>
</div>

页签导航栏

<nav class="tabs"> 中通过 *ngFor="let tab of tabs" 遍历 JS 侧定义的 tab 数组,为每个 tab 渲染一个按钮:

  • [class.is-active]="tab.id === activeTab":当 tab 的 id 等于当前激活 tab 的 id 时,为按钮追加 is-active 类,配合 CSS 实现激活态高亮;
  • (click)="selectTab(tab.id)":点击时调用 JS 侧暴露的 selectTab 处理器,传入该 tab 的 id;
  • {{ tab.label }}:显示页签文案。

tb-toast:把通知消息锁定在组件内部

<div tb-toast toastTarget="layout" class="container"> 使用了 ThingsBoard 的 tb-toast 指令。从 toast.directive.ts 的实现看,该指令接收 toastTarget 输入(默认值为 'root'),并订阅 ToastNotificationService 的消息流:

  • shouldDisplayMessage()(toast.directive.ts)会比较通知消息的 target 与指令的 toastTarget,只有两者一致时才在本容器内展示;
  • 在宽屏(gt-sm)且 toastTarget !== 'root' 时,指令会以内嵌 Toast 面板形式将消息渲染到当前组件内部(showToastPanel),否则退化为全局 SnackBar。

因此这里将 toastTarget 设为 'layout',意味着"复合仪表盘"内部产生的通知只显示在它自己的布局区域中,不会弹到整个页面顶部——这对嵌在大型仪表盘中的复合组件非常实用。

tb-dashboard-state:把另一个仪表盘状态嵌进来

<tb-dashboard-state <a href="https://link.gitcode.com/i/5ab174da193dea43da0a91aae62e0c1c" target="_blank">ctx]="ctx" [stateId]="stateId" [syncParentStateParams]="true"> 是整套方案的核心。它由 ThingsBoard 提供,用于将仪表盘配置中的某个状态(state)嵌入当前组件内部渲染。对应实现位于 [dashboard-state.component.ts,支持的输入参数如下:

输入 含义 默认值
ctx 当前组件的 WidgetContext,用于读取仪表盘配置与状态控制器 必填
stateId 要嵌入的仪表盘状态 ID(须存在于当前仪表盘的 configuration.states) —
syncParentStateParams 是否同步父仪表盘状态参数并监听状态变更 false
defaultAutofillLayout 是否将嵌入布局的 autoFillHeight / mobileAutoFillHeight 强制设为 true true
defaultMargin / defaultOuterMargin / defaultBackgroundColor 覆盖嵌入布局的边距与背景色 不覆盖

从 dashboard-state.component.ts 的 ngOnInit 可以看到它的工作方式:

  1. 通过 ctx.stateController.dashboardCtrl.dashboardCtx.getDashboard() 深拷贝当前仪表盘配置;
  2. 取出 configuration.states[stateId] 对应的状态,若存在则遍历其所有 layout,应用 autoFillHeight、margin、outerMargin、backgroundColor 等覆盖项;
  3. 调用 updateCurrentState()(dashboard-state.component.ts),把当前状态参数(含可选的 entityId)编码为 currentState,供内嵌的仪表盘页使用;
  4. 若 syncParentStateParams 为 true,则订阅 stateChanged 事件,父级状态一旦变化就同步刷新;
  5. 若指定 stateId 不存在,stateExists 置为 false,模板会渲染"仪表盘状态不存在"的错误提示(见 dashboard-state.component.html)。

内嵌渲染本身由 dashboard-state.component.html 中的 <tb-dashboard-page [embedded]="true" [hideToolbar]="true" [currentState]="currentState" ...> 完成——它把目标状态作为一个"无工具栏的嵌入式仪表盘页"呈现在组件内部,这正是"复合仪表盘"能实现嵌套的根本机制。

JavaScript 容器函数:状态驱动的页签逻辑

与模板配套的容器函数(angular_tabs_js.md)负责全部数据与交互逻辑:

this.ctx = ctx;

this.tabs = [
  { id: 'assets', label: 'Assets', stateId: 'assetsState' },
  { id: 'devices', label: 'Devices', stateId: 'devicesState' },
  { id: 'customers', label: 'Customers', stateId: 'customersState' },
];

const apply = (subState) => {
  const tab = this.tabs.find((t) => t.id === subState) || this.tabs[0];
  this.activeTab = tab.id;
  this.stateId = tab.stateId;
};

apply(ctx.stateController.getStateParams().subState);

this.selectTab = (subState) => ctx.stateController.updateState(null, { subState });

const stateSub = ctx.stateController.dashboardCtrl.dashboardCtx.stateChanged.subscribe(() => {
  apply(ctx.stateController.getStateParams().subState);
  ctx.detectChanges();
});

ctx.registerDestroyCallback(() => {
  stateSub.unsubscribe();
});

其设计思路可拆解为四点:

1. 页签与仪表盘状态的映射。 tabs 数组中每个元素携带三要素:id(页签标识,同时作为 URL/状态参数中的 subState 值)、label(页签文案)、stateId(指向仪表盘配置中实际存在的状态)。模板中的 <tb-dashboard-state [stateId]="stateId"> 正是消费这里的 stateId。

2. 用状态参数驱动激活页签。 apply(subState) 根据传入的 subState 查找对应 tab,同时更新 this.activeTab 与 this.stateId。组件初始化时直接读取 ctx.stateController.getStateParams().subState 完成首屏定位——这意味着 当前激活的是哪个页签,本身就是一个可随仪表盘状态持久化的参数,刷新或跳转后可以恢复。

3. 点击页签即更新仪表盘状态。 selectTab 调用 ctx.stateController.updateState(null, { subState })。对照 IStateController 接口定义(widget-api.models.ts),updateState(id?: string, params?: StateParams, openRightLayout?: boolean) 的第一个参数为空表示停留在当前状态、仅更新状态参数 subState。这正是 HTML Container 组件能"操控"外层仪表盘状态的关键能力。

4. 响应式刷新与资源清理。 组件订阅了 ctx.stateController.dashboardCtrl.dashboardCtx.stateChanged,任何状态变化(包括由页签点击、地址栏或外部控件触发的)都会重新调用 apply 并执行 ctx.detectChanges() 触发 Angular 变更检测,让模板中的 activeTab / stateId 与最新状态保持一致。同时,该订阅通过 ctx.registerDestroyCallback(() => stateSub.unsubscribe()) 注册销毁回调,确保组件被移除时不会产生内存泄漏——这是 container_angular_fn.md 中反复强调的规范。

注意:Angular 模式下 HTML Container 不会自动绑定组件数据源。JS 侧并没有使用 ctx.data,因为这类容器组件不会依据 widget 配置自动填充数据(参见 container_angular_fn.md 的 Notes)。如果需要实时数据,应通过 ctx.subscriptionApi.createSubscription(...) 显式创建订阅,并在 ctx.registerDestroyCallback(...) 中释放。

CSS:页签视觉与布局

配套样式(angular_tabs_css.md)定义了三层结构:

  • .composite:display: flex; flex-direction: column,撑满容器高度,上下依次排列页签栏与内容区;
  • .tabs / .tab:页签栏采用 inline-flex 胶囊形态(圆角背景 #eef1f6);普通态为灰色文字、hover 加深、激活态(.tab.is-active)切换为白色背景 + #2f6bff 蓝色文字,营造"当前页签"的视觉反馈;
  • .container / .state:内容区 flex-grow: 1,带边框圆角并 overflow: hidden,内嵌的 .state 同样撑满,保证 <tb-dashboard-state> 渲染出的子仪表盘填满剩余空间。

运行原理:Angular 模式下模板如何被编译执行

了解底层机制有助于排查问题。在 html-container-widget.component.ts 中,Angular 模式初始化分为三步:

  1. loadAngularModules():加载 Resources 中标记为 isModule("资源为扩展模块")的外部 Angular 模块,用于在模板中使用第三方组件;
  2. compileAngularFunction():通过 parseTbFunction(this.ctx.http, this.settings.js, <a href="https://link.gitcode.com/i/4e07270688c0f0437a11ee587422c249" target="_blank">'ctx']) 编译 JS 函数,参数列表只有 ctx([html-container-widget.component.ts);
  3. initAngularComponent():调用 DynamicComponentFactoryService.createDynamicComponent(...) 将设置中的 HTML 作为模板、CSS 作为组件样式,动态创建一个容器组件实例,并把 ThingsBoard 的 sharedModule、widgetComponentsModule、homeComponentsModule(以及外部扩展模块)并入其编译环境(html-container-widget.component.ts)。

动态组件的 ngOnInit 会执行 containerFunction.apply(this, [instance.ctx])——这正是 JS 侧 this 绑定得以成立、模板能够无前缀引用 tabs / selectTab 等标识符的原因。组件还通过 ngDoCheck 周期性执行 detectChanges,因此 JS 侧在状态变更后调用 ctx.detectChanges() 即可让模板立刻重渲染。

在 Widget 编辑器中配置与验证

要在自己的仪表盘中复现该示例,可按以下步骤操作:

  1. 在仪表盘编辑页添加 HTML Container 组件,进入组件设置;
  2. 在 Type 切换开关处选择 Angular;
  3. 将示例 HTML 模板、CSS、JavaScript 函数分别粘贴到 HTML / CSS / JavaScript 编辑区;
  4. 关键前提:示例中的 assetsState、devicesState、customersState 必须是你仪表盘 States 配置里真实存在的状态 ID(在仪表盘编辑器的 States 面板中创建并命名);从源码看,tb-dashboard-state 会校验 configuration.states[stateId],若缺失将显示"non-existent-dashboard-state"错误;
  5. 保存并预览:点击页签应能切换到对应的仪表盘状态,且该页面产生的 Toast 消息会局限在组件布局区域内显示。

最佳实践与注意事项

  • 辅助函数用箭头函数:container_angular_fn.md 明确要求"Define helper functions as arrow functions to preserve this",示例中的 apply 与 selectTab 均为此写法;
  • 手动清理订阅:任何 subscribe 都必须在 ctx.registerDestroyCallback(...) 中 unsubscribe,否则组件销毁后会遗留状态监听;
  • 变更检测:修改模板绑定的属性后调用 ctx.detectChanges(),否则界面不会刷新;
  • 数据订阅需显式创建:HTML Container 不会自动填充 ctx.data,实时数据要用 ctx.subscriptionApi.createSubscription(...);
  • 无 container 参数:Angular 模式没有 Plain 模式那样的 container 参数,需要访问 DOM 根元素时用 ctx.$container[0] 或从事件对象经 event.currentTarget.closest(...) 定位;
  • stateId 与状态参数协同:让"当前页签"可被外部恢复的最优雅做法,就是像示例一样把 subState 放进状态参数,并让模板的 stateId 完全由它推导。

相关仓库资源

同目录下还有 angular_split_html.md(可调整分割的主从布局)、angular_drawer_html.md(侧滑设备详情 + 双向 RPC)等姊妹示例,它们是进一步掌握 HTML Container 组件能力的绝佳范本。

登录后查看全文
thingsboard