本文所有内容都来自原创,思路开阔,敢想敢做,经过不懈努力,终于成功扩展 ThingsBoard 功能模块

先来看一段视频,体验一下新增的功能,前端增加的菜单和功能演示。

前言

此次二次开发,主要是针对 ThingsBoard 新增功能模块,以报表功能为目的,提供一个可以独立与原有功能的 Report/报表 模块,咱们先这么来定义。就像 Device/设备、Asset/资产模块,增删改查必不可少,还需要兼容 ThingsBoard 原有操作习惯,还可以将 Report/报表 分配给特定的 Customer/客户。上图,这是开始的设想:
tb1.png

后端

代码结构

modified:   application/src/main/java/org/thingsboard/server/controller/BaseController.java
new file:   application/src/main/java/org/thingsboard/server/controller/ReportController.java
modified:   application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java
modified:   application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java
modified:   application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java
modified:   application/src/main/resources/thingsboard.yml
new file:   common/dao-api/src/main/java/org/thingsboard/server/dao/report/ReportService.java
modified:   common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java
modified:   common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java
new file:   common/data/src/main/java/org/thingsboard/server/common/data/Report.java
new file:   common/data/src/main/java/org/thingsboard/server/common/data/ReportInfo.java
modified:   common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java
new file:   common/data/src/main/java/org/thingsboard/server/common/data/id/ReportId.java
modified:   common/data/src/main/java/org/thingsboard/server/common/data/query/EntityFilterType.java
new file:   common/data/src/main/java/org/thingsboard/server/common/data/query/ReportSearchQueryFilter.java
new file:   common/data/src/main/java/org/thingsboard/server/common/data/query/ReportTypeFilter.java
new file:   common/data/src/main/java/org/thingsboard/server/common/data/report/ReportSearchQuery.java
new file:   dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractReportEntity.java
new file:   dao/src/main/java/org/thingsboard/server/dao/model/sql/ReportEntity.java
new file:   dao/src/main/java/org/thingsboard/server/dao/model/sql/ReportInfoEntity.java
new file:   dao/src/main/java/org/thingsboard/server/dao/report/ReportDao.java
new file:   dao/src/main/java/org/thingsboard/server/dao/report/ReportServiceImpl.java
modified:   dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java
modified:   dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java
new file:   dao/src/main/java/org/thingsboard/server/dao/sql/report/JpaReportDao.java
new file:   dao/src/main/java/org/thingsboard/server/dao/sql/report/ReportRepository.java
modified:   pom.xml

根据 git 提供的log,看出来新增文件比修改文件多,也是此次二次开发的难点所在。

  • 新增文件:新增的文件主要以扩展功能模块为主,大概是这样:POJO(DTO,ID),Entity,Repository,Dao,Service/Impl,Controller,基本都是依葫芦画瓢;
  • 修改文件:因为要达到 ThingsBoard 原有的一些功能体验,势必要满足其实现逻辑,那就是找到所有的 Device 功能模块的做法,在所有需要的地方增加 Report 功能,无非就是一些新增枚举值,方法调用,新增 case 判断;

这个开发过程中,我发现,频繁读取一些 API,且都是需要数据库交互,如果用户数量大,不是一个好方法,这里举一个例子:

  • 获取实体类型频繁数据库交互
    types.png
    调用后台 API 为: /api/report/types,追查代码发现其实是一次 SELECT,瞬间尴尬了

    @Query("SELECT DISTINCT d.type FROM ReportEntity d WHERE d.tenantId = :tenantId")
    List<String> findTenantReportTypes(@Param("tenantId") UUID tenantId);
  • WEB API,大概就是把 device 都替换成了 report,其他逻辑上没有做更改,为了配合前端的调用,这些都是必不可少的内容。因为用 device 衍生过来,把 cridentail 相关内容删除了。
    tb3.png

数据表

