ThingsBoard HTML 容器部件 Angular 模式实战:用 Container Function 驱动可交互 Angular 模板

原创2026-10-03 17:22:531,628 阅读
文章标签:物联网后端数据可视化微服务消息队列

ThingsBoard HTML 容器部件 Angular 模式实战:用 Container Function 驱动可交互 Angular 模板

本篇技术指南聚焦 ThingsBoard 前端(ui-ngx)中 HTML 容器(HTML Container)部件的 Angular 模式:讲解容器函数(Container Function)的签名、WidgetContext 参数能力、this 上下文暴露机制,并结合仓库源码解析其动态编译运行原理。读者读完将掌握如何用一份 JavaScript 函数 + Angular 模板 + CSS 构建主从分栏、抽屉式设备详情、标签页复合看板等可直接运行的高交互部件,以及如何通过 ctx.subscriptionApi 手动订阅数据并正确释放资源。

一、容器函数是什么:Angular 模式与 Plain 模式的差异

HTML 容器部件允许开发者完全自定义一个部件的 HTML 模板、CSS 与 JavaScript 逻辑。部件类型由设置中的 type 字段决定,定义在 html-container-widget.models.ts:

export enum HtmlContainerWidgetType {
  PLAIN = 'PLAIN',
  ANGULAR = 'ANGULAR'
}

export interface HtmlContainerWidgetSettings {
  type: HtmlContainerWidgetType;
  html: string;
  css: string;
  js: TbFunction;
  resources: WidgetResource[];
}

export type WidgetContainerPlainFunction = (ctx: WidgetContext, container: HTMLElement) => void;
export type WidgetContainerAngularFunction = (ctx: WidgetContext) => void;
  • Plain 模式:html 是普通 HTML,容器函数签名为 function (ctx, container): void,逻辑通过 container.querySelector(...) 手动操作原生 DOM。
  • Angular 模式:html 是一段完整的 Angular 模板,容器函数签名为 function (ctx): void,通过把数据和处理器挂到 this 上供模板直接引用。模板里可以使用 *ngFor、*ngIf、(click)、[style.width.px] 等全部 Angular 语法,还可以使用 mat-drawer、mat-table 等 Material 组件与 tb-dashboard-state 这类 ThingsBoard 内置组件。

两种模式共用同一套设置面板;设置组件 html-container-settings.component.ts 会根据当前 type 值切换不同的代码补全器:Angular 模式下使用 AngularContainerFunctionEditorCompleter(只提示 ctx 一个参数),Plain 模式下使用 HTMLContainerFunctionEditorCompleter(额外提示 container)。这正是"Angular 模式没有 container 参数"在编辑器层面的直观体现。

二、函数签名、返回值与 ctx 参数

2.1 签名

function (ctx): void

一个 JavaScript 函数,通过把这些值赋值给 this 来把数据和处理函数暴露给 Angular 模板;在模板中引用它们时不需要 this 前缀。

返回值为空:本函数不返回任何值,模板中需要用到的所有内容都必须显式挂载到 this 上。

2.2 ctx:WidgetContext 参数

ctx 是 WidgetContext 类的实例,持有部件实例所需的全部 API 与数据,可用来发送 RPC、切换看板状态、创建数据订阅、读取设置以及访问平台服务。从 widget-component.models.ts 源码可以看到其核心能力:

能力 ctx 成员 说明
数据订阅 ctx.subscriptionApi createSubscription(options, subscribe) / createSubscriptionFromInfo(...) / removeSubscription(id),接口定义见 widget-api.models.ts
RPC 控制 ctx.controlApi sendOneWayCommand / sendTwoWayCommand / completedCommand
看板状态 ctx.stateController updateState(id, params) / getStateParams() / openState(...) / stateChanged() 等,见 state-controller.models.ts
通知 ctx.showSuccessToast(...) / showWarnToast(...) / showErrorToast(...) 等 便捷通知
平台服务 ctx.http / ctx.deviceService / ctx.attributeService / ctx.entityRelationService 等 直接调用后端 API
工具 ctx.utils / ctx.httpUtils / ctx.rxjs 格式化、HTTP 选项、rxjs 全套操作符
DOM 访问 ctx.$container jQuery 包装的容器元素,ctx.$container[0] 即原生根元素

