feat: 完成设备详情、运行状态、设备模拟、日志管理页面

- 新增日志管理组件
- 重构运行状态组件,采用标签页布局
- 新增事件面板组件
- 优化参数模态框,支持数据类型和表单类型联动
- 调整设备模拟组件名称
This commit is contained in:
fhysy 2025-08-25 15:08:37 +08:00
parent 1ac9339eb3
commit b2a8aa545e
8 changed files with 2194 additions and 584 deletions

View File

@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { TabPane, Tabs } from 'ant-design-vue';
import FunctionSimulation from './simulation/FunctionSimulation.vue';
interface Props {
deviceId: string;
deviceInfo: any;
}
const props = defineProps<Props>();
// tab
const activeTab = ref('function');
// 使使
const getMetadata = () => {
try {
const raw = props.deviceInfo?.productObj?.metadata;
if (!raw) return { functions: [] } as any;
const obj = JSON.parse(raw || '{}');
return {
functions: obj?.functions || [],
};
} catch (error) {
console.warn('parse metadata error', error);
return { functions: [] } as any;
}
};
//
const metadata = computed(() => getMetadata());
//
const functionList = computed(() => {
return metadata.value.functions || [];
});
</script>
<template>
<div class="device-simulation">
<Tabs v-model:active-key="activeTab">
<TabPane key="property" tab="属性">
<div>属性功能开发中...</div>
</TabPane>
<TabPane key="function" tab="功能">
<FunctionSimulation
:device-id="deviceId"
:device-info="deviceInfo"
:function-list="functionList"
/>
</TabPane>
<TabPane key="event" tab="事件">
<div>事件功能开发中...</div>
</TabPane>
<TabPane key="online" tab="上下线">
<div>上下线功能开发中...</div>
</TabPane>
</Tabs>
</div>
</template>
<style lang="scss" scoped>
.device-simulation {
//
}
</style>

View File

