feat(@vben/web-antd): 新增区域设备可视化管理页面
左侧区域树 + 右侧设备卡片网格布局,点击卡片打开详情 Drawer, 支持设备基础信息查看、类型化实时数据展示(客流趋势/工牌状态)、 业务配置编辑及设备绑定/解绑操作。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
311
apps/web-antd/src/views/ops/area-device/index.vue
Normal file
311
apps/web-antd/src/views/ops/area-device/index.vue
Normal 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>
|
||||
260
apps/web-antd/src/views/ops/area-device/modules/device-card.vue
Normal file
260
apps/web-antd/src/views/ops/area-device/modules/device-card.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user