此外 ctx 还暴露 settings(当前部件设置)、widgetConfig、currentUser、width / height(部件尺寸)、isEdit / isPreview / isMobile 等运行时信息,以及 date、imagePipe、translate 等 Angular 管道/服务。

三、核心机制:this 即模板上下文

容器函数执行时,它的 this 就是动态创建的 Angular 组件实例本身。函数内所有 this.xxx = ... 的赋值都会成为模板可直接读取的成员;this.handler = () => {} 定义的方法可以直接绑定到模板事件上。

3.1 源码级运行原理

在 html-container-widget.component.ts 中,Angular 模式的完整初始化链路如下:

  1. ngOnInit() 合并默认设置后,先执行 loadWidgetResources() 加载非模块资源(普通 JS/CSS 文件),再执行 loadAngularModules() 加载标记为模块(isModule: true)的资源——这是 Angular 模式下可以引入自定义 Angular 模块的关键。
  2. compileAngularFunction() 调用 parseTbFunction(this.ctx.http, this.settings.js, ['ctx']) 编译容器函数,注意参数列表只有 ['ctx'],与 Plain 模式的 ['ctx', 'container'] 形成对比,从代码层面印证了"Angular 模式没有 container 参数"。
  3. initAngularComponent() 通过 dynamicComponentFactoryService.createDynamicComponent(...) 以 settings.html 为模板、合并 sharedModule、widgetComponentsModule、homeComponentsModule 与自定义模块动态创建组件类型,然后 viewContainerRef.createComponent(...) 实例化。
  4. 动态组件实例的 ngOnInit() 中执行 containerFunction.apply(this, [instance.ctx])——apply 把组件实例作为 this,因此你在函数里写的 this.devices = ... 就落在了组件实例上,模板自然能拿到。
  5. 组件还实现了 ngDoCheck(),每轮变更检测都会执行 this.cdr.detectChanges(),保证容器函数引起的绑定更新能被渲染。

3.2 三个黄金法则

  • 赋值给 this:this.devices = []; this.selected = null; 模板中直接写 {{ devices }}、*ngFor="let d of devices"。
  • 处理器也用 this 挂载:this.select = (d) => {...},模板中 (click)="select(d)"。
  • 模板中引用不要 this 前缀:{{ selected.name }},而不是 {{ this.selected.name }}。

3.3 辅助函数必须使用箭头函数

文档明确要求:Define helper functions as arrow functions to preserve this(辅助函数定义为箭头函数以保留 this)。因为普通 function 声明拥有自己的 this,在事件回调或订阅回调中会丢失外层组件实例;箭头函数不绑定自己的 this,会从定义处继承。示例代码中 this.select = (d) => {...}、this.startDrag = (event) => {...} 全部使用箭头形式即出于此因。

四、四个关键注意点

4.1 Angular 模式没有 container 参数

与 Plain 模式不同,Angular 模式函数体内拿不到 container 元素。需要访问根元素时,用 ctx.$container[0](jQuery 包装下的原生节点);或在事件处理器中使用 event.currentTarget.closest(...) 从触发元素向上查找祖先节点,例如分栏拖拽示例中的 event.currentTarget.closest('.split')。

4.2 修改模板绑定值后调用 ctx.detectChanges()

Angular 的变更检测不会感知你在异步回调(订阅 onDataUpdated、RPC 完成、鼠标移动事件)中对 this 成员的非 @Input 赋值。每次修改模板绑定值后都应调用 ctx.detectChanges() 强制触发检测。该方法的实现在 widget-component.models.ts 中:

detectChanges(updateWidgetParams: boolean = false) {
  if (!this.destroyed) {
    if (updateWidgetParams) { this.dashboardWidget.updateWidgetParams(); }
    try {
      if (this.ngZone) {
        this.ngZone.run(() => { this.changeDetectorValue?.detectChanges(); });
      } else {
        this.changeDetectorValue?.detectChanges();
      }
    } catch (e) { /* ignored */ }
  }
}

可以看到它在 ngZone.run 内调用 detectChanges(),确保即使回调运行在 Angular Zone 之外也能安全刷新视图。

4.3 部件不会自动绑定数据源

HTML 容器部件不像普通图表部件那样自动把数据源结果填进 ctx.data——从配置层面根本没有创建默认订阅。因此 ctx.data 始终为空,不要指望它自动携带实时数据。

4.4 手动订阅与释放

要读取实时数据,必须显式调用 ctx.subscriptionApi.createSubscription(options, subscribe) 创建订阅,并通过 ctx.registerDestroyCallback(...) 注册销毁回调,在部件销毁时用 ctx.subscriptionApi.removeSubscription(id) 释放。这是文档强调的规范动作,也是避免内存泄漏的关键。

五、实战示例一:Resizable split master–detail(可拖拽分栏主从视图)

该示例在左侧渲染设备列表(实时订阅 peopleCount 时序数据),中间拖拽手柄可调整列表宽度,右侧通过 tb-dashboard-state 嵌入看板状态 deviceCharts 展示选中设备的图表,并用 ctx.stateController.updateState(...) 把选中设备同步到父级状态参数。

HTML 模板

<div class="split">
  <aside class="split__list" [style.width.px]="listWidth">
    <h3 class="split__title">Devices</h3>
    <ul class="split__items">
      <li *ngFor="let d of devices"
          class="split__item"
          [class.is-active]="d.entityId === selectedId"
          (click)="select(d)">
        <span class="split__name">{{ d.name }}</span>
        <span class="split__value">{{ d.lastValue }}</span>
      </li>
    </ul>
  </aside>

  <div class="split__divider" (mousedown)="startDrag($event)"></div>

  <section class="split__detail">
    <ng-container *ngIf="selected; else empty">
      <header>
        <div class="split__eyebrow">Device</div>
        <h3 class="split__detail-title">{{ selected.name }}</h3>
      </header>
      <div class="split__state">
        <tb-dashboard-state [ctx]="ctx" stateId="deviceCharts" [syncParentStateParams]="true"></tb-dashboard-state>
      </div>
    </ng-container>
    <ng-template #empty><div class="split__empty">Select a device</div></ng-template>
  </section>
</div>

CSS

.split {
  display: flex;
  height: 100%;
  box-sizing: border-box;
  font-family: 'Inter', 'Roboto', system-ui, sans-serif;
  color: #0f172a;
  background: #fff;
  border-radius: 16px;
  box-shadow: 0 4px 24px rgba(15, 23, 42, 0.06);
  overflow: hidden;
}

.split__list {
  flex: 0 0 auto;
  min-width: 160px;
  overflow: auto;
  border-right: 1px solid #eef1f6;
}

.split__title {
  margin: 0;
  padding: 16px;
  font-family: 'Roboto', sans-serif;
  font-size: 20px;
  font-weight: 500;
}

.split__items {
  list-style: none;
  margin: 0;
  padding: 0;
}

.split__item {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  padding: 12px 16px;
  cursor: pointer;
  border-bottom: 1px solid #f1f5f9;
  transition: background 0.15s ease;
}

.split__item:hover {
  background: #f8fafc;
}

.split__item.is-active {
  background: rgba(47, 107, 255, 0.08);
  box-shadow: inset 3px 0 0 #2f6bff;
}

.split__value {
  font-weight: 700;
  color: #475569;
}

.split__divider {
  flex: 0 0 12px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: col-resize;
  background: #eef1f6;
  transition: background 0.15s ease;
}

.split__divider:hover {
  background: #e2e8f0;
}

