feat: 添加设备管理和设备详情页面,解决产品物模型修改乱序问题

- 新增设备详情页面组件和相关功能
- 实现设备信息加载和显示
- 添加运行状态、设备功能、日志管理等子页面
- 优化设备状态展示和操作
- 增加物模型查看功能
This commit is contained in:
fhysy 2025-08-21 14:37:51 +08:00
parent 60e3f4fc73
commit 1ac9339eb3
24 changed files with 2144 additions and 472 deletions

View File

@ -0,0 +1,62 @@
import type { DeviceForm, DeviceQuery, DeviceVO } from './model';
import type { ID, IDS, PageResult } from '#/api/common';
import { commonExport } from '#/api/helper';
import { requestClient } from '#/api/request';
/**
*
* @param params
* @returns
*/
export function deviceList(params?: DeviceQuery) {
return requestClient.get<PageResult<DeviceVO>>('/device/device/list', {
params,
});
}
/**
*
* @param params
* @returns
*/
export function deviceExport(params?: DeviceQuery) {
return commonExport('/device/device/export', params ?? {});
}
/**
*
* @param id id
* @returns
*/
export function deviceInfo(id: ID) {
return requestClient.get<DeviceVO>(`/device/device/${id}`);
}
/**
*
* @param data
* @returns void
*/
export function deviceAdd(data: DeviceForm) {
return requestClient.postWithMsg<void>('/device/device', data);
}
/**
*
* @param data
* @returns void
*/
export function deviceUpdate(data: DeviceForm) {
return requestClient.putWithMsg<void>('/device/device', data);
}
/**
*
* @param id id
* @returns void
*/
export function deviceRemove(id: ID | IDS) {
return requestClient.deleteWithMsg<void>(`/device/device/${id}`);
}

View File

@ -0,0 +1,147 @@
import type { BaseEntity, PageQuery } from '#/api/common';
export interface DeviceVO {
/**
*
*/
id: number | string;
/**
*
*/
deviceName: string;
/**
*
*/
deviceType: string;
/**
*
*/
description: string;
/**
* id
*/
productId: number | string;
/**
*
*/
deviceConf: string;
/**
*
*/
metadata: string;
/**
*
*/
deviceState: string;
/**
*
*/
registTime: string;
/**
* id
*/
parentId: number | string;
}
export interface DeviceForm extends BaseEntity {
/**
*
*/
id?: number | string;
/**
*
*/
deviceName?: string;
/**
*
*/
deviceType?: string;
/**
*
*/
description?: string;
/**
* id
*/
productId?: number | string;
/**
*
*/
deviceConf?: string;
/**
*
*/
metadata?: string;
/**
*
*/
deviceState?: string;
/**
*
*/
registTime?: string;
/**
* id
*/
parentId?: number | string;
}
export interface DeviceQuery extends PageQuery {
/**
*
*/
deviceName?: string;
/**
*
*/
deviceType?: string;
/**
* id
*/
productId?: number | string;
/**
*
*/
metadata?: string;
/**
*
*/
deviceState?: string;
/**
*
*/
registTime?: string;
/**
* id
*/
parentId?: number | string;
/**
*
*/
params?: any;
}

View File