@ -0,0 +1,257 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import {
Button,
DatePicker,
Empty,
Modal,
Select,
Space,
Table,
Tag,
} from 'ant-design-vue';
import { Dayjs } from 'dayjs';
interface Props {
deviceId: string;
deviceInfo: any;
}
const props = defineProps<Props>();
//
const logType = ref<string>('');
const timeRange = ref<[Dayjs, Dayjs] | undefined>();
//
const loading = ref(false);
const dataSource = ref<any[]>([]);
const pagination = ref({ current: 1, pageSize: 10, total: 0 });
//
const logTypeOptions = [
{ label: '全部', value: '' },
{ label: '上报', value: 'upload' },
{ label: '下发', value: 'download' },
];
//
const columns = [
{ title: '类型', dataIndex: 'logType', key: 'logType', width: 120 },
{ title: '名称内容', dataIndex: 'content', key: 'content', ellipsis: true },
{ title: '时间', dataIndex: 'timestamp', key: 'timestamp', width: 200 },
{ title: '操作', key: 'action', width: 100 },
];
//
const viewVisible = ref(false);
const viewRecord = ref<any>(null);
const openView = (record: any) => {
viewRecord.value = record;
viewVisible.value = true;
};
const closeView = () => {
viewVisible.value = false;
viewRecord.value = null;
};
//
const loadList = async () => {
try {
loading.value = true;
const [start, end] = timeRange.value || [];
const params = {
deviceId: props.deviceId,
startTime: start?.format('YYYY-MM-DD HH:mm:ss'),
endTime: end?.format('YYYY-MM-DD HH:mm:ss'),
logType: logType.value || undefined,
pageNo: pagination.value.current,
pageSize: pagination.value.pageSize,
};
console.log('query logs with', params);
// TODO:
const mock = [
{
id: 1,
timestamp: '2025-01-20 10:30:15',
logType: '上报',
content: '设备启动成功固件版本v1.0.0',
},
{
id: 2,
timestamp: '2025-01-20 10:30:20',
logType: '下发',
content: '{"switch": "on"}',
},
{
id: 3,
timestamp: '2025-01-20 10:30:25',
logType: '上报',
content: '网络连接不稳定重试次数3',
},
{
id: 4,
timestamp: '2025-01-20 10:30:30',
logType: '下发',
content:
'功能执行失败错误码E001错误信息参数无效功能执行失败错误码E001错误信息参数无效功能执行失败错误码E001错误信息参数无效功能执行失败错误码E001错误信息参数无效',
},
{
id: 5,
timestamp: '2025-01-20 10:30:35',
logType: '上报',
content: '调试信息:设备响应时间 150ms',
},
];
dataSource.value = mock;
pagination.value.total = mock.length;
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.value.current = 1;
loadList();
};
const handleReset = () => {
logType.value = '';
timeRange.value = undefined;
pagination.value.current = 1;
loadList();
};
const handleTableChange = (page: any) => {
pagination.value.current = page.current;
pagination.value.pageSize = page.pageSize;
loadList();
};
onMounted(() => {
loadList();
});
</script>
<template>
<div class="log-management">
<!-- 查询区 -->
<div class="query-bar">
<Space>
<span>日志类型:</span>
<Select
v-model:value="logType"
:options="logTypeOptions"
allow-clear
placeholder="请选择日志类型"
style="width: 220px"
/>
<span>时间范围:</span>
<DatePicker.RangePicker
v-model:value="timeRange"
:show-time="true"
format="YYYY-MM-DD HH:mm:ss"
style="width: 360px"
/>
<Button @click="handleReset">重置</Button>
<Button type="primary" @click="handleSearch">查询</Button>
</Space>
</div>
<!-- 列表 -->
<Table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'logType'">
<Tag :color="record.logType === '上报' ? 'blue' : 'green'">
{{ record.logType }}
</Tag>
</template>
<template v-if="column.key === 'action'">
<Button type="link" size="small" @click="openView(record)">
查看
</Button>
</template>
</template>
</Table>
<div v-if="!loading && dataSource.length === 0" class="empty-wrap">
<Empty />
</div>
<!-- 查看内容弹窗 -->
<Modal
v-model:open="viewVisible"
title="日志详情"
width="720px"
@cancel="closeView"
:footer="null"
>
<div class="log-detail">
<div class="detail-item">
<span class="label">类型:</span>
<Tag :color="viewRecord?.logType === '上报' ? 'blue' : 'green'">
{{ viewRecord?.logType }}
</Tag>
</div>
<div class="detail-item">
<span class="label">时间:</span>
<span>{{ viewRecord?.timestamp }}</span>
</div>
<div class="detail-item">
<span class="label">内容:</span>
<pre class="content-view">{{ viewRecord?.content }}</pre>
</div>
</div>
</Modal>
</div>
</template>
<style lang="scss" scoped>
.log-management {
.query-bar {
margin-bottom: 16px;
}
.empty-wrap {
padding: 40px 0;
text-align: center;
}
}
.log-detail {
.detail-item {
display: flex;
align-items: flex-start;
margin-bottom: 16px;
.label {
flex-shrink: 0;
width: 40px;
font-weight: 500;
}
.content-view {
flex: 1;
max-height: 300px;
padding: 12px;
margin: 0;
overflow: auto;
word-break: break-all;
white-space: pre-wrap;
background: #fafafa;
border-radius: 4px;
}
}
}
</style>

View File

@ -1,26 +1,10 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { computed } from 'vue';
import { EllipsisText } from '@vben/common-ui';
import { TabPane, Tabs } from 'ant-design-vue';
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';
import EventsPanel from './running/EventsPanel.vue';
import RealtimePanel from './running/RealtimePanel.vue';
interface Props {
deviceId: string;
@ -29,543 +13,41 @@ interface Props {
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;
//
const metadata = computed(() => {
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' },
];
const raw = props.deviceInfo?.productObj?.metadata;
if (!raw) return { properties: [], propertyGroups: [], events: [] } as any;
const obj = JSON.parse(raw || '{}');
return {
properties: obj?.properties || [],
propertyGroups: obj?.propertyGroups || [],
events: obj?.events || [],
};
} catch (error) {
console.error('获取属性日志失败:', error);
} finally {
logLoading.value = false;
console.warn('parse metadata error', error);
return { properties: [], propertyGroups: [], events: [] } as any;
}
};
//
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>
<Tabs type="card">
<TabPane key="realtime" tab="实时数据">
<RealtimePanel :device-id="props.deviceId" :metadata="metadata" />
</TabPane>
<TabPane key="events" tab="事件">
<EventsPanel :device-id="props.deviceId" :metadata="metadata" />
</TabPane>
</Tabs>
</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;
}
}
:deep(.ant-tabs-content-holder) {
padding-top: 0;
}
.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,219 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import {
Button,
DatePicker,
Empty,
Input,
Modal,
Select,
Space,
Table,
} from 'ant-design-vue';
import dayjs, { Dayjs } from 'dayjs';
interface Props {
deviceId: string;
metadata: any;
}
const props = defineProps<Props>();
//
const messageId = ref<string>('');
const timeRange = ref<[Dayjs, Dayjs] | undefined>();
const selectedEventId = ref<string | undefined>();
//
const loading = ref(false);
const dataSource = ref<any[]>([]);
const pagination = ref({ current: 1, pageSize: 10, total: 0 });
// id -> name
const eventNameMap = computed<Record<string, string>>(() => {
const map: Record<string, string> = {};
(props.metadata?.events || []).forEach((e: any) => {
map[e.id] = e.name || e.id;
});
return map;
});
//
const eventOptions = computed(() => {
return (props.metadata?.events || []).map((e: any) => ({
label: e.name || e.id,
value: e.id,
}));
});
//
const columns = [
{ title: '消息ID', dataIndex: 'messageId', key: 'messageId', width: 220 },
{ title: '名称', dataIndex: 'eventName', key: 'eventName', width: 160 },
{ title: '标识', dataIndex: 'eventId', key: 'eventId', width: 160 },
{ title: '时间', dataIndex: 'timestamp', key: 'timestamp', width: 200 },
{ title: '操作', key: 'action', width: 100 },
];
//
const viewVisible = ref(false);
const viewRecord = ref<any>(null);
const openView = (record: any) => {
viewRecord.value = record;
viewVisible.value = true;
};
const closeView = () => {
viewVisible.value = false;
viewRecord.value = null;
};
// API
const loadList = async () => {
try {
loading.value = true;
const [start, end] = timeRange.value || [];
const params = {
deviceId: props.deviceId,
startTime: start?.format('YYYY-MM-DD HH:mm:ss'),
endTime: end?.format('YYYY-MM-DD HH:mm:ss'),
messageId: messageId.value?.trim() || undefined,
eventId: selectedEventId.value || undefined,
pageNo: pagination.value.current,
pageSize: pagination.value.pageSize,
};
console.log('query events with', params);
// TODO:
const eid =
selectedEventId.value || props.metadata?.events?.[0]?.id || 'temple';
const mock = Array.from({ length: 5 }).map((_, i) => ({
messageId: `${Date.now()}_${i}`,
eventId: eid,
eventName: eventNameMap.value[eid] || '事件',
payload: { level: 'info', msg: '模拟事件' },
timestamp: dayjs().format('YYYY-MM-DD HH:mm:ss.SSS'),
}));
dataSource.value = mock;
pagination.value.total = mock.length;
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.value.current = 1;
loadList();
};
const handleReset = () => {
messageId.value = '';
timeRange.value = undefined;
selectedEventId.value = undefined;
pagination.value.current = 1;
loadList();
};
const handleTableChange = (page: any) => {
pagination.value.current = page.current;
pagination.value.pageSize = page.pageSize;
loadList();
};
onMounted(() => {
loadList();
});
</script>
<template>
<div class="events-panel">
<!-- 查询区 -->
<div class="query-bar">
<Space>
<span>消息ID:</span>
<Input
v-model:value="messageId"
placeholder="请输入"
style="width: 220px"
allow-clear
/>
<span>事件:</span>
<Select
v-model:value="selectedEventId"
:options="eventOptions"
allow-clear
placeholder="请选择事件"
style="width: 220px"
/>
<span>时间范围:</span>
<DatePicker.RangePicker
v-model:value="timeRange"
:show-time="true"
format="YYYY-MM-DD HH:mm:ss"
style="width: 360px"
/>
<Button @click="handleReset">重置</Button>
<Button type="primary" @click="handleSearch">查询</Button>
</Space>
</div>
<!-- 列表 -->
<Table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="messageId"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<Button type="link" size="small" @click="openView(record)">
查看
</Button>
</template>
</template>
</Table>
<div v-if="!loading && dataSource.length === 0" class="empty-wrap">
<Empty />
</div>
<!-- 查看内容弹窗 -->
<Modal
v-model:open="viewVisible"
title="事件内容"
width="720px"
@cancel="closeView"
:footer="null"
>
<pre class="json-view">{{
JSON.stringify(viewRecord?.payload ?? {}, null, 2)
}}</pre>
</Modal>
</div>
</template>
<style lang="scss" scoped>
.events-panel {
.query-bar {
margin-bottom: 16px;
}
.empty-wrap {
padding: 40px 0;
text-align: center;
}
.json-view {
max-height: 420px;
padding: 12px;
margin: 0;
overflow: auto;
background: #fafafa;
border-radius: 4px;
}
}
</style>

View File

@ -0,0 +1,481 @@
<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;
metadata: any;
}
const props = defineProps<Props>();
const loading = ref(false);
const selectedGroup = ref('all');
const selectedTypes = ref(['R', 'RW']);
// metadata
const runtimeProperties = ref<any[]>([]);
const initRuntime = () => {
const properties = (props.metadata?.properties || []).map((prop: any) => ({
...prop,
value: null,
timestamp: dayjs().format('YYYY-MM-DD HH:mm:ss'),
}));
runtimeProperties.value = properties;
};
//
const groupOptions = computed(() => {
const groups = props.metadata?.propertyGroups || [];
return [
{ label: '全部', value: 'all' },
...groups.map((group: any) => ({ label: group.name, value: group.id })),
];
});
//
const filteredProperties = computed(() => {
let properties = [...runtimeProperties.value];
if (selectedGroup.value !== 'all') {
const group = (props.metadata?.propertyGroups || []).find(
(g: any) => g.id === selectedGroup.value,
);
if (group?.properties) {
const ids = new Set(group.properties.map((p: any) => p.id));
properties = properties.filter((p: any) => ids.has(p.id));
}
}
properties = properties.filter((p: any) => {
const type = p.expands?.type || 'R';
return selectedTypes.value.includes(type);
});
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:
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' },
];
} finally {
logLoading.value = false;
}
};
const handleDateRangeChange = (dates: any) => {
logDateRange.value = dates;
if (dates && dates.length === 2) loadPropertyLog();
};
const handleQuickTimeSelect = (e: any) => {
const now = dayjs();
switch (e?.target?.value) {
case 'month': {
logDateRange.value = [now.subtract(1, 'month'), now];
break;
}
case 'week': {
logDateRange.value = [now.subtract(7, 'day'), now];
break;
}
default: {
logDateRange.value = [now.startOf('day'), now];
}
}
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;
}
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 = () => {
console.log('开始生成随机数据');
refreshTimer = setInterval(() => {
runtimeProperties.value.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');
});
}, 3000);
};
const stopRefreshTimer = () => {
console.log('停止生成随机数据');
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
};
onMounted(() => {
initRuntime();
startRefreshTimer();
});
onUnmounted(() => {
stopRefreshTimer();
});
watch(
() => props.metadata,
() => initRuntime(),
{ deep: true },
);
</script>
<template>
<div class="realtime-panel">
<!-- 控制面板 -->
<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>
<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>
.realtime-panel {
.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;
}
}
}
}
.empty-state {
padding: 60px 0;
text-align: center;
}
}
:deep(.ant-card-body) {
padding: 12px;
}
</style>