.split__divider::before {
  content: '';
  width: 4px;
  height: 32px;
  background-image: radial-gradient(circle, #94a3b8 1.2px, transparent 1.4px);
  background-position: center;
  background-size: 4px 6px;
  background-repeat: repeat-y;
}

.split--dragging {
  user-select: none;
}

.split--dragging .split__list,
.split--dragging .split__detail {
  pointer-events: none;
}

.split__detail {
  flex: 1 1 0;
  min-width: 220px;
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 20px;
  overflow: auto;
}

.split__eyebrow {
  font-size: 11px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: #94a3b8;
}

.split__detail-title {
  margin: 2px 0 0;
  font-size: 20px;
  font-weight: 800;
}

.split__state {
  flex: 1 1 auto;
  min-height: 260px;
  border: 1px solid #eef1f6;
  border-radius: 14px;
  overflow: hidden;
}

.split__state tb-dashboard-state {
  display: block;
  width: 100%;
  height: 100%;
}

.split__empty {
  margin: auto;
  color: #94a3b8;
  font-size: 14px;
}

JavaScript 容器函数

this.ctx = ctx;
this.devices = [];
this.selected = null;
this.selectedId = null;
this.listWidth = 280;

this.select = (d) => {
  this.selected = d;
  this.selectedId = d.entityId;
  ctx.stateController.updateState(null, {
    entityId: { entityType: d.entityType, id: d.entityId },
    entityName: d.name,
  });
  ctx.detectChanges();
};

this.startDrag = (event) => {
  event.preventDefault();
  const root = event.currentTarget.closest('.split');
  const left = root.getBoundingClientRect().left;
  root.classList.add('split--dragging');
  const onMove = (e) => {
    this.listWidth = Math.max(160, Math.min(e.clientX - left, root.clientWidth - 220));
    ctx.detectChanges();
  };
  const onUp = () => {
    root.classList.remove('split--dragging');
    window.removeEventListener('mousemove', onMove);
    window.removeEventListener('mouseup', onUp);
  };
  window.addEventListener('mousemove', onMove);
  window.addEventListener('mouseup', onUp);
};

const subOpts = {
  type: 'latest',
  datasources: [
    {
      type: 'entity',
      entityFilter: { type: 'deviceType', deviceTypes: ['peopleCount'], deviceNameFilter: '', resolveMultiple: true },
      dataKeys: [{ type: 'timeseries', name: 'peopleCount', settings: {} }],
    },
  ],
  callbacks: {
    onDataUpdated: (subscription) => {
      this.devices = (subscription.data || []).map((entry) => {
        const ds = entry.datasource;
        const last = entry.data && entry.data.length ? entry.data[entry.data.length - 1] : null;
        return { entityId: ds.entityId, entityType: ds.entityType, name: ds.entityName, lastValue: last ? last[1] : '—' };
      });
      ctx.detectChanges();
    },
    onDataUpdateError: (subscription, e) => console.error(e),
  },
};

let subscriptionId = null;
ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => {
  subscriptionId = subscription.id;
});

ctx.registerDestroyCallback(() => {
  if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId);
});

完整示例文件见 angular_split_html.md、angular_split_css.md、angular_split_js.md。

要点拆解:

  • subOpts.type = 'latest' 表示最新值订阅;entityFilter 用设备类型过滤 peopleCount 设备并 resolveMultiple: true 解析为多个实体;dataKeys 订阅 peopleCount 时序键。
  • onDataUpdated 回调里把 subscription.data 映射为列表行数据(含 last[0] 时间戳与 last[1] 最新值),每次更新后 ctx.detectChanges()。
  • ctx.registerDestroyCallback 中调用 removeSubscription 完成清理,防止看板切换后泄漏。

六、实战示例二:Slide-over device detail with two-way RPC(抽屉式设备详情 + 双向 RPC)

该示例用 Material 的 mat-drawer-container 实现右侧滑出抽屉:主区域是 mat-table 设备表,点击行打开抽屉显示设备详情卡片、Alarms 与 Charts 两个内嵌看板状态,并提供 Reboot 与 Reset counter 两个按钮,通过 ctx.http.post('/api/rpc/twoway/' + entityId, ...) 调用设备端双向 RPC 方法,mat-progress-bar 展示请求进行中状态。

