refactor: 重构设备页面样式和功能、对接设备实时数据和设备在线状态、产品、设备管理页面适配白天/黑暗模式切换

- 更新设备详情页面布局和样式
- 优化实时数据展示和图表功能
- 统一模拟界面样式
- 调整产品详情组件样式
- 优化主题切换支持
This commit is contained in:
fhysy 2025-09-03 15:08:23 +08:00
parent f0a666fb1d
commit a9a4c31c1f
32 changed files with 963 additions and 408 deletions

431
apps/web-antd/loading.html Normal file
View File

@ -0,0 +1,431 @@
<style data-app-loading="inject-css">
@keyframes bounce05 {
85%,
92%,
100% {
transform: translateY(0);
}
89% {
transform: translateY(-4px);
}
95% {
transform: translateY(2px);
}
}
@keyframes slide05 {
5% {
transform: translateX(14px);
}
15%,
30% {
transform: translateX(6px);
}
40%,
55% {
transform: translateX(0);
}
65%,
70% {
transform: translateX(-4px);
}
80%,
89% {
transform: translateX(-12px);
}
100% {
transform: translateX(14px);
}
}
@keyframes paper05 {
5% {
transform: translateY(46px);
}
20%,
30% {
transform: translateY(34px);
}
40%,
55% {
transform: translateY(22px);
}
65%,
70% {
transform: translateY(10px);
}
80%,
85% {
transform: translateY(0);
}
92%,
100% {
transform: translateY(46px);
}
}
@keyframes keyboard05 {
5%,
12%,
21%,
30%,
39%,
48%,
57%,
66%,
75%,
84% {
box-shadow:
15px 0 0 var(--key),
30px 0 0 var(--key),
45px 0 0 var(--key),
60px 0 0 var(--key),
75px 0 0 var(--key),
90px 0 0 var(--key),
22px 10px 0 var(--key),
37px 10px 0 var(--key),
52px 10px 0 var(--key),
60px 10px 0 var(--key),
68px 10px 0 var(--key),
83px 10px 0 var(--key);
}
9% {
box-shadow:
15px 2px 0 var(--key),
30px 0 0 var(--key),
45px 0 0 var(--key),
60px 0 0 var(--key),
75px 0 0 var(--key),
90px 0 0 var(--key),
22px 10px 0 var(--key),
37px 10px 0 var(--key),
52px 10px 0 var(--key),
60px 10px 0 var(--key),
68px 10px 0 var(--key),
83px 10px 0 var(--key);
}
18% {
box-shadow:
15px 0 0 var(--key),
30px 0 0 var(--key),
45px 0 0 var(--key),
60px 2px 0 var(--key),
75px 0 0 var(--key),
90px 0 0 var(--key),
22px 10px 0 var(--key),
37px 10px 0 var(--key),
52px 10px 0 var(--key),
60px 10px 0 var(--key),
68px 10px 0 var(--key),
83px 10px 0 var(--key);
}
27% {
box-shadow:
15px 0 0 var(--key),
30px 0 0 var(--key),
45px 0 0 var(--key),
60px 0 0 var(--key),
75px 0 0 var(--key),
90px 0 0 var(--key),
22px 12px 0 var(--key),
37px 10px 0 var(--key),
52px 10px 0 var(--key),
60px 10px 0 var(--key),
68px 10px 0 var(--key),
83px 10px 0 var(--key);
}
36% {
box-shadow:
15px 0 0 var(--key),
30px 0 0 var(--key),
45px 0 0 var(--key),
60px 0 0 var(--key),
75px 0 0 var(--key),
90px 0 0 var(--key),
22px 10px 0 var(--key),
37px 10px 0 var(--key),
52px 12px 0 var(--key),
60px 12px 0 var(--key),
68px 12px 0 var(--key),
83px 10px 0 var(--key);
}
45% {
box-shadow:
15px 0 0 var(--key),
30px 0 0 var(--key),
45px 0 0 var(--key),
60px 0 0 var(--key),
75px 0 0 var(--key),
90px 2px 0 var(--key),
22px 10px 0 var(--key),
37px 10px 0 var(--key),
52px 10px 0 var(--key),
60px 10px 0 var(--key),
68px 10px 0 var(--key),
83px 10px 0 var(--key);
}
54% {
box-shadow:
15px 0 0 var(--key),
30px 2px 0 var(--key),
45px 0 0 var(--key),
60px 0 0 var(--key),
75px 0 0 var(--key),
90px 0 0 var(--key),
22px 10px 0 var(--key),
37px 10px 0 var(--key),
52px 10px 0 var(--key),
60px 10px 0 var(--key),
68px 10px 0 var(--key),
83px 10px 0 var(--key);
}
63% {
box-shadow:
15px 0 0 var(--key),
30px 0 0 var(--key),
45px 0 0 var(--key),
60px 0 0 var(--key),
75px 0 0 var(--key),
90px 0 0 var(--key),
22px 10px 0 var(--key),
37px 10px 0 var(--key),
52px 10px 0 var(--key),
60px 10px 0 var(--key),
68px 10px 0 var(--key),
83px 12px 0 var(--key);
}
72% {
box-shadow:
15px 0 0 var(--key),
30px 0 0 var(--key),
45px 2px 0 var(--key),
60px 0 0 var(--key),
75px 0 0 var(--key),
90px 0 0 var(--key),
22px 10px 0 var(--key),
37px 10px 0 var(--key),
52px 10px 0 var(--key),
60px 10px 0 var(--key),
68px 10px 0 var(--key),
83px 10px 0 var(--key);
}
81% {
box-shadow:
15px 0 0 var(--key),
30px 0 0 var(--key),
45px 0 0 var(--key),
60px 0 0 var(--key),
75px 0 0 var(--key),
90px 0 0 var(--key),
22px 10px 0 var(--key),
37px 12px 0 var(--key),
52px 10px 0 var(--key),
60px 10px 0 var(--key),
68px 10px 0 var(--key),
83px 10px 0 var(--key);
}
}
body {
margin: 0;
}
/* stylelint-disable selector-id-pattern */ /* 禁用规则 */
#__app-loading__ {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100vh;
}
#__app-loading__.hidden {
visibility: hidden;
pointer-events: none;
opacity: 0;
transition: all 1s ease-out;
}
#__app-loading__ .title {
margin-top: 20px;
font-size: 30px;
font-weight: 600;
color: rgb(0 0 0 / 85%);
}
.dark #__app-loading__ {
background: #0d0d10;
}
.dark #__app-loading__ .title {
color: #fff;
}
/* stylelint-enable selector-id-pattern */ /* 重新启用规则 */
/* ... */
.typewriter {
--blue: #5c86ff;
--blue-dark: #275efe;
--key: #fff;
--paper: #eef0fd;
--text: #d3d4ec;
--tool: #fbc56c;
--duration: 3s;
position: relative;
animation: bounce05 var(--duration) linear infinite;
}
.typewriter .slide {
width: 92px;
height: 20px;
margin-left: 14px;
background: linear-gradient(var(--blue), var(--blue-dark));
border-radius: 3px;
transform: translateX(14px);
animation: slide05 var(--duration) ease infinite;
}
.typewriter .slide::before,
.typewriter .slide::after,
.typewriter .slide i::before {
position: absolute;
content: '';
background: var(--tool);
}
.typewriter .slide::before {
top: 6px;
left: 100%;
width: 2px;
height: 8px;
}
.typewriter .slide::after {
top: 3px;
left: 94px;
width: 6px;
height: 14px;
border-radius: 3px;
}
.typewriter .slide i {
position: absolute;
top: 4px;
right: 100%;
display: block;
width: 6px;
height: 4px;
background: var(--tool);
}
.typewriter .slide i::before {
top: -2px;
right: 100%;
width: 4px;
height: 14px;
border-radius: 2px;
}
.typewriter .paper {
position: absolute;
top: -26px;
left: 24px;
width: 40px;
height: 46px;
background: var(--paper);
border-radius: 5px;
transform: translateY(46px);
animation: paper05 var(--duration) linear infinite;
}
.typewriter .paper::before {
position: absolute;
top: 7px;
right: 6px;
left: 6px;
height: 4px;
content: '';
background: var(--text);
border-radius: 2px;
box-shadow:
0 12px 0 var(--text),
0 24px 0 var(--text),
0 36px 0 var(--text);
transform: scaleY(0.8);
}
.typewriter .keyboard {
position: relative;
z-index: 1;
width: 120px;
height: 56px;
margin-top: -10px;
}
.typewriter .keyboard::before,
.typewriter .keyboard::after {
position: absolute;
content: '';
}
.typewriter .keyboard::before {
inset: 0;
background: linear-gradient(135deg, var(--blue), var(--blue-dark));
border-radius: 7px;
transform: perspective(10px) rotateX(2deg);
transform-origin: 50% 100%;
}
.typewriter .keyboard::after {
top: 25px;
left: 2px;
width: 11px;
height: 4px;
border-radius: 2px;
box-shadow:
15px 0 0 var(--key),
30px 0 0 var(--key),
45px 0 0 var(--key),
60px 0 0 var(--key),
75px 0 0 var(--key),
90px 0 0 var(--key),
22px 10px 0 var(--key),
37px 10px 0 var(--key),
52px 10px 0 var(--key),
60px 10px 0 var(--key),
68px 10px 0 var(--key),
83px 10px 0 var(--key);
animation: keyboard05 var(--duration) linear infinite;
}
</style>
<div id="__app-loading__">
<!-- ... -->
<div class="typewriter">
<div class="slide"><i></i></div>
<div class="paper"></div>
<div class="keyboard"></div>
</div>
<div class="title"><%= VITE_APP_TITLE %></div>
</div>

