feat: 新增产品详情页面,完成设备配置信息页和参数配置功能

- 添加产品详情页面路由和访问权限
- 实现产品基本信息展示和编辑功能
- 增加产品参数编辑功能
- 实现产品状态变更和配置应用
- 添加设备数量统计和跳转功能
This commit is contained in:
fhysy 2025-08-13 12:01:08 +08:00
parent aef11b310f
commit 1e5302ee64
13 changed files with 970 additions and 29 deletions

View File

@ -52,6 +52,24 @@ export function productUpdate(data: ProductForm) {
return requestClient.putWithMsg<void>('/device/product', data);
}
/**
*
* @param data
* @returns void
*/
export function productUpdateById(id: ID, data: ProductForm) {
return requestClient.putWithMsg<void>(`/device/product/${id}`, data);
}
/**
*
* @param data
* @returns void
*/
export function productUpdateStatus(data: ProductForm) {
return requestClient.putWithMsg<void>('/device/product/enable', data);
}
/**
*
* @param id id
@ -60,3 +78,15 @@ export function productUpdate(data: ProductForm) {
export function productRemove(id: ID | IDS) {
return requestClient.deleteWithMsg<void>(`/device/product/${id}`);
}
/**
*
* @param params
* @returns
*/
export function getPoliciesList(params?: any) {
return requestClient.get<PageResult<GatewayVO>>(
'/device/product/storage/policies',
{ params },
);
}

View File