CREATE TABLE public.report
(
    id uuid NOT NULL,
    created_time bigint NOT NULL,
    additional_info character varying COLLATE pg_catalog."default",
    customer_id uuid,
    type character varying(255) COLLATE pg_catalog."default",
    name character varying(255) COLLATE pg_catalog."default",
    label character varying(255) COLLATE pg_catalog."default",
    search_text character varying(255) COLLATE pg_catalog."default",
    tenant_id uuid,
    CONSTRAINT report_pkey PRIMARY KEY (id),
    CONSTRAINT report_name_unq_key UNIQUE (tenant_id, name)
)
  • 如果你观察仔细,发现跟 device 表几乎一样,那是因为我们以 device 为原型做的二次开发。开发自己功能时,表的定义请随意啊。不过,name, type, search_text, id, customer_id, tenant_id, create_time 都是不能少的,因为框架业务逻辑有代码实现一些功能;
  • 建议增加几个不影响 ThingsBoard 框架工作的“扩展字段”,也可以直接使用 additional_info 来存储自己的数据,这个字段最长可以有 1G,足够放你需要的结构化或非结构化数据;类似:
{
    "hello": "world",
    "thingsboard": "iot",
    "company": "yiqisoft",
    "location": "shanghai",
    "protocol": ["coap", "mqtt", "http"]
}
  • 自由业务逻辑,针对“扩展字段”的操作,请随意,数据频繁修改的还是用自己的业务表,不要混为一谈。

业务逻辑实现

tb2.png
对于自己的业务逻辑开发方面,建议不要改动 ThingsBoard 的代码结构,可以使用外挂一个 jar,来独立实现业务功能,这个具体要看业务复杂度,是不是需要与 ThingsBoard 耦合,可以查看前期我们发布的文章:如何无缝扩展 ThingsBoard 功能?原来二次开发如此简单!如何无缝扩展 ThingsBoard 功能?原来二次开发如此简单!【续】

前端

代码结构

modified:   ui-ngx/src/app/core/http/entity.service.ts
new file:   ui-ngx/src/app/core/http/report.service.ts
modified:   ui-ngx/src/app/core/services/menu.service.ts
modified:   ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.models.ts
modified:   ui-ngx/src/app/modules/home/dialogs/add-entities-to-customer-dialog.component.ts
modified:   ui-ngx/src/app/modules/home/dialogs/assign-to-customer-dialog.component.ts
modified:   ui-ngx/src/app/modules/home/dialogs/home-dialogs.service.ts
modified:   ui-ngx/src/app/modules/home/pages/customer/customer-routing.module.ts
modified:   ui-ngx/src/app/modules/home/pages/customer/customer.component.html
modified:   ui-ngx/src/app/modules/home/pages/customer/customers-table-config.resolver.ts
modified:   ui-ngx/src/app/modules/home/pages/home-pages.module.ts
new file:   ui-ngx/src/app/modules/home/pages/report/report-routing.module.ts
new file:   ui-ngx/src/app/modules/home/pages/report/report-table-header.component.html
new file:   ui-ngx/src/app/modules/home/pages/report/report-table-header.component.scss
new file:   ui-ngx/src/app/modules/home/pages/report/report-table-header.component.ts
new file:   ui-ngx/src/app/modules/home/pages/report/report-tabs.component.html
new file:   ui-ngx/src/app/modules/home/pages/report/report-tabs.component.ts
new file:   ui-ngx/src/app/modules/home/pages/report/report.component.html
new file:   ui-ngx/src/app/modules/home/pages/report/report.component.scss
new file:   ui-ngx/src/app/modules/home/pages/report/report.component.ts
new file:   ui-ngx/src/app/modules/home/pages/report/report.module.ts
new file:   ui-ngx/src/app/modules/home/pages/report/reports-table-config.resolver.ts
modified:   ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts
modified:   ui-ngx/src/app/shared/components/entity/entity-subtype-autocomplete.component.ts
modified:   ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts
modified:   ui-ngx/src/app/shared/components/entity/entity-subtype-select.component.ts
modified:   ui-ngx/src/app/shared/components/footer.component.html
modified:   ui-ngx/src/app/shared/models/alias.models.ts
modified:   ui-ngx/src/app/shared/models/entity-type.models.ts
new file:   ui-ngx/src/app/shared/models/id/report-id.ts
new file:   ui-ngx/src/app/shared/models/report.models.ts
modified:   ui-ngx/src/assets/locale/locale.constant-en_US.json
modified:   ui-ngx/src/assets/locale/locale.constant-zh_CN.json
modified:   ui-ngx/src/assets/logo_title_white.svg
modified:   ui-ngx/src/environments/environment.prod.ts
modified:   ui-ngx/src/environments/environment.ts
modified:   ui-ngx/src/index.html
new file:   ui-ngx/src/yiqisoft.ico
  • 先复制一份 ui-ngx/src/app/modules/home/pages/device 到 ui-ngx/src/app/modules/home/pages/report,就是组件要完整,文件名都改掉,内部都是替换操作: Device -> Report, device -> report, DEVICE -> REPORT,这样我们的 ReportModule 就做好了;
  • 在 home-pages.module.ts 载入 ReportModule ;
  • 在 ui-ngx/src/app/core/services/menu.service.ts 增加需要的菜单;
  • 根据 report 目录里面的内容,需要从 device 功能模块复制一些组件和 service,挺多的,自己慢慢找,也算是熟悉一下前端代码;
  • 汉化,就是修改 ui-ngx/src/assets/locale/ 目录下相应的语言包,很容易;