View File

@ -0,0 +1,975 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
import {
Button,
DatePicker,
Form,
Input,
InputNumber,
message,
Modal,
Select,
SelectOption,
Slider,
Switch,
Table,
TabPane,
Tabs,
TimePicker,
} from 'ant-design-vue';
import MonacoEditor from '#/components/MonacoEditor/index.vue';
import { dataTypeOptions } from '#/constants/dicts';
interface Props {
deviceId: string;
deviceInfo: any;
functionList: any[];
}
const props = defineProps<Props>();
//
const selectedFunctionId = ref('');
// simple() / advanced()
const currentMode = ref('simple');
//
const formRef = ref();
// JSON
const jsonContent = ref('{}');
//
const parameterContent = ref('');
//
const submitResult = ref('');
//
const currentFunction = ref();
//
const currentInputs = ref([]);
//
const selectedRowKeys = ref([]);
//
const formData = ref({});
//
const preCheckVisible = ref(false);
const preCheckType = ref('');
const preCheckForm = ref({
password: '',
phone: '',
smsCode: '',
});
const preCheckFormRef = ref();
const smsCodeLoading = ref(false);
const smsCountdown = ref(0);
//
const columns = [
{
title: '参数名称',
dataIndex: 'name',
key: 'name',
width: 150,
},
{
title: '输入类型',
dataIndex: 'dataType',
key: 'dataType',
width: 100,
},
{
title: '值',
dataIndex: 'value',
key: 'value',
},
];
//
const tableDataSource = computed(() => {
return currentInputs.value.map((input) => ({
key: input.id,
id: input.id,
name: input.name,
dataType:
dataTypeOptions.find(
(option) => option.value === input.valueParams.dataType,
)?.label || input.valueParams.dataType,
required: input.required,
formType: input.valueParams.formType,
valueParams: input.valueParams,
}));
});
//
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (selectedKeys: string[]) => {
selectedRowKeys.value = selectedKeys;
},
onSelectAll: (selected: boolean) => {
selectedRowKeys.value = selected ? tableDataSource.value.map((row) => row.key) : tableDataSource.value
.filter((row) => row.required)
.map((row) => row.key);
},
onSelect: (record: any, selected: boolean) => {
if (record.required && !selected) {
//
return;
}
selectedRowKeys.value = selected
? [...selectedRowKeys.value, record.key]
: selectedRowKeys.value.filter((key) => key !== record.key);
},
getCheckboxProps: (record: any) => ({
disabled: record.required, //
}),
}));
//
const initializeSelectedRows = () => {
selectedRowKeys.value = currentInputs.value
.filter((input) => input.required)
.map((input) => input.id);
};
//
const initializeFormData = () => {
const defaultData = {};
currentInputs.value.forEach((input) => {
const { formType } = input.valueParams;
//
switch (formType) {
// case 'number': {
// // 0
// defaultData[input.id] = input.valueParams.min || 0;
// break;
// }
case 'progress': {
//
defaultData[input.id] = input.valueParams.min || 0;
break;
}
// case 'select': {
// //
// if (
// input.valueParams.enumConf &&
// input.valueParams.enumConf.length > 0
// ) {
// defaultData[input.id] = input.valueParams.enumConf[0].value;
// }
// break;
// }
case 'switch': {
//
defaultData[input.id] = input.valueParams.dataType === 'boolean' ? false : input.valueParams.falseValue || 'false' ;
break;
}
default: {
// undefined
break;
}
}
});
formData.value = { ...defaultData };
};
//
const getSmsCode = async () => {
if (!preCheckForm.value.phone) {
message.error('请输入手机号');
return;
}
//
const phoneRegex = /^1[3-9]\d{9}$/;
if (!phoneRegex.test(preCheckForm.value.phone)) {
message.error('请输入正确的手机号');
return;
}
try {
smsCodeLoading.value = true;
//
console.log('发送短信验证码到:', preCheckForm.value.phone);
//
smsCountdown.value = 60;
const timer = setInterval(() => {
smsCountdown.value--;
if (smsCountdown.value <= 0) {
clearInterval(timer);
}
}, 1000);
message.success('验证码已发送');
} catch {
message.error('发送验证码失败');
} finally {
smsCodeLoading.value = false;
}
};
//
watch(selectedFunctionId, (newFunctionId) => {
if (newFunctionId) {
//
const selectedFunc = props.functionList.find(
(func) => func.id === newFunctionId,
);
if (selectedFunc) {
currentFunction.value = selectedFunc;
currentInputs.value = selectedFunc.inputs || [];
//
initializeSelectedRows();
//
initializeFormData();
} else {
resetForm();
}
} else {
resetForm();
}
generateDefaultContent();
});
//
watch(currentMode, () => {
resetForm();
generateDefaultContent();
});
//
const resetForm = () => {
if (formRef.value) {
formRef.value.resetFields();
}
formData.value = {};
submitResult.value = '';
};
//
const generateDefaultContent = () => {
if (!currentFunction.value) return;
if (currentMode.value === 'advanced') {
// key:null
const params = {};
currentInputs.value.forEach((item) => {
params[item.id] = null;
});
jsonContent.value = JSON.stringify(params, null, 2);
parameterContent.value = '';
} else {
//
parameterContent.value = '';
}
};
// JSON
const handleJsonChange = (value: string) => {
jsonContent.value = value;
};
//
const checkPreCheck = () => {
const currentFunc = currentFunction.value;
if (currentFunc?.expands?.preCheck) {
preCheckType.value = currentFunc.expands.checkType || 'userPassword';
preCheckForm.value = {
password: '',
phone: '',
smsCode: '',
};
preCheckVisible.value = true;
return true;
}
return false;
};
//
const executeSubmit = async (checkValue?: string) => {
try {
let parameters = {};
if (currentMode.value === 'simple') {
//
if (selectedRowKeys.value.length === 0) {
message.error('请至少选择一个参数');
return;
}
//
await formRef.value.validate();
const formValues = formData.value;
console.log('表单值:', formValues);
//
// for (const input of currentInputs.value) {
// if (
// input.required &&
// selectedRowKeys.value.includes(input.id) &&
// (formValues[input.id] === undefined ||
// formValues[input.id] === null ||
// formValues[input.id] === '' ||
// (typeof formValues[input.id] === 'string' &&
// formValues[input.id].trim() === ''))
// ) {
// message.error(`${input.name}`);
// return;
// }
// }
for (const input of currentInputs.value) {
if (selectedRowKeys.value.includes(input.id)) {
const value = formValues[input.id];
const { formType } = input.valueParams;
//
let isEmpty = false;
isEmpty = formType === 'switch'
? value === undefined || value === null // if
: value === undefined || value === null || value === '' || // else
(typeof value === 'string' && value.trim() === '');
if (isEmpty) {
message.error(`请填写参数:${input.name}`);
return;
}
}
}
// key:value
parameters = {};
selectedRowKeys.value.forEach((key) => {
const value = formValues[key];
// false
const input = currentInputs.value.find((item) => item.id === key);
const isSwitch = input?.valueParams?.formType === 'switch';
if (
(value !== undefined && value !== null) ||
(isSwitch && value === false)
) {
if (input) {
const { dataType, formType } = input.valueParams;
let processedValue = value;
// dataTypeformType
if (formType === 'switch') {
if (dataType === 'boolean') {
// boolean
processedValue = Boolean(value);
} else if (dataType === 'string') {
// string
processedValue = value
? input.valueParams.trueValue || 'true'
: input.valueParams.falseValue || 'false';
}
} else if (formType === 'time' && dataType === 'date') {
// date
if (value && typeof value === 'object' && value.valueOf) {
processedValue = value.valueOf(); //
console.log(
`时间字段 ${key} (date): ${value} -> ${processedValue}`,
);
} else if (value && typeof value === 'string') {
processedValue = new Date(value).getTime(); //
}
}
parameters[key] = processedValue;
} else {
parameters[key] = value;
}
}
});
} else {
// JSON
try {
parameters = JSON.parse(jsonContent.value);
} catch {
message.error('JSON格式错误请检查格式');
return;
}
}
//
const submitData: any = {
deviceId: props.deviceId,
functionId: selectedFunctionId.value,
parameter: parameters,
};
//
if (checkValue) {
submitData.checkValue = checkValue;
}
//
parameterContent.value = JSON.stringify(submitData, null, 2);
// API
console.log('执行功能:', submitData);
console.log('选中的参数keys:', selectedRowKeys.value);
console.log('提交的参数对象:', parameters);
//
const mockResult = {
success: true,
message: '执行成功',
data: {
deviceId: props.deviceId,
functionId: selectedFunctionId.value,
executeTime: new Date().toISOString(),
result: 'OK',
},
};
submitResult.value = JSON.stringify(mockResult, null, 2);
message.success('执行成功');
} catch (error) {
message.error('执行失败');
console.error('执行错误:', error);
}
};
//
const handleSubmit = async () => {
//
if (checkPreCheck()) {
return; //
}
//
await executeSubmit();
};
//
const handlePreCheckConfirm = async () => {
try {
await preCheckFormRef.value.validate();
let checkValue = '';
// checkValue
switch (preCheckType.value) {
case 'staticPassword': {
checkValue = preCheckForm.value.password;
break;
}
case 'userPassword': {
checkValue = preCheckForm.value.password;
break;
}
case 'userSms': {
checkValue = preCheckForm.value.smsCode;
break;
}
}
//
preCheckVisible.value = false;
//
await executeSubmit(checkValue);
} catch (error) {
console.error('预检查验证失败:', error);
}
};
//
const handlePreCheckCancel = () => {
preCheckVisible.value = false;
preCheckForm.value = {
password: '',
phone: '',
smsCode: '',
};
};
//
const getPreCheckTitle = () => {
switch (preCheckType.value) {
case 'staticPassword': {
return '固定密码验证';
}
case 'userPassword': {
return '用户密码验证';
}
case 'userSms': {
return '短信验证';
}
default: {
return '身份验证';
}
}
};
onMounted(() => {
//
if (props.functionList.length > 0 && !selectedFunctionId.value) {
selectedFunctionId.value = props.functionList[0].id;
currentFunction.value = props.functionList[0];
currentInputs.value = props.functionList[0].inputs || [];
//
initializeSelectedRows();
//
initializeFormData();
//
generateDefaultContent();
}
});
</script>
<template>
<div class="function-simulation">
<div class="function-content">
<!-- 左侧功能列表 -->
<div class="function-sidebar">
<div class="sidebar-header">功能列表</div>
<div
v-for="func in functionList"
:key="func.id"
class="sidebar-item"
:class="{ active: selectedFunctionId === func.id }"
@click="selectedFunctionId = func.id"
>
<div class="function-name">{{ func.name }}</div>
<div class="function-id">{{ func.id }}</div>
</div>
</div>
<!-- 右侧内容区域 -->
<div class="function-main">
<!-- 模式切换 -->
<div class="mode-tabs">
<Tabs v-model:active-key="currentMode">
<TabPane key="simple" tab="精简模式">
<div class="mode-info">
<ExclamationCircleOutlined />
精简模式下参数只支持输入框的方式录入
</div>
</TabPane>
<TabPane key="advanced" tab="高级模式">
<div class="mode-info">
<ExclamationCircleOutlined />
高级模式下支持JSON格式直接编辑
</div>
</TabPane>
</Tabs>
</div>
<div class="content-area">
<!-- 左侧参数输入 -->
<div class="input-section">
<div v-if="currentMode === 'simple'" class="simple-form">
<Form ref="formRef" :model="formData" layout="vertical">
<Table
:columns="columns"
:data-source="tableDataSource"
:row-selection="rowSelection"
:pagination="false"
:scroll="{ y: 300 }"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<span>{{ record.name }}</span>
</template>
<template v-else-if="column.key === 'value'">
<Form.Item
:name="record.id"
:rules="
record.required
? [
{
required: true,
message: `请输入${record.name}`,
},
]
: []
"
style="margin-bottom: 0"
>
<Input
v-if="record?.valueParams?.formType === 'input'"
v-model:value="formData[record.id]"
:maxlength="record?.valueParams?.length || null"
:placeholder="`请输入${record.name}`"
style="width: 100%"
/>
<InputNumber
v-else-if="record?.valueParams?.formType === 'number'"
v-model:value="formData[record.id]"
type="number"
:min="record?.valueParams?.min || null"
:max="record?.valueParams?.max || null"
:precision="record?.valueParams?.scale || 0"
:placeholder="`请输入${record.name}`"
style="width: 100%"
/>
<Slider
v-else-if="
record?.valueParams?.formType === 'progress'
"
v-model:value="formData[record.id]"
:min="record?.valueParams?.min || 0"
:max="record?.valueParams?.max || 100"
style="width: 99%"
/>
<Select
v-else-if="record?.valueParams?.formType === 'select'"
v-model:value="formData[record.id]"
:placeholder="`请选择${record.name}`"
style="width: 100%"
>
<SelectOption
v-for="item in record.valueParams.enumConf"
:key="item.value"
:value="item.value"
>
{{ item.text }}
</SelectOption>
</Select>
<Switch
v-else-if="
record?.valueParams?.formType === 'switch' &&
record.valueParams.dataType === 'boolean'
"
v-model:checked="formData[record.id]"
:checked-children="
record?.valueParams?.trueText || '是'
"
:un-checked-children="
record?.valueParams?.falseText || '否'
"
/>
<Switch
v-else-if="
record?.valueParams?.formType === 'switch' &&
record.valueParams.dataType === 'string'
"
v-model:checked="formData[record.id]"
:checked-children="
record?.valueParams?.trueText || '是'
"
:checked-value="
record?.valueParams?.trueValue || true
"
:un-checked-children="
record?.valueParams?.falseText || '否'
"
:un-checked-value="
record?.valueParams?.falseValue || false
"
/>
<DatePicker
v-else-if="
record?.valueParams?.formType === 'time' &&
record?.valueParams?.format?.includes('YYYY-MM-DD')
"
v-model:value="formData[record.id]"
:show-time="
record?.valueParams?.format?.includes('HH:mm:ss')
"
:format="record?.valueParams?.format"
:value-format="record?.valueParams?.format"
style="width: 100%"
/>
<TimePicker
v-else-if="
record?.valueParams?.formType === 'time' &&
record?.valueParams?.format?.includes('HH:mm:ss')
"
v-model:value="formData[record.id]"
:format="record?.valueParams?.format"
:value-format="record?.valueParams?.format"
style="width: 100%"
/>
</Form.Item>
</template>
</template>
</Table>
</Form>
</div>
<div v-else class="advanced-form">
<MonacoEditor
v-model="jsonContent"
lang="json"
theme="vs-dark"
style="
height: 300px;
border: 1px solid #d9d9d9;
border-radius: 6px;
"
@update:model-value="handleJsonChange"
/>
</div>
<div class="execute-button">
<Button type="primary" @click="handleSubmit">执行</Button>
</div>
</div>
<!-- 右侧参数框和结果 -->
<div class="result-section">
<div class="parameter-box">
<div class="parameter-header">参数:</div>
<div class="parameter-content">
<pre>{{ parameterContent || '点击执行后显示执行参数' }}</pre>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 预检查弹窗 -->
<Modal
:open="preCheckVisible"
:title="getPreCheckTitle()"
@ok="handlePreCheckConfirm"
@cancel="handlePreCheckCancel"
>
<Form ref="preCheckFormRef" :model="preCheckForm" layout="vertical">
<!-- 用户密码 -->
<Form.Item
v-if="preCheckType === 'userPassword'"
label="请输入用户密码"
name="password"
:rules="[{ required: true, message: '请输入密码' }]"
>
<Input.Password
v-model:value="preCheckForm.password"
placeholder="请输入密码"
/>
</Form.Item>
<!-- 固定密码 -->
<Form.Item
v-if="preCheckType === 'staticPassword'"
label="请输入固定密码"
name="password"
:rules="[{ required: true, message: '请输入密码' }]"
>
<Input.Password
v-model:value="preCheckForm.password"
placeholder="请输入密码"
/>
</Form.Item>
<!-- 用户短信 -->
<template v-if="preCheckType === 'userSms'">
<Form.Item
label="手机号"
name="phone"
:rules="[
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' },
]"
>
<Input
v-model:value="preCheckForm.phone"
placeholder="请输入手机号"
/>
</Form.Item>
<Form.Item
label="验证码"
name="smsCode"
:rules="[{ required: true, message: '请输入验证码' }]"
>
<div style="display: flex; gap: 8px">
<Input
v-model:value="preCheckForm.smsCode"
placeholder="请输入验证码"
style="flex: 1"
/>
<Button
:loading="smsCodeLoading"
:disabled="smsCountdown > 0"
@click="getSmsCode"
>
{{ smsCountdown > 0 ? `${smsCountdown}s` : '获取验证码' }}
</Button>
</div>
</Form.Item>
</template>
</Form>
</Modal>
</div>
</template>
<style lang="scss" scoped>
.function-simulation {
.function-content {
display: flex;
gap: 16px;
min-height: 600px;
.function-sidebar {
width: 200px;
background: #fafafa;
border: 1px solid #d9d9d9;
border-radius: 6px;
.sidebar-header {
padding: 10px;
font-weight: 500;
color: #262626;
background: #f0f0f0;
border-bottom: 1px solid #d9d9d9;
border-radius: 6px 6px 0 0;
}
.sidebar-item {
padding: 10px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
transition: all 0.3s;
&:hover {
background: #e6f7ff;
}
&.active {
color: white;
background: #1890ff;
.function-id {
color: rgb(255 255 255 / 80%);
}
}
.function-name {
font-size: 14px;
font-weight: 500;
}
.function-id {
font-size: 12px;
color: #8c8c8c;
}
}
}
.function-main {
flex: 1;
.mode-tabs {
margin-bottom: 16px;
.mode-info {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 0;
font-size: 14px;
color: #8c8c8c;
.info-icon {
font-weight: bold;
color: #1890ff;
}
}
}
.content-area {
display: flex;
gap: 16px;
.input-section {
flex: 5;
min-height: 300px;
border: 1px solid #d9d9d9;
border-radius: 6px;
.simple-form {
:deep(.ant-table) {
.ant-table-thead > tr > th {
font-weight: 500;
background: #fafafa;
}
.ant-table-tbody > tr > td {
vertical-align: middle;
}
}
:deep(.ant-form-item) {
margin-bottom: 0;
}
}
.execute-button {
margin: 10px;
margin-top: 15px;
text-align: right;
}
}
.result-section {
display: flex;
flex: 2;
flex-direction: column;
gap: 16px;
min-width: 300px;
min-height: 300px;
.parameter-box {
height: 100%;
}
.parameter-box,
.result-box {
padding: 16px;
border: 1px solid #d9d9d9;
border-radius: 6px;
.parameter-header,
.result-header {
margin-bottom: 12px;
font-weight: 500;
color: #262626;
}
.parameter-content,
.result-content {
min-height: calc(100% - 32px);
padding: 12px;
background: #f5f5f5;
border-radius: 4px;
pre {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: #262626;
word-break: break-all;
white-space: pre-wrap;
}
}
}
.result-box {
.result-content {
background: #f0f9ff;
border: 1px solid #91d5ff;
}
}
}
}
}
}
}
</style>

