From 3120af6464f3ed3f63834dea07cfc6470e0ef6ca Mon Sep 17 00:00:00 2001 From: 2995840376 <2995840376@xiaomi.com> Date: Tue, 27 Oct 2026 12:45:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(C2-C3):=20=E5=90=8E=E5=8F=B0=E4=BA=A7?= =?UTF-8?q?=E5=93=81/=E8=AE=BE=E5=A4=87=E7=AE=A1=E7=90=86=E9=87=8D?= =?UTF-8?q?=E6=9E=84=20-=20=E4=BA=A7=E5=93=81=E4=B8=AD=E5=BF=83=20+=20?= =?UTF-8?q?=E5=85=B3=E7=B3=BB=E5=8F=AF=E8=A7=86=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - Product 实体新增统计字段(deviceCount/propertyCount/eventCount/ mainSwitchCode/mainSwitchName, 非持久化) - ProductServiceImpl.fillProductStats(): 列表批量回填 一次 selectBatchIds, 无 N+1; 让产品-设备-物模型的关系在 UI 层可见 (此前关系在数据层存在但 UI 层完全不可见, 这就是'看起来乱'的根因) - Device 实体新增 productName/productKey 非持久化字段 - DeviceServiceImpl.fillProductInfo(): 设备列表回填所属产品 前端 api/product.ts: - Product/ProductProperty/ControlItem 接口补齐语义与统计字段 - 新增 syncProductSpec(): 从涂鸦云同步物模型 - 新增 listProductDevices(): 复用设备列表按 productId 过滤 产品列表 (ProductList.vue): - 新增'物模型'列: DP 数量 + 主开关名 (未同步显示告警标签) - 新增'设备'列: 挂载台数, 点击跳转设备列表并带 productId 过滤 - 新增'同步'按钮: 从涂鸦云同步物模型 - 新增'详情'按钮 -> 产品详情页 - 删除提示改为动态显示设备数 新增产品详情页 (ProductDetail.vue): - 概览统计卡: 设备数/DP数/事件数/主开关 - 5 Tab: 基本信息 / 物模型 / 设备 / 固件与OTA - 物模型 Tab 展示 semantic/grp/isPrimary/dpId, 并可切换属性/事件/服务 设备列表 (DeviceList.vue): - 新增'所属产品'列, 可点击跳产品详情, 未关联显示'未关联' - 新增'所属产品'筛选下拉(含各产品设备数) - 支持从产品页跳转带 productId 参数自动筛选 MainLayout.vue 修复: - groupedRoutes 未过滤 meta.hidden, 详情页会混进侧边栏菜单 验证: mvn 编译通过; npx vite build 通过(ProductDetail chunk 已产出) --- .../java/com/iot/device/entity/Product.java | 23 ++ .../service/impl/ProductServiceImpl.java | 49 +++- frontend/admin/src/api/product.ts | 27 ++ frontend/admin/src/components.d.ts | 2 + frontend/admin/src/layout/MainLayout.vue | 4 + frontend/admin/src/router/index.ts | 6 + .../admin/src/views/Device/DeviceList.vue | 59 +++- .../admin/src/views/Product/ProductDetail.vue | 267 ++++++++++++++++++ .../admin/src/views/Product/ProductList.vue | 53 +++- 9 files changed, 484 insertions(+), 6 deletions(-) create mode 100644 frontend/admin/src/views/Product/ProductDetail.vue diff --git a/backend/iot-device/src/main/java/com/iot/device/entity/Product.java b/backend/iot-device/src/main/java/com/iot/device/entity/Product.java index 1cda92d..7479d92 100644 --- a/backend/iot-device/src/main/java/com/iot/device/entity/Product.java +++ b/backend/iot-device/src/main/java/com/iot/device/entity/Product.java @@ -1,5 +1,6 @@ package com.iot.device.entity; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import com.iot.common.entity.BaseEntity; import io.swagger.v3.oas.annotations.media.Schema; @@ -53,4 +54,26 @@ public class Product extends BaseEntity { @Schema(description = "物模型版本") private Integer specVersion; + + // ===== 列表展示统计(非持久化,列表查询时批量回填)===== + + @Schema(description = "挂载设备数") + @TableField(exist = false) + private Long deviceCount; + + @Schema(description = "物模型属性(DP)数量") + @TableField(exist = false) + private Integer propertyCount; + + @Schema(description = "物模型事件数") + @TableField(exist = false) + private Integer eventCount; + + @Schema(description = "主开关功能点code") + @TableField(exist = false) + private String mainSwitchCode; + + @Schema(description = "主开关功能点名称") + @TableField(exist = false) + private String mainSwitchName; } diff --git a/backend/iot-device/src/main/java/com/iot/device/service/impl/ProductServiceImpl.java b/backend/iot-device/src/main/java/com/iot/device/service/impl/ProductServiceImpl.java index 12549d1..11bd441 100644 --- a/backend/iot-device/src/main/java/com/iot/device/service/impl/ProductServiceImpl.java +++ b/backend/iot-device/src/main/java/com/iot/device/service/impl/ProductServiceImpl.java @@ -5,6 +5,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.iot.common.constants.PlatformType; import java.util.ArrayList; +import java.util.Map; +import java.util.stream.Collectors; import com.iot.common.exception.BusinessException; import com.iot.common.result.ResultCode; import com.iot.device.dto.ProductSpec; @@ -92,7 +94,52 @@ public class ProductServiceImpl implements ProductService { w.eq(Product::getPlatform, platform); } w.orderByDesc(Product::getCreateTime); - return productMapper.selectPage(new Page<>(page, size), w); + Page result = productMapper.selectPage(new Page<>(page, size), w); + fillProductStats(result.getRecords()); + return result; + } + + /** + * 批量回填产品统计:挂载设备数 / 物模型DP数 / 事件数 / 主开关。 + * + *

后台产品列表据此一眼判断三件事:物模型齐不齐、挂了哪些设备、主开关是谁 —— + * 这正是此前"产品与设备管理看起来乱"的根因(关系在数据层存在但 UI 层不可见)。

+ */ + private void fillProductStats(List products) { + if (products == null || products.isEmpty()) { + return; + } + List pids = products.stream().map(Product::getId).toList(); + // 设备数 + Map devCounts = deviceMapper.selectList(new LambdaQueryWrapper() + .in(Device::getProductId, pids)) + .stream().filter(d -> d.getProductId() != null) + .collect(Collectors.groupingBy(Device::getProductId, Collectors.counting())); + // 属性数 + 主开关 + List props = productPropertyMapper.selectList( + new LambdaQueryWrapper().in(ProductProperty::getProductId, pids)); + Map> propsByProduct = props.stream() + .filter(p -> p.getProductId() != null) + .collect(Collectors.groupingBy(ProductProperty::getProductId)); + // 事件数 + Map eventCounts = productEventMapper.selectList(new LambdaQueryWrapper() + .in(ProductEvent::getProductId, pids)) + .stream().filter(e -> e.getProductId() != null) + .collect(Collectors.groupingBy(ProductEvent::getProductId, Collectors.counting())); + + for (Product p : products) { + p.setDeviceCount(devCounts.getOrDefault(p.getId(), 0L)); + p.setEventCount(eventCounts.getOrDefault(p.getId(), 0L).intValue()); + List pp = propsByProduct.getOrDefault(p.getId(), List.of()); + p.setPropertyCount(pp.size()); + ProductProperty primary = pp.stream() + .filter(x -> x.getIsPrimary() != null && x.getIsPrimary() == 1) + .findFirst().orElse(null); + if (primary != null) { + p.setMainSwitchCode(primary.getCode()); + p.setMainSwitchName(primary.getName()); + } + } } @Override diff --git a/frontend/admin/src/api/product.ts b/frontend/admin/src/api/product.ts index f86f2aa..561e5ba 100644 --- a/frontend/admin/src/api/product.ts +++ b/frontend/admin/src/api/product.ts @@ -10,6 +10,7 @@ export interface Product { platform?: string platformProductId?: string connectType?: string + protocol?: string netConfigType?: string icon?: string description?: string @@ -17,6 +18,12 @@ export interface Product { specVersion?: number createTime?: string updateTime?: string + // 列表统计(后端批量回填) + deviceCount?: number + propertyCount?: number + eventCount?: number + mainSwitchCode?: string + mainSwitchName?: string } export interface ProductProperty { @@ -31,9 +38,16 @@ export interface ProductProperty { step?: string unit?: string enumList?: string + enumLabel?: string dpId?: string defaultValue?: string sort?: number + // 语义扩展(后端 SpecSemanticResolver 落库) + semantic?: string + grp?: string + isPrimary?: number + channel?: string + completionMode?: string } export interface ProductEvent { @@ -152,10 +166,15 @@ export interface ControlItem { step?: number unit?: string enumValues?: string[] + enumLabels?: Record defaultValue?: string sort?: number writable?: boolean value?: any + // 语义扩展 + semantic?: string + grp?: string + isPrimary?: number } export interface ControlSchema { @@ -171,3 +190,11 @@ export interface ControlSchema { export const getControlSchema = (id: number) => request({ url: `/products/${id}/control-schema`, method: 'get' }) + +// 从涂鸦云同步产品物模型(语义/可写性/主开关一并重算) +export const syncProductSpec = (id: number) => + request({ url: `/products/${id}/reconcile-spec`, method: 'post' }) + +// 产品下的设备(复用设备列表接口按 productId 过滤) +export const listProductDevices = (id: number, params?: Record) => + request({ url: '/devices', method: 'get', params: { ...(params || {}), productId: id } }) diff --git a/frontend/admin/src/components.d.ts b/frontend/admin/src/components.d.ts index 21c1313..3e9cc59 100644 --- a/frontend/admin/src/components.d.ts +++ b/frontend/admin/src/components.d.ts @@ -34,10 +34,12 @@ declare module 'vue' { ElIcon: typeof import('element-plus/es')['ElIcon'] ElInput: typeof import('element-plus/es')['ElInput'] ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] + ElLink: typeof import('element-plus/es')['ElLink'] ElMain: typeof import('element-plus/es')['ElMain'] ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElOption: typeof import('element-plus/es')['ElOption'] + ElPageHeader: typeof import('element-plus/es')['ElPageHeader'] ElPagination: typeof import('element-plus/es')['ElPagination'] ElProgress: typeof import('element-plus/es')['ElProgress'] ElRadio: typeof import('element-plus/es')['ElRadio'] diff --git a/frontend/admin/src/layout/MainLayout.vue b/frontend/admin/src/layout/MainLayout.vue index bc1cda4..484ad22 100644 --- a/frontend/admin/src/layout/MainLayout.vue +++ b/frontend/admin/src/layout/MainLayout.vue @@ -118,6 +118,10 @@ const menuRoutes = computed(() => { const groupedRoutes = computed(() => { const groups: Record = { device: [], alarm: [], system: [] } for (const r of menuRoutes.value) { + // hidden 的路由(如详情页)不进入侧边栏菜单 + if (r.meta?.hidden) { + continue + } if (r.meta?.group && groups[r.meta.group]) { groups[r.meta.group].push(r) } diff --git a/frontend/admin/src/router/index.ts b/frontend/admin/src/router/index.ts index 00a91e0..43b24a9 100644 --- a/frontend/admin/src/router/index.ts +++ b/frontend/admin/src/router/index.ts @@ -67,6 +67,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/Product/ProductList.vue'), meta: { title: '产品管理', icon: 'Box', group: 'device' } }, + { + path: '/products/:id', + name: 'ProductDetail', + component: () => import('@/views/Product/ProductDetail.vue'), + meta: { title: '产品详情', hidden: true, group: 'device' } + }, { path: '/system/users', name: 'SystemUsers', diff --git a/frontend/admin/src/views/Device/DeviceList.vue b/frontend/admin/src/views/Device/DeviceList.vue index ce8be41..cde3a83 100644 --- a/frontend/admin/src/views/Device/DeviceList.vue +++ b/frontend/admin/src/views/Device/DeviceList.vue @@ -51,6 +51,23 @@ + + + + + 搜索 重置 @@ -76,6 +93,14 @@ {{ getOwnerName(row.ownerId) }} + + + - - + + + + + + + +