功能扩展过程

前期,我们发布了文章,如何进行 ThingsBoard 前端二次开发?超强 Angular 框架帮你忙。,介绍如果用 Angular 二次开发的要点,具体看开发者对于 Angular 框架掌握程度。

功能扩展建议

  • 像上面的实体类型获取,增加功能特别不容易,因为代码实现是通过枚举值来 switch/case 来判断,那么新增功能模块时,需要改动太大了,如果能够独立到每个功能模块的组件中,那就方便多了。但是,前端代码就会有很多重复。为了扩展 REPORT,必须要找到所有的 case,上代码:

      switch (entity.id.entityType as EntityType | string) {
        case 'function':
          materialIcon = 'functions';
          break;
        case EntityType.DEVICE:
          materialIcon = 'devices_other';
          break;
        case EntityType.REPORT:
          materialIcon = 'equalizer';
          break;
        case EntityType.ASSET:
          materialIcon = 'domain';
          break;
      }
  • 更改 LOGO,需要使用工具做一个 svg 矢量图,替换 ui-ngx/src/assets/logo_title_white.svg 即可;
  • 更改 Swagger 显示内容需要改 thingsboard.yml;

欢迎大家一起来加入 ThingsBoard 讨论,可以在下方留言给作者。其实,我是最不建议在 ThingsBoard 上改动,我崇尚无缝扩展,但是太难了,所以才有了本文的发表,把自己的业务融入进去是一次尝试。当然,有能力的朋友可以自己独立做业务系统,对于简单物联网应用,何必重复制造轮子呢?

ThingsBoard 采用 Angular 框架作为前端,真是先知先觉。

当然,在国内,用 React 和 Vue 的前端居多,所以,不是很流行的 Angular 自然成为 ThingsBoard 二次开发的一大障碍。细细品,还是觉得 ThingsBoard 团队强大的先知先觉。

本文之前,有关 ThingsBoard 后端的无缝二次开发已经有比较详细的文章,请需要的朋友自取。

Angular 基础

学习一项新技术,新框架,难免要下苦功,熟读官方文档是必不可少的:https://angular.cn/

  • 框架理解
  • 基本语法
  • TypeScript
  • 教程:英雄指南
  • 自己操练一下,熟能生巧

ThingsBoard UI 架构

  • 由于 angular 这个框架本身包含了太多的组件和功能特性,好处是不需要额外的外部框架来支撑一站式解决,不好之处就是以前学过的框架可能无法直接拿来即用。
  • 因为有了 angular 基础,再来熟悉 ThingsBoard UI 会现对容易一些,层次分明,结构清晰。
  • 都说码 angular 项目过程像 Java 工程,所以,后端 Java 工程人员是一大捷径。

    Component 层

  • 无非就是建立自己的 Component:TS,路由,HTML,CSS;
  • 参考官方组件,做自己的组件就现对容易一些;