View File

@ -12,7 +12,7 @@ import { deviceStateOptions } from '#/constants/dicts';
import { useDeviceStore } from '#/store/device';
import BasicInfo from './components/BasicInfo.vue';
import DeviceFunction from './components/DeviceFunction.vue';
import DeviceSimulation from './components/DeviceSimulation.vue';
import LogManagement from './components/LogManagement.vue';
import RunningStatus from './components/RunningStatus.vue';
@ -36,12 +36,12 @@ const loadDeviceInfo = async () => {
//
const handleStatusChange = async (checked: boolean) => {
try {
console.log('checked', checked);
// await deviceUpdateStatus({
// id: deviceId.value,
// enabled: checked,
// });
// await loadDeviceInfo();
console.log("checked",checked)
} catch {
message.error('状态更新失败');
}
@ -98,7 +98,11 @@ onUnmounted(() => {
:class="deviceStateOptions.find((item) => item.value === currentDevice.deviceState)?.type"
></span>
<span class="status-text">
{{ deviceStateOptions.find((item) => item.value === currentDevice.deviceState)?.label }}
{{
deviceStateOptions.find(
(item) => item.value === currentDevice.deviceState,
)?.label
}}
</span>
<Switch
:checked="currentDevice.enabled === '1'"
@ -138,15 +142,23 @@ onUnmounted(() => {
</div>
<!-- 标签页内容 -->
<Tabs v-model:active-key="activeTab" class="detail-tabs" @change="handleTabChange">
<Tabs
v-model:active-key="activeTab"
class="detail-tabs"
@change="handleTabChange"
:destroy-inactive-tab-pane="true"
>
<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 key="DeviceSimulation" tab="设备模拟">
<DeviceSimulation
:device-id="deviceId"
:device-info="currentDevice"
/>
</TabPane>
<TabPane key="LogManagement" tab="日志管理">
<LogManagement :device-id="deviceId" :device-info="currentDevice" />
@ -264,11 +276,5 @@ onUnmounted(() => {
}
}
}
.detail-tabs {
:deep(.ant-tabs-content-holder) {
padding-top: 16px;
}
}
}
</style>

View File

@ -13,20 +13,43 @@ import {
Modal,
Row,
Select,
SelectOption,
Switch,
} from 'ant-design-vue';
import { dataTypeOptions, formTypeOptions } from '#/constants/dicts';
import {
dataTypeOptions,
formTypeOptions,
timeOptions,
} from '#/constants/dicts';
import EnumListModal from './EnumListModal.vue';
interface ParameterItem {
id: string;
name: string;
dataType: string;
sort: number;
description: string;
required: boolean;
formType: string;
expands: {
source: string;
type: string;
};
valueParams: {
dataType: string;
enumConf: any[];
falseText?: string;
falseValue?: string;
format?: string;
formType: string;
length: any;
max?: number;
min?: number;
scale?: number;
trueText?: string;
trueValue?: string;
unit: string;
viewType: string;
};
}
interface Props {
@ -48,8 +71,66 @@ 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 defaultPropertyData: PropertyData = {
const defaultPropertyData: ParameterItem = {
id: '',
name: '',
sort: 1,
@ -114,6 +195,36 @@ const modalTitle = computed(() => {
return props.parameterType === 'add' ? '新增参数' : '编辑参数';
});
//
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 validateData = (): boolean => {
try {
@ -162,6 +273,19 @@ 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>
@ -192,32 +316,17 @@ watch(
<Select
v-model:value="formData.valueParams.dataType"
placeholder="请选择数据类型"
style="width: 100%"
>
<SelectOption
v-for="option in dataTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectOption>
</Select>
:options="dataTypeOptions"
@change="handleDataTypeChange"
/>
</FormItem>
<FormItem label="表单类型" name="valueParams.formType">
<Select
v-model:value="formData.valueParams.formType"
placeholder="请选择表单类型"
style="width: 100%"
>
<SelectOption
v-for="option in formTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectOption>
</Select>
:options="filterFormTypeOptions"
/>
</FormItem>
<!-- 开关类型配置 -->
@ -312,6 +421,12 @@ watch(
<InputNumber
style="width: 100%"
v-model:value="formData.valueParams.min"
:precision="
formData.valueParams.dataType === 'double' ||
formData.valueParams.dataType === 'float'
? undefined
: 0
"
placeholder="请输入最小值"
/>
</FormItem>
@ -321,6 +436,12 @@ watch(
<InputNumber
style="width: 100%"
v-model:value="formData.valueParams.max"
:precision="
formData.valueParams.dataType === 'double' ||
formData.valueParams.dataType === 'float'
? undefined
: 0
"
placeholder="请输入最大值"
/>
</FormItem>
@ -330,8 +451,8 @@ watch(
label="小数位"
name="valueParams.scale"
v-if="
formData.valueParams.formType === 'number' ||
formData.valueParams.formType === 'progress'
formData.valueParams.dataType === 'double' ||
formData.valueParams.dataType === 'float'
"
>
<InputNumber
@ -359,7 +480,7 @@ watch(
<Select
v-model:value="formData.valueParams.format"
placeholder="请选择时间格式"
:options="timeOptions"
:options="filterTimeOptions"
/>
</FormItem>