feat(@vben/web-antd): 新增区域设备可视化管理页面

左侧区域树 + 右侧设备卡片网格布局,点击卡片打开详情 Drawer,
支持设备基础信息查看、类型化实时数据展示(客流趋势/工牌状态)、
业务配置编辑及设备绑定/解绑操作。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lzh
2026-03-08 00:14:04 +08:00
parent f6f495fd25
commit 975bf975b9
5 changed files with 1443 additions and 0 deletions

View File

@@ -21,6 +21,16 @@ const routes: RouteRecordRaw[] = [
},
component: () => import('#/views/ops/area/index.vue'),
},
// 区域设备管理
{
path: 'area-device',
name: 'OpsAreaDevice',
meta: {
title: '区域设备管理',
activePath: '/ops/area-device',
},
component: () => import('#/views/ops/area-device/index.vue'),
},
// 客流统计
{
path: 'traffic',

View File

@@ -0,0 +1,311 @@
<script setup lang="ts">
import type { OpsAreaApi } from '#/api/ops/area';
import { ref } from 'vue';
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
import {
Button,
Card,
Col,
Empty,
message,
Modal,
Row,
Spin,
} from 'ant-design-vue';
import { getDevice } from '#/api/iot/device/device';
import { getDeviceRelationList, removeDeviceRelation } from '#/api/ops/area';
import DeviceSelectModal from '../area/modules/device-select-modal.vue';
import AreaTree from '../components/AreaTree.vue';
import DeviceCard from './modules/device-card.vue';
import DeviceDetailDrawer from './modules/device-detail-drawer.vue';
defineOptions({ name: 'OpsAreaDevice' });
// ========== Area tree ==========
const areaTreeRef = ref<InstanceType<typeof AreaTree>>();
const selectedArea = ref<null | OpsAreaApi.BusArea>(null);
const selectedAreaPath = ref('');
function handleAreaSelect(area: null | OpsAreaApi.BusArea) {
if (area && area.id !== null && area.id !== undefined) {
selectedArea.value = area;
selectedAreaPath.value =
areaTreeRef.value?.getAreaPath(area.id) || area.areaName;
fetchDeviceList(area.id);
} else {
selectedArea.value = null;
selectedAreaPath.value = '';
deviceList.value = [];
}
}
// ========== Device list ==========
const deviceList = ref<OpsAreaApi.AreaDeviceRelation[]>([]);
const deviceStateMap = ref<Record<number, number>>({});
const loading = ref(false);
async function fetchDeviceList(areaId: number) {
loading.value = true;
try {
deviceList.value = await getDeviceRelationList(areaId);
fetchDeviceStates();
} catch {
deviceList.value = [];
} finally {
loading.value = false;
}
}
/** Batch-fetch device online/offline state (concurrency limited to 5) */
async function fetchDeviceStates() {
const list = [...deviceList.value];
const batchSize = 5;
for (let i = 0; i < list.length; i += batchSize) {
const batch = list.slice(i, i + batchSize);
const results = await Promise.allSettled(
batch.map(async (d) => {
const device = await getDevice(d.deviceId);
deviceStateMap.value[d.deviceId] = device.state ?? -1;
}),
);
results.forEach((r) => {
if (r.status === 'rejected') {
console.error('[fetchDeviceStates] 获取设备状态失败:', r.reason);
}
});
}
}
function refreshList() {
if (selectedArea.value?.id) {
fetchDeviceList(selectedArea.value.id);
}
}
// ========== Add device modal ==========
const [DeviceModal, deviceModalApi] = useVbenModal({
connectedComponent: DeviceSelectModal,
});
function handleAddDevice() {
if (!selectedArea.value?.id) {
message.warning('请先选择一个区域');
return;
}
deviceModalApi.setData({ areaId: selectedArea.value.id });
deviceModalApi.open();
}
// ========== Device detail drawer ==========
const [DetailDrawer, detailDrawerApi] = useVbenDrawer({
connectedComponent: DeviceDetailDrawer,
});
function handleCardClick(relation: OpsAreaApi.AreaDeviceRelation) {
detailDrawerApi.setData(relation);
detailDrawerApi.open();
}
// ========== Unbind (with confirmation) ==========
function handleUnbind(relation: OpsAreaApi.AreaDeviceRelation) {
if (!relation.id) return;
Modal.confirm({
title: '确认解除绑定',
content: `确定要解除设备「${relation.nickname || relation.deviceName || ''}」的绑定吗?`,
okText: '确定',
cancelText: '取消',
okButtonProps: { danger: true },
async onOk() {
await removeDeviceRelation(relation.id!);
message.success('已解除绑定');
refreshList();
},
});
}
</script>
<template>
<div class="area-device-page">
<Row :gutter="12" class="layout-row">
<!-- Left: Area tree -->
<Col :xs="24" :sm="24" :md="6" :lg="5" :xl="4" class="tree-col">
<Card class="tree-card" title="业务区域">
<AreaTree ref="areaTreeRef" @select="handleAreaSelect" />
</Card>
</Col>
<!-- Right: Device grid -->
<Col :xs="24" :sm="24" :md="18" :lg="19" :xl="20">
<Spin :spinning="loading">
<!-- Header -->
<div class="content-header mb-3">
<div class="content-header__left">
<span class="content-title">
{{ selectedAreaPath || '请选择区域' }}
</span>
<span v-if="selectedArea" class="content-subtitle">
- 区域设备 ({{ deviceList.length }})
</span>
</div>
<Button v-if="selectedArea" type="primary" @click="handleAddDevice">
+ 添加设备
</Button>
</div>
<!-- Empty state: no area selected -->
<Card v-if="!selectedArea" class="empty-card">
<Empty description="请在左侧选择一个区域以查看设备" />
</Card>
<!-- Empty state: no devices -->
<Card
v-else-if="deviceList.length === 0 && !loading"
class="empty-card"
>
<Empty description="该区域暂无绑定设备">
<Button type="primary" @click="handleAddDevice">
添加设备
</Button>
</Empty>
</Card>
<!-- Device cards grid -->
<Row v-else :gutter="[12, 12]">
<Col
v-for="item in deviceList"
:key="item.id"
:xs="24"
:sm="12"
:md="12"
:lg="8"
:xl="6"
>
<DeviceCard
:relation="item"
:device-state="deviceStateMap[item.deviceId]"
@click="handleCardClick"
@unbind="handleUnbind"
/>
</Col>
</Row>
</Spin>
</Col>
</Row>
<!-- Modals & Drawers -->
<DeviceModal @success="refreshList" />
<DetailDrawer @success="refreshList" />
</div>
</template>
<style scoped lang="scss">
@media (max-width: 768px) {
.area-device-page {
padding: 8px;
}
.layout-row {
flex-direction: column;
}
.tree-col {
margin-bottom: 12px;
}
.tree-card {
:deep(.ant-card-body) {
max-height: 200px;
}
}
}
.area-device-page {
padding: 16px;
}
.layout-row {
display: flex;
flex-wrap: wrap;
}
.tree-col {
display: flex;
}
.tree-card {
display: flex;
flex-direction: column;
width: 100%;
border-radius: 8px;
:deep(.ant-card-head) {
min-height: 44px;
padding: 0 16px;
.ant-card-head-title {
padding: 12px 0;
font-size: 14px;
font-weight: 600;
}
}
:deep(.ant-card-body) {
flex: 1;
padding: 12px 16px;
overflow-y: auto;
}
:deep(.ant-tree) {
background: transparent;
}
}
.content-header {
display: flex;
align-items: center;
justify-content: space-between;
&__left {
display: flex;
gap: 8px;
align-items: baseline;
}
}
.content-title {
font-size: 16px;
font-weight: 600;
color: #262626;
}
.content-subtitle {
font-size: 13px;
color: #8c8c8c;
}
.empty-card {
border-radius: 8px;
:deep(.ant-card-body) {
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
}
}
html.dark {
.content-title {
color: rgb(255 255 255 / 85%);
}
.content-subtitle {
color: rgb(255 255 255 / 45%);
}
}
</style>

View File

@@ -0,0 +1,260 @@
<script setup lang="ts">
import type { OpsAreaApi } from '#/api/ops/area';
import { computed } from 'vue';
import { Badge, Card, Dropdown, Menu } from 'ant-design-vue';
import { RELATION_TYPE_COLORS, RELATION_TYPE_OPTIONS } from '../../area/data';
const props = defineProps<{
deviceState?: number;
relation: OpsAreaApi.AreaDeviceRelation;
}>();
const emit = defineEmits<{
(e: 'click', relation: OpsAreaApi.AreaDeviceRelation): void;
(e: 'unbind', relation: OpsAreaApi.AreaDeviceRelation): void;
}>();
const nicknameLabel = computed(() => {
return props.relation.nickname || '--';
});
const deviceNameLabel = computed(() => {
return props.relation.deviceName || props.relation.deviceKey || '--';
});
const productLabel = computed(() => {
return props.relation.productName || props.relation.productKey || '--';
});
const relationLabel = computed(() => {
return (
RELATION_TYPE_OPTIONS.find((o) => o.value === props.relation.relationType)
?.label ??
props.relation.relationType ??
'--'
);
});
const relationTagColor = computed(() => {
return (
RELATION_TYPE_COLORS[props.relation.relationType ?? ''] ?? {
bg: '#f5f5f5',
text: '#8c8c8c',
}
);
});
function handleCardClick() {
emit('click', props.relation);
}
function handleMenuClick({ key }: { key: string }) {
if (key === 'detail') {
emit('click', props.relation);
} else if (key === 'unbind') {
emit('unbind', props.relation);
}
}
</script>
<template>
<Card
class="device-card"
hoverable
:body-style="{ padding: '0' }"
@click="handleCardClick"
>
<div class="device-card__body">
<!-- Header: nickname + type badge + dropdown -->
<div class="device-card__header">
<span class="device-card__name" :title="nicknameLabel">
{{ nicknameLabel }}
</span>
<div class="device-card__header-right">
<span
class="device-card__type-badge"
:style="{
background: relationTagColor.bg,
color: relationTagColor.text,
}"
>
{{ relationLabel }}
</span>
<Dropdown :trigger="['click']">
<span class="device-card__more" @click.stop>...</span>
<template #overlay>
<Menu @click="handleMenuClick">
<Menu.Item key="detail">查看详情</Menu.Item>
<Menu.Item key="unbind" danger>解除绑定</Menu.Item>
</Menu>
</template>
</Dropdown>
</div>
</div>
<!-- Key info: deviceName + product -->
<div class="device-card__info">
<div class="device-card__info-row">
<span class="device-card__info-label">设备标识</span>
<span class="device-card__info-value">{{ deviceNameLabel }}</span>
</div>
<div class="device-card__info-row">
<span class="device-card__info-label">产品所属</span>
<span class="device-card__info-value">{{ productLabel }}</span>
</div>
</div>
<!-- Footer: device state + enabled status -->
<div class="device-card__footer">
<Badge
:status="
props.deviceState === 1
? 'success'
: props.deviceState === 0
? 'error'
: 'default'
"
:text="
props.deviceState === 1
? '在线'
: props.deviceState === 0
? '离线'
: '未知'
"
/>
<span class="device-card__footer-sep">|</span>
<Badge
:status="relation.enabled ? 'success' : 'error'"
:text="relation.enabled ? '启用' : '停用'"
/>
</div>
</div>
</Card>
</template>
<style scoped lang="scss">
.device-card {
overflow: hidden;
cursor: pointer;
border-radius: 8px;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 16px rgb(0 0 0 / 8%);
transform: translateY(-2px);
}
&__body {
padding: 16px;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
&__name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
font-size: 15px;
font-weight: 600;
color: #262626;
white-space: nowrap;
}
&__header-right {
display: flex;
flex-shrink: 0;
gap: 6px;
align-items: center;
}
&__type-badge {
padding: 2px 10px;
font-size: 12px;
font-weight: 600;
line-height: 20px;
white-space: nowrap;
border-radius: 10px;
}
&__more {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
font-size: 16px;
font-weight: bold;
color: #8c8c8c;
letter-spacing: 1px;
cursor: pointer;
border-radius: 4px;
&:hover {
color: #1677ff;
background: #f0f0f0;
}
}
&__info {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
font-size: 13px;
}
&__info-row {
display: flex;
gap: 8px;
align-items: center;
}
&__info-label {
flex-shrink: 0;
color: #8c8c8c;
}
&__info-value {
overflow: hidden;
text-overflow: ellipsis;
color: #595959;
white-space: nowrap;
}
&__footer {
display: flex;
gap: 8px;
align-items: center;
}
&__footer-sep {
color: #d9d9d9;
}
}
html.dark {
.device-card__name {
color: rgb(255 255 255 / 85%);
}
.device-card__info-label {
color: rgb(255 255 255 / 45%);
}
.device-card__info-value {
color: rgb(255 255 255 / 65%);
}
.device-card__more:hover {
background: rgb(255 255 255 / 8%);
}
}
</style>

