feat(C2-C3): 后台产品/设备管理重构 - 产品中心 + 关系可视化
后端: - 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 已产出)
This commit is contained in:
parent
fdf0e265fc
commit
3120af6464
@ -1,5 +1,6 @@
|
|||||||
package com.iot.device.entity;
|
package com.iot.device.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
import com.iot.common.entity.BaseEntity;
|
import com.iot.common.entity.BaseEntity;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
@ -53,4 +54,26 @@ public class Product extends BaseEntity {
|
|||||||
|
|
||||||
@Schema(description = "物模型版本")
|
@Schema(description = "物模型版本")
|
||||||
private Integer specVersion;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|||||||
import com.iot.common.constants.PlatformType;
|
import com.iot.common.constants.PlatformType;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import com.iot.common.exception.BusinessException;
|
import com.iot.common.exception.BusinessException;
|
||||||
import com.iot.common.result.ResultCode;
|
import com.iot.common.result.ResultCode;
|
||||||
import com.iot.device.dto.ProductSpec;
|
import com.iot.device.dto.ProductSpec;
|
||||||
@ -92,7 +94,52 @@ public class ProductServiceImpl implements ProductService {
|
|||||||
w.eq(Product::getPlatform, platform);
|
w.eq(Product::getPlatform, platform);
|
||||||
}
|
}
|
||||||
w.orderByDesc(Product::getCreateTime);
|
w.orderByDesc(Product::getCreateTime);
|
||||||
return productMapper.selectPage(new Page<>(page, size), w);
|
Page<Product> result = productMapper.selectPage(new Page<>(page, size), w);
|
||||||
|
fillProductStats(result.getRecords());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量回填产品统计:挂载设备数 / 物模型DP数 / 事件数 / 主开关。
|
||||||
|
*
|
||||||
|
* <p>后台产品列表据此一眼判断三件事:物模型齐不齐、挂了哪些设备、主开关是谁 ——
|
||||||
|
* 这正是此前"产品与设备管理看起来乱"的根因(关系在数据层存在但 UI 层不可见)。</p>
|
||||||
|
*/
|
||||||
|
private void fillProductStats(List<Product> products) {
|
||||||
|
if (products == null || products.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<Long> pids = products.stream().map(Product::getId).toList();
|
||||||
|
// 设备数
|
||||||
|
Map<Long, Long> devCounts = deviceMapper.selectList(new LambdaQueryWrapper<Device>()
|
||||||
|
.in(Device::getProductId, pids))
|
||||||
|
.stream().filter(d -> d.getProductId() != null)
|
||||||
|
.collect(Collectors.groupingBy(Device::getProductId, Collectors.counting()));
|
||||||
|
// 属性数 + 主开关
|
||||||
|
List<ProductProperty> props = productPropertyMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<ProductProperty>().in(ProductProperty::getProductId, pids));
|
||||||
|
Map<Long, List<ProductProperty>> propsByProduct = props.stream()
|
||||||
|
.filter(p -> p.getProductId() != null)
|
||||||
|
.collect(Collectors.groupingBy(ProductProperty::getProductId));
|
||||||
|
// 事件数
|
||||||
|
Map<Long, Long> eventCounts = productEventMapper.selectList(new LambdaQueryWrapper<ProductEvent>()
|
||||||
|
.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<ProductProperty> 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
|
@Override
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export interface Product {
|
|||||||
platform?: string
|
platform?: string
|
||||||
platformProductId?: string
|
platformProductId?: string
|
||||||
connectType?: string
|
connectType?: string
|
||||||
|
protocol?: string
|
||||||
netConfigType?: string
|
netConfigType?: string
|
||||||
icon?: string
|
icon?: string
|
||||||
description?: string
|
description?: string
|
||||||
@ -17,6 +18,12 @@ export interface Product {
|
|||||||
specVersion?: number
|
specVersion?: number
|
||||||
createTime?: string
|
createTime?: string
|
||||||
updateTime?: string
|
updateTime?: string
|
||||||
|
// 列表统计(后端批量回填)
|
||||||
|
deviceCount?: number
|
||||||
|
propertyCount?: number
|
||||||
|
eventCount?: number
|
||||||
|
mainSwitchCode?: string
|
||||||
|
mainSwitchName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductProperty {
|
export interface ProductProperty {
|
||||||
@ -31,9 +38,16 @@ export interface ProductProperty {
|
|||||||
step?: string
|
step?: string
|
||||||
unit?: string
|
unit?: string
|
||||||
enumList?: string
|
enumList?: string
|
||||||
|
enumLabel?: string
|
||||||
dpId?: string
|
dpId?: string
|
||||||
defaultValue?: string
|
defaultValue?: string
|
||||||
sort?: number
|
sort?: number
|
||||||
|
// 语义扩展(后端 SpecSemanticResolver 落库)
|
||||||
|
semantic?: string
|
||||||
|
grp?: string
|
||||||
|
isPrimary?: number
|
||||||
|
channel?: string
|
||||||
|
completionMode?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductEvent {
|
export interface ProductEvent {
|
||||||
@ -152,10 +166,15 @@ export interface ControlItem {
|
|||||||
step?: number
|
step?: number
|
||||||
unit?: string
|
unit?: string
|
||||||
enumValues?: string[]
|
enumValues?: string[]
|
||||||
|
enumLabels?: Record<string, string>
|
||||||
defaultValue?: string
|
defaultValue?: string
|
||||||
sort?: number
|
sort?: number
|
||||||
writable?: boolean
|
writable?: boolean
|
||||||
value?: any
|
value?: any
|
||||||
|
// 语义扩展
|
||||||
|
semantic?: string
|
||||||
|
grp?: string
|
||||||
|
isPrimary?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ControlSchema {
|
export interface ControlSchema {
|
||||||
@ -171,3 +190,11 @@ export interface ControlSchema {
|
|||||||
|
|
||||||
export const getControlSchema = (id: number) =>
|
export const getControlSchema = (id: number) =>
|
||||||
request({ url: `/products/${id}/control-schema`, method: 'get' })
|
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<string, any>) =>
|
||||||
|
request({ url: '/devices', method: 'get', params: { ...(params || {}), productId: id } })
|
||||||
|
|||||||
2
frontend/admin/src/components.d.ts
vendored
2
frontend/admin/src/components.d.ts
vendored
@ -34,10 +34,12 @@ declare module 'vue' {
|
|||||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||||
|
ElLink: typeof import('element-plus/es')['ElLink']
|
||||||
ElMain: typeof import('element-plus/es')['ElMain']
|
ElMain: typeof import('element-plus/es')['ElMain']
|
||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
|
ElPageHeader: typeof import('element-plus/es')['ElPageHeader']
|
||||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||||
|
|||||||
@ -118,6 +118,10 @@ const menuRoutes = computed(() => {
|
|||||||
const groupedRoutes = computed(() => {
|
const groupedRoutes = computed(() => {
|
||||||
const groups: Record<string, any[]> = { device: [], alarm: [], system: [] }
|
const groups: Record<string, any[]> = { device: [], alarm: [], system: [] }
|
||||||
for (const r of menuRoutes.value) {
|
for (const r of menuRoutes.value) {
|
||||||
|
// hidden 的路由(如详情页)不进入侧边栏菜单
|
||||||
|
if (r.meta?.hidden) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (r.meta?.group && groups[r.meta.group]) {
|
if (r.meta?.group && groups[r.meta.group]) {
|
||||||
groups[r.meta.group].push(r)
|
groups[r.meta.group].push(r)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,6 +67,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/Product/ProductList.vue'),
|
component: () => import('@/views/Product/ProductList.vue'),
|
||||||
meta: { title: '产品管理', icon: 'Box', group: 'device' }
|
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',
|
path: '/system/users',
|
||||||
name: 'SystemUsers',
|
name: 'SystemUsers',
|
||||||
|
|||||||
@ -51,6 +51,23 @@
|
|||||||
<el-option label="异常" value="error" />
|
<el-option label="异常" value="error" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="所属产品">
|
||||||
|
<el-select
|
||||||
|
v-model="queryForm.productId"
|
||||||
|
placeholder="全部产品"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
style="width: 190px"
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="p in productOptions"
|
||||||
|
:key="p.id"
|
||||||
|
:label="`${p.name || '未命名'} (${p.deviceCount ?? 0}台)`"
|
||||||
|
:value="p.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
|
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
|
||||||
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
|
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
|
||||||
@ -76,6 +93,14 @@
|
|||||||
{{ getOwnerName(row.ownerId) }}
|
{{ getOwnerName(row.ownerId) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="所属产品" min-width="140" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link v-if="row.productId" type="primary" @click="goProduct(row)">
|
||||||
|
{{ row.productName || row.productKey || row.productId }}
|
||||||
|
</el-link>
|
||||||
|
<span v-else class="muted">未关联</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="分组" width="120">
|
<el-table-column label="分组" width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ getGroupName(row.groupId) }}
|
{{ getGroupName(row.groupId) }}
|
||||||
@ -162,14 +187,16 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import { Plus, Search, Refresh } from '@element-plus/icons-vue'
|
import { Plus, Search, Refresh } from '@element-plus/icons-vue'
|
||||||
import { getDeviceList, createDevice, updateDevice, deleteDevice } from '@/api/device'
|
import { getDeviceList, createDevice, updateDevice, deleteDevice } from '@/api/device'
|
||||||
import { getGroupTree, type DeviceGroup } from '@/api/deviceGroup'
|
import { getGroupTree, type DeviceGroup } from '@/api/deviceGroup'
|
||||||
import { getMemberList, type Member } from '@/api/member'
|
import { getMemberList, type Member } from '@/api/member'
|
||||||
|
import { listProducts, type Product } from '@/api/product'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
@ -195,6 +222,8 @@ const queryForm = reactive({
|
|||||||
status: '',
|
status: '',
|
||||||
ownerId: null as number | null,
|
ownerId: null as number | null,
|
||||||
groupId: null as number | null,
|
groupId: null as number | null,
|
||||||
|
productId: null as number | null,
|
||||||
|
productKeyword: '',
|
||||||
pageNum: 1,
|
pageNum: 1,
|
||||||
pageSize: 10
|
pageSize: 10
|
||||||
})
|
})
|
||||||
@ -296,12 +325,26 @@ const handleSearch = () => {
|
|||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
Object.assign(queryForm, {
|
Object.assign(queryForm, {
|
||||||
deviceId: '', deviceName: '', deviceType: '', status: '',
|
deviceId: '', deviceName: '', deviceType: '', status: '',
|
||||||
ownerId: null, groupId: null,
|
ownerId: null, groupId: null, productId: null, productKeyword: '',
|
||||||
pageNum: 1, pageSize: 10
|
pageNum: 1, pageSize: 10
|
||||||
})
|
})
|
||||||
loadData()
|
loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 所属产品 ----------
|
||||||
|
const productOptions = ref<Product[]>([])
|
||||||
|
const loadProducts = async () => {
|
||||||
|
try {
|
||||||
|
const res: any = await listProducts({ page: 1, size: 500 })
|
||||||
|
productOptions.value = res?.data?.records || res?.records || res?.data || []
|
||||||
|
} catch {
|
||||||
|
productOptions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const goProduct = (row: any) => {
|
||||||
|
if (row.productId) router.push(`/products/${row.productId}`)
|
||||||
|
}
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
dialogTitle.value = '新增设备'
|
dialogTitle.value = '新增设备'
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
@ -370,14 +413,26 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
/* ========== 初始化 ========== */
|
/* ========== 初始化 ========== */
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// 支持从产品页跳转过来时自动带上产品筛选
|
||||||
|
const pid = route.query.productId
|
||||||
|
if (pid) {
|
||||||
|
const n = Number(pid)
|
||||||
|
if (!Number.isNaN(n)) queryForm.productId = n
|
||||||
|
}
|
||||||
loadData()
|
loadData()
|
||||||
loadMembers()
|
loadMembers()
|
||||||
loadGroups()
|
loadGroups()
|
||||||
|
loadProducts()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.device-list {
|
.device-list {
|
||||||
|
.muted {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
267
frontend/admin/src/views/Product/ProductDetail.vue
Normal file
267
frontend/admin/src/views/Product/ProductDetail.vue
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<el-page-header @back="goBack" class="mb16">
|
||||||
|
<template #content>
|
||||||
|
<span class="ph-title">{{ product?.name || '产品详情' }}</span>
|
||||||
|
<el-tag v-if="product?.platform" size="small" class="ml8">{{ product.platform }}</el-tag>
|
||||||
|
<el-tag v-if="product?.status" :type="product.status === 'published' ? 'success' : 'info'" size="small" class="ml8">
|
||||||
|
{{ product.status === 'published' ? '已发布' : '草稿' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-page-header>
|
||||||
|
|
||||||
|
<!-- 概览统计 -->
|
||||||
|
<el-row :gutter="12" class="mb16">
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<div class="stat-label">挂载设备</div>
|
||||||
|
<div class="stat-value">{{ product?.deviceCount ?? 0 }}</div>
|
||||||
|
<el-link type="primary" @click="tab = 'devices'">查看设备</el-link>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<div class="stat-label">功能点(DP)</div>
|
||||||
|
<div class="stat-value">{{ product?.propertyCount ?? 0 }}</div>
|
||||||
|
<el-link type="primary" @click="tab = 'spec'">查看物模型</el-link>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<div class="stat-label">事件</div>
|
||||||
|
<div class="stat-value">{{ product?.eventCount ?? 0 }}</div>
|
||||||
|
<el-link type="primary" @click="tab = 'spec'">查看物模型</el-link>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<div class="stat-label">主开关</div>
|
||||||
|
<div class="stat-value sm">{{ product?.mainSwitchName || product?.mainSwitchCode || '未识别' }}</div>
|
||||||
|
<div class="muted">{{ product?.mainSwitchCode || '—' }}</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-card shadow="never">
|
||||||
|
<el-tabs v-model="tab">
|
||||||
|
<!-- ============ 基本信息 ============ -->
|
||||||
|
<el-tab-pane label="基本信息" name="info">
|
||||||
|
<el-descriptions :column="3" border>
|
||||||
|
<el-descriptions-item label="产品编码">{{ product?.productKey || '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="产品名称">{{ product?.name || '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="品类">{{ product?.category || '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="平台">{{ product?.platform || '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="平台产品ID">{{ product?.platformProductId || '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="联网协议">{{ product?.protocol || product?.connectType || '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="厂商">{{ product?.manufacturer || '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="型号">{{ product?.model || '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="物模型版本">v{{ product?.specVersion ?? 1 }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="说明" :span="3">{{ product?.description || '—' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- ============ 物模型 ============ -->
|
||||||
|
<el-tab-pane label="物模型" name="spec">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-radio-group v-model="specType">
|
||||||
|
<el-radio-button label="properties">属性</el-radio-button>
|
||||||
|
<el-radio-button label="events">事件</el-radio-button>
|
||||||
|
<el-radio-button label="actions">服务</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
<div>
|
||||||
|
<el-button size="small" :loading="syncing" @click="onSync">从涂鸦云同步物模型</el-button>
|
||||||
|
<el-button size="small" @click="onEditSpec">编辑物模型</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-table :data="specRows" border stripe size="small">
|
||||||
|
<el-table-column prop="code" label="功能点" width="170" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column label="类型" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" type="info">{{ row.type || '—' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="权限" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="row.access === 'write' || row.access === 'read,write' ? 'success' : 'info'">
|
||||||
|
{{ row.access || '—' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="语义" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.semantic" size="small" :type="semanticType(row.semantic)">{{ row.semantic }}</el-tag>
|
||||||
|
<span v-else class="muted">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="分组" width="100">
|
||||||
|
<template #default="{ row }">{{ row.grp || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="DP别名" width="90">
|
||||||
|
<template #default="{ row }">{{ row.dpId || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="主开关" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.isPrimary === 1" size="small" type="danger">主</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="sort" label="排序" width="70" align="center" />
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- ============ 设备 ============ -->
|
||||||
|
<el-tab-pane label="设备" name="devices">
|
||||||
|
<el-table :data="devices" border stripe size="small" v-loading="loadingDevices">
|
||||||
|
<el-table-column prop="deviceId" label="设备ID" width="170" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="deviceName" label="设备名称" min-width="150" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="platform" label="平台" width="90" />
|
||||||
|
<el-table-column prop="deviceType" label="类型" width="100" />
|
||||||
|
<el-table-column prop="status" label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="row.status === 'online' ? 'success' : 'info'">
|
||||||
|
{{ row.status === 'online' ? '在线' : '离线' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="location" label="位置" min-width="120" show-overflow-tooltip />
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- ============ 固件与 OTA ============ -->
|
||||||
|
<el-tab-pane label="固件与OTA" name="ota">
|
||||||
|
<ota-panel :product-id="productId" :product-name="product?.name" />
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 物模型编辑 -->
|
||||||
|
<el-drawer v-model="specVisible" title="编辑物模型" size="72%" destroy-on-close>
|
||||||
|
<spec-editor v-if="specVisible" :product-id="productId" :product-name="product?.name" @saved="loadSpec" />
|
||||||
|
</el-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import {
|
||||||
|
getProduct, getProductSpec, listProductDevices, syncProductSpec,
|
||||||
|
type Product, type ProductSpec
|
||||||
|
} from '@/api/product'
|
||||||
|
import SpecEditor from './components/SpecEditor.vue'
|
||||||
|
import OtaPanel from './components/OtaPanel.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const productId = Number(route.params.id)
|
||||||
|
|
||||||
|
const product = ref<Product | null>(null)
|
||||||
|
const spec = ref<ProductSpec>({})
|
||||||
|
const devices = ref<any[]>([])
|
||||||
|
const tab = ref('info')
|
||||||
|
const specType = ref<'properties' | 'events' | 'actions'>('properties')
|
||||||
|
const specVisible = ref(false)
|
||||||
|
const syncing = ref(false)
|
||||||
|
const loadingDevices = ref(false)
|
||||||
|
|
||||||
|
const specRows = computed<any[]>(() => {
|
||||||
|
if (specType.value === 'properties') return spec.value.properties || []
|
||||||
|
if (specType.value === 'events') return spec.value.events || []
|
||||||
|
return spec.value.actions || []
|
||||||
|
})
|
||||||
|
|
||||||
|
const semanticType = (s: string) =>
|
||||||
|
(s === 'power' ? 'danger' : s === 'switch' ? 'warning' : 'info') as any
|
||||||
|
|
||||||
|
const loadProduct = async () => {
|
||||||
|
try {
|
||||||
|
const res: any = await getProduct(productId)
|
||||||
|
product.value = res?.data || res
|
||||||
|
} catch {
|
||||||
|
product.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSpec = async () => {
|
||||||
|
try {
|
||||||
|
const res: any = await getProductSpec(productId)
|
||||||
|
spec.value = res?.data || res || {}
|
||||||
|
} catch {
|
||||||
|
spec.value = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadDevices = async () => {
|
||||||
|
loadingDevices.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await listProductDevices(productId, { pageNum: 1, pageSize: 200 })
|
||||||
|
const d = res?.data
|
||||||
|
devices.value = d?.records || d || []
|
||||||
|
} catch {
|
||||||
|
devices.value = []
|
||||||
|
} finally {
|
||||||
|
loadingDevices.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSync = async () => {
|
||||||
|
syncing.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await syncProductSpec(productId)
|
||||||
|
ElMessage.success(`物模型已同步,更新 ${res?.data ?? 0} 个功能点`)
|
||||||
|
await Promise.all([loadProduct(), loadSpec()])
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '同步失败')
|
||||||
|
} finally {
|
||||||
|
syncing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEditSpec = () => { specVisible.value = true }
|
||||||
|
|
||||||
|
const goBack = () => router.push('/product')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadProduct()
|
||||||
|
loadSpec()
|
||||||
|
loadDevices()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.page-container {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.mb16 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.ml8 {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
.ph-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.stat-value.sm {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -48,10 +48,31 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="specVersion" label="物模型版本" width="95" align="center" />
|
<el-table-column prop="specVersion" label="物模型版本" width="95" align="center" />
|
||||||
<el-table-column prop="createTime" label="创建时间" width="165" />
|
<el-table-column label="物模型" width="160" align="center">
|
||||||
<el-table-column label="操作" width="280" fixed="right">
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.propertyCount" type="success" size="small">
|
||||||
|
{{ row.propertyCount }} 个 DP
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-else type="warning" size="small">未同步</el-tag>
|
||||||
|
<div v-if="row.mainSwitchCode" class="muted mt4">
|
||||||
|
主开关:{{ row.mainSwitchName || row.mainSwitchCode }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="设备" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link v-if="row.deviceCount" type="primary" @click="goDevices(row)">
|
||||||
|
{{ row.deviceCount }} 台
|
||||||
|
</el-link>
|
||||||
|
<span v-else class="muted">0</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createTime" label="创建时间" width="165" />
|
||||||
|
<el-table-column label="操作" width="380" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="goDetail(row)">详情</el-button>
|
||||||
<el-button size="small" @click="openSpec(row)">物模型</el-button>
|
<el-button size="small" @click="openSpec(row)">物模型</el-button>
|
||||||
|
<el-button size="small" :loading="syncingId === row.id" @click="onSyncSpec(row)">同步</el-button>
|
||||||
<el-button size="small" type="primary" plain @click="openOta(row)">固件OTA</el-button>
|
<el-button size="small" type="primary" plain @click="openOta(row)">固件OTA</el-button>
|
||||||
<el-button size="small" type="primary" @click="openEdit(row)">编辑</el-button>
|
<el-button size="small" type="primary" @click="openEdit(row)">编辑</el-button>
|
||||||
<el-button size="small" type="danger" plain @click="onDelete(row)">删除</el-button>
|
<el-button size="small" type="danger" plain @click="onDelete(row)">删除</el-button>
|
||||||
@ -127,9 +148,11 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
listProducts, createProduct, updateProduct, deleteProduct, reconcileProducts,
|
listProducts, createProduct, updateProduct, deleteProduct, reconcileProducts,
|
||||||
|
syncProductSpec,
|
||||||
type Product
|
type Product
|
||||||
} from '@/api/product'
|
} from '@/api/product'
|
||||||
import SpecEditor from './components/SpecEditor.vue'
|
import SpecEditor from './components/SpecEditor.vue'
|
||||||
@ -204,7 +227,8 @@ const onSave = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onDelete = (row: Product) => {
|
const onDelete = (row: Product) => {
|
||||||
ElMessageBox.confirm(`确认删除产品「${row.name}」?已关联设备的产品不可删除。`, '提示', { type: 'warning' })
|
const devTip = row.deviceCount ? `该产品下有 ${row.deviceCount} 台设备,需先解除关联才能删除。` : ''
|
||||||
|
ElMessageBox.confirm(`确认删除产品「${row.name}」?${devTip}`, '提示', { type: 'warning' })
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await deleteProduct(row.id!)
|
await deleteProduct(row.id!)
|
||||||
ElMessage.success('已删除')
|
ElMessage.success('已删除')
|
||||||
@ -238,6 +262,27 @@ const current = ref<Product | null>(null)
|
|||||||
const openSpec = (row: Product) => { current.value = row; specVisible.value = true }
|
const openSpec = (row: Product) => { current.value = row; specVisible.value = true }
|
||||||
const openOta = (row: Product) => { current.value = row; otaVisible.value = true }
|
const openOta = (row: Product) => { current.value = row; otaVisible.value = true }
|
||||||
|
|
||||||
|
// ---------- 从涂鸦云同步物模型 ----------
|
||||||
|
const syncingId = ref<number | undefined>()
|
||||||
|
const onSyncSpec = async (row: Product) => {
|
||||||
|
syncingId.value = row.id
|
||||||
|
try {
|
||||||
|
const res: any = await syncProductSpec(row.id!)
|
||||||
|
const n = res?.data ?? res
|
||||||
|
ElMessage.success(`物模型已同步,更新 ${n ?? 0} 个功能点`)
|
||||||
|
loadData()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '同步失败')
|
||||||
|
} finally {
|
||||||
|
syncingId.value = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 跳转 ----------
|
||||||
|
const router = useRouter()
|
||||||
|
const goDetail = (row: Product) => router.push(`/products/${row.id}`)
|
||||||
|
const goDevices = (row: Product) => router.push({ path: '/devices', query: { productId: String(row.id) } })
|
||||||
|
|
||||||
onMounted(() => loadData())
|
onMounted(() => loadData())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -246,5 +291,7 @@ onMounted(() => loadData())
|
|||||||
.mb16 { margin-bottom: 16px; }
|
.mb16 { margin-bottom: 16px; }
|
||||||
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
|
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||||
.muted { color: #909399; font-size: 12px; }
|
.muted { color: #909399; font-size: 12px; }
|
||||||
|
.mt4 { margin-top: 4px; }
|
||||||
|
.muted { color: #909399; font-size: 12px; }
|
||||||
.ml4 { margin-left: 4px; }
|
.ml4 { margin-left: 4px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user