View File

@ -52,7 +52,6 @@
"lodash-es": "^4.17.21",
"monaco-editor": "^0.52.2",
"pinia": "catalog:",
"qrcode.vue": "^3.6.0",
"rxjs": "^7.8.2",
"tinymce": "^7.3.0",
"unplugin-vue-components": "^0.27.3",

View File

@ -41,3 +41,14 @@ const tokenTheme = computed(() => {
</App>
</ConfigProvider>
</template>
<style>
.ant-drawer .ant-drawer-content {
background: hsl(var(--background)) !important;
}
.ant-modal .ant-modal-header,
.ant-modal .ant-modal-content {
background: hsl(var(--background)) !important;
}
</style>

View File

@ -93,5 +93,7 @@ defineExpose({
<style scoped lang="scss">
.editor {
height: 100%;
border: 1px solid hsl(var(--border)) !important;
border-radius: 0 !important;
}
</style>

View File

@ -28,6 +28,10 @@ export const useDeviceStore = defineStore('device', {
this.deviceCount = count;
},
updateDeviceStatus(state: string) {
this.currentDevice.deviceState = state;
},
async getDetail(id: string) {
try {
const res = await deviceInfo(id);

View File

@ -6,10 +6,16 @@ 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 {
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 TSLViewer from '#/views/device/product/detail/components/metadata/TSLViewer.vue';
import deviceDrawer from '../../device-drawer.vue';
@ -53,7 +59,12 @@ const metadataObj = computed(() => {
<div class="info-header">
<h3>设备信息</h3>
<Space>
<Button type="link" size="small" @click="handleEdit" v-access:code="['device:device:edit']">
<Button
type="link"
size="small"
@click="handleEdit"
v-access:code="['device:device:edit']"
>
<template #icon>
<EditOutlined />
</template>
@ -79,7 +90,11 @@ const metadataObj = computed(() => {
{{ deviceInfo.deviceName }}
</DescriptionsItem>
<DescriptionsItem label="设备类型">
{{ deviceTypeOptions.find((option) => option.value === deviceInfo.deviceType)?.label }}
{{
deviceTypeOptions.find(
(option) => option.value === deviceInfo.deviceType,
)?.label
}}
</DescriptionsItem>
<!-- <DescriptionsItem label="产品名称">
{{ deviceInfo.productName }}
@ -88,7 +103,11 @@ const metadataObj = computed(() => {
{{ deviceInfo.otaVersion || '-' }}
</DescriptionsItem>
<DescriptionsItem label="接入方式">
{{ networkTypeOptions.find((option) => option.value === deviceInfo?.productObj?.provider)?.label || '-' }}
{{
networkTypeOptions.find(
(option) => option.value === deviceInfo?.productObj?.provider,
)?.label || '-'
}}
</DescriptionsItem>
<DescriptionsItem label="消息协议">
{{ deviceInfo?.productObj?.protocolName || '-' }}
@ -112,12 +131,16 @@ const metadataObj = computed(() => {
</div>
<!-- 产品参数展示 -->
<div class="product-params-section">
<div class="product-params-section" v-if="deviceInfo?.productParamArray">
<div class="section-header">
<h3>设备参数</h3>
</div>
<Descriptions bordered :column="3">
<DescriptionsItem v-for="param in deviceInfo.productParamArray" :key="param.key" :label="param.label">
<DescriptionsItem
v-for="param in deviceInfo.productParamArray"
:key="param.key"
:label="param.label"
>
{{ param.value }}
</DescriptionsItem>
</Descriptions>
@ -126,8 +149,14 @@ const metadataObj = computed(() => {
<DeviceDrawer @reload="emit('refresh')" />
<!-- TSL查看抽屉 -->
<Drawer v-model:open="tslVisible" title="物模型" width="800px" @close="handleTSLClose">
<Drawer
v-model:open="tslVisible"
title="物模型"
width="800px"
@close="handleTSLClose"
>
<TSLViewer
v-if="tslVisible"
:product-id="deviceInfo?.productId"
:product-info="deviceInfo?.productObj"
:file-name="deviceInfo?.deviceName"
@ -149,12 +178,10 @@ const metadataObj = computed(() => {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #262626;
}
}
.info-content {
background: #fff;
border-radius: 6px;
}
@ -173,13 +200,11 @@ const metadataObj = computed(() => {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #262626;
}
}
.empty-params {
padding: 20px 0;
color: #8c8c8c;
text-align: center;
p {
@ -187,36 +212,6 @@ const metadataObj = computed(() => {
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

@ -249,7 +249,6 @@ onMounted(() => {
overflow: auto;
word-break: break-all;
white-space: pre-wrap;
background: #fafafa;
border-radius: 4px;
}
}

View File

@ -0,0 +1,160 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import {
DataZoomInsideComponent,
DataZoomSliderComponent,
} from 'echarts/components';
import { use } from 'echarts/core';
const props = withDefaults(defineProps<Props>(), {
data: () => [],
});
use([DataZoomSliderComponent, DataZoomInsideComponent]);
interface Props {
data?: Array<{ property: string; timestamp: number; value: number }>;
}
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const renderChart = () => {
if (props.data.length === 0) return;
//
const sortedData = [...props.data].sort((a, b) => a.timestamp - b.timestamp);
// ECharts[, ]
const seriesData = sortedData.map((item) => [
item.timestamp,
Number.parseFloat(item.value),
]);
renderEcharts({
tooltip: {
//
trigger: 'axis',
formatter(params) {
const item = params[0];
const date = new Date(item.value[0]);
const timeStr = `${date.getFullYear()}-${(date.getMonth() + 1)
.toString()
.padStart(
2,
'0',
)}-${date.getDate().toString().padStart(2, '0')} ${date
.getHours()
.toString()
.padStart(
2,
'0',
)}:${date.getMinutes().toString().padStart(2, '0')}:${date
.getSeconds()
.toString()
.padStart(2, '0')}`;
return `时间: ${timeStr}<br/>值: ${item.value[1]}`;
},
},
grid: {
top: 30,
bottom: 90,
},
xAxis: {
type: 'category',
name: '时间',
axisLabel: {
rotate: 30,
formatter(value: number) {
const date = new Date(value);
return `${date.getFullYear()}-${(date.getMonth() + 1)
.toString()
.padStart(
2,
'0',
)}-${date.getDate().toString().padStart(2, '0')}\n${date
.getHours()
.toString()
.padStart(
2,
'0',
)}:${date.getMinutes().toString().padStart(2, '0')}:${date
.getSeconds()
.toString()
.padStart(2, '0')}`;
},
},
},
yAxis: {
type: 'value',
name: '值',
},
dataZoom: [
{
type: 'slider', //
show: true, //
xAxisIndex: [0], // x
top: '90%', //
start: 0, // 0%
end: 100, // 100%
left: '17%',
width: '65%',
},
{
type: 'inside', //
xAxisIndex: [0], // x
start: 0,
end: 100,
},
],
series: [
{
name: '数据系列',
type: 'line',
data: seriesData,
// 线
lineStyle: {
color: '#5470C6',
},
//
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: 'rgba(84, 112, 198, 0.5)', //
},
{
offset: 1,
color: 'rgba(84, 112, 198, 0.1)', //
},
],
},
},
//
symbolSize: 6,
},
],
});
};
watch(() => props.data, renderChart, { deep: true });
onMounted(() => {
renderChart();
});
</script>
<template>
<EchartsUI ref="chartRef" height="400px" />
</template>

View File

@ -3,10 +3,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { EllipsisText } from '@vben/common-ui';
import {
LineChartOutlined,
UnorderedListOutlined,
} from '@ant-design/icons-vue';
import { UnorderedListOutlined } from '@ant-design/icons-vue';
import {
Button,
Card,
@ -27,6 +24,8 @@ import dayjs from 'dayjs';
import { getWebSocket } from '#/utils/websocket';
import RealtimeChart from './RealtimeChart.vue';
interface Props {
deviceId: string;
metadata: any;
@ -59,15 +58,16 @@ const subscribeRealtimeData = () => {
properties: props.metadata.properties.map((p: any) => p.id),
interval: '3s',
}).subscribe((data: any) => {
if (data.payload?.deviceKey === deviceKey && data.payload?.properties) {
console.log('收到设备实时数据', data);
if (data.payload?.value && data.requestId === id) {
//
data.payload.properties.forEach((propData: any) => {
Object.entries(data.payload.value).forEach(([propId, propValue]) => {
const property = runtimeProperties.value.find(
(p: any) => p.id === propData.id,
(p: any) => p.id === propId,
);
if (property) {
property.value = propData.value;
property.timestamp = dayjs(propData.timestamp).format(
property.value = propValue;
property.timestamp = dayjs(data.payload.timeString).format(
'YYYY-MM-DD HH:mm:ss',
);
}
@ -135,8 +135,14 @@ 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 },
{
title: '值',
dataIndex: 'value',
key: 'value',
width: 150,
align: 'center',
},
// { title: '', key: 'action', width: 100 },
];
const openPropertyLog = (property: any) => {
@ -154,6 +160,24 @@ const loadPropertyLog = async () => {
{ 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' },
{ timestamp: '2025-08-20 16:19:40', value: '0.5' },
{ timestamp: '2025-08-20 16:18:40', value: '0.4' },
{ timestamp: '2025-08-20 16:17:40', value: '0.3' },
{ timestamp: '2025-08-21 14:19:40', value: '0.5' },
{ timestamp: '2025-08-21 16:18:40', value: '0.4' },
{ timestamp: '2025-08-21 18:17:40', value: '0.3' },
{ timestamp: '2025-08-22 16:19:40', value: '0.5' },
{ timestamp: '2025-08-22 16:18:40', value: '0.4' },
{ timestamp: '2025-08-22 16:17:40', value: '0.3' },
{ timestamp: '2025-08-23 16:19:40', value: '0.5' },
{ timestamp: '2025-08-23 16:18:40', value: '0.4' },
{ timestamp: '2025-08-23 16:17:40', value: '0.3' },
{ timestamp: '2025-08-24 16:19:40', value: '0.5' },
{ timestamp: '2025-08-24 16:18:40', value: '0.4' },
{ timestamp: '2025-08-24 16:17:40', value: '0.3' },
{ timestamp: '2025-08-25 16:19:40', value: '0.5' },
{ timestamp: '2025-08-25 16:18:40', value: '0.4' },
{ timestamp: '2025-08-25 16:17:40', value: '0.3' },
];
} finally {
logLoading.value = false;
@ -203,7 +227,7 @@ const formatValue = (property: any) => {
return valueParams.enumConf.find((item: any) => item.value === value)?.text;
}
if (valueParams?.formType === 'time') {
if (valueParams?.formType === 'time' && valueParams?.dataType !== 'string') {
return dayjs(value).format(valueParams.format);
}
@ -230,74 +254,12 @@ const closeLogModal = () => {
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();
subscribeRealtimeData();
});
onUnmounted(() => {
stopRefreshTimer();
unsubscribeRealtimeData();
});
@ -417,6 +379,7 @@ watch(
:pagination="false"
size="small"
row-key="timestamp"
:scroll="{ y: 500 }"
>
<template #bodyCell="{ column }">
<template v-if="column.key === 'action'">
@ -433,8 +396,22 @@ watch(
>
<div class="chart-container">
<div class="chart-placeholder">
<LineChartOutlined style="font-size: 48px; color: #d9d9d9" />
<p>图表功能开发中...</p>
<RealtimeChart
v-if="logData && logData.length > 0"
:data="logData"
/>
<div
v-else
style="
display: flex;
align-items: center;
justify-content: center;
height: 300px;
color: #999;
"
>
<Empty :image="Empty.PRESENTED_IMAGE_SIMPLE" />
</div>
</div>
</div>
</Tabs.TabPane>
@ -459,7 +436,6 @@ watch(
.control-label {
font-weight: 500;
color: #262626;
white-space: nowrap;
}
}
@ -483,15 +459,14 @@ watch(
margin: 0;
font-size: 16px;
font-weight: 600;
color: #262626;
}
.property-log-btn {
padding: 4px;
color: #8c8c8c;
color: hsl(var(--muted-foreground));
&:hover {
color: #1890ff;
color: hsl(var(--primary));
}
}
}
@ -506,7 +481,7 @@ watch(
margin-bottom: 12px;
font-size: 32px;
font-weight: 700;
color: #1890ff;
color: hsl(var(--primary));
}
.property-unit {
@ -514,7 +489,7 @@ watch(
right: 0;
bottom: 0;
font-size: 20px;
color: #8c8c8c;
color: hsl(var(--muted-foreground));
text-align: right;
}
}

View File

@ -578,32 +578,30 @@ onMounted(() => {
.event-sidebar {
width: 200px;
background: #fafafa;
border: 1px solid #d9d9d9;
border: 1px solid hsl(var(--border));
border-radius: 6px;
.sidebar-header {
padding: 10px;
font-weight: 500;
color: #262626;
background: #f0f0f0;
border-bottom: 1px solid #d9d9d9;
background: hsl(var(--muted));
border-bottom: 1px solid hsl(var(--border));
border-radius: 6px 6px 0 0;
}
.sidebar-item {
padding: 10px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
border-bottom: 1px solid hsl(var(--border));
transition: all 0.3s;
&:hover {
background: #e6f7ff;
background: hsl(var(--muted));
}
&.active {
color: white;
background: #1890ff;
background: hsl(var(--primary));
.event-id {
color: rgb(255 255 255 / 80%);
@ -634,7 +632,7 @@ onMounted(() => {
align-items: center;
padding: 8px 0;
font-size: 14px;
color: #8c8c8c;
color: hsl(var(--muted-foreground));
}
}
@ -645,14 +643,14 @@ onMounted(() => {
.input-section {
flex: 5;
min-height: 300px;
border: 1px solid #d9d9d9;
border: 1px solid hsl(var(--border));
border-radius: 6px;
.simple-form {
:deep(.ant-table) {
.ant-table-thead > tr > th {
font-weight: 500;
background: #fafafa;
background: hsl(var(--muted));
}
.ant-table-tbody > tr > td {
@ -687,28 +685,26 @@ onMounted(() => {
.parameter-box,
.result-box {
padding: 16px;
border: 1px solid #d9d9d9;
border: 1px solid hsl(var(--border));
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;
background: hsl(var(--muted));
border-radius: 4px;
pre {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: #262626;
word-break: break-all;
white-space: pre-wrap;
}

View File

@ -839,32 +839,30 @@ onMounted(() => {
.function-sidebar {
width: 200px;
background: #fafafa;
border: 1px solid #d9d9d9;
border: 1px solid hsl(var(--border));
border-radius: 6px;
.sidebar-header {
padding: 10px;
font-weight: 500;
color: #262626;
background: #f0f0f0;
border-bottom: 1px solid #d9d9d9;
background: hsl(var(--muted));
border-bottom: 1px solid hsl(var(--border));
border-radius: 6px 6px 0 0;
}
.sidebar-item {
padding: 10px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
border-bottom: 1px solid hsl(var(--border));
transition: all 0.3s;
&:hover {
background: #e6f7ff;
background: hsl(var(--muted));
}
&.active {
color: white;
background: #1890ff;
background: hsl(var(--primary));
.function-id {
color: rgb(255 255 255 / 80%);
@ -895,11 +893,11 @@ onMounted(() => {
align-items: center;
padding: 8px 0;
font-size: 14px;
color: #8c8c8c;
color: hsl(var(--muted-foreground));
.info-icon {
font-weight: bold;
color: #1890ff;
color: hsl(var(--primary));
}
}
}
@ -911,14 +909,14 @@ onMounted(() => {
.input-section {
flex: 5;
min-height: 300px;
border: 1px solid #d9d9d9;
border: 1px solid hsl(var(--border));
border-radius: 6px;
.simple-form {
:deep(.ant-table) {
.ant-table-thead > tr > th {
font-weight: 500;
background: #fafafa;
background: hsl(var(--muted));
}
.ant-table-tbody > tr > td {
@ -953,40 +951,31 @@ onMounted(() => {
.parameter-box,
.result-box {
padding: 16px;
border: 1px solid #d9d9d9;
border: 1px solid hsl(var(--border));
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;
background: hsl(var(--muted));
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;
}
}
}
}
}

View File

@ -327,7 +327,7 @@ onMounted(() => {
align-items: center;
padding: 8px 0;
font-size: 14px;
color: #8c8c8c;
color: hsl(var(--muted-foreground));
}
}
@ -337,14 +337,14 @@ onMounted(() => {
.input-section {
flex: 5;
border: 1px solid #d9d9d9;
border: 1px solid hsl(var(--border));
border-radius: 6px;
.simple-form {
:deep(.ant-table) {
.ant-table-thead > tr > th {
font-weight: 500;
background: #fafafa;
background: hsl(var(--muted));
}
.ant-table-tbody > tr > td {
@ -373,28 +373,26 @@ onMounted(() => {
.parameter-box,
.result-box {
padding: 16px;
border: 1px solid #d9d9d9;
border: 1px solid hsl(var(--border));
border-radius: 6px;
.parameter-header,
.result-header {
margin-bottom: 12px;
font-weight: 500;
color: #262626;
}
.parameter-content,
.result-content {
min-height: 120px;
padding: 12px;
background: #f5f5f5;
background: hsl(var(--muted));
border-radius: 4px;
pre {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: #262626;
word-break: break-all;
white-space: pre-wrap;
}

View File

@ -712,32 +712,30 @@ onMounted(() => {
.property-sidebar {
width: 200px;
background: #fafafa;
border: 1px solid #d9d9d9;
border: 1px solid hsl(var(--border));
border-radius: 6px;
.sidebar-header {
padding: 10px;
font-weight: 500;
color: #262626;
background: #f0f0f0;
border-bottom: 1px solid #d9d9d9;
background: hsl(var(--muted));
border-bottom: 1px solid hsl(var(--border));
border-radius: 6px 6px 0 0;
}
.sidebar-item {
padding: 10px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
border-bottom: 1px solid hsl(var(--border));
transition: all 0.3s;
&:hover {
background: #e6f7ff;
background: hsl(var(--muted));
}
&.active {
color: white;
background: #1890ff;
background: hsl(var(--primary));
}
.function-name {
@ -763,7 +761,6 @@ onMounted(() => {
.control-label {
font-weight: 500;
color: #262626;
white-space: nowrap;
}
}
@ -778,11 +775,11 @@ onMounted(() => {
align-items: center;
padding: 8px 0;
font-size: 14px;
color: #8c8c8c;
color: hsl(var(--muted-foreground));
.info-icon {
font-weight: bold;
color: #1890ff;
color: hsl(var(--primary));
}
}
}
@ -794,14 +791,14 @@ onMounted(() => {
.input-section {
flex: 5;
min-height: 300px;
border: 1px solid #d9d9d9;
border: 1px solid hsl(var(--border));
border-radius: 6px;
.simple-form {
:deep(.ant-table) {
.ant-table-thead > tr > th {
font-weight: 500;
background: #fafafa;
background: hsl(var(--muted));
}
.ant-table-tbody > tr > td {
@ -821,7 +818,6 @@ onMounted(() => {
align-items: center;
justify-content: center;
height: 300px;
color: #8c8c8c;
text-align: center;
p {
@ -849,26 +845,24 @@ onMounted(() => {
.parameter-box {
height: 100%;
padding: 16px;
border: 1px solid #d9d9d9;
border: 1px solid hsl(var(--border));
border-radius: 6px;
.parameter-header {
margin-bottom: 12px;
font-weight: 500;
color: #262626;
}
.parameter-content {
min-height: calc(100% - 32px);
padding: 12px;
background: #f5f5f5;
background: hsl(var(--muted));
border-radius: 4px;
pre {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: #262626;
word-break: break-all;
white-space: pre-wrap;
}

View File

@ -1,12 +1,11 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
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 { Image, message, Switch, TabPane, Tabs } from 'ant-design-vue';
import QrcodeVue from 'qrcode.vue';
import { Image, message, QRCode, Switch, TabPane, Tabs } from 'ant-design-vue';
import { deviceStateOptions } from '#/constants/dicts';
// import { deviceUpdateStatus } from '#/api/device/device';
@ -33,6 +32,8 @@ let deviceStatusSubscription: any = null;
const subscribeDeviceStatus = () => {
if (!currentDevice.value) return;
if (deviceStatusSubscription) return;
const productKey = currentDevice.value.productObj.productKey;
const id = `device-status-${currentDevice.value.deviceKey}`;
@ -41,9 +42,12 @@ const subscribeDeviceStatus = () => {
deviceStatusSubscription = getWebSocket(id, topic, {
deviceKey: currentDevice.value.deviceKey,
}).subscribe((data: any) => {
if (data.payload?.deviceKey === currentDevice.value.deviceKey) {
if (data.requestId === id) {
//
deviceStore.updateDeviceStatus(data.payload);
deviceStore.updateDeviceStatus(data.payload.value);
message.success(
`设备‘${currentDevice.value.deviceName}${data.payload.value === 'online' ? '上线' : '下线'}`,
);
}
});
};
@ -114,7 +118,7 @@ onUnmounted(() => {
<template>
<Page :auto-content-height="true">
<div class="device-detail">
<div class="device-detail bg-background">
<!-- 页面头部 -->
<div class="detail-header">
<div class="header-left">
@ -155,15 +159,12 @@ onUnmounted(() => {
</div>
</div>
<div class="header-right">
<QrcodeVue
<QRCode
:value="currentDevice.deviceKey"
:image-settings="{
src: currentDevice.imgUrl,
height: 20,
width: 20,
}"
:size="80"
level="H"
:size="100"
:icon-size="20"
error-level="H"
:icon="currentDevice.imgUrl"
/>
<!-- <a-button
type="primary"
@ -234,7 +235,6 @@ onUnmounted(() => {
<style lang="scss" scoped>
.device-detail {
padding: 24px;
background: #fff;
.detail-header {
position: relative;
@ -261,7 +261,6 @@ onUnmounted(() => {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #262626;
}
.device-status {
@ -271,7 +270,7 @@ onUnmounted(() => {
.status-label {
font-size: 14px;
color: #8c8c8c;
color: hsl(var(--muted-foreground));
}
.status-dot {
@ -284,21 +283,21 @@ onUnmounted(() => {
line-height: 20px;
&.warning {
color: #faad14;
color: hsl(var(--warning));
}
&.success {
color: #52c41a;
color: hsl(var(--success));
}
&.error {
color: #ff4d4f;
color: hsl(var(--secondary));
}
}
.status-text {
font-size: 14px;
color: #262626;
color: hsl(var(--muted-foreground));
}
.status-switch {
@ -314,8 +313,6 @@ onUnmounted(() => {
right: 0;
width: 100px;
height: 100px;
padding: 10px;
border: 1px solid #d9d9d9;
border-radius: 10px;
}
}
@ -332,16 +329,15 @@ onUnmounted(() => {
.basic-label {
font-size: 14px;
color: #8c8c8c;
color: hsl(var(--muted-foreground));
}
.basic-value {
font-size: 14px;
font-weight: 500;
color: #262626;
&.product-link {
color: #1890ff;
color: hsl(var(--primary));
cursor: pointer;
&:hover {

View File

@ -99,6 +99,7 @@ export const drawerSchema: FormSchemaGetter = () => [
label: '产品KEY',
fieldName: 'productKey',
component: 'Input',
rules: 'required',
},
{
label: '产品名称',
@ -139,8 +140,9 @@ export const drawerSchema: FormSchemaGetter = () => [
h(
'span',
{
class: 'text-[14px] text-black/25 truncate',
style: 'max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;',
class: 'text-[14px] text-muted-foreground truncate',
style:
'max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;',
},
option.tooltip,
),

View File

@ -41,7 +41,8 @@ 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}`,
});
//
@ -55,7 +56,8 @@ const providerOptions = [
value: 'child-device',
channel: 'child-device',
transport: 'Gateway',
description: '需要通过网关与平台进行数据通信的设备,将作为网关子设备接入到平台。',
description:
'需要通过网关与平台进行数据通信的设备,将作为网关子设备接入到平台。',
},
];
@ -230,7 +232,10 @@ 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">
@ -309,18 +314,17 @@ onMounted(() => {
.gateway-card {
height: 100%;
cursor: pointer;
border: 1px solid #e4e4e7;
border: 1px solid hsl(var(--border));
transition: all 0.3s ease;
&:hover {
border-color: #1890ff;
box-shadow: 0 4px 12px rgb(24 144 255 / 15%);
border-color: hsl(var(--primary));
box-shadow: hsl(var(--accent-hover));
}
&.selected {
background: #f0f8ff;
border-color: #1890ff;
box-shadow: 0 4px 12px rgb(24 144 255 / 20%);
border-color: hsl(var(--primary));
box-shadow: hsl(var(--accent-hover));
}
.gateway-main {
@ -343,7 +347,7 @@ onMounted(() => {
font-size: 12px;
font-weight: bold;
color: white;
background: #1890ff;
background: hsl(var(--primary));
border-radius: 8px;
}
}
@ -367,7 +371,6 @@ onMounted(() => {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #262626;
}
}
@ -392,12 +395,11 @@ onMounted(() => {
.gateway-footer-item-label {
font-size: 12px;
color: #3d3d3d;
color: hsl(var(--muted-foreground));
}
.gateway-footer-item-value {
font-size: 14px;
color: #000;
}
}
}
@ -416,7 +418,7 @@ onMounted(() => {
font-size: 12px;
font-weight: bold;
color: white;
background: #1890ff;
background: hsl(var(--primary));
border-radius: 50%;
}
}
@ -436,9 +438,7 @@ onMounted(() => {
}
.footer-actions {
padding-top: 16px;
text-align: right;
border-top: 1px solid #f0f0f0;
}
}
</style>

View File

@ -6,7 +6,7 @@ import { computed } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { EditOutlined } from '@ant-design/icons-vue';
import { Button, Descriptions, DescriptionsItem } from 'ant-design-vue';
import { Button, Descriptions, DescriptionsItem, Empty } from 'ant-design-vue';
import dayjs from 'dayjs';
import { deviceTypeOptions } from '#/constants/dicts';
@ -152,7 +152,10 @@ const productParams = computed(() =>
</div>
<div v-if="productParams.length === 0" class="empty-params">
<p>暂无产品参数</p>
<Empty
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无产品参数"
/>
</div>
<Descriptions bordered :column="3" v-else>
@ -187,7 +190,6 @@ const productParams = computed(() =>
margin: 0;
font-size: 16px;
font-weight: 600;
color: #262626;
}
.header-actions {
@ -225,45 +227,15 @@ const productParams = computed(() =>
font-size: 14px;
}
}
.params-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
.param-card {
padding: 12px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 4px;
.param-key {
margin-bottom: 4px;
font-size: 12px;
color: #8c8c8c;
}
.param-label {
margin-bottom: 8px;
font-size: 14px;
font-weight: 500;
color: #262626;
}
.param-value {
text-align: right;
}
}
}
}
:deep(.ant-descriptions-item-label) {
font-weight: 500;
color: #595959;
//color: #595959;
}
:deep(.ant-descriptions-item-content) {
color: #262626;
//color: #262626;
}
}
</style>

View File

@ -69,7 +69,8 @@ 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
? [
@ -265,9 +266,18 @@ 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>
@ -297,7 +307,11 @@ 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="此配置来自于产品接入网关所选择的协议">
@ -329,13 +343,19 @@ 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="请输入"
/>
@ -348,7 +368,9 @@ onMounted(() => {
<div class="config-section">
<div class="section-header">
<h4>存储策略</h4>
<Tooltip title="若修改存储策略,需要手动做数据迁移,平台只能搜索最新存储策略中的数据">
<Tooltip
title="若修改存储策略,需要手动做数据迁移,平台只能搜索最新存储策略中的数据"
>
<QuestionCircleOutlined />
</Tooltip>
</div>
@ -364,18 +386,27 @@ 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信息'
}}
@ -427,7 +458,11 @@ 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>
@ -443,7 +478,7 @@ onMounted(() => {
.config-section {
padding: 16px;
margin-bottom: 24px;
border: 1px solid #f0f0f0;
border: 1px solid hsl(var(--border));
border-radius: 6px;
.section-header {
@ -456,7 +491,6 @@ onMounted(() => {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #262626;
}
}
@ -465,12 +499,10 @@ onMounted(() => {
margin: 0 0 8px;
font-size: 16px;
font-weight: 500;
color: #262626;
}
.access-desc {
margin: 0;
color: #8c8c8c;
}
.address-item {

View File

@ -14,9 +14,9 @@ import {
import { productUpdateById } from '#/api/device/product';
import ImportForm from './ImportForm.vue';
import MetadataTable from './MetadataTable.vue';
import TSLViewer from './TSLViewer.vue';
import ImportForm from './metadata/ImportForm.vue';
import MetadataTable from './metadata/MetadataTable.vue';
import TSLViewer from './metadata/TSLViewer.vue';
const props = defineProps<{
productId: string;
@ -424,6 +424,7 @@ onBeforeRouteLeave((to, from, next) => {
<!-- 导入抽屉 -->
<Drawer
v-model:open="importVisible"
v-if="importVisible"
title="快速导入"
width="600px"
@close="handleImportClose"
@ -442,6 +443,7 @@ onBeforeRouteLeave((to, from, next) => {
@close="handleTSLClose"
>
<TSLViewer
v-if="tslVisible"
:product-id="productInfo.id"
:product-info="productInfo"
:file-name="productInfo.productName"
@ -468,12 +470,11 @@ onBeforeRouteLeave((to, from, next) => {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #262626;
}
.desc {
font-size: 14px;
color: #8c8c8c;
color: hsl(var(--muted-foreground));
}
}
}

View File

@ -226,7 +226,6 @@ defineExpose({
.param-title {
font-size: 16px;
font-weight: 600;
color: #262626;
}
}
@ -245,12 +244,11 @@ defineExpose({
}
.param-items {
border-bottom: 1px solid #f0f0f0;
border-bottom: 1px solid hsl(var(--border));
.param-item {
padding: 16px;
background: #fafafa;
border: 1px solid #f0f0f0;
border: 1px solid hsl(var(--border));
border-bottom: none;
.param-row {
@ -267,7 +265,6 @@ defineExpose({
margin-bottom: 6px;
font-size: 12px;
font-weight: 500;
color: #595959;
}
}
@ -282,15 +279,15 @@ defineExpose({
.param-tips {
padding: 12px 16px;
background: #f6ffed;
border: 1px solid #b7eb8f;
background: hsl(var(--success-foreground));
border: 1px solid hsl(var(--success));
border-radius: 6px;
.tip-text {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: #52c41a;
color: hsl(var(--success));
}
}
}

View File

@ -164,7 +164,6 @@ const handleCancel = () => {
display: flex;
margin-bottom: 16px;
font-weight: 500;
color: #262626;
.header-item {
flex: 1;
@ -205,7 +204,7 @@ const handleCancel = () => {
padding-top: 16px;
margin-top: 24px;
text-align: center;
border-top: 1px dashed #d9d9d9;
border-top: 1px dashed hsl(var(--border));
.add-button {
width: 100%;

View File

@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { usePreferences } from '@vben/preferences';
import { UploadOutlined } from '@ant-design/icons-vue';
import {
Button,
@ -25,6 +27,8 @@ defineExpose({});
const formRef = ref();
const importLoading = ref(false);
const editorTheme = ref('vs');
const formData = reactive({
script: `{
"properties": [],
@ -146,9 +150,6 @@ const parseFileContent = (content: string, fileName: string) => {
throw error;
}
};
// const handleJsonChange = (value: string) => {
// formData.script = value;
// };
//
const handleImport = async () => {
@ -194,6 +195,9 @@ const handleImport = async () => {
const handleCancel = () => {
emit('success');
};
// x
const { isDark } = usePreferences();
editorTheme.value = isDark.value ? 'vs-dark' : 'vs';
</script>
<template>
@ -223,8 +227,8 @@ const handleCancel = () => {
<MonacoEditor
v-model="formData.script"
lang="json"
theme="vs"
style="height: 400px; border: 1px solid #d9d9d9; border-radius: 6px"
:theme="editorTheme"
style="min-height: 100%"
/>
</div>
</FormItem>
@ -248,66 +252,14 @@ const handleCancel = () => {
<style lang="scss" scoped>
.import-form {
height: 100%;
.upload-tip {
margin-top: 8px;
font-size: 12px;
color: #8c8c8c;
}
.preview-area {
overflow: hidden;
border: 1px solid #d9d9d9;
border-radius: 6px;
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #fafafa;
border-bottom: 1px solid #d9d9d9;
}
.preview-tips {
padding: 12px 16px;
font-size: 14px;
line-height: 1.5;
color: #666;
background: #f0f9ff;
border-bottom: 1px solid #d9d9d9;
}
.preview-content {
border-bottom: 1px solid #d9d9d9;
}
.category-preview {
padding: 16px;
background: #fff;
:deep(.ant-tabs) {
.ant-tabs-tab {
padding: 8px 16px;
}
.ant-tabs-content-holder {
padding-top: 16px;
}
}
:deep(.ant-table) {
.ant-table-thead > tr > th {
font-weight: 500;
background: #fafafa;
}
.ant-table-tbody > tr > td {
padding: 8px 12px;
}
}
}
}
.script-header {
display: flex;
align-items: center;
@ -316,9 +268,7 @@ const handleCancel = () => {
}
.script-editor {
overflow: hidden;
border: 1px solid #d9d9d9;
border-radius: 6px;
height: calc(100vh - 300px);
}
.form-actions {

View File

@ -35,7 +35,8 @@ const tableData = ref<any[]>([]);
const formData = ref<any>(null);
//
const generatePk = (): string => `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
const generatePk = (): string =>
`${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
//
const withPk = (record: any) => {
@ -52,7 +53,8 @@ const withPk = (record: any) => {
};
//
const findIndexByPk = (pk: string) => tableData.value.findIndex((item) => item._pk === pk);
const findIndexByPk = (pk: string) =>
tableData.value.findIndex((item) => item._pk === pk);
//
const columns = computed(() => {
@ -273,7 +275,11 @@ 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>
@ -281,26 +287,52 @@ onMounted(() => {
</Button>
</div>
<Table :columns="columns" :data-source="tableData" :loading="loading" :pagination="false" row-key="_pk">
<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

@ -579,25 +579,4 @@ watch(
</Drawer>
</template>
<style lang="scss" scoped>
.enum-preview {
.enum-items-preview {
padding: 12px;
margin-top: 12px;
background-color: #fafafa;
border-radius: 4px;
.preview-title {
margin-bottom: 8px;
font-size: 12px;
color: #666;
}
.preview-items {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
}
</style>
<style></style>

View File

@ -140,7 +140,9 @@ 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);
@ -176,7 +178,12 @@ 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">
@ -202,12 +209,23 @@ 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>
@ -218,12 +236,21 @@ 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>
@ -231,7 +258,9 @@ 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

@ -172,6 +172,7 @@ const getReadWriteTypeLabel = (type: string) => {
:pagination="false"
row-key="id"
size="small"
:scroll="{ y: 500 }"
:row-selection="{
selectedRowKeys: selectedPropertyIds,
onChange: (selectedRowKeys: string[]) => {
@ -218,14 +219,12 @@ const getReadWriteTypeLabel = (type: string) => {
.search-section {
padding: 16px;
margin-bottom: 16px;
background-color: #fafafa;
border-radius: 6px;
}
.pagination-info {
margin-top: 16px;
font-size: 14px;
color: #666;
text-align: right;
}
</style>

View File

@ -1,7 +1,13 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import { CopyOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import { usePreferences } from '@vben/preferences';
import {
CopyOutlined,
DownloadOutlined,
ReloadOutlined,
} from '@ant-design/icons-vue';
import { message, Space } from 'ant-design-vue';
import dayjs from 'dayjs';
@ -14,6 +20,8 @@ const props = defineProps<{
productInfo: object;
}>();
const editorTheme = ref('vs');
const mockData = {
properties: [],
functions: [],
@ -117,6 +125,9 @@ watch(
onMounted(() => {
loadTSLData();
});
// x
const { isDark } = usePreferences();
editorTheme.value = isDark.value ? 'vs-dark' : 'vs';
</script>
<template>
@ -151,7 +162,12 @@ 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="editorTheme"
/>
</div>
</div>
</div>
@ -171,8 +187,8 @@ onMounted(() => {
.viewer-content-tips {
padding: 12px;
margin-bottom: 12px;
color: #8c8c8c;
background: #f5f5f5;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted));
}
.viewer-content {

View File

@ -106,7 +106,7 @@ onUnmounted(() => {
<template>
<Page :auto-content-height="true">
<div class="product-detail">
<div class="product-detail bg-background">
<!-- 页面头部 -->
<div class="detail-header">
<div class="header-left">
@ -185,7 +185,6 @@ onUnmounted(() => {
<style lang="scss" scoped>
.product-detail {
padding: 24px;
background: #fff;
.detail-header {
display: flex;
@ -211,7 +210,6 @@ onUnmounted(() => {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #262626;
}
.product-status {

View File

@ -204,7 +204,7 @@ export const drawerSchema: FormSchemaGetter = () => [
h(
'span',
{
class: 'text-[14px] text-black/25 truncate',
class: 'text-[14px] text-muted-foreground truncate',
style:
'max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;',
},