HTML 模板

<mat-drawer-container class="dd">
  <mat-drawer-content class="dd__main">
    <h3 class="dd__title">Devices</h3>
    <div class="dd__card">
      <mat-table [dataSource]="devices" class="dd__table">
        <ng-container matColumnDef="name">
          <mat-header-cell *matHeaderCellDef>Device</mat-header-cell>
          <mat-cell *matCellDef="let d">{{ d.name }}</mat-cell>
        </ng-container>
        <ng-container matColumnDef="people">
          <mat-header-cell *matHeaderCellDef>People</mat-header-cell>
          <mat-cell *matCellDef="let d">{{ d.lastValue }}</mat-cell>
        </ng-container>
        <mat-header-row *matHeaderRowDef="columns"></mat-header-row>
        <mat-row *matRowDef="let d; columns: columns" [class.is-active]="d.entityId === selectedId" (click)="openDevice(d)"></mat-row>
      </mat-table>
    </div>
  </mat-drawer-content>

  <mat-drawer class="dd__drawer" mode="over" position="end" [opened]="drawerOpen" (openedChange)="drawerOpen = $event">
    <div class="dd__panel" *ngIf="selected">
      <header class="dd__panel-bar">
        <div>
          <div class="dd__panel-eyebrow">Device</div>
          <h3 class="dd__panel-title">{{ selected.name }}</h3>
        </div>
        <button class="dd__close" (click)="drawerOpen = false">✕</button>
      </header>

      <div class="dd__cards">
        <div class="card"><span class="card__label">People</span><span class="card__value">{{ selected.lastValue }}</span></div>
        <div class="card"><span class="card__label">Entity type</span><span class="card__value">{{ selected.entityType }}</span></div>
        <div class="card"><span class="card__label">Last update</span><span class="card__value">{{ selected.lastSeen }}</span></div>
      </div>

      <div class="dd__actions">
        <div class="dd__action">
          <button class="dd__btn" [disabled]="rebooting" (click)="reboot()">Reboot</button>
          <mat-progress-bar *ngIf="rebooting" mode="indeterminate"></mat-progress-bar>
        </div>
        <div class="dd__action">
          <button class="dd__btn dd__btn--warn" [disabled]="resetting" (click)="resetCounter()">Reset counter</button>
          <mat-progress-bar *ngIf="resetting" mode="indeterminate"></mat-progress-bar>
        </div>
      </div>

      <section class="dd__section">
        <h4 class="dd__section-title">Alarms</h4>
        <div class="dd__state"><tb-dashboard-state [ctx]="ctx" stateId="deviceAlarms" [syncParentStateParams]="true"></tb-dashboard-state></div>
      </section>
      <section class="dd__section">
        <h4 class="dd__section-title">Charts</h4>
        <div class="dd__state"><tb-dashboard-state [ctx]="ctx" stateId="deviceCharts" [syncParentStateParams]="true"></tb-dashboard-state></div>
      </section>
    </div>
  </mat-drawer>
</mat-drawer-container>

CSS

.dd {
  height: 100%;
  font-family: 'Inter', 'Roboto', system-ui, sans-serif;
  color: #0f172a;
}

.dd__main {
  padding: 20px;
  box-sizing: border-box;
}

.dd__title {
  margin: 0 0 16px;
  font-family: 'Roboto', sans-serif;
  font-size: 20px;
  font-weight: 500;
}

.dd__card {
  background: #fff;
  border-radius: 8px;
  overflow: hidden;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.12);
}

.dd__table {
  width: 100%;
}

.dd__table .mat-column-name {
  flex: 1;
}

.dd__table .mat-column-people {
  flex: 0 0 120px;
  justify-content: flex-end;
}

.dd__table .mat-mdc-row {
  cursor: pointer;
}

