news 2026/10/3 2:14:19

ThingsBoard 自定义 Widget 动作:用 JavaScript + HTML 模板实现设备/资产编辑对话框

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ThingsBoard 自定义 Widget 动作:用 JavaScript + HTML 模板实现设备/资产编辑对话框
  • 物联网
  • 后端
  • 数据可视化
  • 消息队列

【免费下载链接】thingsboard

All-in-one IoT Platform - Device management, data collection, processing and visualization.

项目地址:https://gitcode.com/GitHub_Trending/th/thingsboard
点击查看免费下载

导读

本文围绕 ThingsBoard(开源版 IoT 平台)Dashboard 中「自定义动作(Custom Action)」的核心能力,以官方帮助文档提供的**「编辑设备或资产」示例**(custom_pretty_edit_dialog_js.md+custom_pretty_edit_dialog_html.md)为主线,完整讲解如何在 Widget 中通过 JavaScript 函数与 HTML 模板组合出一个可运行的「自定义对话框」,实现对目标实体的名称/标签、服务端属性、实体关系的新增、修改与删除。读完本文,你将掌握widgetContext、servicesMap、customDialog的调用方式,理解其底层运行原理,并能够把这段示例改造为适合自己的业务对话框。

一、示例文档在项目中的位置与定位

在 ThingsBoard 前端的帮助文档体系中,本示例位于:

  • JavaScript 函数:ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_edit_dialog_js.md
  • HTML 模板:ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_edit_dialog_html.md

它是「Custom action (with HTML template) function」帮助主题下的五个官方示例之一(其余还包括创建对话框、创建用户、编辑图片属性、克隆设备等,同目录下均有_js与_html两个文件)。这些示例被 Dashboard 的 Widget 动作配置界面(Action 弹窗中的tb-help-popup)直接引用,用户在界面上即可打开查看与复制。

示例对应的动作函数签名如下(见 custom_pretty_action_fn.md):

function ($event, widgetContext, entityId, entityName, htmlTemplate, additionalParams, entityLabel): void

其中htmlTemplate参数即为用户在「HTML」标签页中定义的模板字符串,用于渲染自定义对话框;entityId、entityName、entityLabel为动作触发时由 Widget 传入的目标实体信息。

二、函数骨架:从 widgetContext 获取服务

示例 JavaScript 的第一步,是从widgetContext取出 Angular 注入器与相关服务:

let $injector = widgetContext.$scope.$injector; let customDialog = $injector.get(widgetContext.servicesMap.get('customDialog')); let entityService = $injector.get(widgetContext.servicesMap.get('entityService')); let assetService = $injector.get(widgetContext.servicesMap.get('assetService')); let deviceService = $injector.get(widgetContext.servicesMap.get('deviceService')); let attributeService = $injector.get(widgetContext.servicesMap.get('attributeService')); let entityRelationService = $injector.get(widgetContext.servicesMap.get('entityRelationService'));

原理说明:widgetContext是 ThingsBoard Widget 运行时提供给动作函数的核心对象,其类型定义见 widget-component.models.ts,其中:

  • servicesMap:Map<string, Type<any>>,预注册的服务类型映射表;
  • $injector:AngularInjector,用于按类型实例化服务;
  • $scope:IDynamicWidgetComponent动态组件作用域,可借此访问其$injector;
  • rxjs:{...RxJS, ...RxJSOperators},即 rxjs 全量 API 及操作符集合,本示例中的forkJoin、of均来自该对象。

也就是说,示例中的取服务方式等价于先通过servicesMap查得服务类,再通过$injector.get()创建实例。这些服务对应后端 REST API 封装,其定义位于 ui-ngx/src/app/core/http 目录下。

三、入口函数与控制器:customDialog 的调用模式

