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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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<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
|
||||
|
||||
@ -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<string, string>
|
||||
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<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']
|
||||
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']
|
||||
|
||||
@ -118,6 +118,10 @@ const menuRoutes = computed(() => {
|
||||
const groupedRoutes = computed(() => {
|
||||
const groups: Record<string, any[]> = { 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)
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -51,6 +51,23 @@
|
||||
<el-option label="异常" value="error" />
|
||||
</el-select>
|
||||
</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-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
|
||||
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
|
||||
@ -76,6 +93,14 @@
|
||||
{{ getOwnerName(row.ownerId) }}
|
||||
</template>
|
||||
</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">
|
||||
<template #default="{ row }">
|
||||
{{ getGroupName(row.groupId) }}
|
||||
@ -162,14 +187,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { Plus, Search, Refresh } from '@element-plus/icons-vue'
|
||||
import { getDeviceList, createDevice, updateDevice, deleteDevice } from '@/api/device'
|
||||
import { getGroupTree, type DeviceGroup } from '@/api/deviceGroup'
|
||||
import { getMemberList, type Member } from '@/api/member'
|
||||
import { listProducts, type Product } from '@/api/product'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
@ -195,6 +222,8 @@ const queryForm = reactive({
|
||||
status: '',
|
||||
ownerId: null as number | null,
|
||||
groupId: null as number | null,
|
||||
productId: null as number | null,
|
||||
productKeyword: '',
|
||||
pageNum: 1,
|
||||
pageSize: 10
|
||||
})
|
||||
@ -296,12 +325,26 @@ const handleSearch = () => {
|
||||
const handleReset = () => {
|
||||
Object.assign(queryForm, {
|
||||
deviceId: '', deviceName: '', deviceType: '', status: '',
|
||||
ownerId: null, groupId: null,
|
||||
ownerId: null, groupId: null, productId: null, productKeyword: '',
|
||||
pageNum: 1, pageSize: 10
|
||||
})
|
||||
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 = () => {
|
||||
dialogTitle.value = '新增设备'
|
||||
Object.assign(form, {
|
||||
@ -370,14 +413,26 @@ const handleSubmit = async () => {
|
||||
|
||||
/* ========== 初始化 ========== */
|
||||
onMounted(() => {
|
||||
// 支持从产品页跳转过来时自动带上产品筛选
|
||||
const pid = route.query.productId
|
||||
if (pid) {
|
||||
const n = Number(pid)
|
||||
if (!Number.isNaN(n)) queryForm.productId = n
|
||||
}
|
||||
loadData()
|
||||
loadMembers()
|
||||
loadGroups()
|
||||
loadProducts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.device-list {
|
||||
.muted {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
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>
|
||||
</el-table-column>
|
||||
<el-table-column prop="specVersion" label="物模型版本" width="95" align="center" />
|
||||
<el-table-column prop="createTime" label="创建时间" width="165" />
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<el-table-column label="物模型" width="160" align="center">
|
||||
<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" :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" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" plain @click="onDelete(row)">删除</el-button>
|
||||
@ -127,9 +148,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
listProducts, createProduct, updateProduct, deleteProduct, reconcileProducts,
|
||||
syncProductSpec,
|
||||
type Product
|
||||
} from '@/api/product'
|
||||
import SpecEditor from './components/SpecEditor.vue'
|
||||
@ -204,7 +227,8 @@ const onSave = async () => {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
await deleteProduct(row.id!)
|
||||
ElMessage.success('已删除')
|
||||
@ -238,6 +262,27 @@ const current = ref<Product | null>(null)
|
||||
const openSpec = (row: Product) => { current.value = row; specVisible.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())
|
||||
</script>
|
||||
|
||||
@ -246,5 +291,7 @@ onMounted(() => loadData())
|
||||
.mb16 { margin-bottom: 16px; }
|
||||
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||
.muted { color: #909399; font-size: 12px; }
|
||||
.mt4 { margin-top: 4px; }
|
||||
.muted { color: #909399; font-size: 12px; }
|
||||
.ml4 { margin-left: 4px; }
|
||||
</style>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user