Service 层

  • 官方工程有很多例程,供参考,大都是比较好理解的(虽然没有注释);
  • 很多前端人员不太理解为什么要有服务层,我也不太理解,但是这样做的确是方便了扩展和复用;

其他

  • 结合后台业务逻辑,完成二次开发目标,需要很长的过程,毕竟这是一套功能强大的框架。
  • 也有人用 VUE 改写了 ThingsBoard UI 前端,感觉有点重复制造轮子,真的理解了在官方上面改造不是更好?

二次开发要点

新建组件

  • ng cli 自动创建组件或手工创建 src/app/modules/home/pages/report 目录来完成组件开发;
  • 依次创建 report.module.ts, report.component.ts, report.component.html, report.component.css, report-routing.module.ts, 其他需要的格式文件
  • 在 src/app/modules/home/pages/home-pages.module.ts 引入 ReportModule 组件;
    这样,组件算是创建好了。

业务逻辑

  • Service 创建集成
  • 业务逻辑实现

主菜单扩展

  • 在 src/app/core/services/menu.service.ts 下新增菜单,注意区分不同角色的菜单项目;
  • 在 src/assets/locale/ 下相应的语音包下新增翻译;
    menu.png

深化

后面的文章,将进一步讲解组件中每个文件的用法。敬请期待

使用 Jaspersoft Studio 设计报表样式