.dd__table .mat-mdc-row:hover {
  background: rgba(0, 0, 0, 0.04);
}

.dd__table .mat-mdc-row.is-active {
  background: rgba(47, 107, 255, 0.08);
}

.dd__drawer {
  width: 70%;
  box-sizing: border-box;
}

.dd__panel {
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 24px;
}

.dd__panel-bar {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
}

.dd__panel-eyebrow {
  font-size: 11px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: .08em;
  color: #94a3b8;
}

.dd__panel-title {
  margin: 2px 0 0;
  font-size: 22px;
  font-weight: 800;
}

.dd__close {
  border: 0;
  background: #f1f5f9;
  border-radius: 10px;
  width: 36px;
  height: 36px;
  font-size: 16px;
  cursor: pointer;
  color: #475569;
}

.dd__close:hover {
  background: #e2e8f0;
}

.dd__cards {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 12px;
}

.card {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 14px 16px;
  border-radius: 14px;
  background: linear-gradient(180deg, #f8fafc, #eef2f7);
  border: 1px solid #e6ebf2;
}

.card__label {
  font-size: 10px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: .08em;
  color: #94a3b8;
}

.card__value {
  font-size: 18px;
  font-weight: 800;
}

.dd__actions {
  display: flex;
  gap: 10px;
}

.dd__action {
  display: inline-flex;
  flex-direction: column;
  gap: 6px;
}

.dd__btn {
  padding: 9px 16px;
  border: 1px solid #d8dee9;
  border-radius: 10px;
  background: #fff;
  color: #0f172a;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.15s ease;
}

.dd__btn:hover {
  background: #f8fafc;
}

.dd__btn:disabled {
  opacity: 0.6;
  cursor: default;
}

.dd__btn--warn {
  color: #b91c1c;
  border-color: #fecaca;
}

.dd__btn--warn:hover {
  background: #fef2f2;
}

.dd__section {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.dd__section-title {
  margin: 0;
  font-size: 13px;
  font-weight: 700;
  color: #334155;
}

.dd__state {
  height: 280px;
  border: 1px solid #eef1f6;
  border-radius: 14px;
  overflow: hidden;
}

.dd__state tb-dashboard-state {
  display: block;
  width: 100%;
  height: 100%;
}

JavaScript 容器函数

this.ctx = ctx;
this.devices = [];
this.columns = ['name', 'people'];
this.selected = null;
this.selectedId = null;
this.drawerOpen = false;

this.openDevice = (d) => {
  this.selected = d;
  this.selectedId = d.entityId;
  this.drawerOpen = true;
  ctx.stateController.updateState(null, {
    entityId: { entityType: d.entityType, id: d.entityId },
    entityName: d.name,
  });
  ctx.detectChanges();
};

this.rebooting = false;
this.resetting = false;

this.reboot = () => {
  if (!this.selected || this.rebooting) return;
  this.rebooting = true;
  ctx.detectChanges();
  ctx.http.post('/api/rpc/twoway/' + this.selected.entityId, { method: 'reboot', params: {} }).subscribe({
    error: (e) => {
      console.error(e);
      this.rebooting = false;
      ctx.detectChanges();
    },
    complete: () => {
      this.rebooting = false;
      ctx.detectChanges();
    },
  });
};

this.resetCounter = () => {
  if (!this.selected || this.resetting) return;
  this.resetting = true;
  ctx.detectChanges();
  ctx.http.post('/api/rpc/twoway/' + this.selected.entityId, { method: 'resetCounter', params: {} }).subscribe({
    error: (e) => {
      console.error(e);
      this.resetting = false;
      ctx.detectChanges();
    },
    complete: () => {
      this.resetting = false;
      ctx.detectChanges();
    },
  });
};

const subOpts = {
  type: 'latest',
  datasources: [
    {
      type: 'entity',
      entityFilter: { type: 'deviceType', deviceTypes: ['peopleCount'], deviceNameFilter: '', resolveMultiple: true },
      dataKeys: [{ type: 'timeseries', name: 'peopleCount', settings: {} }],
    },
  ],
  callbacks: {
    onDataUpdated: (subscription) => {
      this.devices = (subscription.data || []).map((entry) => {
        const ds = entry.datasource;
        const last = entry.data && entry.data.length ? entry.data[entry.data.length - 1] : null;
        return {
          entityId: ds.entityId,
          entityType: ds.entityType,
          name: ds.entityName,
          lastValue: last ? last[1] : '—',
          lastSeen: last ? new Date(last[0]).toLocaleString() : '—',
        };
      });
      ctx.detectChanges();
    },
    onDataUpdateError: (subscription, e) => console.error(e),
  },
};

let subscriptionId = null;
ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => {
  subscriptionId = subscription.id;
});

ctx.registerDestroyCallback(() => {
  if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId);
});

