chore: 添加设备展示实时数据卡片(蓝牙工牌、客流计数器)

This commit is contained in:
lzh
2025-12-28 01:30:24 +08:00
parent d8405aba7d
commit a39d333f44
4 changed files with 917 additions and 3 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'"