@ -54,13 +54,21 @@ export function productUpdate(data: ProductForm) {
/**
*
* @param data
* @param id data
* @returns void
*/
export function productUpdateById(id: ID, data: ProductForm) {
return requestClient.putWithMsg<void>(`/device/product/${id}`, data);
}
/**
*
* @param id
* @returns void
*/
export function productPushMetadataById(id: ID) {
return requestClient.putWithMsg<void>(`/device/product/pushMetadata/${id}`);
}
/**
*
* @param data
@ -85,8 +93,5 @@ export function productRemove(id: ID | IDS) {
* @returns
*/
export function getPoliciesList(params?: any) {
return requestClient.get<PageResult<GatewayVO>>(
'/device/product/storage/policies',
{ params },
);
return requestClient.get<PageResult<GatewayVO>>('/device/product/storage/policies', { params });
}

View File

@ -50,7 +50,7 @@ export const dataTypeOptions = [
},
{
value: 'date',
label: 'date(时间)',
label: 'date(时间)',
},
{
value: 'enum',
@ -107,7 +107,13 @@ export const readWriteTypeOptions = [
];
export const timeOptions = [
{ label: 'yyyy-MM-dd HH:mm:ss', value: 'yyyy-MM-dd HH:mm:ss' },
{ label: 'yyyy-MM-dd', value: 'yyyy-MM-dd' },
{ label: 'YYYY-MM-DD HH:mm:ss', value: 'YYYY-MM-DD HH:mm:ss' },
{ label: 'YYYY-MM-DD', value: 'YYYY-MM-DD' },
{ label: 'HH:mm:ss', value: 'HH:mm:ss' },
];
export const deviceStateOptions = [
{ label: '未激活', value: 'notActive', type: 'warning' },
{ label: '在线', value: 'online', type: 'success' },
{ label: '离线', value: 'offline', type: 'error' },
];

View File

@ -37,6 +37,11 @@ const routeMetaMapping: Record<string, Omit<RouteMeta, 'title'>> = {
requireHomeRedirect: true,
},
'/device/device/detail/:id': {
activePath: '/device/device',
requireHomeRedirect: true,
},
'/system/oss-config/index': {
activePath: '/system/oss',
requireHomeRedirect: true,
@ -64,10 +69,7 @@ const routeMetaMapping: Record<string, Omit<RouteMeta, 'title'>> = {
* @param parentPath
* @returns vben路由
*/
function backMenuToVbenMenu(
menuList: Menu[],
parentPath = '',
): RouteRecordStringComponent[] {
function backMenuToVbenMenu(menuList: Menu[], parentPath = ''): RouteRecordStringComponent[] {
const resultList: RouteRecordStringComponent[] = [];
menuList.forEach((menu) => {
// 根目录为菜单形式
@ -100,10 +102,7 @@ function backMenuToVbenMenu(
// 外链: http开头 & 组件为Layout || ParentView
// 正则判断是否为http://或者https://开头
if (
/^https?:\/\//.test(menu.path) &&
(menu.component === 'Layout' || menu.component === 'ParentView')
) {
if (/^https?:\/\//.test(menu.path) && (menu.component === 'Layout' || menu.component === 'ParentView')) {
menu.component = 'Link';
}

View File

@ -0,0 +1,52 @@
import { defineStore } from 'pinia';
import { deviceInfo } from '#/api/device/device';
export const useDeviceStore = defineStore('device', {
state: () => ({
currentDevice: {} as any,
tabActiveKey: 'BasicInfo',
deviceCount: 0,
}),
getters: {
getCurrentDevice: (state) => state.currentDevice,
getTabActiveKey: (state) => state.tabActiveKey,
getDeviceCount: (state) => state.deviceCount,
},
actions: {
setCurrent(device: any) {
this.currentDevice = device;
},
setTabActiveKey(key: string) {
this.tabActiveKey = key;
},
setDeviceCount(count: number) {
this.deviceCount = count;
},
async getDetail(id: string) {
try {
const res = await deviceInfo(id);
if (res?.productObj?.productParam) {
res.productParamArray = JSON.parse(res.productObj.productParam) || [];
}
this.currentDevice = res;
return res;
} catch (error) {
console.error('获取设备详情失败:', error);
throw error;
}
},
reset() {
this.currentDevice = {};
this.tabActiveKey = 'BasicInfo';
this.deviceCount = 0;
},
},
});

View File

@ -0,0 +1,126 @@
import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { deviceStateOptions, deviceTypeOptions } from '#/constants/dicts';
export const querySchema: FormSchemaGetter = () => [
{
component: 'Input',
fieldName: 'deviceKey',
label: '设备KEY',
},
{
component: 'Input',
fieldName: 'deviceName',
label: '设备名称',
},
{
component: 'Select',
fieldName: 'productId',
label: '所属产品',
},
{
component: 'Select',
componentProps: {
options: deviceStateOptions,
},
fieldName: 'deviceState',
label: '设备状态',
},
{
component: 'Select',
componentProps: {
options: deviceTypeOptions,
},
fieldName: 'deviceType',
label: '设备类型',
},
];
// 需要使用i18n注意这里要改成getter形式 否则切换语言不会刷新
// export const columns: () => VxeGridProps['columns'] = () => [
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 },
// {
// title: '编号',
// field: 'id',
// },
{
title: '设备KEY',
field: 'deviceKey',
},
{
title: '设备名称',
field: 'deviceName',
},
{
title: '所属产品',
field: 'productName',
},
{
title: '设备类型',
field: 'deviceType',
slots: { default: 'deviceType' },
},
{
title: '设备状态',
field: 'deviceState',
slots: { default: 'deviceState' },
},
{
title: '描述',
field: 'description',
},
{
field: 'action',
fixed: 'right',
slots: { default: 'action' },
title: '操作',
width: 180,
},
];
export const drawerSchema: FormSchemaGetter = () => [
{
label: '编号',
fieldName: 'id',
component: 'Input',
dependencies: {
show: () => false,
triggerFields: [''],
},
},
{
label: '设备KEY',
fieldName: 'deviceKey',
component: 'Input',
rules: 'required',
},
{
label: '设备名称',
fieldName: 'deviceName',
component: 'Input',
rules: 'required',
},
{
label: '所属产品',
fieldName: 'productId',
component: 'Select',
componentProps: {},
rules: 'selectRequired',
},
{
label: '设备图片',
fieldName: 'imgId',
component: 'ImageUpload',
componentProps: {
// accept: 'image/*', // 可选拓展名或者mime类型 ,拼接
// maxCount: 1, // 最大上传文件数 默认为1 为1会绑定为string而非string[]类型
},
},
{
label: '描述',
fieldName: 'description',
component: 'Textarea',
},
];

View File

@ -0,0 +1,222 @@
<script setup lang="ts">
import type { DeviceVO } from '#/api/device/device/model';
import { computed, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { EditOutlined, EyeOutlined } from '@ant-design/icons-vue';
import { Button, Descriptions, DescriptionsItem, Drawer, Space } from 'ant-design-vue';
import { deviceTypeOptions, networkTypeOptions } from '#/constants/dicts';
import TSLViewer from '#/views/device/product/detail/components/TSLViewer.vue';
import deviceDrawer from '../../device-drawer.vue';
const props = defineProps<{
deviceInfo: DeviceVO;
}>();
const emit = defineEmits<{
refresh: [];
}>();
const [DeviceDrawer, drawerApi] = useVbenDrawer({
connectedComponent: deviceDrawer,
});
const tslVisible = ref(false);
//
const handleEdit = () => {
drawerApi.setData({ id: props.deviceInfo.id });
drawerApi.open();
};
// TSL
const handleViewTSL = () => {
tslVisible.value = true;
};
// TSL
const handleTSLClose = () => {
tslVisible.value = false;
};
const metadataObj = computed(() => {
return JSON.parse(props.deviceInfo.metadata || '{}');
});
</script>
<template>
<div class="basic-info">
<div class="info-header">
<h3>设备信息</h3>
<Space>
<Button type="link" size="small" @click="handleEdit" v-access:code="['device:device:edit']">
<template #icon>
<EditOutlined />
</template>
编辑
</Button>
<Button type="link" size="small" @click="handleViewTSL">
<template #icon>
<EyeOutlined />
</template>
物模型
</Button>
</Space>
</div>
<div class="info-content">
<Descriptions :column="3" bordered>
<!-- <DescriptionsItem label="设备ID">
{{ deviceInfo.id }}
</DescriptionsItem> -->
<DescriptionsItem label="设备KEY">
{{ deviceInfo.deviceKey }}
</DescriptionsItem>
<DescriptionsItem label="设备名称">
{{ deviceInfo.deviceName }}
</DescriptionsItem>
<DescriptionsItem label="设备类型">
{{ deviceTypeOptions.find((option) => option.value === deviceInfo.deviceType)?.label }}
</DescriptionsItem>
<!-- <DescriptionsItem label="产品名称">
{{ deviceInfo.productName }}
</DescriptionsItem> -->
<DescriptionsItem label="固件版本">
{{ deviceInfo.otaVersion || '-' }}
</DescriptionsItem>
<DescriptionsItem label="接入方式">
{{ networkTypeOptions.find((option) => option.value === deviceInfo?.productObj?.provider)?.label || '-' }}
</DescriptionsItem>
<DescriptionsItem label="消息协议">
{{ deviceInfo?.productObj?.protocolName || '-' }}
</DescriptionsItem>
<DescriptionsItem label="创建时间">
{{ deviceInfo.createTime }}
</DescriptionsItem>
<DescriptionsItem label="注册时间">
{{ deviceInfo.registTime }}
</DescriptionsItem>
<DescriptionsItem label="最后上线时间">
{{ deviceInfo.updateTime }}
</DescriptionsItem>
<DescriptionsItem label="父设备" v-if="deviceInfo.parentId">
{{ deviceInfo.parentName }}
</DescriptionsItem>
<DescriptionsItem label="说明" :span="3">
{{ deviceInfo.description }}
</DescriptionsItem>
</Descriptions>
</div>
<!-- 产品参数展示 -->
<div class="product-params-section">
<div class="section-header">
<h3>设备参数</h3>
</div>
<Descriptions bordered :column="3">
<DescriptionsItem v-for="param in deviceInfo.productParamArray" :key="param.key" :label="param.label">
{{ param.value }}
</DescriptionsItem>
</Descriptions>
</div>
<DeviceDrawer @reload="emit('refresh')" />
<!-- TSL查看抽屉 -->
<Drawer v-model:open="tslVisible" title="物模型" width="800px" @close="handleTSLClose">
<TSLViewer
:product-id="deviceInfo?.productId"
:product-info="deviceInfo?.productObj"
:file-name="deviceInfo?.deviceName"
:metadata="metadataObj"
/>
</Drawer>
</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;
}
}
.info-content {
background: #fff;
border-radius: 6px;
}
.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;
}
}
}
}
}
</style>

View File

@ -0,0 +1,571 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { EllipsisText } from '@vben/common-ui';
import { LineChartOutlined, UnorderedListOutlined } from '@ant-design/icons-vue';
import {
Button,
Card,
Checkbox,
Col,
DatePicker,
Empty,
Modal,
RadioButton,
RadioGroup,
Row,
Select,
Space,
Table,
Tabs,
} from 'ant-design-vue';
import dayjs from 'dayjs';
interface Props {
deviceId: string;
deviceInfo: any;
}
const props = defineProps<Props>();
const loading = ref(false);
const selectedGroup = ref('all');
const selectedTypes = ref(['R', 'RW']); //
const metadata = ref({ properties: [], propertyGroups: [] });
// //
// const metadata = computed(() => {
// if (props.deviceInfo?.productObj?.metadata) {
// try {
// return JSON.parse(props.deviceInfo.productObj.metadata);
// } catch {
// return { properties: [], propertyGroups: [] };
// }
// }
// return { properties: [], propertyGroups: [] };
// });
const getMetadata = () => {
let metadataObj = { properties: [], propertyGroups: [] };
if (props.deviceInfo?.productObj?.metadata) {
metadataObj = JSON.parse(props.deviceInfo.productObj.metadata);
}
metadataObj.properties = metadataObj.properties.map((prop: any) => ({
...prop,
value: null,
timestamp: dayjs().format('YYYY-MM-DD HH:mm:ss'),
}));
metadata.value = metadataObj;
};
//
const groupOptions = computed(() => {
const groups = metadata.value.propertyGroups || [];
return [
{ label: '全部', value: 'all' },
...groups.map((group: any) => ({
label: group.name,
value: group.id,
})),
];
});
//
const filteredProperties = computed(() => {
let properties = metadata.value.properties || [];
//
if (selectedGroup.value !== 'all') {
const group = metadata.value.propertyGroups?.find((g: any) => g.id === selectedGroup.value);
if (group?.properties) {
const groupPropertyIds = new Set(group.properties.map((p: any) => p.id));
properties = properties.filter((p: any) => groupPropertyIds.has(p.id));
}
}
//
properties = properties.filter((p: any) => {
const type = p.expands?.type || 'R';
return selectedTypes.value.includes(type);
});
// value
return properties;
});
//
const logModalVisible = ref(false);
const currentProperty = ref<any>(null);
const logTabActiveKey = ref('list');
const activeQuickTime = ref('today');
const logDateRange = ref([dayjs().startOf('day'), dayjs()]);
const logLoading = ref(false);
const logData = ref<any[]>([]);
//
const logColumns = [
{
title: '时间',
dataIndex: 'timestamp',
key: 'timestamp',
width: 200,
},
{
title: '值',
dataIndex: 'value',
key: 'value',
width: 150,
},
{
title: '操作',
key: 'action',
width: 100,
},
];
//
const openPropertyLog = (property: any) => {
currentProperty.value = property;
logModalVisible.value = true;
loadPropertyLog();
};
//
const loadPropertyLog = async () => {
if (!currentProperty.value) return;
try {
logLoading.value = true;
// TODO: API
// const res = await getPropertyLog({
// deviceId: props.deviceId,
// propertyId: currentProperty.value.id,
// startTime: logDateRange.value[0]?.format('YYYY-MM-DD HH:mm:ss'),
// endTime: logDateRange.value[1]?.format('YYYY-MM-DD HH:mm:ss')
// });
// logData.value = res.data || [];
//
logData.value = [
{ timestamp: '2025-08-19 16:19:40', value: '0.5' },
{ timestamp: '2025-08-19 16:18:40', value: '0.4' },
{ timestamp: '2025-08-19 16:17:40', value: '0.3' },
];
} catch (error) {
console.error('获取属性日志失败:', error);
} finally {
logLoading.value = false;
}
};
//
const handleDateRangeChange = (dates: any) => {
logDateRange.value = dates;
if (dates && dates.length === 2) {
loadPropertyLog();
}
};
//
const handleQuickTimeSelect = (type: string) => {
console.log(type);
const now = dayjs();
switch (type.target.value) {
case 'month': {
logDateRange.value = [now.subtract(1, 'month'), now];
break;
}
case 'today': {
logDateRange.value = [now.startOf('day'), now];
break;
}
case 'week': {
logDateRange.value = [now.subtract(7, 'day'), now];
break;
}
}
loadPropertyLog();
};
//
const formatValue = (property: any) => {
const { value, valueParams } = property;
if (value === undefined || value === null) return '--';
let displayValue = value;
if (valueParams?.formType === 'input' && valueParams?.length) {
displayValue = value.slice(0, valueParams.length);
}
//
if (valueParams?.formType === 'switch') {
return value.toString() === valueParams.trueValue ? valueParams.trueText : valueParams.falseText;
}
if (valueParams?.formType === 'select') {
return valueParams.enumConf.find((item: any) => item.value === value)?.text;
}
if (valueParams?.formType === 'time') {
return dayjs(value).format(valueParams.format);
}
if (valueParams?.formType === 'number' || valueParams?.formType === 'progress') {
if (valueParams?.scale) {
return value.toFixed(valueParams.scale);
}
return value;
}
// displayValue = `${value}`;
return displayValue;
};
//
const canShowChart = (property: any) => {
const { valueParams } = property;
const numericTypes = ['int', 'float', 'double'];
return numericTypes.includes(valueParams?.dataType);
};
//
const closeLogModal = () => {
logModalVisible.value = false;
currentProperty.value = null;
logData.value = [];
};
//
let refreshTimer: NodeJS.Timeout | null = null;
const generateRandomString = (length: number) => {
//
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
//
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
};
const startRefreshTimer = () => {
refreshTimer = setInterval(() => {
// TODO:
console.log('刷新属性值');
metadata.value.properties.forEach((item: any) => {
switch (item.valueParams?.dataType) {
case 'boolean': {
item.value = Math.random() > 0.5 ? 'true' : 'false';
break;
}
case 'date': {
item.value = Date.now();
break;
}
case 'double':
case 'float':
case 'int':
case 'long': {
item.value = Math.random() * 100;
break;
}
case 'enum': {
item.value = item.valueParams.enumConf[Math.floor(Math.random() * item.valueParams.enumConf.length)].value;
break;
}
case 'string': {
item.value = generateRandomString(Math.floor(Math.random() * 1_000_000));
break;
}
// No default
}
item.timestamp = dayjs().format('YYYY-MM-DD HH:mm:ss');
});
console.log(metadata.value.properties);
}, 3000); // 30
};
const stopRefreshTimer = () => {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
};
onMounted(() => {
startRefreshTimer();
getMetadata();
});
onUnmounted(() => {
stopRefreshTimer();
});
//
watch(
() => props.deviceInfo,
() => {
//
},
{ deep: true },
);
</script>
<template>
<div class="running-status">
<!-- 控制面板 -->
<div class="control-panel">
<div class="control-item">
<span class="control-label">属性分组:</span>
<Select
v-model:value="selectedGroup"
:options="groupOptions"
style="width: 200px"
placeholder="请选择属性分组"
/>
</div>
<div class="control-item">
<span class="control-label">访问权限:</span>
<Checkbox.Group v-model:value="selectedTypes">
<Checkbox value="RW">读写</Checkbox>
<Checkbox value="R">只读</Checkbox>
<Checkbox value="W">只写</Checkbox>
</Checkbox.Group>
</div>
</div>
<!-- 属性卡片网格 -->
<div class="property-grid">
<Row :gutter="16">
<Col v-for="property in filteredProperties" :key="property.id" :span="6" class="property-col">
<Card class="property-card" :loading="loading">
<div class="property-header">
<h4 class="property-title">{{ property.name }}</h4>
<Button type="text" size="small" class="property-log-btn" @click="openPropertyLog(property)">
<UnorderedListOutlined />
</Button>
</div>
<div class="property-content">
<EllipsisText class="property-value" :line="3" expand>
{{ formatValue(property) }}
</EllipsisText>
<div class="property-unit">
{{ property.valueParams?.unit || '' }}
</div>
</div>
<div class="property-footer">
<span class="property-timestamp">{{ property.timestamp }}</span>
</div>
</Card>
</Col>
</Row>
<!-- 空状态 -->
<div v-if="filteredProperties.length === 0" class="empty-state">
<Empty description="暂无属性数据" />
</div>
</div>
<!-- 属性日志弹窗 -->
<Modal
v-model:open="logModalVisible"
:title="`${currentProperty?.name || ''} - 详情`"
width="800px"
@cancel="closeLogModal"
:footer="null"
>
<div class="log-modal-content">
<!-- 时间选择 -->
<div class="time-selection">
<Space>
<RadioGroup v-model:value="activeQuickTime" @change="handleQuickTimeSelect" button-style="solid">
<RadioButton value="today">今日</RadioButton>
<RadioButton value="week">近一周</RadioButton>
<RadioButton value="month">近一月</RadioButton>
</RadioGroup>
<!-- <Button
type="primary"
size="small"
@click="handleQuickTimeSelect('today')"
>
今日
</Button>
<Button size="small" @click="handleQuickTimeSelect('week')">
近一周
</Button>
<Button size="small" @click="handleQuickTimeSelect('month')">
近一月
</Button> -->
<DatePicker.RangePicker
v-model:value="logDateRange"
:show-time="true"
format="YYYY-MM-DD HH:mm:ss"
@change="handleDateRangeChange"
/>
</Space>
</div>
<!-- 标签页 -->
<Tabs v-model:active-key="logTabActiveKey" class="log-tabs">
<Tabs.TabPane key="list" tab="列表">
<Table
:columns="logColumns"
:data-source="logData"
:loading="logLoading"
:pagination="false"
size="small"
row-key="timestamp"
>
<template #bodyCell="{ column }">
<template v-if="column.key === 'action'">
<Button type="link" size="small">查看</Button>
</template>
</template>
</Table>
</Tabs.TabPane>
<Tabs.TabPane v-if="currentProperty && canShowChart(currentProperty)" key="chart" tab="图表">
<div class="chart-container">
<div class="chart-placeholder">
<LineChartOutlined style="font-size: 48px; color: #d9d9d9" />
<p>图表功能开发中...</p>
</div>
</div>
</Tabs.TabPane>
</Tabs>
</div>
</Modal>
</div>
</template>
<style lang="scss" scoped>
.running-status {
.control-panel {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 20px;
.control-item {
display: flex;
gap: 12px;
align-items: center;
.control-label {
font-weight: 500;
color: #262626;
white-space: nowrap;
}
}
}
.property-grid {
.property-col {
margin-bottom: 16px;
}
.property-card {
height: 100%;
.property-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.property-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #262626;
}
.property-log-btn {
padding: 4px;
color: #8c8c8c;
&:hover {
color: #1890ff;
}
}
}
.property-content {
position: relative;
.property-value {
display: flex;
align-items: center;
height: 80px;
margin-bottom: 12px;
font-size: 32px;
font-weight: 700;
color: #1890ff;
}
.property-unit {
position: absolute;
right: 0;
bottom: 0;
font-size: 20px;
color: #8c8c8c;
text-align: right;
}
.property-info {
display: flex;
gap: 8px;
justify-content: center;
}
}
.property-footer {
.property-timestamp {
font-size: 12px;
color: #8c8c8c;
}
}
}
}
.empty-state {
padding: 60px 0;
text-align: center;
}
}
.log-modal-content {
.time-selection {
padding: 16px 0;
}
.log-tabs {
.chart-container {
.chart-placeholder {
padding: 60px 0;
color: #8c8c8c;
text-align: center;
p {
margin: 16px 0 0;
}
}
}
}
}
:deep(.ant-card-body) {
padding: 12px;
}
</style>