用你熟悉的环境设计一套最简单的模板,名称定义为: Reports.jrxml
report_template.png
下载源文件:report.jrxml

  • 语法比较特殊,需要努力学习

    <?xml version="1.0" encoding="UTF-8"?>
    <jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd" name="report1" pageWidth="595" pageHeight="842" columnWidth="555" leftMargin="20" rightMargin="20" topMargin="20" bottomMargin="20" uuid="84feeac3-d1f7-4503-84bf-fc3fa153df55">
      <queryString>
          <![CDATA[]]>
      </queryString>
      <field name="id" class="java.lang.Long"/>
      <field name="price" class="java.math.BigDecimal"/>
      <field name="quantity" class="java.lang.Integer"/>
      <field name="categoryName" class="java.lang.String"/>
      <field name="name" class="java.lang.String"/>
      <background>
          <band splitType="Stretch"/>
      </background>
      <title>
          <band height="36" splitType="Stretch"/>
      </title>
      <pageHeader>
          <band height="49" splitType="Stretch">
              <staticText>
                  <reportElement x="170" y="10" width="230" height="30" uuid="04d35299-5eb0-4a34-b340-3ca5cf8290fc"/>
                  <textElement>
                      <font fontName="宋体" size="20"/>
                  </textElement>
                  <text><![CDATA[Product List | 产品列表]]></text>
              </staticText>
          </band>
      </pageHeader>
      <columnHeader>
          <band height="37" splitType="Stretch">
              <staticText>
                  <reportElement x="0" y="10" width="70" height="20" uuid="bcaf85c0-57ff-4a34-af27-cbf1330125ae"/>
                  <textElement>
                      <font fontName="宋体" isBold="true"/>
                  </textElement>
                  <text><![CDATA[ID]]></text>
              </staticText>
              <staticText>
                  <reportElement x="90" y="10" width="100" height="20" uuid="faf9b9ee-7cf2-4ea5-80b8-d371c3c753b9"/>
                  <textElement>
                      <font fontName="宋体" isBold="true"/>
                  </textElement>
                  <text><![CDATA[名称]]></text>
              </staticText>
              <staticText>
                  <reportElement x="210" y="10" width="70" height="20" uuid="93727672-9f69-4856-aa26-33837e8dc417"/>
                  <textElement>
                      <font fontName="宋体" isBold="true"/>
                  </textElement>
                  <text><![CDATA[价格]]></text>
              </staticText>
              <staticText>
                  <reportElement x="314" y="10" width="70" height="20" uuid="09b47abe-09b2-479a-bb76-9770e361a23c"/>
                  <textElement>
                      <font fontName="宋体" isBold="true"/>
                  </textElement>
                  <text><![CDATA[数量]]></text>
              </staticText>
              <staticText>
                  <reportElement x="410" y="10" width="100" height="20" uuid="c258f47b-da6a-418d-a5e0-892388ec65c5"/>
                  <textElement>
                      <font fontName="宋体" isBold="true"/>
                  </textElement>
                  <text><![CDATA[类别]]></text>
              </staticText>
          </band>
      </columnHeader>
      <detail>
          <band height="39" splitType="Stretch">
              <textField>
                  <reportElement x="0" y="4" width="70" height="16" uuid="99fa4c16-cfe2-4482-828e-4439abb9e8f0"/>
                  <textFieldExpression><![CDATA[$F{id}]]></textFieldExpression>
              </textField>
              <textField>
                  <reportElement x="210" y="4" width="70" height="16" uuid="ba6e9cfa-6d4c-470b-8bc4-eb2e3411c21e"/>
                  <textFieldExpression><![CDATA[$F{price}]]></textFieldExpression>
              </textField>
              <textField>
                  <reportElement x="310" y="4" width="70" height="16" uuid="447253d3-d268-4873-b0b7-9c174c6a7c9f"/>
                  <textFieldExpression><![CDATA[$F{quantity}]]></textFieldExpression>
              </textField>
              <textField>
                  <reportElement x="410" y="4" width="100" height="16" uuid="8992d0ad-1f5e-4210-b50b-1e29dc66f119"/>
                  <textFieldExpression><![CDATA[$F{categoryName}]]></textFieldExpression>
              </textField>
              <line>
                  <reportElement x="-3" y="27" width="521" height="1" uuid="9b493a65-a499-4199-90bb-b3fedbe45f85"/>
              </line>
              <textField>
                  <reportElement x="90" y="4" width="100" height="16" uuid="fcdd8979-dc44-46e3-be56-11e5f33b8e0c">
                      <property name="com.jaspersoft.studio.spreadsheet.connectionID" value="73fad7b3-74b5-4436-bd46-d2b9c3c113fd"/>
                  </reportElement>
                  <textFieldExpression><![CDATA[$F{name}]]></textFieldExpression>
              </textField>
          </band>
      </detail>
      <columnFooter>
          <band height="45" splitType="Stretch"/>
      </columnFooter>
      <pageFooter>
          <band height="54" splitType="Stretch"/>
      </pageFooter>
      <summary>
          <band height="42" splitType="Stretch"/>
      </summary>
    </jasperReport>

    新建 Spring Boot 项目

  • 新建 Spring Boot 项目,不多说
    需包含的内容比较特殊,pom.xml,jasperreports 是依赖包,jasperreports-fonts 是字体支持包

          <dependency>
              <groupId>net.sf.jasperreports</groupId>
              <artifactId>jasperreports</artifactId>
              <version>6.15.0</version>
          </dependency>
          <dependency>
              <groupId>net.sf.jasperreports</groupId>
              <artifactId>jasperreports-fonts</artifactId>
              <version>6.15.0</version>
          </dependency>
          <dependency>
              <groupId>org.springframework</groupId>
              <artifactId>spring-support</artifactId>
              <version>2.0.8</version>
          </dependency>
  • 新建各种类
    controller, domain, repository, service, 其他,大概目录结构如下
    report2.png
  • 创建 Postgresql 数据表

    CREATE TABLE public.product
    (
      id bigint NOT NULL,
      name character varying(255) COLLATE pg_catalog."default",
      price numeric,
      quantity integer,
      category_name character varying(255) COLLATE pg_catalog."default",
      CONSTRAINT product_pkey PRIMARY KEY (id)
    )

集成 JasperReports 到 Spring Boot 项目