完整示例见 angular_drawer_html.md、angular_drawer_css.md、angular_drawer_js.md。

要点拆解:

  • RPC 不通过 ctx.controlApi.sendTwoWayCommand,而是直接调用平台 REST API /api/rpc/twoway/{entityId},POST body 携带 { method, params },这也是一种官方支持的调用方式(ctx.controlApi 内部同样基于此实现)。
  • 请求期间用 rebooting / resetting 布尔值驱动按钮 disabled 与 mat-progress-bar,完成后在 complete/error 回调复位并 ctx.detectChanges()。
  • mat-drawer 的 [opened]="drawerOpen" 与 (openedChange) 双向同步抽屉开关状态。
  • tb-dashboard-state 通过 stateId 内嵌 deviceAlarms / deviceCharts 两个看板状态,[syncParentStateParams]="true" 让子状态自动同步父级 entityId 参数。

七、实战示例三:Composite dashboard with tab navigation(标签页复合看板)

该示例用一个顶层标签导航切换三个看板状态(Assets / Devices / Customers),通过 ctx.stateController.getStateParams().subState 与 ctx.stateController.updateState(null, { subState }) 实现状态参数驱动的"单容器多状态"切换,并订阅 stateChanged 事件让标签高亮与外部导航保持同步。

HTML 模板

<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>

CSS

.composite {
  display: flex;
  flex-direction: column;
  gap: 12px;
  height: 100%;
  box-sizing: border-box;
  padding: 12px;
  font-family: 'Roboto', system-ui, sans-serif;
}

.tabs {
  display: inline-flex;
  gap: 4px;
  padding: 4px;
  align-self: flex-start;
  background: #eef1f6;
  border-radius: 10px;
}

.tab {
  border: 0;
  background: transparent;
  padding: 8px 16px;
  border-radius: 7px;
  font-size: 13px;
  font-weight: 600;
  color: #5b6472;
  cursor: pointer;
  transition: background 0.15s ease, color 0.15s ease;
}

.tab:hover {
  color: #1f2733;
}

.tab.is-active {
  background: #fff;
  color: #2f6bff;
  box-shadow: 0 1px 2px rgba(16, 24, 40, 0.08);
}

.container {
  flex-grow: 1;
  width: 100%;
  display: flex;
  padding: 0;
  border: 1px solid #e6eaf0;
  border-radius: 12px;
  overflow: hidden;
}

.state {
  flex-grow: 1;
  width: 100%;
  height: 100%;
  padding: 0;
}

JavaScript 容器函数

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();
});

完整示例见 angular_tabs_html.md、angular_tabs_css.md、angular_tabs_js.md。

要点拆解:

  • stateId 是一个动态绑定的模板属性,由容器函数决定当前显示哪个看板状态,实现"一个部件宿主 + 多个可切换子状态"的复合看板。
  • 初始化时通过 apply(ctx.stateController.getStateParams().subState) 读取 URL/状态参数,使标签页可直接通过外部导航(如从其他看板传入 subState 参数)打开。
  • stateChanged 订阅让内部标签高亮与外部状态切换(如浏览器前进/后退)保持同步,并在销毁回调中 unsubscribe()。
  • 该示例演示了状态订阅与数据订阅之外另一类必须手动清理的"监听器"资源:凡是 subscribe 得到的流,都应在 registerDestroyCallback 中释放。