View File

@ -0,0 +1,274 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { ArrowLeftOutlined } from '@ant-design/icons-vue';
import { message, Switch, TabPane, Tabs } from 'ant-design-vue';
import { deviceStateOptions } from '#/constants/dicts';
// import { deviceUpdateStatus } from '#/api/device/device';
import { useDeviceStore } from '#/store/device';
import BasicInfo from './components/BasicInfo.vue';
import DeviceFunction from './components/DeviceFunction.vue';
import LogManagement from './components/LogManagement.vue';
import RunningStatus from './components/RunningStatus.vue';
const route = useRoute();
const router = useRouter();
const deviceStore = useDeviceStore();
const deviceId = computed(() => route.params.id as string);
const activeTab = computed(() => deviceStore.getTabActiveKey);
const currentDevice = computed(() => deviceStore.getCurrentDevice);
//
const loadDeviceInfo = async () => {
try {
await deviceStore.getDetail(deviceId.value);
} catch {
message.error('加载设备信息失败');
}
};
//
const handleStatusChange = async (checked: boolean) => {
try {
console.log('checked', checked);
// await deviceUpdateStatus({
// id: deviceId.value,
// enabled: checked,
// });
// await loadDeviceInfo();
} catch {
message.error('状态更新失败');
}
};
//
const goBack = () => {
router.back();
};
//
const handleTabChange = (key: string) => {
deviceStore.setTabActiveKey(key);
if (key === 'BasicInfo') {
loadDeviceInfo();
}
};
const handleProductClick = () => {
router.push(`/device/product/detail/${currentDevice.value.productId}`);
};
onMounted(() => {
loadDeviceInfo();
});
// store
onUnmounted(() => {
deviceStore.reset();
});
</script>
<template>
<Page :auto-content-height="true">
<div class="device-detail">
<!-- 页面头部 -->
<div class="detail-header">
<div class="header-left">
<a-button @click="goBack" class="back-btn">
<template #icon>
<ArrowLeftOutlined />
</template>
返回
</a-button>
<div class="device-info">
<h2 class="device-name">
{{ currentDevice.deviceName || deviceId }}
</h2>
<div class="device-status">
<span class="status-label">在线状态:</span>
<!-- prettier-ignore -->
<span
class="status-dot"
:class="deviceStateOptions.find((item) => item.value === currentDevice.deviceState)?.type"
></span>
<span class="status-text">
{{ deviceStateOptions.find((item) => item.value === currentDevice.deviceState)?.label }}
</span>
<Switch
:checked="currentDevice.enabled === '1'"
checked-children="启用"
un-checked-children="禁用"
checked-value="1"
un-checked-value="0"
@change="handleStatusChange"
class="status-switch"
/>
</div>
</div>
</div>
<div class="header-right">
<!-- <a-button
type="primary"
@click="handleStatusChange(!(currentDevice.enabled === '1'))"
v-access:code="['device:device:edit']"
>
{{ currentDevice.enabled === '1' ? '禁用设备' : '启用设备' }}
</a-button> -->
</div>
</div>
<!-- 设备基本信息 -->
<div class="device-basic">
<div class="basic-item">
<span class="basic-label">ID:</span>
<span class="basic-value">{{ deviceId }}</span>
</div>
<div class="basic-item">
<span class="basic-label">所属产品:</span>
<a class="basic-value product-link" @click="handleProductClick">{{
currentDevice?.productObj?.productName || '未知产品'
}}</a>
</div>
</div>
<!-- 标签页内容 -->
<Tabs v-model:active-key="activeTab" class="detail-tabs" @change="handleTabChange">
<TabPane key="BasicInfo" tab="实例信息">
<BasicInfo :device-info="currentDevice" @refresh="loadDeviceInfo" />
</TabPane>
<TabPane key="RunningStatus" tab="运行状态">
<RunningStatus :device-id="deviceId" :device-info="currentDevice" />
</TabPane>
<TabPane key="DeviceFunction" tab="设备功能">
<DeviceFunction :device-id="deviceId" :device-info="currentDevice" />
</TabPane>
<TabPane key="LogManagement" tab="日志管理">
<LogManagement :device-id="deviceId" :device-info="currentDevice" />
</TabPane>
</Tabs>
</div>
</Page>
</template>
<style lang="scss" scoped>
.device-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;
}
.device-info {
display: flex;
gap: 16px;
align-items: center;
.device-name {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #262626;
}
.device-status {
display: flex;
gap: 8px;
align-items: center;
.status-label {
font-size: 14px;
color: #8c8c8c;
}
.status-dot {
display: flex;
align-items: center;
height: 20px;
margin-top: -5px;
font-size: 24px;
font-weight: bold;
line-height: 20px;
&.warning {
color: #faad14;
}
&.success {
color: #52c41a;
}
&.error {
color: #ff4d4f;
}
}
.status-text {
font-size: 14px;
color: #262626;
}
.status-switch {
margin-left: 8px;
}
}
}
}
}
.device-basic {
display: flex;
gap: 24px;
padding: 16px 0;
.basic-item {
display: flex;
gap: 8px;
align-items: center;
.basic-label {
font-size: 14px;
color: #8c8c8c;
}
.basic-value {
font-size: 14px;
font-weight: 500;
color: #262626;
&.product-link {
color: #1890ff;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
}
}
.detail-tabs {
:deep(.ant-tabs-content-holder) {
padding-top: 16px;
}
}
}
</style>

View File

@ -0,0 +1,141 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { cloneDeep } from '@vben/utils';
import { useVbenForm } from '#/adapter/form';
import { deviceAdd, deviceInfo, deviceUpdate } from '#/api/device/device';
import { productList } from '#/api/device/product';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import { drawerSchema } from './data';
const emit = defineEmits<{ reload: [] }>();
const isUpdate = ref(false);
const title = computed(() => {
return isUpdate.value ? $t('pages.common.edit') : $t('pages.common.add');
});
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
//
formItemClass: 'col-span-2',
// label px
labelWidth: 80,
//
componentProps: {
class: 'w-full',
},
},
schema: drawerSchema(),
showDefaultActions: false,
wrapperClass: 'grid-cols-2',
});
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff({
initializedGetter: defaultFormValueGetter(formApi),
currentGetter: defaultFormValueGetter(formApi),
});
const [BasicDrawer, drawerApi] = useVbenDrawer({
//
class: 'w-[550px]',
fullscreenButton: false,
onBeforeClose,
onClosed: handleClosed,
onConfirm: handleConfirm,
onOpenChange: async (isOpen) => {
if (!isOpen) {
return null;
}
drawerApi.drawerLoading(true);
const { id } = drawerApi.getData() as { id?: number | string };
isUpdate.value = !!id;
formApi.updateSchema([
{ componentProps: { disabled: isUpdate.value }, fieldName: 'deviceKey' },
{ componentProps: { disabled: isUpdate.value }, fieldName: 'productId' },
]);
if (isUpdate.value && id) {
const record = await deviceInfo(id);
await formApi.setValues(record);
}
await markInitialized();
drawerApi.drawerLoading(false);
getProductOptionList();
},
});
async function handleConfirm() {
try {
drawerApi.lock(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
// getValuesreadonly
const data = cloneDeep(await formApi.getValues());
await (isUpdate.value ? deviceUpdate(data) : deviceAdd(data));
resetInitialized();
emit('reload');
drawerApi.close();
} catch (error) {
console.error(error);
} finally {
drawerApi.lock(false);
}
}
async function handleClosed() {
await formApi.resetForm();
resetInitialized();
}
async function getProductOptionList() {
try {
const res = await productList({
pageNum: 1,
pageSize: 1000,
enabled: '1',
});
const productOptions = res.rows.map((item) => ({
label: item.productName,
value: item.id,
imgId: item.imgId,
}));
const placeholder = productOptions.length > 0 ? '请选择' : '暂无产品';
formApi.updateSchema([
{
componentProps: {
options: productOptions || [],
placeholder,
onChange: (value: number | string) => {
//
if (value) {
const selectedProduct = productOptions.find((option) => option.value === value);
formApi.setFieldValue('imgId', selectedProduct?.imgId || '');
}
},
},
fieldName: 'productId',
},
]);
} catch (error) {
console.error('获取产品列表失败:', error);
}
}
</script>
<template>
<BasicDrawer :title="title">
<BasicForm />
</BasicDrawer>
</template>

View File

@ -0,0 +1,212 @@
<script setup lang="ts">
import type { VbenFormProps } from '@vben/common-ui';
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { DeviceForm } from '#/api/device/device/model';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { getVxePopupContainer } from '@vben/utils';
import { Modal, Popconfirm, Space, Tag } from 'ant-design-vue';
import { useVbenVxeGrid, vxeCheckboxChecked } from '#/adapter/vxe-table';
import { deviceExport, deviceList, deviceRemove } from '#/api/device/device';
import { productList } from '#/api/device/product';
import { deviceStateOptions, deviceTypeOptions } from '#/constants/dicts';
import { commonDownloadExcel } from '#/utils/file/download';
import { columns, querySchema } from './data';
import deviceDrawer from './device-drawer.vue';
const router = useRouter();
const productOptions = ref<{ label: string; value: string }[]>([]);
const formOptions: VbenFormProps = {
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
schema: querySchema(),
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
// RangePicker /
//
// fieldMappingTime: [
// [
// 'createTime',
// ['params[beginTime]', 'params[endTime]'],
// ['YYYY-MM-DD 00:00:00', 'YYYY-MM-DD 23:59:59'],
// ],
// ],
};
const gridOptions: VxeGridProps = {
checkboxConfig: {
//
highlight: true,
//
reserve: true,
//
// trigger: 'row',
},
// 使i18ngetter
// columns: columns(),
columns,
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
return await deviceList({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
//
id: 'device-device-index',
};
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
});
const [DeviceDrawer, drawerApi] = useVbenDrawer({
connectedComponent: deviceDrawer,
});
function handleAdd() {
drawerApi.setData({});
drawerApi.open();
}
//
async function handleView(row: any) {
router.push(`/device/device/detail/${row.id}`);
}
//
async function handleEdit(row: any) {
drawerApi.setData({ id: row.id });
drawerApi.open();
}
async function handleDelete(row: Required<DeviceForm>) {
await deviceRemove(row.id);
await tableApi.query();
}
function handleMultiDelete() {
const rows = tableApi.grid.getCheckboxRecords();
const ids = rows.map((row: Required<DeviceForm>) => row.id);
Modal.confirm({
title: '提示',
okType: 'danger',
content: `确认删除选中的${ids.length}条记录吗?`,
onOk: async () => {
await deviceRemove(ids);
await tableApi.query();
},
});
}
function handleDownloadExcel() {
commonDownloadExcel(deviceExport, '设备数据', tableApi.formApi.form.values, {
fieldMappingTime: formOptions.fieldMappingTime,
});
}
async function getProductOptionList() {
try {
const res = await productList({
pageNum: 1,
pageSize: 1000,
});
productOptions.value = res.rows.map((item) => ({
label: item.productName,
value: item.id,
}));
const placeholder = productOptions.value.length > 0 ? '请选择' : '暂无产品';
tableApi.formApi.updateSchema([
{
componentProps: {
options: productOptions.value || [],
placeholder,
},
fieldName: 'productId',
},
]);
} catch (error) {
console.error('获取产品列表失败:', error);
}
}
getProductOptionList();
</script>
<template>
<Page :auto-content-height="true">
<BasicTable table-title="设备列表">
<template #toolbar-tools>
<Space>
<a-button v-access:code="['device:device:export']" @click="handleDownloadExcel">
{{ $t('pages.common.export') }}
</a-button>
<a-button
:disabled="!vxeCheckboxChecked(tableApi)"
danger
type="primary"
v-access:code="['device:device:remove']"
@click="handleMultiDelete"
>
{{ $t('pages.common.delete') }}
</a-button>
<a-button type="primary" v-access:code="['device:device:add']" @click="handleAdd">
{{ $t('pages.common.add') }}
</a-button>
</Space>
</template>
<template #deviceType="{ row }">
<Tag color="processing">
{{ deviceTypeOptions.find((option) => option.value === row.deviceType)?.label }}
</Tag>
</template>
<template #deviceState="{ row }">
<Tag :color="deviceStateOptions.find((option) => option.value === row.deviceState)?.type">
{{ deviceStateOptions.find((option) => option.value === row.deviceState)?.label }}
</Tag>
</template>
<template #action="{ row }">
<Space>
<ghost-button v-access:code="['device:device:view']" @click.stop="handleView(row)"> 详情 </ghost-button>
<ghost-button v-access:code="['device:device:edit']" @click.stop="handleEdit(row)">
{{ $t('pages.common.edit') }}
</ghost-button>
<Popconfirm
:get-popup-container="getVxePopupContainer"
placement="left"
title="确认删除?"
@confirm="handleDelete(row)"
>
<ghost-button danger v-access:code="['device:device:remove']" @click.stop="">
{{ $t('pages.common.delete') }}
</ghost-button>
</Popconfirm>
</Space>
</template>
</BasicTable>
<DeviceDrawer @reload="tableApi.query()" />
</Page>
</template>

View File

@ -9,7 +9,7 @@ export const querySchema: FormSchemaGetter = () => [
{
component: 'Input',
fieldName: 'productKey',
label: '产品编码',
label: '产品KEY',
},
{
component: 'Input',
@ -51,7 +51,7 @@ export const columns: VxeGridProps['columns'] = [
// field: 'id',
// },
{
title: '产品编码',
title: '产品KEY',
field: 'productKey',
},
{
@ -96,7 +96,7 @@ export const drawerSchema: FormSchemaGetter = () => [
},
},
{
label: '产品编码',
label: '产品KEY',
fieldName: 'productKey',
component: 'Input',
},
@ -140,8 +140,7 @@ export const drawerSchema: FormSchemaGetter = () => [
'span',
{
class: 'text-[14px] text-black/25 truncate',
style:
'max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;',
style: 'max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;',
},
option.tooltip,
),

View File

@ -18,7 +18,7 @@ import {
} from 'ant-design-vue';
import { gatewayList } from '#/api/operations/gateway';
import { enabledOptions, networkTypeOptions } from '#/constants/dicts';
import { networkTypeOptions } from '#/constants/dicts';
const emit = defineEmits<{
close: [];
@ -32,7 +32,6 @@ const selectedGateway = ref<any>(null);
const searchForm = ref({
name: '',
provider: undefined,
enabled: undefined,
});
//
@ -42,8 +41,7 @@ const pagination = reactive({
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total: number, range: [number, number]) =>
`${range[0]}-${range[1]}条/总共${total}`,
showTotal: (total: number, range: [number, number]) => `${range[0]}-${range[1]}条/总共${total}`,
});
//
@ -57,8 +55,7 @@ const providerOptions = [
value: 'child-device',
channel: 'child-device',
transport: 'Gateway',
description:
'需要通过网关与平台进行数据通信的设备,将作为网关子设备接入到平台。',
description: '需要通过网关与平台进行数据通信的设备,将作为网关子设备接入到平台。',
},
];
@ -69,6 +66,7 @@ const loadGatewayList = async () => {
const response = await gatewayList({
pageNum: pagination.current,
pageSize: pagination.pageSize,
enabled: '1',
...searchForm.value,
});
@ -91,7 +89,6 @@ const loadGatewayList = async () => {
const resetSearch = () => {
searchForm.value.name = '';
searchForm.value.provider = undefined;
searchForm.value.enabled = undefined;
pagination.current = 1;
loadGatewayList();
};
@ -199,16 +196,6 @@ onMounted(() => {
@change="(value) => (searchForm.provider = value)"
/>
</FormItem>
<FormItem label="状态">
<Select
v-model="searchForm.enabled"
placeholder="请选择"
allow-clear
style="width: 140px"
:options="enabledOptions"
@change="(value) => (searchForm.enabled = value)"
/>
</FormItem>
<FormItem>
<Space>
<Button @click="resetSearch">重置</Button>
@ -243,10 +230,7 @@ onMounted(() => {
{{ getProviderText(gateway.provider) }}
</Tag>
</div>
<Tag
class="gateway-status"
:color="getStatusColor(gateway.enabled)"
>
<Tag class="gateway-status" :color="getStatusColor(gateway.enabled)">
{{ getStatusText(gateway.enabled) }}
</Tag>
<div class="gateway-footer">

View File

@ -69,8 +69,7 @@ const accessConfigs = ref([]);
//
const tableColumns = computed(() => {
const isMQTT =
accessInfo.value.provider === 'mqtt-server-gateway' ||
accessInfo.value.provider === 'mqtt-client-gateway';
accessInfo.value.provider === 'mqtt-server-gateway' || accessInfo.value.provider === 'mqtt-client-gateway';
return isMQTT
? [
@ -143,6 +142,7 @@ const handleSave = async () => {
id: props.productInfo.id,
provider: accessInfo.value.provider,
gatewayId: accessInfo.value.id,
protocolId: accessInfo.value.protocol,
storePolicy: selectedStorePolicy.value,
protocolConf: JSON.stringify(formData),
});
@ -254,8 +254,8 @@ onMounted(() => {
</div>
<div class="section-content">
<p>{{ accessInfo.protocolName }}</p>
<!-- <div v-if="accessInfo.document" v-html="markdownToHtml"></div>-->
<div>{{markdownToHtml}}</div>
<!-- <div v-if="accessInfo.document" v-html="markdownToHtml"></div>-->
<div>{{ markdownToHtml }}</div>
</div>
</div>
@ -265,18 +265,9 @@ onMounted(() => {
<h4>连接信息</h4>
</div>
<div class="section-content">
<div
v-if="accessInfo.addresses && accessInfo.addresses.length > 0"
>
<div
v-for="item in accessInfo.addresses"
:key="item.address"
class="address-item"
>
<Badge
:color="item.health === -1 ? 'red' : 'green'"
:text="item.address"
/>
<div v-if="accessInfo.addresses && accessInfo.addresses.length > 0">
<div v-for="item in accessInfo.addresses" :key="item.address" class="address-item">
<Badge :color="item.health === -1 ? 'red' : 'green'" :text="item.address" />
</div>
</div>
<div v-else class="no-address">暂无连接信息</div>
@ -306,11 +297,7 @@ onMounted(() => {
</div> -->
<!-- 其它接入配置 -->
<div
v-for="(config, index) in accessConfigs"
:key="index"
class="config-section"
>
<div v-for="(config, index) in accessConfigs" :key="index" class="config-section">
<div class="section-header">
<h4>{{ config.name }}</h4>
<Tooltip title="此配置来自于产品接入网关所选择的协议">
@ -327,11 +314,7 @@ onMounted(() => {
:rules="[
{
required: !!item?.type?.expands?.required,
message: `${
item.type.type === 'enum' || 'boolean'
? '请选择'
: '请输入'
}${item.name}`,
message: `${item.type.type === 'enum' || 'boolean' ? '请选择' : '请输入'}${item.name}`,
},
]"
>
@ -346,19 +329,13 @@ onMounted(() => {
placeholder="请输入"
/>
<Select
v-if="
item.type.type === 'enum' || item.type.type === 'boolean'
"
v-if="item.type.type === 'enum' || item.type.type === 'boolean'"
v-model:value="formData[item.property]"
placeholder="请选择"
:options="getOptions(item)"
/>
<InputNumber
v-if="
['int', 'float', 'double', 'long'].includes(
item.type.type,
)
"
v-if="['int', 'float', 'double', 'long'].includes(item.type.type)"
v-model:value="formData[item.property]"
placeholder="请输入"
/>
@ -371,9 +348,7 @@ onMounted(() => {
<div class="config-section">
<div class="section-header">
<h4>存储策略</h4>
<Tooltip
title="若修改存储策略,需要手动做数据迁移,平台只能搜索最新存储策略中的数据"
>
<Tooltip title="若修改存储策略,需要手动做数据迁移,平台只能搜索最新存储策略中的数据">
<QuestionCircleOutlined />
</Tooltip>
</div>
@ -389,27 +364,18 @@ onMounted(() => {
<!-- 保存按钮 -->
<div class="action-buttons">
<a-button
type="primary"
@click="handleSave"
:loading="saveLoading"
v-access:code="['device:product:edit']"
>
<a-button type="primary" @click="handleSave" :loading="saveLoading" v-access:code="['device:product:edit']">
保存
</a-button>
</div>
</Col>
<!-- 右侧信息展示 -->
<Col
:span="12"
v-if="accessInfo.routes && accessInfo.routes.length > 0"
>
<Col :span="12" v-if="accessInfo.routes && accessInfo.routes.length > 0">
<div class="info-panel">
<h4>
{{
accessInfo.provider === 'mqtt-server-gateway' ||
accessInfo.provider === 'mqtt-client-gateway'
accessInfo.provider === 'mqtt-server-gateway' || accessInfo.provider === 'mqtt-client-gateway'
? 'Topic信息'
: 'URL信息'
}}
@ -461,11 +427,7 @@ onMounted(() => {
@cancel="handleAccessModalClose"
:footer="null"
>
<AccessSelector
:product-id="productInfo.id"
@select="handleModalSelect"
@close="handleAccessModalClose"
/>
<AccessSelector :product-id="productInfo.id" @select="handleModalSelect" @close="handleAccessModalClose" />
</Modal>
</div>
</template>

View File

@ -163,6 +163,7 @@ const handleSave = async () => {
emit('save', { ...formData.value });
message.success('保存成功');
visible.value = false;
resetForm();
} catch {
message.error('保存失败');
} finally {
@ -196,12 +197,7 @@ watch(
</script>
<template>
<Drawer
v-model:open="visible"
:title="drawerTitle"
width="800px"
@close="handleDrawerClose"
>
<Drawer v-model:open="visible" :title="drawerTitle" width="800px" @close="handleDrawerClose">
<Form ref="formRef" :model="formData" :rules="formRules" layout="vertical">
<Row :gutter="24">
<Col :span="12">
@ -220,18 +216,10 @@ watch(
<Col :span="24">
<FormItem label="输出参数" name="outputs">
<div class="parameter-preview">
<Table
:columns="outputColumns"
:data-source="formData.outputs"
:pagination="false"
>
<Table :columns="outputColumns" :data-source="formData.outputs" :pagination="false">
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'dataType'">
{{
dataTypeOptions.find(
(item) => item.value === record.valueParams.dataType,
)?.label
}}
{{ dataTypeOptions.find((item) => item.value === record.valueParams.dataType)?.label }}
</template>
<template v-if="column.key === 'required'">
<Tag color="processing">
@ -239,26 +227,12 @@ watch(
</Tag>
</template>
<template v-if="column.key === 'formType'">
{{
formTypeOptions.find(
(item) => item.value === record.valueParams.formType,
)?.label
}}
{{ formTypeOptions.find((item) => item.value === record.valueParams.formType)?.label }}
</template>
<template v-if="column.key === 'action'">
<Space>
<Button
type="link"
size="small"
@click="handleEditOutputParam(record, index)"
>
编辑
</Button>
<Popconfirm
placement="left"
title="确认删除?"
@confirm="handleDeleteOutputParam(index)"
>
<Button type="link" size="small" @click="handleEditOutputParam(record, index)"> 编辑 </Button>
<Popconfirm placement="left" title="确认删除?" @confirm="handleDeleteOutputParam(index)">
<Button type="link" size="small" danger> 删除 </Button>
</Popconfirm>
</Space>
@ -266,11 +240,7 @@ watch(
</template>
</Table>
<div class="add-button-container">
<a-button
@click="handleAddOutputParam"
type="primary"
class="add-button"
>
<a-button @click="handleAddOutputParam" type="primary" class="add-button">
<template #icon>
<PlusOutlined />
</template>
@ -283,21 +253,13 @@ watch(
<Col :span="12">
<FormItem label="排序" name="sort">
<InputNumber
style="width: 100%"
v-model:value="formData.sort"
placeholder="请输入排序"
/>
<InputNumber style="width: 100%" v-model:value="formData.sort" placeholder="请输入排序" />
</FormItem>
</Col>
<Col :span="24">
<FormItem label="描述" name="description">
<Textarea
v-model:value="formData.description"
placeholder="请输入描述"
:rows="3"
/>
<Textarea v-model:value="formData.description" placeholder="请输入描述" :rows="3" />
</FormItem>
</Col>
</Row>
@ -306,9 +268,7 @@ watch(
<template #footer>
<Space>
<Button @click="handleDrawerClose">取消</Button>
<Button type="primary" @click="handleSave" :loading="saveLoading">
确认
</Button>
<Button type="primary" @click="handleSave" :loading="saveLoading"> 确认 </Button>
</Space>
</template>

View File

@ -204,6 +204,7 @@ const handleSave = async () => {
emit('save', { ...formData.value });
visible.value = false;
resetForm();
} catch {
message.error('保存失败');
} finally {
@ -219,10 +220,7 @@ const handleDrawerClose = () => {
//
const resetForm = () => {
Object.assign(
formData.value,
JSON.parse(JSON.stringify(defaultFunctionData)),
);
Object.assign(formData.value, JSON.parse(JSON.stringify(defaultFunctionData)));
};
//
@ -240,12 +238,7 @@ watch(
</script>
<template>
<Drawer
v-model:open="visible"
:title="drawerTitle"
width="800px"
@close="handleDrawerClose"
>
<Drawer v-model:open="visible" :title="drawerTitle" width="800px" @close="handleDrawerClose">
<Form ref="formRef" :model="formData" :rules="formRules" layout="vertical">
<Row :gutter="24">
<Col :span="12">
@ -262,18 +255,10 @@ watch(
<Col :span="24">
<FormItem label="输入参数" name="inputs">
<div class="parameter-preview">
<Table
:columns="inputColumns"
:data-source="formData.inputs"
:pagination="false"
>
<Table :columns="inputColumns" :data-source="formData.inputs" :pagination="false">
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'dataType'">
{{
dataTypeOptions.find(
(item) => item.value === record.valueParams.dataType,
)?.label
}}
{{ dataTypeOptions.find((item) => item.value === record.valueParams.dataType)?.label }}
</template>
<template v-if="column.key === 'required'">
<Tag color="processing">
@ -281,26 +266,12 @@ watch(
</Tag>
</template>
<template v-if="column.key === 'formType'">
{{
formTypeOptions.find(
(item) => item.value === record.valueParams.formType,
)?.label
}}
{{ formTypeOptions.find((item) => item.value === record.valueParams.formType)?.label }}
</template>
<template v-if="column.key === 'action'">
<Space>
<Button
type="link"
size="small"
@click="handleEditInputParam(record, index)"
>
编辑
</Button>
<Popconfirm
placement="left"
title="确认删除?"
@confirm="handleDeleteInputParam(index)"
>
<Button type="link" size="small" @click="handleEditInputParam(record, index)"> 编辑 </Button>
<Popconfirm placement="left" title="确认删除?" @confirm="handleDeleteInputParam(index)">
<Button type="link" size="small" danger> 删除 </Button>
</Popconfirm>
</Space>
@ -308,11 +279,7 @@ watch(
</template>
</Table>
<div class="add-button-container">
<a-button
@click="handleAddInputParam"
type="primary"
class="add-button"
>
<a-button @click="handleAddInputParam" type="primary" class="add-button">
<template #icon>
<PlusOutlined />
</template>
@ -327,11 +294,7 @@ watch(
<Col :span="12">
<FormItem label="是否异步" name="async">
<RadioGroup v-model:value="formData.async" button-style="solid">
<RadioButton
:value="item.value"
v-for="item in asyncOptions"
:key="item.value"
>
<RadioButton :value="item.value" v-for="item in asyncOptions" :key="item.value">
{{ item.label }}
</RadioButton>
</RadioGroup>
@ -340,15 +303,8 @@ watch(
<Col :span="12">
<FormItem label="是否开启前置校验" name="expands.preCheck">
<RadioGroup
v-model:value="formData.expands.preCheck"
button-style="solid"
>
<RadioButton
:value="item.value"
v-for="item in preCheckOptions"
:key="item.value"
>
<RadioGroup v-model:value="formData.expands.preCheck" button-style="solid">
<RadioButton :value="item.value" v-for="item in preCheckOptions" :key="item.value">
{{ item.label }}
</RadioButton>
</RadioGroup>
@ -375,21 +331,13 @@ watch(
<Col :span="12">
<FormItem label="排序" name="sort">
<InputNumber
style="width: 100%"
v-model:value="formData.sort"
placeholder="请输入排序"
/>
<InputNumber style="width: 100%" v-model:value="formData.sort" placeholder="请输入排序" />
</FormItem>
</Col>
<Col :span="24">
<FormItem label="描述" name="description">
<Textarea
v-model:value="formData.description"
placeholder="请输入描述"
:rows="3"
/>
<Textarea v-model:value="formData.description" placeholder="请输入描述" :rows="3" />
</FormItem>
</Col>
</Row>
@ -398,9 +346,7 @@ watch(
<template #footer>
<Space>
<Button @click="handleDrawerClose">取消</Button>
<Button type="primary" @click="handleSave" :loading="saveLoading">
保存
</Button>
<Button type="primary" @click="handleSave" :loading="saveLoading"> 确认 </Button>
</Space>
</template>

View File

@ -1,15 +1,7 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import {
Button,
Drawer,
message,
Modal,
Space,
TabPane,
Tabs,
} from 'ant-design-vue';
import { Button, Drawer, message, Modal, Space, TabPane, Tabs } from 'ant-design-vue';
import { productUpdateById } from '#/api/device/product';
@ -143,7 +135,7 @@ const loadMetadata = async () => {
// objectConf: [],
// unit: '',
// scale: 2,
// format: 'yyyy-MM-dd HH:mm:ss',
// format: 'YYYY-MM-DD HH:mm:ss',
// },
// },
// ],
@ -171,7 +163,7 @@ const loadMetadata = async () => {
// objectConf: [],
// unit: '',
// scale: 2,
// format: 'yyyy-MM-dd HH:mm:ss',
// format: 'YYYY-MM-DD HH:mm:ss',
// },
// expands: {},
// },
@ -197,7 +189,7 @@ const loadMetadata = async () => {
// objectConf: [],
// unit: '',
// scale: 2,
// format: 'yyyy-MM-dd HH:mm:ss',
// format: 'YYYY-MM-DD HH:mm:ss',
// },
// },
// ],
@ -216,15 +208,44 @@ const loadMetadata = async () => {
}
};
// _pk
const stripPkDeep = (data: any): any => {
if (Array.isArray(data)) return data.map((item) => stripPkDeep(item));
if (data && typeof data === 'object') {
const { _pk, ...rest } = data as Record<string, any>;
Object.keys(rest).forEach((k) => (rest[k] = stripPkDeep(rest[k])));
return rest;
}
return data;
};
//
const handleSave = async () => {
try {
// API
const metadata = JSON.parse(JSON.stringify(currentMetadata.value));
metadata.properties.sort((a: any, b: any) => a.sort - b.sort);
metadata.functions.sort((a: any, b: any) => a.sort - b.sort);
metadata.events.sort((a: any, b: any) => a.sort - b.sort);
metadata.propertyGroups.sort((a: any, b: any) => a.sort - b.sort); //
// _pk
metadata.properties = (metadata.properties || [])
.map((x: any) => stripPkDeep(x))
.sort((a: any, b: any) => (a.sort ?? 0) - (b.sort ?? 0));
metadata.functions = (metadata.functions || [])
.map((x: any) => stripPkDeep(x))
.sort((a: any, b: any) => (a.sort ?? 0) - (b.sort ?? 0));
metadata.events = (metadata.events || [])
.map((x: any) => stripPkDeep(x))
.sort((a: any, b: any) => (a.sort ?? 0) - (b.sort ?? 0));
metadata.propertyGroups = (metadata.propertyGroups || [])
.map((g: any) => {
const next = stripPkDeep(g);
next.properties = (next.properties || []).map((item) => stripPkDeep(item));
return next;
})
.sort((a: any, b: any) => (a.sort ?? 0) - (b.sort ?? 0));
await productUpdateById(props.productInfo.id, {
id: props.productInfo.id,
metadata: JSON.stringify(metadata),
@ -285,9 +306,7 @@ watch(
currentMetadata,
() => {
if (originalMetadata.value) {
metadataChanged.value =
JSON.stringify(currentMetadata.value) !==
JSON.stringify(originalMetadata.value);
metadataChanged.value = JSON.stringify(currentMetadata.value) !== JSON.stringify(originalMetadata.value);
}
},
{ deep: true },
@ -302,36 +321,16 @@ loadMetadata();
<div class="metadata-header">
<div class="header-left">
<h3>物模型</h3>
<span class="desc">
设备会默认继承产品的物模型继承的物模型不支持删改
</span>
<span class="desc"> 设备会默认继承产品的物模型继承的物模型不支持删改 </span>
</div>
<div class="header-right">
<Space>
<Button
v-if="metadataChanged"
type="primary"
@click="handleSave"
v-access:code="['device:product:edit']"
>
<Button v-if="metadataChanged" type="primary" @click="handleSave" v-access:code="['device:product:edit']">
保存
</Button>
<Button
v-if="showReset"
@click="handleReset"
v-access:code="['device:product:edit']"
>
重置操作
</Button>
<Button @click="handleImport" v-access:code="['device:product:edit']">
快速导入
</Button>
<Button
@click="handleViewTSL"
v-access:code="['device:product:edit']"
>
物模型
</Button>
<Button v-if="showReset" @click="handleReset" v-access:code="['device:product:edit']"> 重置操作 </Button>
<Button @click="handleImport" v-access:code="['device:product:edit']"> 快速导入 </Button>
<Button @click="handleViewTSL" v-access:code="['device:product:edit']"> 物模型 </Button>
</Space>
</div>
</div>
@ -377,25 +376,16 @@ loadMetadata();
</Tabs>
<!-- 导入抽屉 -->
<Drawer
v-model:open="importVisible"
title="快速导入"
width="600px"
@close="handleImportClose"
>
<Drawer v-model:open="importVisible" title="快速导入" width="600px" @close="handleImportClose">
<ImportForm @success="handleImportSuccess" />
</Drawer>
<!-- TSL查看抽屉 -->
<Drawer
v-model:open="tslVisible"
title="物模型"
width="800px"
@close="handleTSLClose"
>
<Drawer v-model:open="tslVisible" title="物模型" width="800px" @close="handleTSLClose">
<TSLViewer
:product-id="productInfo.id"
:product-info="productInfo"
:file-name="productInfo.productName"
:metadata="tslMetadata"
/>
</Drawer>

View File

@ -34,6 +34,26 @@ const tableData = ref<any[]>([]);
const formData = ref<any>(null);
//
const generatePk = (): string => `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
//
const withPk = (record: any) => {
if (!record) return record;
if (!record._pk) {
Object.defineProperty(record, '_pk', {
value: generatePk(),
enumerable: true, // rowKey
configurable: true,
writable: true,
});
}
return record;
};
//
const findIndexByPk = (pk: string) => tableData.value.findIndex((item) => item._pk === pk);
//
const columns = computed(() => {
const baseColumns = [
@ -148,12 +168,23 @@ const getTypeLabel = () => {
return labels[props.type];
};
//
const sortTable = () => {
tableData.value = [...tableData.value].sort((a: any, b: any) => {
const as = Number(a?.sort ?? 0);
const bs = Number(b?.sort ?? 0);
if (as !== bs) return as - bs;
return String(a?._pk ?? '').localeCompare(String(b?._pk ?? ''));
});
};
//
const loadData = async () => {
loading.value = true;
try {
// 使metadata
tableData.value = [...(props.metadata || [])];
// 使metadata _pk
tableData.value = (props.metadata || []).map((item) => withPk({ ...item }));
sortTable();
} catch {
message.error('加载数据失败');
} finally {
@ -169,9 +200,11 @@ const handleAdd = () => {
};
//
const editingPk = ref<null | string>(null);
const handleEdit = (record: any, index: number) => {
editDataIndex.value = index;
editDataIndex.value = index; //
isEdit.value = true;
editingPk.value = record._pk;
formData.value = JSON.parse(JSON.stringify(record));
drawerVisible.value = true;
};
@ -179,10 +212,10 @@ const handleEdit = (record: any, index: number) => {
//
const handleDelete = async (record: any) => {
try {
//
const index = tableData.value.findIndex((item) => item.id === record.id);
if (index !== -1) {
tableData.value.splice(index, 1);
const idx = findIndexByPk(record._pk);
if (idx !== -1) {
tableData.value.splice(idx, 1);
sortTable();
emit('change', tableData.value);
message.success('删除成功');
}
@ -195,17 +228,21 @@ const handleDelete = async (record: any) => {
const handleSave = async (data: any) => {
try {
if (isEdit.value) {
//
if (editDataIndex.value === -1) {
tableData.value.push({ ...data });
// _pk _pk
const idx = editingPk.value ? findIndexByPk(editingPk.value) : -1;
const next = { ...data };
if (idx === -1) {
tableData.value.push(withPk(next));
} else {
tableData.value[editDataIndex.value] = { ...data };
next._pk = tableData.value[idx]._pk;
tableData.value[idx] = next;
}
} else {
//
tableData.value.push({ ...data });
tableData.value.push(withPk({ ...data }));
}
sortTable();
drawerVisible.value = false;
emit('change', tableData.value);
} catch {
@ -216,6 +253,7 @@ const handleSave = async (data: any) => {
//
const resetForm = () => {
formData.value = null;
editingPk.value = null;
};
// metadata
@ -235,11 +273,7 @@ onMounted(() => {
<template>
<div class="metadata-table">
<div class="table-header">
<Button
type="primary"
@click="handleAdd"
v-access:code="['device:product:edit']"
>
<Button type="primary" @click="handleAdd" v-access:code="['device:product:edit']">
<template #icon>
<PlusOutlined />
</template>
@ -247,52 +281,26 @@ onMounted(() => {
</Button>
</div>
<Table
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="false"
row-key="id"
>
<Table :columns="columns" :data-source="tableData" :loading="loading" :pagination="false" row-key="_pk">
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'action'">
<Space>
<Button
type="link"
size="small"
@click="handleEdit(record, index)"
v-access:code="['device:product:edit']"
>
<Button type="link" size="small" @click="handleEdit(record, index)" v-access:code="['device:product:edit']">
编辑
</Button>
<Popconfirm title="确认删除?" @confirm="handleDelete(record)">
<Button
type="link"
size="small"
danger
v-access:code="['device:product:edit']"
>
删除
</Button>
<Button type="link" size="small" danger v-access:code="['device:product:edit']"> 删除 </Button>
</Popconfirm>
</Space>
</template>
<template v-else-if="column.key === 'dataType'">
<Tag color="processing">
{{
dataTypeOptions.find(
(item) => item.value === record.valueParams?.dataType,
)?.label
}}
{{ dataTypeOptions.find((item) => item.value === record.valueParams?.dataType)?.label }}
</Tag>
</template>
<template v-else-if="column.key === 'expands.type'">
<Tag color="processing">
{{
readWriteTypeOptions.find(
(item) => item.value === record.expands.type,
)?.label
}}
{{ readWriteTypeOptions.find((item) => item.value === record.expands.type)?.label }}
</Tag>
</template>
<template v-else-if="column.key === 'required'">

View File

@ -78,6 +78,55 @@ const visible = computed({
set: (value) => emit('update:open', value),
});
const filterFormTypeOptions = computed(() => {
switch (formData?.value?.valueParams?.dataType) {
case 'boolean': {
return formTypeOptions.filter((item) => {
return item.value === 'switch';
});
}
case 'date': {
return formTypeOptions.filter((item) => {
return item.value === 'time';
});
}
case 'double':
case 'float':
case 'int':
case 'long': {
return formTypeOptions.filter((item) => {
return item.value === 'number' || item.value === 'progress';
});
}
case 'enum': {
return formTypeOptions.filter((item) => {
return item.value === 'select';
});
}
case 'string': {
return formTypeOptions.filter((item) => {
return item.value === 'input' || item.value === 'switch' || item.value === 'select' || item.value === 'time';
});
}
default: {
return formTypeOptions;
}
}
});
const filterTimeOptions = computed(() => {
switch (formData?.value?.valueParams?.dataType) {
case 'date': {
return timeOptions.filter((item) => {
return item.value === 'YYYY-MM-DD' || item.value === 'YYYY-MM-DD HH:mm:ss';
});
}
default: {
return timeOptions;
}
}
});
const formRef = ref();
const saveLoading = ref(false);
const enumModalVisible = ref(false);
@ -127,12 +176,43 @@ const drawerTitle = computed(() => {
return props.isEdit ? '编辑属性' : '新增属性';
});
const handleDataTypeChange = (value: string) => {
switch (value) {
case 'boolean': {
formData.value.valueParams.formType = 'switch';
break;
}
case 'date': {
formData.value.valueParams.formType = 'time';
break;
}
case 'double':
case 'float':
case 'int':
case 'long': {
formData.value.valueParams.formType = 'number';
break;
}
case 'enum': {
formData.value.valueParams.formType = 'select';
break;
}
case 'string': {
formData.value.valueParams.formType = 'input';
break;
}
// No default
}
};
//
const resetForm = () => {
Object.assign(
formData.value,
JSON.parse(JSON.stringify(defaultPropertyData)),
);
Object.assign(formData.value, JSON.parse(JSON.stringify(defaultPropertyData)));
};
//
@ -154,6 +234,7 @@ const handleSave = async () => {
emit('save', { ...formData.value });
visible.value = false;
resetForm();
} catch {
message.error('保存失败');
} finally {
@ -179,15 +260,22 @@ watch(
},
{ immediate: true },
);
watch(
() => formData.value.valueParams.formType,
(newVal) => {
if (newVal === 'switch') {
formData.value.valueParams.trueText = '是';
formData.value.valueParams.trueValue = 'true';
formData.value.valueParams.falseText = '否';
formData.value.valueParams.falseValue = 'false';
}
},
);
</script>
<template>
<Drawer
v-model:open="visible"
:title="drawerTitle"
width="800px"
@close="handleDrawerClose"
>
<Drawer v-model:open="visible" :title="drawerTitle" width="800px" @close="handleDrawerClose">
<Form ref="formRef" :model="formData" :rules="formRules" layout="vertical">
<Row :gutter="24">
<Col :span="12">
@ -208,6 +296,7 @@ watch(
v-model:value="formData.valueParams.dataType"
placeholder="请选择数据类型"
:options="dataTypeOptions"
@change="handleDataTypeChange"
/>
</FormItem>
</Col>
@ -216,7 +305,7 @@ watch(
<Select
v-model:value="formData.valueParams.formType"
placeholder="请选择表单类型"
:options="formTypeOptions"
:options="filterFormTypeOptions"
/>
</FormItem>
</Col>
@ -234,13 +323,7 @@ watch(
<Col :span="24" v-if="formData.valueParams.formType === 'switch'">
<Row
:gutter="24"
style="
padding-top: 16px;
margin: 0;
margin-bottom: 16px;
border: 1px solid #f0f0f0;
border-radius: 4px;
"
style="padding-top: 16px; margin: 0; margin-bottom: 16px; border: 1px solid #f0f0f0; border-radius: 4px"
>
<Col :span="12">
<FormItem label="开关打开名称" name="valueParams.trueText">
@ -285,14 +368,9 @@ watch(
<Col :span="24" v-if="formData.valueParams.formType === 'select'">
<FormItem label="枚举列表" name="valueParams.enumConf">
<div class="enum-preview">
<a-button type="primary" @click="handleEditEnum">
编辑枚举列表
</a-button>
<a-button type="primary" @click="handleEditEnum"> 编辑枚举列表 </a-button>
<Descriptions
v-if="
formData.valueParams.enumConf &&
formData.valueParams.enumConf.length > 0
"
v-if="formData.valueParams.enumConf && formData.valueParams.enumConf.length > 0"
:column="4"
bordered
title=" "
@ -312,30 +390,30 @@ watch(
<!-- 数字类型配置 -->
<Col
:span="12"
v-if="
formData.valueParams.formType === 'number' ||
formData.valueParams.formType === 'progress'
"
v-if="formData.valueParams.formType === 'number' || formData.valueParams.formType === 'progress'"
>
<FormItem label="最小值" name="valueParams.min">
<InputNumber
style="width: 100%"
v-model:value="formData.valueParams.min"
:precision="
formData.valueParams.dataType === 'double' || formData.valueParams.dataType === 'float' ? undefined : 0
"
placeholder="请输入最小值"
/>
</FormItem>
</Col>
<Col
:span="12"
v-if="
formData.valueParams.formType === 'number' ||
formData.valueParams.formType === 'progress'
"
v-if="formData.valueParams.formType === 'number' || formData.valueParams.formType === 'progress'"
>
<FormItem label="最大值" name="valueParams.max">
<InputNumber
style="width: 100%"
v-model:value="formData.valueParams.max"
:precision="
formData.valueParams.dataType === 'double' || formData.valueParams.dataType === 'float' ? undefined : 0
"
placeholder="请输入最大值"
/>
</FormItem>
@ -345,31 +423,19 @@ watch(
<InputNumber
style="width: 100%"
v-model:value="formData.valueParams.length"
:precision="0"
placeholder="请输入长度"
/>
</FormItem>
</Col>
<Col
:span="12"
v-if="
formData.valueParams.formType === 'number' ||
formData.valueParams.formType === 'progress'
"
>
<Col :span="12" v-if="formData.valueParams.dataType === 'double' || formData.valueParams.dataType === 'float'">
<FormItem label="小数位" name="valueParams.scale">
<InputNumber
style="width: 100%"
v-model:value="formData.valueParams.scale"
placeholder="请输入小数位"
/>
<InputNumber style="width: 100%" v-model:value="formData.valueParams.scale" placeholder="请输入小数位" />
</FormItem>
</Col>
<Col :span="12">
<FormItem label="单位" name="valueParams.unit">
<Input
v-model:value="formData.valueParams.unit"
placeholder="请输入单位"
/>
<Input v-model:value="formData.valueParams.unit" placeholder="请输入单位" />
</FormItem>
</Col>
<Col :span="12" v-if="formData.valueParams.formType === 'time'">
@ -377,7 +443,7 @@ watch(
<Select
v-model:value="formData.valueParams.format"
placeholder="请选择时间格式"
:options="timeOptions"
:options="filterTimeOptions"
/>
</FormItem>
</Col>
@ -390,15 +456,8 @@ watch(
</Col>
<Col :span="12">
<FormItem label="读写类型" name="type">
<RadioGroup
v-model:value="formData.expands.type"
button-style="solid"
>
<RadioButton
:value="item.value"
v-for="item in readWriteTypeOptions"
:key="item.value"
>
<RadioGroup v-model:value="formData.expands.type" button-style="solid">
<RadioButton :value="item.value" v-for="item in readWriteTypeOptions" :key="item.value">
{{ item.label }}
</RadioButton>
</RadioGroup>
@ -407,21 +466,13 @@ watch(
<Col :span="12">
<FormItem label="排序" name="sort">
<InputNumber
style="width: 100%"
v-model:value="formData.sort"
placeholder="请输入排序"
/>
<InputNumber style="width: 100%" v-model:value="formData.sort" placeholder="请输入排序" />
</FormItem>
</Col>
<Col :span="24">
<FormItem label="描述" name="description">
<Textarea
v-model:value="formData.description"
placeholder="请输入描述"
:rows="3"
/>
<Textarea v-model:value="formData.description" placeholder="请输入描述" :rows="3" />
</FormItem>
</Col>
</Row>
@ -430,9 +481,7 @@ watch(
<template #footer>
<Space>
<Button @click="handleDrawerClose">取消</Button>
<Button type="primary" @click="handleSave" :loading="saveLoading">
确认
</Button>
<Button type="primary" @click="handleSave" :loading="saveLoading"> 确认 </Button>
</Space>
</template>

View File

@ -140,9 +140,7 @@ const handleDeleteProperty = (propertyId: string) => {
//
const handlePropertySelectionConfirm = (selectedProperties: any[]) => {
//
const newProperties = selectedProperties.filter(
(prop) => !formData.value.properties.some((p) => p.id === prop.id),
);
const newProperties = selectedProperties.filter((prop) => !formData.value.properties.some((p) => p.id === prop.id));
if (newProperties.length > 0) {
formData.value.properties.push(...newProperties);
@ -161,6 +159,8 @@ const handleSave = async () => {
saveLoading.value = true;
emit('save', JSON.parse(JSON.stringify(formData.value)));
visible.value = false;
resetForm();
} catch (error) {
console.error('表单验证失败:', error);
} finally {
@ -176,12 +176,7 @@ const handleClose = () => {
</script>
<template>
<Drawer
v-model:open="visible"
:title="isEdit ? '编辑分组' : '添加'"
width="800px"
@close="handleClose"
>
<Drawer v-model:open="visible" :title="isEdit ? '编辑分组' : '添加'" width="800px" @close="handleClose">
<Form ref="formRef" :model="formData" :rules="formRules" layout="vertical">
<Row :gutter="16">
<Col :span="12">
@ -207,23 +202,12 @@ const handleClose = () => {
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<Button
type="link"
size="small"
danger
@click="handleDeleteProperty(record.id)"
>
删除
</Button>
<Button type="link" size="small" danger @click="handleDeleteProperty(record.id)"> 删除 </Button>
</template>
</template>
</Table>
<div class="add-button-container">
<a-button
@click="handleAddProperty"
type="primary"
class="add-button"
>
<a-button @click="handleAddProperty" type="primary" class="add-button">
<template #icon>
<PlusOutlined />
</template>
@ -234,21 +218,12 @@ const handleClose = () => {
</Col>
<Col :span="12">
<FormItem label="排序" name="sort">
<InputNumber
v-model:value="formData.sort"
:min="1"
style="width: 100%"
placeholder="请输入"
/>
<InputNumber v-model:value="formData.sort" :min="1" style="width: 100%" placeholder="请输入" />
</FormItem>
</Col>
<Col :span="24">
<FormItem label="描述" name="description">
<Textarea
v-model:value="formData.description"
placeholder="请输入"
:rows="3"
/>
<Textarea v-model:value="formData.description" placeholder="请输入" :rows="3" />
</FormItem>
</Col>
</Form>
@ -256,9 +231,7 @@ const handleClose = () => {
<template #footer>
<Space>
<Button @click="handleClose">取消</Button>
<Button type="primary" :loading="saveLoading" @click="handleSave">
确认
</Button>
<Button type="primary" :loading="saveLoading" @click="handleSave"> 确认 </Button>
</Space>
</template>

View File

@ -1,17 +1,14 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import {
CopyOutlined,
DownloadOutlined,
ReloadOutlined,
} from '@ant-design/icons-vue';
import { CopyOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import { message, Space } from 'ant-design-vue';
import dayjs from 'dayjs';
import MonacoEditor from '#/components/MonacoEditor/index.vue';
const props = defineProps<{
fileName: string;
metadata: object;
productId: string;
productInfo: object;
@ -60,7 +57,7 @@ const handleRefresh = () => {
//
const handleExport = () => {
const time = dayjs().format('YYYY-MM-DD_HH:mm:ss');
exportFileName.value = `${props.productInfo.productName}_物模型_${time}`;
exportFileName.value = `${props.fileName}_物模型_${time}`;
handleExportConfirm();
};
@ -154,12 +151,7 @@ onMounted(() => {
<div class="viewer-content">
<div class="viewer-toolbar">
<MonacoEditor
v-model="tslJson"
lang="javascript"
style="height: 100%"
theme="vs"
/>
<MonacoEditor v-model="tslJson" lang="javascript" style="height: 100%" theme="vs" />
</div>
</div>
</div>

View File

@ -5,9 +5,9 @@ import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { ArrowLeftOutlined } from '@ant-design/icons-vue';
import { message, Switch, TabPane, Tabs } from 'ant-design-vue';
import { message, Modal, Switch, TabPane, Tabs } from 'ant-design-vue';
import { productUpdate, productUpdateStatus } from '#/api/device/product';
import { productPushMetadataById, productUpdateStatus } from '#/api/device/product';
import { useProductStore } from '#/store/product';
import BasicInfo from './components/BasicInfo.vue';
@ -51,12 +51,18 @@ const handleStatusChange = async (checked: boolean) => {
//
const handleApplyConfig = async () => {
try {
await productUpdate({
id: productId.value,
enabled: '1',
Modal.confirm({
title: '提示',
content: '确认要应用到产品下的所有设备吗?',
onOk: async () => {
await productPushMetadataById(productId.value);
await loadProductInfo();
},
});
message.success('配置应用成功');
await loadProductInfo();
// await productPushMetadataById({
// id: productId.value,
// });
// message.success('');
} catch {
message.error('配置应用失败');
}
@ -128,7 +134,7 @@ onUnmounted(() => {
:disabled="currentProduct.enabled === '0'"
v-access:code="['device:product:edit']"
>
应用配置
应用到设备
</a-button>
</div>
</div>
@ -139,29 +145,15 @@ onUnmounted(() => {
</div>
<!-- 标签页内容 -->
<Tabs
v-model:active-key="activeTab"
class="detail-tabs"
@change="handleTabChange"
>
<Tabs v-model:active-key="activeTab" class="detail-tabs" @change="handleTabChange">
<TabPane key="BasicInfo" tab="配置信息">
<BasicInfo
:product-info="currentProduct"
@refresh="loadProductInfo"
/>
<BasicInfo :product-info="currentProduct" @refresh="loadProductInfo" />
</TabPane>
<TabPane key="Metadata" tab="物模型">
<Metadata
:product-id="productId"
:product-info="currentProduct"
@refresh="loadProductInfo"
/>
<Metadata :product-id="productId" :product-info="currentProduct" @refresh="loadProductInfo" />
</TabPane>
<TabPane key="DeviceAccess" tab="设备接入">
<DeviceAccess
:product-info="currentProduct"
@refresh="loadProductInfo"
/>
<DeviceAccess :product-info="currentProduct" @refresh="loadProductInfo" />
</TabPane>
</Tabs>
</div>

View File

@ -35,12 +35,10 @@ const [BasicForm, formApi] = useVbenForm({
wrapperClass: 'grid-cols-2',
});
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: defaultFormValueGetter(formApi),
currentGetter: defaultFormValueGetter(formApi),
},
);
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff({
initializedGetter: defaultFormValueGetter(formApi),
currentGetter: defaultFormValueGetter(formApi),
});
const [BasicDrawer, drawerApi] = useVbenDrawer({
//
@ -57,6 +55,7 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
const { id } = drawerApi.getData() as { id?: number | string };
isUpdate.value = !!id;
formApi.updateSchema([{ componentProps: { disabled: isUpdate.value }, fieldName: 'productKey' }]);
if (isUpdate.value && id) {
const record = await productInfo(id);
@ -65,6 +64,7 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
await markInitialized();
drawerApi.drawerLoading(false);
loadFormOptions();
},
});