@ -65,6 +65,11 @@ export interface ProductVO {
*
*/
storePolicyConf: string;
/**
*
*/
productParam?: string;
}
export interface ProductForm extends BaseEntity {
@ -132,6 +137,11 @@ export interface ProductForm extends BaseEntity {
*
*/
storePolicyConf?: string;
/**
*
*/
productParam?: string;
}
export interface ProductQuery extends PageQuery {

View File

@ -31,6 +31,12 @@ const routeMetaMapping: Record<string, Omit<RouteMeta, 'title'>> = {
activePath: '/system/role',
requireHomeRedirect: true,
},
'/device/product/detail/:id': {
activePath: '/device/product',
requireHomeRedirect: true,
},
'/system/oss-config/index': {
activePath: '/system/oss',
requireHomeRedirect: true,

View File

@ -1,2 +1,3 @@
export * from './auth';
export * from './notify';
export * from './product';

View File

@ -0,0 +1,92 @@
import type { ProductVO } from '#/api/device/product/model';
import { defineStore } from 'pinia';
import { productInfo } from '#/api/device/product';
export interface ProductDetailState {
current: Partial<ProductVO>;
detail: Partial<ProductVO>;
tabActiveKey: string;
deviceCount: number;
}
export const useProductStore = defineStore('product', {
state: (): ProductDetailState => ({
current: {},
detail: {},
tabActiveKey: 'BasicInfo',
deviceCount: 0,
}),
getters: {
getCurrentProduct: (state) => state.current,
getDetailProduct: (state) => state.detail,
getTabActiveKey: (state) => state.tabActiveKey,
getDeviceCount: (state) => state.deviceCount,
},
actions: {
/**
*
*/
setCurrent(current: Partial<ProductVO>) {
this.current = current;
this.detail = current;
},
/**
*
*/
async getDetail(id: number | string) {
try {
const resp = await productInfo(id);
if (resp) {
this.current = {
...this.current,
...resp,
};
this.detail = resp;
}
} catch (error) {
console.error('获取产品详情失败:', error);
}
},
/**
*
*/
async refresh(id: number | string) {
await this.getDetail(id);
// TODO: 这里需要添加获取设备数量的 API 调用
// const res = await getDeviceNumber({ productId: id });
// if (res) {
// this.deviceCount = res;
// }
},
/**
* tab
*/
setTabActiveKey(key: string) {
this.tabActiveKey = key;
},
/**
*
*/
setDeviceCount(count: number) {
this.deviceCount = count;
},
/**
*
*/
reset() {
this.current = {};
this.detail = {};
this.tabActiveKey = 'BasicInfo';
this.deviceCount = 0;
},
},
});

View File

@ -6,6 +6,11 @@ import { h } from 'vue';
import { deviceTypeOptions, enabledOptions } from '#/constants/dicts';
export const querySchema: FormSchemaGetter = () => [
{
component: 'Input',
fieldName: 'productKey',
label: '产品编码',
},
{
component: 'Input',
fieldName: 'productName',
@ -53,9 +58,14 @@ export const columns: VxeGridProps['columns'] = [
title: '产品名称',
field: 'productName',
},
{
title: '产品分类',
field: 'categoryName',
},
{
title: '设备类型',
field: 'deviceType',
slots: { default: 'deviceType' },
},
{
title: '启用状态',

View File

@ -0,0 +1,269 @@
<script setup lang="ts">
import type { ProductVO } from '#/api/device/product/model';
import { computed } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { EditOutlined } from '@ant-design/icons-vue';
import { Button, Descriptions, DescriptionsItem } from 'ant-design-vue';
import dayjs from 'dayjs';
import { deviceTypeOptions } from '#/constants/dicts';
import { useProductStore } from '#/store/product';
import productDrawer from '../../product-drawer.vue';
import productParamDrawer from './ProductParamDrawer.vue';
const props = defineProps<{
productInfo: ProductVO;
}>();
const emit = defineEmits<{
refresh: [];
}>();
const productStore = useProductStore();
const [ProductDrawer, drawerApi] = useVbenDrawer({
connectedComponent: productDrawer,
});
const [ProductParamDrawer, paramDrawerApi] = useVbenDrawer({
connectedComponent: productParamDrawer,
});
//
const handleEdit = () => {
drawerApi.setData({ id: props.productInfo.id });
drawerApi.open();
};
//
const handleEditParams = () => {
paramDrawerApi.setData({
productId: props.productInfo.id,
productParam: props.productInfo.productParam,
});
paramDrawerApi.open();
};
//
const handleAccessConfig = () => {
productStore.setTabActiveKey('DeviceAccess');
};
//
const formatTime = (time: string) => {
return time ? dayjs(time).format('YYYY-MM-DD HH:mm:ss') : '-';
};
//
const parseProductParams = (productParam?: string) => {
if (!productParam) return [];
try {
return JSON.parse(productParam);
} catch (error) {
console.error('解析产品参数失败:', error);
return [];
}
};
//
const productParams = computed(() =>
parseProductParams(props.productInfo.productParam),
);
</script>
<template>
<div class="basic-info">
<div class="info-header">
<h3>配置信息</h3>
<div class="header-actions">
<Button
type="link"
@click="handleEdit"
v-access:code="['device:product:edit']"
>
<template #icon>
<EditOutlined />
</template>
编辑
</Button>
</div>
</div>
<Descriptions bordered :column="3">
<DescriptionsItem label="产品ID">
{{ productInfo.id }}
</DescriptionsItem>
<DescriptionsItem label="产品KEY">
{{ productInfo.productKey }}
</DescriptionsItem>
<DescriptionsItem label="产品名称">
{{ productInfo.productName }}
</DescriptionsItem>
<DescriptionsItem label="产品分类">
{{ productInfo.categoryName }}
</DescriptionsItem>
<DescriptionsItem label="设备类型">
{{
deviceTypeOptions.find(
(option) => option.value === productInfo.deviceType,
)?.label
}}
</DescriptionsItem>
<DescriptionsItem label="接入方式">
<Button
type="link"
@click="handleAccessConfig"
v-access:code="['device:product:edit']"
>
{{ productInfo.provider || '配置接入方式' }}
</Button>
</DescriptionsItem>
<DescriptionsItem label="创建时间">
{{ formatTime(productInfo.createTime) }}
</DescriptionsItem>
<DescriptionsItem label="更新时间">
{{ formatTime(productInfo.updateTime) }}
</DescriptionsItem>
<DescriptionsItem label="描述" :span="2">
{{ productInfo.description || '暂无描述' }}
</DescriptionsItem>
</Descriptions>
<!-- 产品参数展示 -->
<div class="product-params-section">
<div class="section-header">
<h3>产品参数</h3>
<Button
type="link"
size="small"
@click="handleEditParams"
v-access:code="['device:product:edit']"
>
<template #icon>
<EditOutlined />
</template>
编辑参数
</Button>
</div>
<div v-if="productParams.length === 0" class="empty-params">
<p>暂无产品参数</p>
</div>
<Descriptions bordered :column="3" v-else>
<DescriptionsItem
v-for="param in productParams"
:key="param.key"
:label="param.label"
>
{{ param.value }}
</DescriptionsItem>
</Descriptions>
</div>
<ProductDrawer @reload="emit('refresh')" />
<ProductParamDrawer
:product-id="productInfo.id"
:product-param="productInfo.productParam"
@refresh="emit('refresh')"
/>
</div>
</template>
<style lang="scss" scoped>
.basic-info {
.info-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #262626;
}
.header-actions {
display: flex;
gap: 8px;
}
}
.product-params-section {
padding: 16px 0;
margin-top: 24px;
border-radius: 6px;
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #262626;
}
}
.empty-params {
padding: 20px 0;
color: #8c8c8c;
text-align: center;
p {
margin: 0;
font-size: 14px;
}
}
.params-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
.param-card {
padding: 12px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 4px;
.param-key {
margin-bottom: 4px;
font-size: 12px;
color: #8c8c8c;
}
.param-label {
margin-bottom: 8px;
font-size: 14px;
font-weight: 500;
color: #262626;
}
.param-value {
text-align: right;
}
}
}
}
:deep(.ant-descriptions-item-label) {
font-weight: 500;
color: #595959;
}
:deep(.ant-descriptions-item-content) {
color: #262626;
}
}
</style>

View File

@ -0,0 +1,297 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue';
import { Button, Input, message } from 'ant-design-vue';
import { productUpdateById } from '#/api/device/product';
interface ProductParam {
key: string;
label: string;
value: string;
}
const props = defineProps<{
productId: number | string;
productParam?: string;
}>();
const emit = defineEmits<{
refresh: [];
}>();
const [BasicDrawer, drawerApi] = useVbenDrawer({
class: 'w-[600px]',
onConfirm: handleConfirm,
});
const params = ref<ProductParam[]>([]);
const loading = ref(false);
//
watch(
() => props.productParam,
(newVal) => {
if (newVal) {
try {
params.value = JSON.parse(newVal);
} catch {
params.value = [];
}
} else {
params.value = [];
}
},
{ immediate: true },
);
//
const addParam = () => {
params.value.push({
key: '',
label: '',
value: '',
});
};
//
const removeParam = (index: number) => {
params.value.splice(index, 1);
};
//
const updateParam = (
index: number,
field: keyof ProductParam,
value: string,
) => {
if (params.value[index]) {
params.value[index][field] = value;
}
};
//
async function handleConfirm() {
try {
loading.value = true;
//
const validParams = params.value.filter(
(param) => param.key.trim() && param.label.trim() && param.value.trim(),
);
if (validParams.length === 0) {
message.warning('请至少添加一个有效的产品参数');
return;
}
// key
const keys = validParams.map((param) => param.key.trim());
const uniqueKeys = new Set(keys);
if (keys.length !== uniqueKeys.size) {
message.error('参数key不能重复');
return;
}
// JSON
const productParam = JSON.stringify(validParams);
// API
await updateProductParam(props.productId, productParam);
message.success('产品参数保存成功');
emit('refresh');
drawerApi.close();
} catch (error) {
console.error('保存产品参数失败:', error);
message.error('保存失败,请重试');
} finally {
loading.value = false;
}
}
// API
async function updateProductParam(id: number | string, productParam: string) {
await productUpdateById(id, { id, productParam });
}
//
const open = () => {
drawerApi.open();
};
//
defineExpose({
open,
});
</script>
<template>
<BasicDrawer title="编辑产品参数" :loading="loading">
<div class="product-param-editor">
<div class="param-header">
<span class="param-title">产品参数配置</span>
<Button type="primary" @click="addParam">
<template #icon>
<PlusOutlined />
</template>
添加参数
</Button>
</div>
<div class="param-list">
<div v-if="params.length === 0" class="empty-state">
<p>暂无产品参数请点击"添加参数"按钮添加</p>
</div>
<div v-else class="param-items header-item">
<div class="param-item">
<div class="param-row">
<div class="param-field">
<label>参数key:</label>
</div>
<div class="param-field">
<label>参数名(单位):</label>
</div>
<div class="param-field">
<label>参数值:</label>
</div>
</div>
</div>
<div v-for="(param, index) in params" :key="index" class="param-item">
<div class="param-row">
<div class="param-field">
<Input
:value="param.key"
placeholder="请输入参数key"
@input="(e) => updateParam(index, 'key', e.target.value)"
/>
</div>
<div class="param-field">
<Input
:value="param.label"
placeholder="请输入参数名和单位,如:电压(V)"
@input="(e) => updateParam(index, 'label', e.target.value)"
/>
</div>
<div class="param-field">
<Input
:value="param.value"
placeholder="请输入参数值"
@input="(e) => updateParam(index, 'value', e.target.value)"
/>
</div>
<Button
type="text"
danger
@click="removeParam(index)"
class="delete-btn"
>
<template #icon>
<DeleteOutlined />
</template>
</Button>
</div>
</div>
</div>
</div>
<div class="param-tips">
<p class="tip-text">
<strong>说明</strong>
参数key用于标识参数参数名(单位)用于显示参数值为默认值
</p>
</div>
</div>
</BasicDrawer>
</template>
<style lang="scss" scoped>
.product-param-editor {
.param-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
margin-bottom: 16px;
.param-title {
font-size: 16px;
font-weight: 600;
color: #262626;
}
}
.param-list {
margin-bottom: 16px;
.empty-state {
padding: 40px 0;
color: #8c8c8c;
text-align: center;
p {
margin: 0;
font-size: 14px;
}
}
.param-items {
border-bottom: 1px solid #f0f0f0;
.param-item {
padding: 16px;
background: #fafafa;
border: 1px solid #f0f0f0;
border-bottom: none;
.param-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr auto;
gap: 12px;
align-items: end;
.param-field {
display: flex;
flex-direction: column;
label {
margin-bottom: 6px;
font-size: 12px;
font-weight: 500;
color: #595959;
}
}
.delete-btn {
height: 32px;
padding: 4px 8px;
}
}
}
}
}
.param-tips {
padding: 12px 16px;
background: #f6ffed;
border: 1px solid #b7eb8f;
border-radius: 6px;
.tip-text {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: #52c41a;
}
}
}
</style>