View File

@@ -0,0 +1,845 @@
<script setup lang="ts">
import type { ECOption } from '@vben/plugins/echarts';
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { OpsAreaApi } from '#/api/ops/area';
import type { DeviceTrafficRealtimeResp } from '#/api/ops/traffic';
import { computed, nextTick, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { CodeEditor } from '@vben/plugins/code-editor';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { formatDateTime } from '@vben/utils';
import {
Badge,
Button,
Card,
Descriptions,
Empty,
message,
Popconfirm,
Progress,
Spin,
Switch,
Table,
Tag,
} from 'ant-design-vue';
import { getDevice, getLatestDeviceProperties } from '#/api/iot/device/device';
import { removeDeviceRelation, updateDeviceRelation } from '#/api/ops/area';
import { getDeviceRealtime } from '#/api/ops/traffic';
import {
RELATION_TYPE_OPTIONS,
RELATION_TYPE_TAG_COLORS,
} from '../../area/data';
const emit = defineEmits<{
(e: 'success'): void;
}>();
const relation = ref<null | OpsAreaApi.AreaDeviceRelation>(null);
const deviceInfo = ref<IotDeviceApi.Device | null>(null);
const properties = ref<IotDeviceApi.DevicePropertyDetail[]>([]);
const loading = ref(false);
const realtimeLoading = ref(false);
const saveLoading = ref(false);
// Relation type helpers
const isTraffic = computed(
() => relation.value?.relationType === 'TRAFFIC_COUNTER',
);
const isBadge = computed(() => relation.value?.relationType === 'BADGE');
// ========== Traffic data ==========
const trafficData = ref<DeviceTrafficRealtimeResp | null>(null);
const hourlyChartRef = ref();
const { renderEcharts: renderHourlyChart } = useEcharts(hourlyChartRef);
function getHourlyChartOptions(): ECOption {
const data = trafficData.value;
if (!data?.hourlyTrend?.hours?.length) return {};
const hourly = data.hourlyTrend;
return {
tooltip: { trigger: 'axis' },
legend: {
data: ['进入', '离开'],
top: '2%',
textStyle: { fontSize: 11 },
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '18%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: hourly.hours,
axisLine: { lineStyle: { color: '#d9d9d9' } },
axisLabel: { color: '#8c8c8c', fontSize: 10 },
},
yAxis: {
type: 'value',
name: '人次',
nameTextStyle: { color: '#8c8c8c', fontSize: 11 },
axisLine: { show: false },
axisLabel: { color: '#8c8c8c', fontSize: 10 },
splitLine: { lineStyle: { color: '#f0f0f0', type: 'dashed' } },
},
series: [
{
name: '进入',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 4,
data: hourly.inData,
lineStyle: { width: 2, color: '#52c41a' },
itemStyle: { color: '#52c41a' },
areaStyle: {
opacity: 0.12,
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(82,196,26,0.3)' },
{ offset: 1, color: 'rgba(82,196,26,0.02)' },
],
},
},
},
{
name: '离开',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 4,
data: hourly.outData,
lineStyle: { width: 2, color: '#ff4d4f' },
itemStyle: { color: '#ff4d4f' },
areaStyle: {
opacity: 0.12,
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(255,77,79,0.25)' },
{ offset: 1, color: 'rgba(255,77,79,0.02)' },
],
},
},
},
],
};
}
// ========== Badge data ==========
interface BadgePropItem {
value?: number | string;
name?: string;
identifier?: string;
updateTime?: Date | number | string;
}
const badgeProps = ref<Record<string, BadgePropItem>>({});
const batteryLevel = computed(
() => badgeProps.value.batteryLevel?.value ?? '--',
);
const batteryColor = computed(() => {
const level = Number(batteryLevel.value);
if (Number.isNaN(level)) return '#8c8c8c';
if (level <= 20) return '#ff4d4f';
if (level <= 50) return '#faad14';
return '#52c41a';
});
const bluetoothDevices = computed(() => {
const val = badgeProps.value.bluetoothDevices?.value;
if (!val) return [];
try {
const parsed = typeof val === 'string' ? JSON.parse(val) : val;
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
});
function getRssiColor(rssi: number) {
const abs = Math.abs(rssi);
if (abs <= 50) return '#52c41a';
if (abs <= 70) return '#1890ff';
if (abs <= 85) return '#faad14';
return '#ff4d4f';
}
// Business config form state
const configEnabled = ref(true);
const configDataStr = ref('{}');
const configJsonError = ref(false);
const isDrawerOpen = ref(false);
function handleCodeChange(val: string) {
configDataStr.value = val;
// 实时校验 JSON 格式
try {
JSON.parse(val);
configJsonError.value = false;
} catch {
configJsonError.value = true;
}
}
const [Drawer, drawerApi] = useVbenDrawer({
onOpenChange: async (isOpen: boolean) => {
isDrawerOpen.value = isOpen;
if (isOpen) {
const data = drawerApi.getData<OpsAreaApi.AreaDeviceRelation>();
if (data) {
relation.value = data;
configEnabled.value = data.enabled ?? true;
configDataStr.value =
data.configData && Object.keys(data.configData).length > 0
? JSON.stringify(data.configData, null, 2)
: '{\n \n}';
configJsonError.value = false;
await loadDeviceDetail(data.deviceId, data.relationType);
}
} else {
relation.value = null;
deviceInfo.value = null;
properties.value = [];
trafficData.value = null;
badgeProps.value = {};
}
},
});
async function loadDeviceDetail(deviceId: number, relationType: string) {
loading.value = true;
realtimeLoading.value = true;
try {
// Always fetch basic device info
const devicePromise = getDevice(deviceId);
// Fetch type-specific realtime data
let realtimePromise: Promise<void>;
if (relationType === 'TRAFFIC_COUNTER') {
realtimePromise = getDeviceRealtime(deviceId)
.then(async (data) => {
trafficData.value = data;
await nextTick();
renderHourlyChart(getHourlyChartOptions());
})
.catch(() => {});
} else if (relationType === 'BADGE') {
realtimePromise = getLatestDeviceProperties({ deviceId })
.then((data) => {
const propMap: Record<string, BadgePropItem> = {};
data.forEach((item: IotDeviceApi.DevicePropertyDetail) => {
propMap[item.identifier] = item;
});
badgeProps.value = propMap;
})
.catch(() => {});
} else {
// Generic: load property table
realtimePromise = getLatestDeviceProperties({ deviceId })
.then((data) => {
properties.value = data || [];
})
.catch(() => {
properties.value = [];
});
}
const [device] = await Promise.all([devicePromise, realtimePromise]);
deviceInfo.value = device;
} catch (error) {
console.error('[loadDeviceDetail] 加载设备详情失败:', error);
} finally {
loading.value = false;
realtimeLoading.value = false;
}
}
async function handleRefreshRealtime() {
if (!relation.value) return;
realtimeLoading.value = true;
const { deviceId, relationType } = relation.value;
try {
if (relationType === 'TRAFFIC_COUNTER') {
trafficData.value = await getDeviceRealtime(deviceId);
await nextTick();
renderHourlyChart(getHourlyChartOptions());
} else if (relationType === 'BADGE') {
const data = await getLatestDeviceProperties({ deviceId });
const propMap: Record<string, BadgePropItem> = {};
data.forEach((item: IotDeviceApi.DevicePropertyDetail) => {
propMap[item.identifier] = item;
});
badgeProps.value = propMap;
} else {
properties.value = await getLatestDeviceProperties({ deviceId });
}
} catch {
// silent
} finally {
realtimeLoading.value = false;
}
}
async function handleSaveConfig() {
if (!relation.value?.id) return;
let configData: Record<string, any> = {};
try {
configData = JSON.parse(configDataStr.value);
} catch {
message.error('配置数据 JSON 格式错误');
return;
}
saveLoading.value = true;
try {
await updateDeviceRelation({
id: relation.value.id,
enabled: configEnabled.value,
configData,
});
message.success('保存成功');
emit('success');
} catch (error) {
console.error('[handleSaveConfig] 保存配置失败:', error);
message.error('保存配置失败');
} finally {
saveLoading.value = false;
}
}
async function handleUnbind() {
if (!relation.value?.id) return;
try {
await removeDeviceRelation(relation.value.id);
message.success('已解除绑定');
drawerApi.close();
emit('success');
} catch (error) {
console.error('[handleUnbind] 解除绑定失败:', error);
message.error('解除绑定失败');
}
}
function getRelationLabel(type?: string) {
return (
RELATION_TYPE_OPTIONS.find((o) => o.value === type)?.label ?? type ?? '--'
);
}
function getRelationTagColor(type?: string) {
return RELATION_TYPE_TAG_COLORS[type ?? ''] ?? 'default';
}
function getDeviceStateText(state?: number) {
if (state === 1) return '在线';
if (state === 0) return '离线';
return '未知';
}
function getDeviceStateStatus(state?: number): 'default' | 'error' | 'success' {
if (state === 1) return 'success';
if (state === 0) return 'error';
return 'default';
}
function formatTime(val?: Date | number | string) {
if (!val) return '--';
return formatDateTime(val);
}
const propertyColumns = [
{ title: '属性名称', dataIndex: 'name', key: 'name', width: 140 },
{ title: '标识符', dataIndex: 'identifier', key: 'identifier', width: 120 },
{ title: '最新值', dataIndex: 'value', key: 'value', width: 120 },
{ title: '更新时间', dataIndex: 'updateTime', key: 'updateTime', width: 170 },
];
</script>
<template>
<Drawer title="设备详情" class="device-detail-drawer">
<Spin :spinning="loading">
<!-- Section 1: Basic Info -->
<Card title="基础信息" size="small" class="mb-4">
<Descriptions :column="2" size="small" bordered>
<Descriptions.Item label="设备名称">
{{ deviceInfo?.deviceName || '--' }}
</Descriptions.Item>
<Descriptions.Item label="设备昵称">
{{ deviceInfo?.nickname || '--' }}
</Descriptions.Item>
<Descriptions.Item label="产品">
{{ relation?.productName || '--' }}
</Descriptions.Item>
<Descriptions.Item label="设备 Key">
{{ relation?.deviceKey || '--' }}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Badge
:status="getDeviceStateStatus(deviceInfo?.state)"
:text="getDeviceStateText(deviceInfo?.state)"
/>
</Descriptions.Item>
<Descriptions.Item label="IP 地址">
{{ deviceInfo?.ip || '--' }}
</Descriptions.Item>
<Descriptions.Item label="上线时间" :span="2">
{{ formatTime(deviceInfo?.onlineTime) }}
</Descriptions.Item>
</Descriptions>
</Card>
<!-- Section 2: Realtime Data (type-specific) -->
<Card title="实时数据" size="small" class="mb-4">
<template #extra>
<Button type="link" size="small" @click="handleRefreshRealtime">
刷新
</Button>
</template>
<Spin :spinning="realtimeLoading">
<!-- TRAFFIC_COUNTER: hourly trend chart + stats -->
<template v-if="isTraffic">
<div v-if="trafficData" class="traffic-realtime">
<div class="traffic-stats">
<div class="traffic-stats__item traffic-stats__item--in">
<IconifyIcon icon="mdi:arrow-right-bold" />
<span class="traffic-stats__label">今日进入</span>
<span class="traffic-stats__value">{{
trafficData.todayIn
}}</span>
</div>
<div class="traffic-stats__item traffic-stats__item--out">
<IconifyIcon icon="mdi:arrow-left-bold" />
<span class="traffic-stats__label">今日离开</span>
<span class="traffic-stats__value">{{
trafficData.todayOut
}}</span>
</div>
<div class="traffic-stats__item traffic-stats__item--net">
<IconifyIcon icon="mdi:account-group" />
<span class="traffic-stats__label">在场</span>
<span class="traffic-stats__value">{{
Math.max(0, trafficData.todayIn - trafficData.todayOut)
}}</span>
</div>
</div>
<EchartsUI ref="hourlyChartRef" class="traffic-chart" />
</div>
<Empty v-else description="暂无客流数据" />
</template>
<!-- BADGE: battery + bluetooth beacons -->
<template v-else-if="isBadge">
<div
v-if="Object.keys(badgeProps).length > 0"
class="badge-realtime"
>
<div class="badge-row">
<div class="badge-item">
<div
class="badge-item__icon"
:style="{
color: batteryColor,
background: `${batteryColor}15`,
}"
>
<IconifyIcon icon="mdi:battery" />
</div>
<div class="badge-item__content">
<span class="badge-item__label">电量</span>
<span
class="badge-item__value"
:style="{ color: batteryColor }"
>
{{ batteryLevel }}%
</span>
</div>
</div>
<div class="badge-item">
<div class="badge-item__icon badge-item__icon--bt">
<IconifyIcon icon="mdi:bluetooth" />
</div>
<div class="badge-item__content">
<span class="badge-item__label">蓝牙信标</span>
<span class="badge-item__value">
{{ bluetoothDevices.length }}
</span>
</div>
</div>
</div>
<!-- Beacon list -->
<div v-if="bluetoothDevices.length > 0" class="beacon-list">
<div class="beacon-list__title">信标信号</div>
<div
v-for="(device, index) in bluetoothDevices"
:key="index"
class="beacon-item"
>
<span class="beacon-item__mac">{{ device.mac }}</span>
<div class="beacon-item__signal">
<Progress
:percent="Math.min(100, Math.max(0, 100 + device.rssi))"
:stroke-color="getRssiColor(device.rssi)"
:show-info="false"
size="small"
style="width: 60px"
/>
<span
class="beacon-item__rssi"
:style="{ color: getRssiColor(device.rssi) }"
>
{{ device.rssi }}dBm
</span>
</div>
</div>
</div>
<div v-else class="beacon-empty">
<IconifyIcon icon="mdi:bluetooth-off" />
<span>无信标信号</span>
</div>
</div>
<Empty v-else description="暂无工牌数据" />
</template>
<!-- Generic: property table -->
<template v-else>
<Table
v-if="properties.length > 0"
:columns="propertyColumns"
:data-source="properties"
:pagination="false"
size="small"
row-key="identifier"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'updateTime'">
{{ formatTime(record.updateTime) }}
</template>
</template>
</Table>
<Empty v-else description="暂无属性数据" />
</template>
</Spin>
</Card>
<!-- Section 3: Business Config -->
<Card title="业务配置" size="small">
<div class="config-form">
<div class="config-item">
<span class="config-label">关联类型</span>
<Tag :color="getRelationTagColor(relation?.relationType)">
{{ getRelationLabel(relation?.relationType) }}
</Tag>
</div>
<div class="config-item">
<span class="config-label">启用状态</span>
<Switch v-model:checked="configEnabled" />
</div>
<div class="config-item config-item--block">
<span class="config-label">配置数据 (JSON)</span>
<div
class="mt-2 h-[240px]"
:class="{ 'config-editor-error': configJsonError }"
>
<CodeEditor
v-if="isDrawerOpen"
:value="configDataStr"
:bordered="true"
:auto-format="false"
@change="handleCodeChange"
/>
</div>
<span v-if="configJsonError" class="config-error-hint">
JSON 格式错误
</span>
</div>
<div class="config-actions">
<Button
type="primary"
:loading="saveLoading"
@click="handleSaveConfig"
>
保存配置
</Button>
<Popconfirm
title="确定要解除该设备的绑定吗?"
ok-text="确定"
cancel-text="取消"
@confirm="handleUnbind"
>
<Button danger>解除绑定</Button>
</Popconfirm>
</div>
</div>
</Card>
</Spin>
</Drawer>
</template>
<style scoped lang="scss">
.device-detail-drawer {
width: 560px;
}
// Traffic realtime
.traffic-realtime {
.traffic-stats {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.traffic-stats__item {
display: flex;
flex: 1;
gap: 6px;
align-items: center;
padding: 10px 12px;
font-size: 13px;
background: #fafafa;
border-radius: 6px;
&--in {
color: #52c41a;
}
&--out {
color: #ff4d4f;
}
&--net {
color: #1677ff;
}
}
.traffic-stats__label {
color: #8c8c8c;
}
.traffic-stats__value {
margin-left: auto;
font-size: 18px;
font-weight: 700;
}
.traffic-chart {
height: 220px;
}
}
// Badge realtime
.badge-realtime {
.badge-row {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.badge-item {
display: flex;
flex: 1;
gap: 10px;
align-items: center;
padding: 12px;
background: #fafafa;
border-radius: 8px;
&__icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
font-size: 18px;
border-radius: 8px;
&--bt {
color: #2f54eb;
background: #f0f5ff;
}
}
&__content {
display: flex;
flex-direction: column;
gap: 2px;
}
&__label {
font-size: 12px;
color: #8c8c8c;
}
&__value {
font-size: 18px;
font-weight: 700;
color: #262626;
}
}
}
.beacon-list {
padding: 12px;
background: #fafafa;
border-radius: 8px;
&__title {
padding-left: 8px;
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: #595959;
border-left: 3px solid #2f54eb;
}
}
.beacon-item {
display: flex;
gap: 12px;
align-items: center;
padding: 6px 0;
&__mac {
font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
color: #595959;
}
&__signal {
display: flex;
gap: 8px;
align-items: center;
margin-left: auto;
}
&__rssi {
min-width: 55px;
font-size: 11px;
font-weight: 600;
text-align: right;
}
}
.beacon-empty {
display: flex;
gap: 6px;
align-items: center;
justify-content: center;
padding: 16px;
font-size: 13px;
color: #bfbfbf;
}
// Config form
.config-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.config-item {
display: flex;
gap: 12px;
align-items: center;
&--block {
flex-direction: column;
align-items: stretch;
}
}
.config-label {
min-width: 80px;
font-size: 13px;
font-weight: 500;
color: #595959;
}
.config-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
padding-top: 8px;
border-top: 1px solid #f0f0f0;
}
.config-editor-error {
border: 1px solid #ff4d4f;
border-radius: 4px;
}
.config-error-hint {
font-size: 12px;
color: #ff4d4f;
}
html.dark {
.config-label {
color: rgb(255 255 255 / 65%);
}
.config-actions {
border-color: rgb(255 255 255 / 8%);
}
.config-editor-error {
border-color: #a61d24;
}
.config-error-hint {
color: #a61d24;
}
.traffic-stats__item,
.badge-item,
.beacon-list {
background: rgb(255 255 255 / 4%);
}
.traffic-stats__label,
.badge-item__label {
color: rgb(255 255 255 / 45%);
}
.badge-item__value {
color: rgb(255 255 255 / 85%);
}
.badge-item__icon--bt {
color: #597ef7;
background: rgb(47 84 235 / 15%);
}
.beacon-item__mac {
color: rgb(255 255 255 / 65%);
}
.beacon-list__title {
color: rgb(255 255 255 / 65%);
}
}
</style>

View File

@@ -44,6 +44,23 @@ export const RELATION_TYPE_OPTIONS: { label: string; value: RelationType }[] = [
{ label: '工牌', value: 'BADGE' },
];
/** 关联类型颜色映射(卡片用) */
export const RELATION_TYPE_COLORS: Record<
string,
{ bg: string; text: string }
> = {
TRAFFIC_COUNTER: { bg: '#e6f4ff', text: '#1677ff' },
BEACON: { bg: '#f6ffed', text: '#52c41a' },
BADGE: { bg: '#fff7e6', text: '#fa8c16' },
};
/** 关联类型 Tag 颜色映射Ant Design Tag 用) */
export const RELATION_TYPE_TAG_COLORS: Record<string, string> = {
TRAFFIC_COUNTER: 'blue',
BEACON: 'green',
BADGE: 'orange',
};
/** 启用状态选项(筛选用) */
export const ACTIVE_OPTIONS = [
{ label: '全部', value: undefined },