chore: 添加设备展示实时数据卡片(蓝牙工牌、客流计数器)
This commit is contained in:
@@ -6,7 +6,7 @@ import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { ContentWrap } from '@vben/common-ui';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { formatDate } from '@vben/utils';
|
||||
import { formatDateTime } from '@vben/utils';
|
||||
|
||||
import {
|
||||
Button,
|
||||
@@ -181,7 +181,7 @@ onMounted(() => {
|
||||
<template #default="{ record }">
|
||||
{{
|
||||
record.request?.reportTime
|
||||
? formatDate(record.request.reportTime)
|
||||
? formatDateTime(record.request.reportTime)
|
||||
: '-'
|
||||
}}
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
<!-- 客流计数器实时数据卡片 ProductKey: 82Zr08RUnstRHRO2 -->
|
||||
<script setup lang="ts">
|
||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { formatDateTime } from '@vben/utils';
|
||||
|
||||
import { Button, Col, Progress, Row } from 'ant-design-vue';
|
||||
|
||||
import { getLatestDeviceProperties } from '#/api/iot/device/device';
|
||||
|
||||
const props = defineProps<{
|
||||
deviceId: number;
|
||||
}>();
|
||||
|
||||
// 数据状态
|
||||
const loading = ref(false);
|
||||
const properties = ref<Record<string, any>>({});
|
||||
const lastUpdateTime = ref<Date | null>(null);
|
||||
|
||||
// 轮询定时器
|
||||
let pollTimer: any = null;
|
||||
const POLL_INTERVAL = 5000;
|
||||
|
||||
/** 获取属性值 */
|
||||
function getPropertyValue(identifier: string, defaultValue: any = 0) {
|
||||
const prop = properties.value[identifier];
|
||||
return prop?.value ?? defaultValue;
|
||||
}
|
||||
|
||||
/** 电量颜色 */
|
||||
const batteryColor = computed(() => {
|
||||
const level = Number(getPropertyValue('battery_tx', 0));
|
||||
if (level <= 20) return '#ff4d4f';
|
||||
if (level <= 50) return '#faad14';
|
||||
return '#52c41a';
|
||||
});
|
||||
|
||||
/** 信号颜色 */
|
||||
const rssiColor = computed(() => {
|
||||
const rssi = Number(getPropertyValue('rssi', -100));
|
||||
const absRssi = Math.abs(rssi);
|
||||
if (absRssi <= 50) return '#52c41a';
|
||||
if (absRssi <= 70) return '#1890ff';
|
||||
if (absRssi <= 85) return '#faad14';
|
||||
return '#ff4d4f';
|
||||
});
|
||||
|
||||
/** 信号百分比 */
|
||||
const rssiPercent = computed(() => {
|
||||
const rssi = Number(getPropertyValue('rssi', -100));
|
||||
return Math.max(0, Math.min(100, 100 + rssi));
|
||||
});
|
||||
|
||||
/** 净流量 */
|
||||
const netFlow = computed(() => {
|
||||
const peopleIn = Number(getPropertyValue('people_in', 0));
|
||||
const peopleOut = Number(getPropertyValue('people_out', 0));
|
||||
return peopleIn - peopleOut;
|
||||
});
|
||||
|
||||
/** 获取设备属性最新数据 */
|
||||
async function fetchProperties() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getLatestDeviceProperties({ deviceId: props.deviceId });
|
||||
const propMap: Record<string, any> = {};
|
||||
data.forEach((item: IotDeviceApi.DevicePropertyDetail) => {
|
||||
propMap[item.identifier] = item;
|
||||
});
|
||||
properties.value = propMap;
|
||||
lastUpdateTime.value = new Date();
|
||||
} catch (error) {
|
||||
console.error('获取设备属性失败:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 开始轮询 */
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
pollTimer = setInterval(() => {
|
||||
fetchProperties();
|
||||
}, POLL_INTERVAL);
|
||||
}
|
||||
|
||||
/** 停止轮询 */
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchProperties();
|
||||
startPolling();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="people-counter-wrapper">
|
||||
<Row :gutter="12">
|
||||
<!-- 客流进 -->
|
||||
<Col :xs="12" :sm="6" :md="5">
|
||||
<div class="stat-item in">
|
||||
<div class="stat-icon">
|
||||
<IconifyIcon icon="mdi:arrow-right-bold" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">客流进</div>
|
||||
<div class="stat-value">{{ getPropertyValue('people_in', 0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<!-- 客流出 -->
|
||||
<Col :xs="12" :sm="6" :md="5">
|
||||
<div class="stat-item out">
|
||||
<div class="stat-icon">
|
||||
<IconifyIcon icon="mdi:arrow-left-bold" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">客流出</div>
|
||||
<div class="stat-value">{{ getPropertyValue('people_out', 0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<!-- 在场人数 -->
|
||||
<Col :xs="12" :sm="6" :md="5">
|
||||
<div class="stat-item net">
|
||||
<div class="stat-icon">
|
||||
<IconifyIcon icon="mdi:account-group" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">在场人数</div>
|
||||
<div class="stat-value" :class="{ positive: netFlow > 0, negative: netFlow < 0 }">
|
||||
{{ netFlow >= 0 ? netFlow : 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<!-- 蓝牙信号 -->
|
||||
<Col :xs="12" :sm="6" :md="4">
|
||||
<div class="stat-item signal">
|
||||
<div class="stat-icon" :style="{ background: rssiColor + '15', color: rssiColor }">
|
||||
<IconifyIcon icon="mdi:signal" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">蓝牙信号</div>
|
||||
<div class="signal-bar">
|
||||
<Progress
|
||||
:percent="rssiPercent"
|
||||
:stroke-color="rssiColor"
|
||||
:show-info="false"
|
||||
size="small"
|
||||
/>
|
||||
<span class="signal-val" :style="{ color: rssiColor }">{{ getPropertyValue('rssi', '-') }}dBm</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<!-- 电量 + 刷新 -->
|
||||
<Col :xs="24" :sm="24" :md="5">
|
||||
<div class="status-row">
|
||||
<div class="battery-info">
|
||||
<IconifyIcon icon="mdi:battery" :style="{ color: batteryColor }" />
|
||||
<span :style="{ color: batteryColor }">{{ getPropertyValue('battery_tx', 0) }}%</span>
|
||||
</div>
|
||||
<div class="update-info">
|
||||
<span class="update-time" v-if="lastUpdateTime">{{ formatDateTime(lastUpdateTime) }}</span>
|
||||
<Button size="small" @click="fetchProperties" :loading="loading">
|
||||
<template #icon><IconifyIcon icon="mdi:refresh" /></template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 流量对比条 -->
|
||||
<div class="flow-bar-section">
|
||||
<div class="flow-bar">
|
||||
<div
|
||||
class="flow-in"
|
||||
:style="{ flex: Number(getPropertyValue('people_in', 0)) || 1 }"
|
||||
>
|
||||
<span>{{ getPropertyValue('people_in', 0) }}</span>
|
||||
</div>
|
||||
<div class="flow-divider">
|
||||
<IconifyIcon icon="mdi:swap-horizontal" />
|
||||
</div>
|
||||
<div
|
||||
class="flow-out"
|
||||
:style="{ flex: Number(getPropertyValue('people_out', 0)) || 1 }"
|
||||
>
|
||||
<span>{{ getPropertyValue('people_out', 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.people-counter-wrapper {
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #f0fff4 0%, #e6fffb 100%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
height: 64px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-item.in .stat-icon {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.stat-item.out .stat-icon {
|
||||
background: #fff1f0;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.stat-item.net .stat-icon {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.stat-item.signal .stat-icon {
|
||||
background: #f0f5ff;
|
||||
color: #2f54eb;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.stat-item.in .stat-value {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.stat-item.out .stat-value {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.stat-value.positive {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.stat-value.negative {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.signal-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.signal-val {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 64px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.battery-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.update-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.update-time {
|
||||
font-size: 11px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.flow-bar-section {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.flow-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.flow-in,
|
||||
.flow-out {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-width: 50px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
transition: flex 0.5s ease;
|
||||
}
|
||||
|
||||
.flow-in {
|
||||
background: linear-gradient(90deg, #52c41a, #73d13d);
|
||||
}
|
||||
|
||||
.flow-out {
|
||||
background: linear-gradient(90deg, #ff7875, #ff4d4f);
|
||||
}
|
||||
|
||||
.flow-divider {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: #8c8c8c;
|
||||
flex-shrink: 0;
|
||||
margin: 0 -16px;
|
||||
z-index: 1;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,489 @@
|
||||
<!-- 智能工牌实时数据卡片 ProductKey: AOQwO9pJWKgfFTk4 -->
|
||||
<script setup lang="ts">
|
||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||
import type { ThingModelData } from '#/api/iot/thingmodel';
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { formatDateTime } from '@vben/utils';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Input,
|
||||
message,
|
||||
Modal,
|
||||
Progress,
|
||||
Row,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
getDeviceMessagePairPage,
|
||||
getLatestDeviceProperties,
|
||||
sendDeviceMessage,
|
||||
} from '#/api/iot/device/device';
|
||||
import { IotDeviceMessageMethodEnum } from '#/views/iot/utils/constants';
|
||||
|
||||
const props = defineProps<{
|
||||
deviceId: number;
|
||||
thingModelList: ThingModelData[];
|
||||
}>();
|
||||
|
||||
// 数据状态
|
||||
const loading = ref(false);
|
||||
const properties = ref<Record<string, any>>({});
|
||||
const buttonEvents = ref<any[]>([]);
|
||||
const lastUpdateTime = ref<Date | null>(null);
|
||||
|
||||
// TTS 相关
|
||||
const ttsModalVisible = ref(false);
|
||||
const ttsText = ref('');
|
||||
const ttsSending = ref(false);
|
||||
|
||||
// 轮询定时器
|
||||
let pollTimer: any = null;
|
||||
const POLL_INTERVAL = 5000;
|
||||
|
||||
/** 获取属性值 */
|
||||
function getPropertyValue(identifier: string, defaultValue: any = '-') {
|
||||
const prop = properties.value[identifier];
|
||||
return prop?.value ?? defaultValue;
|
||||
}
|
||||
|
||||
/** 获取电量颜色 */
|
||||
const batteryColor = computed(() => {
|
||||
const level = Number(getPropertyValue('batteryLevel', 0));
|
||||
if (level <= 20) return '#ff4d4f';
|
||||
if (level <= 50) return '#faad14';
|
||||
return '#52c41a';
|
||||
});
|
||||
|
||||
/** 解析蓝牙信标数据 */
|
||||
const bluetoothDevices = computed(() => {
|
||||
const value = getPropertyValue('bluetoothDevices', null);
|
||||
if (!value) return [];
|
||||
try {
|
||||
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
/** 获取信号强度颜色 */
|
||||
function getRssiColor(rssi: number) {
|
||||
const absRssi = Math.abs(rssi);
|
||||
if (absRssi <= 50) return '#52c41a';
|
||||
if (absRssi <= 70) return '#1890ff';
|
||||
if (absRssi <= 85) return '#faad14';
|
||||
return '#ff4d4f';
|
||||
}
|
||||
|
||||
/** 获取设备属性最新数据 */
|
||||
async function fetchProperties() {
|
||||
try {
|
||||
const data = await getLatestDeviceProperties({ deviceId: props.deviceId });
|
||||
const propMap: Record<string, any> = {};
|
||||
data.forEach((item: IotDeviceApi.DevicePropertyDetail) => {
|
||||
propMap[item.identifier] = item;
|
||||
});
|
||||
properties.value = propMap;
|
||||
lastUpdateTime.value = new Date();
|
||||
} catch (error) {
|
||||
console.error('获取设备属性失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取按键事件列表 */
|
||||
async function fetchButtonEvents() {
|
||||
try {
|
||||
const data = await getDeviceMessagePairPage({
|
||||
deviceId: props.deviceId,
|
||||
method: IotDeviceMessageMethodEnum.EVENT_POST.method,
|
||||
identifier: 'button_event',
|
||||
pageNo: 1,
|
||||
pageSize: 5,
|
||||
});
|
||||
buttonEvents.value = data.list || [];
|
||||
} catch (error) {
|
||||
console.error('获取按键事件失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取所有数据 */
|
||||
async function fetchAllData() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await Promise.all([fetchProperties(), fetchButtonEvents()]);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 开始轮询 */
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
pollTimer = setInterval(() => {
|
||||
fetchAllData();
|
||||
}, POLL_INTERVAL);
|
||||
}
|
||||
|
||||
/** 停止轮询 */
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 发送TTS语音播报 */
|
||||
async function sendTTS() {
|
||||
if (!ttsText.value.trim()) {
|
||||
message.warning('请输入播报内容');
|
||||
return;
|
||||
}
|
||||
ttsSending.value = true;
|
||||
try {
|
||||
await sendDeviceMessage({
|
||||
deviceId: props.deviceId,
|
||||
method: IotDeviceMessageMethodEnum.SERVICE_INVOKE.method,
|
||||
params: {
|
||||
identifier: 'TTS',
|
||||
params: { text: ttsText.value },
|
||||
},
|
||||
});
|
||||
message.success('语音播报已发送');
|
||||
ttsModalVisible.value = false;
|
||||
ttsText.value = '';
|
||||
} catch {
|
||||
message.error('发送失败');
|
||||
} finally {
|
||||
ttsSending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析按键事件参数 */
|
||||
function parseButtonEvent(params: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(params);
|
||||
const data = parsed.params || parsed;
|
||||
const keyState = data.keyState === 1 ? '短按' : '长按';
|
||||
const keyId = data.keyId || '-';
|
||||
return { keyState, keyId };
|
||||
} catch {
|
||||
return { keyState: '-', keyId: '-' };
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取事件时间 */
|
||||
function getEventTime(event: any) {
|
||||
return event.request?.reportTime ? formatDateTime(event.request.reportTime) : '-';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAllData();
|
||||
startPolling();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="smart-badge-wrapper">
|
||||
<Row :gutter="12">
|
||||
<!-- 位置信息 -->
|
||||
<Col :xs="12" :sm="6" :md="4">
|
||||
<div class="data-item">
|
||||
<div class="data-icon location">
|
||||
<IconifyIcon icon="mdi:map-marker" />
|
||||
</div>
|
||||
<div class="data-content">
|
||||
<div class="data-label">位置</div>
|
||||
<div class="data-value small">
|
||||
{{ getPropertyValue('latitude') }}, {{ getPropertyValue('longitude') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<!-- 电量 -->
|
||||
<Col :xs="12" :sm="6" :md="4">
|
||||
<div class="data-item">
|
||||
<div class="data-icon battery" :style="{ background: batteryColor + '20', color: batteryColor }">
|
||||
<IconifyIcon icon="mdi:battery" />
|
||||
</div>
|
||||
<div class="data-content">
|
||||
<div class="data-label">电量</div>
|
||||
<div class="data-value">
|
||||
<span :style="{ color: batteryColor }">{{ getPropertyValue('batteryLevel', 0) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<!-- 蓝牙信标数量 -->
|
||||
<Col :xs="12" :sm="6" :md="4">
|
||||
<div class="data-item">
|
||||
<div class="data-icon bluetooth">
|
||||
<IconifyIcon icon="mdi:bluetooth" />
|
||||
</div>
|
||||
<div class="data-content">
|
||||
<div class="data-label">蓝牙信标</div>
|
||||
<div class="data-value">{{ bluetoothDevices.length }} 个</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<!-- TTS播报按钮 -->
|
||||
<Col :xs="12" :sm="6" :md="4">
|
||||
<div class="data-item action-item" @click="ttsModalVisible = true">
|
||||
<div class="data-icon tts">
|
||||
<IconifyIcon icon="mdi:volume-high" />
|
||||
</div>
|
||||
<div class="data-content">
|
||||
<div class="data-label">语音播报</div>
|
||||
<div class="data-value action">点击发送</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<Col :xs="24" :sm="24" :md="8">
|
||||
<div class="refresh-section">
|
||||
<span class="update-time" v-if="lastUpdateTime">
|
||||
<IconifyIcon icon="mdi:clock-outline" />
|
||||
{{ formatDateTime(lastUpdateTime) }}
|
||||
</span>
|
||||
<Button size="small" @click="fetchAllData" :loading="loading">
|
||||
<template #icon><IconifyIcon icon="mdi:refresh" /></template>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 蓝牙信标详情 -->
|
||||
<div v-if="bluetoothDevices.length > 0" class="bluetooth-section">
|
||||
<div class="section-title">蓝牙信标</div>
|
||||
<div class="bluetooth-list">
|
||||
<div v-for="(device, index) in bluetoothDevices" :key="index" class="bluetooth-item">
|
||||
<span class="bt-mac">{{ device.mac }}</span>
|
||||
<div class="bt-rssi">
|
||||
<Progress
|
||||
:percent="Math.max(0, 100 + device.rssi)"
|
||||
:stroke-color="getRssiColor(device.rssi)"
|
||||
:show-info="false"
|
||||
size="small"
|
||||
style="width: 50px"
|
||||
/>
|
||||
<span class="rssi-val" :style="{ color: getRssiColor(device.rssi) }">{{ device.rssi }}dBm</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按键事件 -->
|
||||
<div v-if="buttonEvents.length > 0" class="event-section">
|
||||
<div class="section-title">按键事件</div>
|
||||
<div class="event-list">
|
||||
<div v-for="(event, index) in buttonEvents" :key="index" class="event-item">
|
||||
<span class="event-time">{{ getEventTime(event) }}</span>
|
||||
<Tag :color="parseButtonEvent(event.request?.params || '{}').keyState === '短按' ? 'blue' : 'orange'">
|
||||
{{ parseButtonEvent(event.request?.params || '{}').keyState }}
|
||||
</Tag>
|
||||
<span class="event-key">按键{{ parseButtonEvent(event.request?.params || '{}').keyId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TTS 弹窗 -->
|
||||
<Modal v-model:open="ttsModalVisible" title="语音播报" :confirm-loading="ttsSending" @ok="sendTTS" width="400px">
|
||||
<Input.TextArea v-model:value="ttsText" :rows="3" placeholder="输入播报内容..." :maxlength="200" show-count />
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.smart-badge-wrapper {
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e4e8ec 100%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.data-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
height: 64px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.data-item:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.action-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-item:hover {
|
||||
background: #f0f5ff;
|
||||
}
|
||||
|
||||
.data-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.data-icon.location {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.data-icon.battery {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.data-icon.bluetooth {
|
||||
background: #f0f5ff;
|
||||
color: #2f54eb;
|
||||
}
|
||||
|
||||
.data-icon.tts {
|
||||
background: #fff7e6;
|
||||
color: #fa8c16;
|
||||
}
|
||||
|
||||
.data-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.data-label {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.data-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.data-value.small {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.data-value.action {
|
||||
color: #1890ff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.refresh-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.update-time {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #595959;
|
||||
margin: 16px 0 8px;
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid #1890ff;
|
||||
}
|
||||
|
||||
.bluetooth-section,
|
||||
.event-section {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.bluetooth-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bluetooth-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: #fafafa;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bt-mac {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.bt-rssi {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rssi-val {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
background: #fafafa;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
color: #8c8c8c;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.event-key {
|
||||
color: #595959;
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@ import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
import type { ThingModelData } from '#/api/iot/thingmodel';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
@@ -21,9 +21,17 @@ import DeviceDetailsMessage from './device-details-message.vue';
|
||||
import DeviceDetailsSimulator from './device-details-simulator.vue';
|
||||
import DeviceDetailsSubDevice from './device-details-sub-device.vue';
|
||||
import DeviceDetailsThingModel from './device-details-thing-model.vue';
|
||||
import DeviceRealtimePeopleCounter from './device-realtime-people-counter.vue';
|
||||
import DeviceRealtimeSmartBadge from './device-realtime-smart-badge.vue';
|
||||
|
||||
defineOptions({ name: 'IoTDeviceDetail' });
|
||||
|
||||
/** 产品Key常量 */
|
||||
const PRODUCT_KEYS = {
|
||||
SMART_BADGE: 'AOQwO9pJWKgfFTk4', // 智能工牌
|
||||
PEOPLE_COUNTER: '82Zr08RUnstRHRO2', // 客流计数器
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -34,6 +42,21 @@ const device = ref<IotDeviceApi.Device>({} as IotDeviceApi.Device);
|
||||
const activeTab = ref('info');
|
||||
const thingModelList = ref<ThingModelData[]>([]);
|
||||
|
||||
/** 是否显示智能工牌卡片 */
|
||||
const showSmartBadgeCard = computed(() => {
|
||||
return product.value?.productKey === PRODUCT_KEYS.SMART_BADGE;
|
||||
});
|
||||
|
||||
/** 是否显示客流计数器卡片 */
|
||||
const showPeopleCounterCard = computed(() => {
|
||||
return product.value?.productKey === PRODUCT_KEYS.PEOPLE_COUNTER;
|
||||
});
|
||||
|
||||
/** 是否有实时数据卡片 */
|
||||
const hasRealtimeCard = computed(() => {
|
||||
return showSmartBadgeCard.value || showPeopleCounterCard.value;
|
||||
});
|
||||
|
||||
/** 获取设备详情 */
|
||||
async function getDeviceData(deviceId: number) {
|
||||
loading.value = true;
|
||||
@@ -95,6 +118,18 @@ onMounted(async () => {
|
||||
/>
|
||||
|
||||
<Tabs v-model:active-key="activeTab" class="mt-4">
|
||||
<!-- 设备卡片Tab - 仅对特定产品显示 -->
|
||||
<Tabs.TabPane v-if="hasRealtimeCard" key="card" tab="设备卡片">
|
||||
<DeviceRealtimeSmartBadge
|
||||
v-if="activeTab === 'card' && showSmartBadgeCard && device.id"
|
||||
:device-id="device.id"
|
||||
:thing-model-list="thingModelList"
|
||||
/>
|
||||
<DeviceRealtimePeopleCounter
|
||||
v-if="activeTab === 'card' && showPeopleCounterCard && device.id"
|
||||
:device-id="device.id"
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="info" tab="设备信息">
|
||||
<DeviceDetailsInfo
|
||||
v-if="activeTab === 'info'"
|
||||
|
||||
Reference in New Issue
Block a user