八、配置入口:部件设置面板

HTML 容器部件的设置保存在 settings 中,由 html-container-widget-settings.component.ts 与 html-container-settings.component.ts 渲染。核心配置字段如下:

字段 类型 说明
type PLAIN / ANGULAR 切换渲染模式,Angular 模式下 HTML 按 Angular 模板编译
html string Angular 模板源码(Angular 模式)或普通 HTML(Plain 模式)
css string 作用域样式,运行时通过 cssjs 解析并按内容 hash 命名空间注入
js TbFunction 容器函数源码,Angular 模式按 ['ctx'] 编译
resources WidgetResource[] 外部资源列表:url + isModule 标记。非模块资源(JS/CSS)在 loadWidgetResources() 中加载;isModule: true 的 JS 资源在 Angular 模式下作为 Angular 模块(NgModule)通过 loadAngularModules() 加载并合并进动态组件的编译模块列表

从 html-container-widget.component.ts 可以看到默认设置:

export const htmlContainerDefaultSettings: HtmlContainerWidgetSettings = {
  type: HtmlContainerWidgetType.PLAIN,
  html: '',
  css: '',
  js: '',
  resources: [],
};

即新建部件默认是 Plain 模式,需要手动切换到 Angular 模式并填入模板与函数。值得注意的是,设置组件在 Plain 模式下会自动过滤掉 JS 资源并强制 isModule = false(见 updateResources()),因为 Plain 模式不支持模块加载——模块资源仅对 Angular 模式有意义。

九、调试与常见问题

  • 模板渲染空白:检查是否忘记调用 ctx.detectChanges()。异步回调中修改 this 数据后若视图不更新,第一嫌疑就是缺少检测触发。
  • this 丢失(Cannot read properties of undefined):确认所有处理器与回调都使用箭头函数。普通 function 会绑定自己的 this。
  • 没有数据:HTML 容器部件不会自动填充 ctx.data,必须显式 createSubscription;同时确认订阅已 subscribe(true) 开始拉取。
  • 切换看板后资源泄漏:所有订阅、事件监听、定时器都应放入 ctx.registerDestroyCallback(...);数据订阅用 ctx.subscriptionApi.removeSubscription(id),rxjs 流用 unsubscribe()。
  • 运行时报错:容器函数或模板编译抛出的异常会被组件捕获并显示在部件错误层(widgetErrorData),同时 handleWidgetException 会在控制台输出堆栈,见 html-container-widget.component.ts 的 handleWidgetException 与 processWidgetException。
  • 组件不可用:如果模板中使用了自定义组件/指令,需要把它所在的 Angular 模块通过 resources 中以 isModule: true 加载,否则模板编译阶段会报未知元素错误。

十、总结

ThingsBoard HTML 容器部件的 Angular 模式把"Angular 模板引擎 + 容器函数"组合成一个完整的自定义部件开发环境:function (ctx): void 通过 this 挂载数据与处理器,模板无前缀引用;ctx 提供订阅、RPC、状态、服务与通知等全套能力;ctx.detectChanges() 负责在异步路径中刷新视图;数据源需手动订阅并由 registerDestroyCallback 兜底清理。仓库内的三个官方示例(可拖拽主从分栏、抽屉式设备详情 + 双向 RPC、标签页复合看板)覆盖了模板渲染、内嵌看板状态、RPC 调用与状态参数导航等核心玩法,可直接复制到部件设置的 HTML/CSS/JS 三个编辑器中运行,是学习 Angular 模式容器函数的最佳起点。相关实现与示例的源码位置:部件运行逻辑 html-container-widget.component.ts、类型定义 html-container-widget.models.ts、API 定义 widget-api.models.ts 与 widget-component.models.ts。

登录后查看全文
thingsboard