View File

@ -0,0 +1,219 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { ArrowLeftOutlined } from '@ant-design/icons-vue';
import { message, Switch, TabPane, Tabs } from 'ant-design-vue';
import dayjs from 'dayjs';
import { productUpdate, productUpdateStatus } from '#/api/device/product';
import { useProductStore } from '#/store/product';
import BasicInfo from './components/BasicInfo.vue';
import DeviceAccess from './components/DeviceAccess.vue';
import Metadata from './components/Metadata.vue';
const route = useRoute();
const router = useRouter();
const productStore = useProductStore();
const productId = computed(() => route.params.id as string);
const activeTab = computed(() => productStore.getTabActiveKey);
const deviceCount = computed(() => productStore.getDeviceCount);
const currentProduct = computed(() => productStore.getCurrentProduct);
//
const loadProductInfo = async () => {
try {
await productStore.getDetail(productId.value);
// TODO:
// 0
productStore.setDeviceCount(0);
} catch {
message.error('加载产品信息失败');
}
};
//
const handleStatusChange = async (checked: boolean) => {
try {
await productUpdateStatus({
id: productId.value,
enabled: checked,
});
await loadProductInfo();
} catch {
message.error('状态更新失败');
}
};
//
const handleApplyConfig = async () => {
try {
await productUpdate({
id: productId.value,
enabled: '1',
});
message.success('配置应用成功');
await loadProductInfo();
} catch {
message.error('配置应用失败');
}
};
//
const jumpToDevices = () => {
router.push({
path: '/device/instance',
query: {
productId: productId.value,
},
});
};
//
const goBack = () => {
router.back();
};
//
const handleTabChange = (key: string) => {
productStore.setTabActiveKey(key);
if (key === 'BasicInfo') {
loadProductInfo();
}
};
onMounted(() => {
loadProductInfo();
});
// store
onUnmounted(() => {
productStore.reset();
});
</script>
<template>
<Page :auto-content-height="true">
<div class="product-detail">
<!-- 页面头部 -->
<div class="detail-header">
<div class="header-left">
<a-button @click="goBack" class="back-btn">
<template #icon>
<ArrowLeftOutlined />
</template>
返回
</a-button>
<div class="product-info">
<h2 class="product-name">{{ currentProduct.productName }}</h2>
<div class="product-status">
<Switch
:checked="currentProduct.enabled"
checked-children="启用"
un-checked-children="禁用"
checked-value="1"
un-checked-value="0"
@change="handleStatusChange"
/>
</div>
</div>
</div>
<div class="header-right">
<a-button
type="primary"
@click="handleApplyConfig"
:disabled="currentProduct.enabled === '0'"
v-access:code="['device:product:edit']"
>
应用配置
</a-button>
</div>
</div>
<!-- 产品统计信息 -->
<div class="product-stats">
<span>设备数量</span><a @click="jumpToDevices">{{ deviceCount }}</a>
</div>
<!-- 标签页内容 -->
<Tabs
v-model:active-key="activeTab"
class="detail-tabs"
@change="handleTabChange"
>
<TabPane key="BasicInfo" tab="配置信息">
<BasicInfo
:product-info="currentProduct"
@refresh="loadProductInfo"
/>
</TabPane>
<TabPane key="Metadata" tab="物模型">
<Metadata :product-id="productId" />
</TabPane>
<TabPane key="DeviceAccess" tab="设备接入">
<DeviceAccess
:product-info="currentProduct"
@refresh="loadProductInfo"
/>
</TabPane>
</Tabs>
</div>
</Page>
</template>
<style lang="scss" scoped>
.product-detail {
padding: 24px;
background: #fff;
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 16px;
.header-left {
display: flex;
gap: 16px;
align-items: center;
.back-btn {
// margin-right: 16px;
}
.product-info {
display: flex;
gap: 16px;
align-items: center;
.product-name {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #262626;
}
.product-status {
display: flex;
align-items: center;
}
}
}
}
.product-stats {
padding: 10px 0;
}
.detail-tabs {
:deep(.ant-tabs-content-holder) {
padding-top: 16px;
}
}
}
</style>