openEditEntityDialog(); function openEditEntityDialog() { customDialog.customDialog(htmlTemplate, EditEntityDialogController).subscribe(); } function EditEntityDialogController(instance) { let vm = instance; // ... 控制器逻辑 }

customDialog.customDialog(template, controller, data?, config?)是服务公开的唯一入口,其实现见 custom-dialog.service.ts。调用流程为:

  1. 将sharedModule、CommonModule及各 Home 组件模块作为 imports,调用DynamicComponentFactoryService.createDynamicComponent()把htmlTemplate编译成一个继承自CustomDialogComponent的动态组件类型;
  2. 将controller与动态组件类型一起封装进CustomDialogContainerData,以disableClose: true、全屏面板类tb-dialog/tb-fullscreen-dialog打开MatDialog对话框;
  3. 对话框关闭后通过tap()销毁动态组件,避免内存泄漏。

而控制器EditEntityDialogController(instance)接收的instance就是CustomDialogComponent实例。从 custom-dialog.component.ts 可以看到该基类内置了:

  • dialogRef:MatDialogRef,控制器中vm.dialogRef.close(...)即用它关闭对话框;
  • fb:UntypedFormBuilder,用于构建响应式表单;
  • validators:AngularValidators对象;
  • data:CUSTOM_DIALOG_DATA注入令牌。

构造函数末尾会立即调用this.data.controller(this),把实例交给控制器初始化。

四、响应式表单结构:编辑对话框的字段设计

控制器用vm.fb.group()构建了表单模型:

vm.editEntityFormGroup = vm.fb.group({ entityName: ['', [vm.validators.required]], entityType: [null], entityLabel: [null], type: ['', [vm.validators.required]], attributes: vm.fb.group({ latitude: [null], longitude: [null], address: [null], owner: [null], number: [null, [vm.validators.pattern(/^-?[0-9]+$/)]], booleanValue: [false] }), oldRelations: vm.fb.array([]), relations: vm.fb.array([]) });

字段说明:

字段类型校验用途
entityName文本required实体名称,示例中设为只读
entityType文本无实体类型(DEVICE / ASSET),只读
entityLabel文本无实体标签,可编辑
type文本required实体类型(设备/资产所属自定义类型),只读
attributes.latitude/longitude数值无服务端属性,经纬度
attributes.address/owner文本无服务端属性
attributes.number数值pattern(/^-?[0-9]+$/)整数属性,非法值会触发校验错误
attributes.booleanValue布尔无布尔属性
oldRelationsFormArray—已存在的实体关系(只读回显)
relationsFormArray—待新增的实体关系

关系条目也通过表单组约束,新增关系必须填写相关实体、关系类型与方向:

vm.addRelation = function() { vm.relations().push(vm.fb.group({ relatedEntity: [null, [vm.validators.required]], relationType: [null, [vm.validators.required]], direction: [null, [vm.validators.required]] })); };

方向取值为vm.entitySearchDirection = {from: "FROM", to: "TO"},即 ThingsBoard 关系模型中的FROM(当前实体为源)与TO(当前实体为目标)。

五、数据加载:forkJoin 并发拉取实体信息

对话框打开后,控制器调用getEntityInfo()通过widgetContext.rxjs.forkJoin并行发起四个请求:

widgetContext.rxjs.forkJoin([ entityRelationService.findInfoByFrom(entityId), // 当前实体作为 FROM 源的关系 entityRelationService.findInfoByTo(entityId), // 当前实体作为 TO 目标的关系 attributeService.getEntityAttributes(entityId, 'SERVER_SCOPE'), // 服务端属性 entityService.getEntity(entityId.entityType, entityId.id) // 实体详情 ]).subscribe(...)

在回调中:

  • getEntityRelations(data.slice(0, 2)):分别遍历relationsFrom(FROM 方向)与relationsTo(TO 方向),把每条关系转换为{direction, relationType, relatedEntity}结构存入vm.oldRelationsData,并调用addOldRelation()往oldRelationsFormArray 追加一条禁用状态的条目(已存在关系不允许在表单里改方向与类型,只能删除);
  • getEntityAttributes(data[2]):把服务端属性数组转换为{key: value}对象存入vm.attributes,作为后续比对「哪些属性被修改」的基线;
  • vm.entity = data[3]:保存实体对象;
  • vm.editEntityFormGroup.patchValue({...}, {emitEvent: false}):一次性回填表单,emitEvent: false避免回填过程触发校验与订阅通知。

注意attributeService.getEntityAttributes(entityId, 'SERVER_SCOPE')与saveEntityAttributes(entityId, "SERVER_SCOPE", attributesArray)使用的都是SERVER_SCOPE(服务端作用域),即这些属性只能由平台/规则引擎修改、对设备不可见,适合存放经纬度、地址等元数据。该作用域枚举在 attribute.service.ts 中定义。

六、保存逻辑:属性、关系、实体的三段式提交

vm.save()同样使用forkJoin合并三类保存任务:

vm.save = function() { vm.editEntityFormGroup.markAsPristine(); widgetContext.rxjs.forkJoin([ saveAttributes(entityId), saveRelations(entityId), saveEntity() ]).subscribe(function () { widgetContext.updateAliases(); vm.dialogRef.close(null); }); };

1. 保存属性:差量更新

saveAttributes遍历表单attributes子组的每个 key,与加载时保存的vm.attributes基线比对,只把值发生变化的属性组装成{key, value}数组提交,避免无谓写入:

for (let key in attributes) { if (attributes[key] !== vm.attributes[key]) { attributesArray.push({key: key, value: attributes[key]}); } } if (attributesArray.length > 0) { return attributeService.saveEntityAttributes(entityId, "SERVER_SCOPE", attributesArray); } return widgetContext.rxjs.of([]); // 无变化时返回空 Observable,保证 forkJoin 可正常完成

widgetContext.rxjs.of([])是关键细节:forkJoin需要所有 Observable 都发出值才能汇聚,因此「没有需要保存的内容」时也要返回一个空流。

2. 保存关系:新增 + 删除

saveRelations处理两类任务:

  • 新增关系:遍历relationsFormArray 的值,根据direction确定from/to端点(FROM表示当前实体是关系源,TO表示当前实体是关系目标),补上typeGroup: 'COMMON'后调用entityRelationService.saveRelation(relation);
  • 删除旧关系:用户在对话框中点击旧关系条目的删除按钮时,removeOldRelation(index, relation)会把该关系对象 push 进vm.relationsToDelete,保存时统一调用entityRelationService.deleteRelation(relation.from, relation.type, relation.to)。

这些 REST 方法定义在 entity-relation.service.ts:saveRelation对应关系新增/更新接口,deleteRelation按(from, type, to)三元组精确删除。

3. 保存实体:仅更新标签

saveEntity只关心表单里可编辑的entityLabel:如果标签发生了变化,才按实体类型分别调用assetService.saveAsset(vm.entity)或deviceService.saveDevice(vm.entity)提交实体对象;否则返回空流。这也是为什么entityName、entityType、type在表单中一律设为readonly——这些字段由平台管理,示例刻意不允许用户在动作中篡改。

七、HTML 模板要点:表单与对话框的绑定

配套的 HTML 模板(custom_pretty_edit_dialog_html.md)核心绑定关系如下:

  • <form #editEntityForm="ngForm" [formGroup]="editEntityFormGroup" (ngSubmit)="save()">:根表单,提交时触发保存;
  • mat-toolbar标题动态拼接Edit {{entityType.toLowerCase()}} {{entityName}};
  • mat-progress-bar通过isLoading$ | async控制加载进度条(该订阅变量由CustomDialogComponent基类的PageComponent提供);
  • 顶层mat-form-field使用formControlName="entityName" / "entityLabel" / "entityType" / "type"与表单组绑定;
  • 属性区使用formGroupName="attributes"分组,latitude/longitude/address/owner/number/booleanValue各有对应输入控件,其中number字段在hasError('pattern')时显示「Invalid integer value.」错误提示;
  • 关系区使用formArrayName="oldRelations"与formArrayName="relations"遍历两条 FormArray,每个条目内用formGroupName="i"索引绑定direction(mat-select+entitySearchDirection)、relationType(tb-relation-type-autocomplete关系类型自动补全组件)、relatedEntity(tb-entity-select实体选择组件);
  • 旧关系条目的删除按钮调用removeOldRelation(i, relation.value),新关系条目的删除按钮调用removeRelation(i),「Add」按钮调用addRelation();
  • 底部操作区:Cancel按钮调用cancel()(vm.dialogRef.close(null)),Save提交按钮的禁用条件是editEntityForm.invalid || !editEntityForm.dirty,即表单非法或未做任何修改时不可提交。

模板中使用的tb-relation-type-autocomplete、tb-entity-select是 ThingsBoard 自带的复合表单控件,它们在运行时由CustomDialogService注入的sharedHomeComponentsModule/homeComponentsModule/widgetComponentsModule等模块提供,这正解释了为什么对话框里能直接用这些组件——它们是动态组件编译时被合并进 imports 的。

八、从源码验证完整调用链

结合源码可以把整个动作的运行时链路串起来:

  1. Dashboard 中 Widget 配置的 JS 动作函数被执行时,框架注入widgetContext(widget-component.models.ts);
  2. 函数从$injector取出customDialog(CustomDialogService)等服务;
  3. CustomDialogService.customDialog()调用DynamicComponentFactoryService把 HTML 模板实时编译为 Angular 组件(custom-dialog.service.ts);
  4. 动态组件继承CustomDialogComponent,其构造函数执行this.data.controller(this)即调用我们的EditEntityDialogController(custom-dialog.component.ts);
  5. 控制器内通过vm.fb、vm.validators、vm.dialogRef完成表单构建、校验与关闭;
  6. 保存时经attributeService/entityRelationService/assetService/deviceService(位于 ui-ngx/src/app/core/http)调用后端 REST API;
  7. 全部保存完成后调用widgetContext.updateAliases()刷新 Widget 的实体别名数据,再dialogRef.close(null)关闭对话框。

与示例同目录的其他文件(custom_pretty_create_dialog_js.md、custom_pretty_clone_device_js.md 等)复用同一套customDialog + 控制器模式,可对比学习新增、克隆等不同业务场景的写法。此外,Widget 动作配置内置的示例模板 custom-sample-js.raw 也给出了customDialog.customDialog(htmlTemplate, ...)的最小可运行骨架,可作为改造起点。

九、改造与复用建议

基于本示例,你可以按以下思路进行定制:

  • 调整属性集合:修改attributes子组的字段与对应 HTML 输入控件,即可编辑任意服务端属性;如需编辑共享/客户端作用域属性,把两处'SERVER_SCOPE'换成'SHARED_SCOPE'或'CLIENT_SCOPE'即可(作用域枚举见 attribute.service.ts);
  • 扩展校验规则:在表单字段上追加vm.validators.required、pattern、min/max等,并同步在 HTML 中增加mat-error提示,参考number字段的整数校验写法;
  • 开放更多实体字段:若希望允许修改实体名称等字段,可去掉对应输入框的readonly,并在saveEntity中按需提交entity.name;
  • 更换目标实体类型:把saveEntity中的分支扩展为其他实体(如entityService.saveEntity或专门的 service)即可支持更多实体类型,后端统一封装入口可参考 entity.service.ts;
  • 对话框层级与行为:customDialog()的第四个参数config可覆盖MatDialogConfig(如disableClose、width),需要非全屏或可点击遮罩关闭时可在此调整。

小结

本文完整还原了 ThingsBoard「编辑设备/资产」自定义动作的官方示例:从widgetContext获取服务、customDialog动态编译 HTML 模板、控制器初始化响应式表单,到属性差量保存、关系新增/删除、实体标签更新,再到底层CustomDialogService、CustomDialogComponent与各 HTTP 服务类的源码佐证。掌握这套「JS 函数 + HTML 模板 + 控制器」三段式写法后,你可以在 ThingsBoard Dashboard 中自由构建属于自己的编辑、创建、克隆类业务对话框,而无需改动平台本体代码。

  • 物联网
  • 后端
  • 数据可视化
  • 消息队列

【免费下载链接】thingsboard

All-in-one IoT Platform - Device management, data collection, processing and visualization.

项目地址:https://gitcode.com/GitHub_Trending/th/thingsboard
点击查看免费下载

相关推荐

上一篇:163MusicLyrics:如何用开源工具三步搞定多平台歌词管理?
下一篇:如何让老款Mac免费升级最新系统:OpenCore Legacy Patcher终极指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 2:12:44

分布式缓存系统设计实战:分片、一致性哈希与高可用架构

其实很多朋友第一次看到“设计一个分布式缓存系统”这种题&#xff0c;第一反应是&#xff1a;分布式缓存不就是 Redis 集群吗&#xff1f;把 Redis Cluster 搭起来&#xff0c;客户端连上去&#xff0c;好像就完事了。但在真正的系统设计面试或实际架构评审里&#xff0c;面试…

作者头像 李华
网站建设 2026/10/3 2:12:29

Linux云主机Python全栈部署实战:FastAPI+pandas+定时任务完整指南

去年冬天团队接了一个“内部数据服务”的需求&#xff1a;每天从公司多个业务系统拉取数据&#xff0c;加工成统计报表&#xff0c;再通过网页给运营同事看。我们选了 HoRain云 上的一台 2C4G 云主机作为生产环境&#xff0c;用 Python 全栈方案从头搭建。这篇文章把整个过程、…

作者头像 李华
网站建设 2026/10/3 2:11:17

彻底解除 Wand 免费时长限制:本地补丁四步走

彻底解除 Wand 免费时长限制&#xff1a;本地补丁四步走 【免费下载链接】Wand-Enhancer Advanced UX and interoperability extension for Wand (WeMod) app 项目地址: https://gitcode.com/GitHub_Trending/we/Wand-Enhancer 每次用 Wand 到第二小时&#xff0c;那个弹…

作者头像 李华
网站建设 2026/10/3 2:09:41

Node.js 安全实践:拒绝用动态变量加载模块(Safe Module Loading)

文档教程后端 【免费下载链接】nodebestpractices ✅ The Node.js best practices list (July 2026) 项目地址&#xff1a; https://gitcode.com/GitHub_Trending/no/nodebestpractices 点击查看 免费下载 本篇文章聚焦于 Node.js 最佳实践清单&#xff08;README.md 第 6.17 …

作者头像 李华