中文支持文章:Spring Boot 集成 JasperReports,并支持中文PDF

  • Service 服务层处理要求

    @RequestMapping(value = "/report", method = RequestMethod.GET)
      public void report(HttpServletResponse response, @RequestParam(name = "type", defaultValue = "pdf") String type) throws Exception {
    
          JRBeanCollectionDataSource dataSource = new JRBeanCollectionDataSource(productService.report());
          InputStream inputStream = this.getClass().getResourceAsStream("/report_templates/report.jrxml");
          JasperReport jasperReport = JasperCompileManager.compileReport(inputStream);
          JasperPrint jasperPrint = JasperFillManager.fillReport(jasperReport, null, dataSource);
    
          switch (type) {
          case "html":
              response.setContentType("text/html;charset=utf-8");
              HtmlExporter exporter = new HtmlExporter(DefaultJasperReportsContext.getInstance());
              exporter.setExporterInput(new SimpleExporterInput(jasperPrint));
              exporter.setExporterOutput(new SimpleHtmlExporterOutput(response.getWriter()));
              exporter.exportReport();
              break;
    
          case "pdf":
    
          default:
              JRPdfExporter exporterPdf = new JRPdfExporter();
              exporterPdf.setExporterInput(new SimpleExporterInput(jasperPrint));
              OutputStream outputStream = response.getOutputStream();
              exporterPdf.setExporterOutput(new SimpleOutputStreamExporterOutput(outputStream));
              exporterPdf.exportReport();
              break;
          }
    
      }
  • 可以是 HTML 版本
    r1.png
  • 也可以是 PDF 版本
    r2.png

源码下载: https://github.com/jiekechoo/YiqisoftReport

  • 新建 docker-compose.yml

    version: '2'
    services:
    web:
      image: odoo:14.0
      depends_on:
        - db
      ports:
        - "8069:8069"
      volumes:
        - odoo-web-data:/var/lib/odoo
        - ./config:/etc/odoo
        - ./addons:/mnt/extra-addons
    db:
      image: postgres:10
      environment:
        - POSTGRES_DB=postgres
        - POSTGRES_PASSWORD=odoo
        - POSTGRES_USER=odoo
        - PGDATA=/var/lib/postgresql/data/pgdata
      volumes:
        - odoo-db-data:/var/lib/postgresql/data/pgdata
    volumes:
    odoo-web-data:
    odoo-db-data:
  • 启动 docker

    docker-compose up -d
  • 打开浏览器 http://localhost:8069
  • 使用 Apache HTTPD 反向代理

    <VirtualHost *:80>
      DocumentRoot "/var/www/html/odoo.cn"
      ServerName www.odoo.cn
      RewriteEngine on
      RewriteCond %{SERVER_PORT} !^443$
      RewriteRule ^(.*)$ https://%{SERVER_NAME}$1 [L,R]
      <Directory "/var/www/html/odoo.cn">
          allow from all
          Options None
          Require all granted
      </Directory>
    </VirtualHost>
    <VirtualHost *:443>
      DocumentRoot "/var/www/html/odoo.cn"
      ServerName www.odoo.cn
      RewriteEngine on
      <Directory "/var/www/html/odoo.cn">
          allow from all
          Options FollowSymLinks
          Require all granted
          AllowOverride All 
      </Directory>
      SSLEngine on
      SSLCertificateFile /etc/httpd/conf.d/www.odoo.cn_public.crt
      SSLCertificateKeyFile /etc/httpd/conf.d/www.odoo.cn.key
      SSLCertificateChainFile /etc/httpd/conf.d/www.odoo.cn_chain.crt
      ProxyRequests Off
      ProxyPreserveHost On
      ProxyPass / http://127.0.0.1:8069/
      ProxyPassReverse / http://127.0.0.1:8069/
      ProxyErrorOverride off
    </VirtualHost>
  • 打开浏览器 http://www.odoo.cn ,直接访问域名,注意要使用 https 需申请域名 ssl 证书

软件平台演示

产品特性

YiAIR-86-印刷-2.png

  • 无线远传:NB-IoT全网通
  • 干电池供电:2节AA电池(5号)
  • 静态功耗:< 20uA,可用1年*
  • 温/湿度精度:±0.2℃/±3%
  • 私有化 IoT 平台:多租户管理
  • 尺寸:86 x 86 x 30mm

适用范围

  • 供暖、冷链、厂房、仓库
  • 办公室、会议室、学校、幼儿园
  • 酒店、医院、商场、咖啡厅
  • 酒吧、餐馆、地铁、地下室

多租户

多租户.png

仪表板

dashboard.png