View File

@ -4,6 +4,8 @@ import type { VbenFormProps } from '@vben/common-ui';
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { ProductForm } from '#/api/device/product/model';
import { useRouter } from 'vue-router';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { getVxePopupContainer } from '@vben/utils';
@ -16,6 +18,8 @@ import {
productRemove,
} from '#/api/device/product';
import { productCategoryTreeList } from '#/api/device/productCategory';
import { deviceTypeOptions } from '#/constants/dicts';
import { useProductStore } from '#/store/product';
import { commonDownloadExcel } from '#/utils/file/download';
import { columns, querySchema } from './data';
@ -40,6 +44,8 @@ const formOptions: VbenFormProps = {
// ],
// ],
};
const router = useRouter();
const productStore = useProductStore();
const gridOptions: VxeGridProps = {
checkboxConfig: {
@ -111,6 +117,11 @@ function handleAdd() {
}
async function handleView(row: Required<ProductForm>) {
// store
productStore.setCurrent(row);
// tab
productStore.setTabActiveKey('BasicInfo');
//
router.push(`/device/product/detail/${row.id}`);
}
@ -179,6 +190,14 @@ function handleDownloadExcel() {
</a-button>
</Space>
</template>
<template #deviceType="{ row }">
<Tag color="processing">
{{
deviceTypeOptions.find((option) => option.value === row.deviceType)
?.label
}}
</Tag>
</template>
<template #enabled="{ row }">
<Tag :color="row.enabled === '1' ? 'success' : 'error'">
{{ row.enabled === '1' ? '启用' : '禁用' }}

View File

@ -15,6 +15,7 @@ import {
networkList,
networkRemove,
} from '#/api/operations/network';
import { networkTypeOptions } from '#/constants/dicts';
import { commonDownloadExcel } from '#/utils/file/download';
import { columns, querySchema } from './data';
@ -122,31 +123,6 @@ function handleDownloadExcel() {
);
}
// JSON
function parseNetworkConfig(networkConfig: string) {
try {
return JSON.parse(networkConfig);
} catch (error) {
console.error('解析网络配置失败:', error);
return null;
}
}
//
function formatNetworkConfig(row: any) {
const config = parseNetworkConfig(row.networkConfig);
if (!config) {
return '配置解析失败';
}
if (row.networkType === 'HTTP_SERVER') {
return `本地: ${config.localAddress}:${config.localPort} | 公网: ${config.publicAddress}:${config.publicPort} | TLS: ${config.enableTls === '1' ? '是' : '否'}`;
} else if (row.networkType === 'MQTT_CLIENT') {
return `远程: ${config.remoteAddress}:${config.remotePort} | ClientId: ${config.clientId} | TLS: ${config.enableTls === '1' ? '是' : '否'}`;
}
return '未知配置';
}
</script>
<template>
@ -179,7 +155,13 @@ function formatNetworkConfig(row: any) {
</Space>
</template>
<template #networkType="{ row }">
<Tag color="processing">{{ row.networkType }}</Tag>
<Tag color="processing">
{{
networkTypeOptions.find(
(option) => option.value === row.networkType,
)?.label
}}
</Tag>
</template>
<template #enabled="{ row }">
<Tag :color="row.enabled === '1' ? 'success' : 'error'">

View File

@ -3,7 +3,7 @@ import type { VxeGridProps } from '#/adapter/vxe-table';
import { enabledOptions } from '#/constants/dicts';
const protocolTypeOptions = [
export const protocolTypeOptions = [
{ label: 'local', value: 'local' },
{ label: 'jar', value: 'jar' },
];

View File

@ -17,7 +17,7 @@ import {
} from '#/api/operations/protocol';
import { commonDownloadExcel } from '#/utils/file/download';
import { columns, querySchema } from './data';
import { columns, protocolTypeOptions, querySchema } from './data';
import protocolDrawer from './protocol-drawer.vue';
const formOptions: VbenFormProps = {
@ -153,7 +153,13 @@ function handleDownloadExcel() {
</Space>
</template>
<template #protocolType="{ row }">
<Tag color="processing">{{ row.protocolType }}</Tag>
<Tag color="processing">
{{
protocolTypeOptions.find(
(option) => option.value === row.protocolType,
)?.label
}}
</Tag>
</template>
<template #enabled="{ row }">
<Tag :color="row.enabled === '1' ? 'success' : 'error'">