仓库传感器详情页
This commit is contained in:
@@ -83,6 +83,19 @@ export const constantRoutes = [
|
||||
meta: { title: '个人中心', icon: 'user' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/worn/warehouse-dashboard',
|
||||
component: Layout,
|
||||
hidden: true,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: () => import('@/views/worn/warehouseDashboard/index'),
|
||||
name: 'WarehouseDashboard',
|
||||
meta: { title: '仓库传感器看板', activeMenu: '/index' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -13,6 +13,14 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="tickerMessage" :key="tickerKey" class="ws-ticker">
|
||||
<div class="ws-ticker-track">
|
||||
<span class="ws-ticker-badge">WebSocket</span>
|
||||
<strong>实时消息</strong>
|
||||
<p>{{ tickerMessage }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stat-grid">
|
||||
<div v-for="item in overviewCards" :key="item.key" :class="['stat-card', item.theme]">
|
||||
<div class="stat-icon">{{ item.icon }}</div>
|
||||
@@ -76,7 +84,12 @@
|
||||
<span>在线率</span>
|
||||
<span>状态</span>
|
||||
</div>
|
||||
<div v-for="item in pagedProjectList" :key="item.key" class="project-row">
|
||||
<div
|
||||
v-for="item in pagedProjectList"
|
||||
:key="item.key"
|
||||
class="project-row project-row-link"
|
||||
@click="openWarehouseDetail(item)"
|
||||
>
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span>{{ item.warehouseName }}</span>
|
||||
<span>{{ item.onlineRate }}</span>
|
||||
@@ -152,6 +165,7 @@
|
||||
|
||||
<script setup name="Index">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { closeWs, connectWs } from '@/utils/ws'
|
||||
import {
|
||||
getHomeAlarmStat,
|
||||
@@ -173,6 +187,8 @@ const overview = reactive({
|
||||
alarmUnhandled: 0
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const exceptionStats = reactive({
|
||||
total: 0
|
||||
})
|
||||
@@ -184,9 +200,13 @@ const projectList = ref([])
|
||||
const realtimeEvents = ref([])
|
||||
const recentEventKeys = new Map()
|
||||
const projectPage = ref(1)
|
||||
const tickerMessage = ref('')
|
||||
const tickerKey = ref(0)
|
||||
let tickerTimer = null
|
||||
|
||||
const DUPLICATE_EVENT_WINDOW = 3000
|
||||
const PROJECT_PAGE_SIZE = 5
|
||||
const TICKER_DURATION = 22000
|
||||
|
||||
const alarmTrend = ref([])
|
||||
|
||||
@@ -223,6 +243,7 @@ function handleWsMessage(data) {
|
||||
}
|
||||
|
||||
pushRealtimeEvent(event)
|
||||
playTicker(event)
|
||||
|
||||
if (event.level === 'alarm' || event.level === 'warning') {
|
||||
pushRealtimeAlarm(event)
|
||||
@@ -305,6 +326,21 @@ function changeProjectPage(step) {
|
||||
projectPage.value = nextPage
|
||||
}
|
||||
|
||||
function openWarehouseDetail(item) {
|
||||
if (!item.deptId) {
|
||||
return
|
||||
}
|
||||
|
||||
router.push({
|
||||
path: '/worn/warehouse-dashboard',
|
||||
query: {
|
||||
deptId: item.deptId,
|
||||
warehouseName: item.warehouseName,
|
||||
projectName: item.name
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeProject(item, index) {
|
||||
const onlineRate = toNumber(item.onlineRate, 0)
|
||||
const name = String(item.parentName || item.projectName || item.name || '未命名项目').trim()
|
||||
@@ -312,6 +348,7 @@ function normalizeProject(item, index) {
|
||||
|
||||
return {
|
||||
key: `${name}-${warehouseName}-${index}`,
|
||||
deptId: item.deptId || item.warehouseId || item.id,
|
||||
name,
|
||||
warehouseName,
|
||||
onlineRate: `${onlineRate}%`,
|
||||
@@ -588,6 +625,25 @@ function pushRealtimeEvent(event) {
|
||||
realtimeEvents.value = realtimeEvents.value.slice(0, STREAM_TOTAL_SIZE)
|
||||
}
|
||||
|
||||
function playTicker(event) {
|
||||
const metrics = event.metrics
|
||||
.slice(0, 4)
|
||||
.map((metric) => `${metric.label}: ${metric.value}`)
|
||||
.join(' ')
|
||||
|
||||
tickerMessage.value = metrics ? `${event.message} ${metrics}` : event.message
|
||||
tickerKey.value += 1
|
||||
|
||||
if (tickerTimer) {
|
||||
clearTimeout(tickerTimer)
|
||||
}
|
||||
|
||||
tickerTimer = setTimeout(() => {
|
||||
tickerMessage.value = ''
|
||||
tickerTimer = null
|
||||
}, TICKER_DURATION)
|
||||
}
|
||||
|
||||
function pushRealtimeAlarm(event) {
|
||||
realtimeAlarms.value.unshift({
|
||||
id: event.id,
|
||||
@@ -603,12 +659,19 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (tickerTimer) {
|
||||
clearTimeout(tickerTimer)
|
||||
tickerTimer = null
|
||||
}
|
||||
closeWs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.home-page {
|
||||
--home-left-column: minmax(520px, 0.88fr);
|
||||
--home-right-column: minmax(680px, 1.12fr);
|
||||
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: calc(100vh - 84px);
|
||||
@@ -764,16 +827,108 @@ onBeforeUnmount(() => {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.ws-ticker {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
overflow: hidden;
|
||||
height: 48px;
|
||||
margin: -4px 0 18px;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(240, 253, 250, 0.94), rgba(239, 246, 255, 0.92)),
|
||||
radial-gradient(circle at 4% 50%, rgba(20, 184, 166, 0.24), transparent 24%);
|
||||
border: 1px solid rgba(20, 184, 166, 0.18);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 14px 34px rgba(15, 118, 110, 0.1);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
width: 96px;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
background: linear-gradient(90deg, rgba(240, 253, 250, 1), transparent);
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: 0;
|
||||
background: linear-gradient(270deg, rgba(239, 246, 255, 1), transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.ws-ticker-track {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: max-content;
|
||||
padding: 0 18px;
|
||||
white-space: nowrap;
|
||||
transform: translate(-110%, -50%);
|
||||
animation: wsTickerMove 22s linear forwards;
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
.ws-ticker-badge {
|
||||
padding: 5px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.12em;
|
||||
color: #0f766e;
|
||||
text-transform: uppercase;
|
||||
background: rgba(204, 251, 241, 0.9);
|
||||
border: 1px solid rgba(20, 184, 166, 0.24);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@keyframes wsTickerMove {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-110%, -50%);
|
||||
}
|
||||
|
||||
10%,
|
||||
82% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(calc(100vw + 40px), -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(430px, 0.88fr) minmax(520px, 1.12fr);
|
||||
grid-template-columns: var(--home-left-column) var(--home-right-column);
|
||||
align-items: stretch;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 22px;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border: 1px solid rgba(255, 255, 255, 0.92);
|
||||
@@ -782,6 +937,11 @@ onBeforeUnmount(() => {
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.alarm-panel,
|
||||
.project-panel {
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -825,6 +985,7 @@ onBeforeUnmount(() => {
|
||||
grid-template-columns: 1.35fr 0.65fr;
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
min-height: 184px;
|
||||
}
|
||||
|
||||
.trend-card,
|
||||
@@ -837,6 +998,9 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.trend-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding: 18px;
|
||||
|
||||
p {
|
||||
@@ -870,6 +1034,7 @@ onBeforeUnmount(() => {
|
||||
.donut-card {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 184px;
|
||||
}
|
||||
|
||||
.donut {
|
||||
@@ -909,6 +1074,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.alarm-list {
|
||||
flex: 1;
|
||||
min-height: 118px;
|
||||
padding: 16px;
|
||||
|
||||
@@ -944,12 +1110,13 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.project-table {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.project-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 0.8fr 0.8fr 44px;
|
||||
grid-template-columns: minmax(120px, 1fr) minmax(220px, 1.6fr) minmax(80px, 0.55fr) 42px;
|
||||
align-items: center;
|
||||
min-height: 42px;
|
||||
font-size: 13px;
|
||||
@@ -965,11 +1132,27 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.project-row-link {
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(20, 184, 166, 0.08);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.project-row-head {
|
||||
min-height: 34px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: #94a3b8;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.project-pager {
|
||||
@@ -977,7 +1160,8 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
margin-top: auto;
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
@@ -1009,8 +1193,9 @@ onBeforeUnmount(() => {
|
||||
|
||||
.stream-layout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
grid-template-columns: var(--home-left-column) var(--home-right-column);
|
||||
align-items: stretch;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
@@ -1068,6 +1253,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
.history-empty {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-height: 96px;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
|
||||
891
src/views/worn/warehouseDashboard/index.vue
Normal file
891
src/views/worn/warehouseDashboard/index.vue
Normal file
@@ -0,0 +1,891 @@
|
||||
<template>
|
||||
<div class="warehouse-page">
|
||||
<section class="warehouse-hero">
|
||||
<button type="button" class="back-btn" @click="goBack">
|
||||
<span></span>
|
||||
返回首页
|
||||
</button>
|
||||
<div>
|
||||
<p>{{ projectName || '项目仓库' }}</p>
|
||||
<h1>{{ warehouseName || '仓库传感器看板' }}</h1>
|
||||
<em>实时汇聚仓库内全部传感器状态、指标与最新上报数据</em>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<strong>{{ onlineCount }}/{{ sensorCards.length }}</strong>
|
||||
<span>在线设备</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="summary-grid">
|
||||
<div class="summary-card">
|
||||
<span>设备总数</span>
|
||||
<strong>{{ sensorCards.length }}</strong>
|
||||
</div>
|
||||
<div class="summary-card online">
|
||||
<span>在线设备</span>
|
||||
<strong>{{ onlineCount }}</strong>
|
||||
</div>
|
||||
<div class="summary-card offline">
|
||||
<span>离线设备</span>
|
||||
<strong>{{ offlineCount }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span>最近上报</span>
|
||||
<strong>{{ latestReportTime || '--' }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-loading="loading" class="sensor-grid">
|
||||
<div v-if="!loading && sensorCards.length === 0" class="empty-state">
|
||||
当前仓库暂无传感器设备
|
||||
</div>
|
||||
|
||||
<article v-for="item in sensorCards" :key="item.cardKey" :class="['sensor-card', item.statusClass]">
|
||||
<div class="sensor-content">
|
||||
<div class="sensor-main">
|
||||
<div class="sensor-top">
|
||||
<div :class="['sensor-icon', item.type]">
|
||||
<span>{{ item.icon }}</span>
|
||||
</div>
|
||||
<div class="sensor-state">
|
||||
<i></i>
|
||||
{{ item.onlineText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sensor-body">
|
||||
<p>{{ item.locationName }}</p>
|
||||
<h2>{{ item.typeName }}</h2>
|
||||
<strong>{{ item.summary }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-panel">
|
||||
<p>实时指标</p>
|
||||
<div class="metric-list">
|
||||
<span v-for="metric in item.metrics" :key="metric.label">
|
||||
<em>{{ metric.label }}</em>
|
||||
<strong>{{ metric.value }}</strong>
|
||||
</span>
|
||||
<span v-if="item.metrics.length === 0" class="muted">
|
||||
<em>状态</em>
|
||||
<strong>等待数据</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sensor-footer">
|
||||
<span>DevEUI:{{ item.devEui || '--' }}</span>
|
||||
<span>{{ item.reportTime || '暂无上报' }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="WarehouseDashboard">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { listMqttDevice } from '@/api/worn/mqttDevice'
|
||||
import { listMqttData } from '@/api/worn/mqttData'
|
||||
import { closeWs, connectWs } from '@/utils/ws'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const devices = ref([])
|
||||
const latestDataMap = reactive({})
|
||||
const ENV_CARD_TYPES = ['tempHum', 'toxicGas', 'hydrogenSulfide']
|
||||
|
||||
const deptId = computed(() => route.query.deptId)
|
||||
const projectName = computed(() => route.query.projectName || '')
|
||||
const warehouseName = computed(() => route.query.warehouseName || '')
|
||||
|
||||
const sensorCards = computed(() => devices.value.flatMap((device) => expandDeviceCards(device)))
|
||||
const onlineCount = computed(() => sensorCards.value.filter((item) => item.isOnline).length)
|
||||
const offlineCount = computed(() => Math.max(sensorCards.value.length - onlineCount.value, 0))
|
||||
const latestReportTime = computed(() => {
|
||||
const times = sensorCards.value.map((item) => item.reportTime).filter(Boolean)
|
||||
return times[0] || ''
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
router.push('/index')
|
||||
}
|
||||
|
||||
function loadDevices() {
|
||||
if (!deptId.value) {
|
||||
devices.value = []
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
listMqttDevice({
|
||||
pageNum: 1,
|
||||
pageSize: 100,
|
||||
deptId: deptId.value
|
||||
}).then((res) => {
|
||||
devices.value = res.rows || []
|
||||
return loadLatestData()
|
||||
}).finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function loadLatestData() {
|
||||
const tasks = devices.value.map((device) => {
|
||||
if (!device.id) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return listMqttData({
|
||||
pageNum: 1,
|
||||
pageSize: 1,
|
||||
deviceId: device.id
|
||||
}).then((res) => {
|
||||
const row = (res.rows || [])[0]
|
||||
if (row) {
|
||||
latestDataMap[getDeviceKey(device)] = normalizePayload(row)
|
||||
}
|
||||
}).catch(() => {})
|
||||
})
|
||||
|
||||
return Promise.all(tasks)
|
||||
}
|
||||
|
||||
function handleWsMessage(data) {
|
||||
if (!data || String(data.deptId || '') !== String(deptId.value || '')) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = getDeviceKey(data)
|
||||
latestDataMap[key] = normalizePayload(data)
|
||||
|
||||
const index = devices.value.findIndex((device) => {
|
||||
return String(device.id || '') === String(data.deviceId || '') ||
|
||||
String(device.devEui || '').toLowerCase() === String(data.devEUI || data.devEui || '').toLowerCase()
|
||||
})
|
||||
|
||||
if (index > -1) {
|
||||
devices.value[index] = {
|
||||
...devices.value[index],
|
||||
status: '0',
|
||||
deviceName: data.deviceName || devices.value[index].deviceName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function expandDeviceCards(device) {
|
||||
const data = latestDataMap[getDeviceKey(device)] || {}
|
||||
const type = getSensorType(device, data)
|
||||
if (type === 'env') {
|
||||
return ENV_CARD_TYPES.map((displayType) => buildSensorCard(device, displayType))
|
||||
}
|
||||
return [buildSensorCard(device)]
|
||||
}
|
||||
|
||||
function buildSensorCard(device, displayType) {
|
||||
const data = latestDataMap[getDeviceKey(device)] || {}
|
||||
const sourceType = getSensorType(device, data)
|
||||
const type = displayType || sourceType
|
||||
const metrics = buildMetrics(data, type)
|
||||
const reportTime = formatTime(data.createTime || data.gatewayTime || data.time)
|
||||
const isOnline = String(device.status) === '0'
|
||||
|
||||
return {
|
||||
...device,
|
||||
cardKey: `${getDeviceKey(device) || device.id || device.devEui || 'sensor'}-${type}`,
|
||||
sourceType,
|
||||
type,
|
||||
icon: getSensorIcon(type),
|
||||
typeName: getSensorTypeName(type),
|
||||
locationName: getLocationName(device, data),
|
||||
summary: getSensorSummary(data, type),
|
||||
metrics,
|
||||
reportTime,
|
||||
isOnline,
|
||||
onlineText: isOnline ? '在线' : '离线',
|
||||
statusClass: isOnline ? 'online' : 'offline'
|
||||
}
|
||||
}
|
||||
|
||||
function getDeviceKey(device) {
|
||||
return device.deviceId || device.id || device.devEUI || device.devEui || device.dev_eui
|
||||
}
|
||||
|
||||
function normalizePayload(row) {
|
||||
const json = parseJson(row.dataJson) || parseJson(row.payload) || {}
|
||||
return {
|
||||
...json,
|
||||
...row,
|
||||
devEUI: row.devEUI || row.devEui || json.devEUI || json.devEui,
|
||||
deviceName: row.deviceName || json.deviceName
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson(value) {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getSensorType(device, data) {
|
||||
const raw = `${device.deviceType || ''} ${device.deviceName || ''} ${data.type || ''}`.toLowerCase()
|
||||
if (raw.includes('smoke')) return 'smoke'
|
||||
if (raw.includes('water')) return 'water'
|
||||
if (raw.includes('socket')) return 'socket'
|
||||
if (raw.includes('door')) return 'door'
|
||||
if (raw.includes('env') || raw.includes('temperature') || raw.includes('humidity')) return 'env'
|
||||
return 'sensor'
|
||||
}
|
||||
|
||||
function getSensorIcon(type) {
|
||||
const iconMap = {
|
||||
smoke: '烟',
|
||||
water: '水',
|
||||
socket: '电',
|
||||
door: '门',
|
||||
env: '温',
|
||||
tempHum: '温',
|
||||
toxicGas: '氨',
|
||||
hydrogenSulfide: '硫',
|
||||
sensor: '测'
|
||||
}
|
||||
return iconMap[type] || '测'
|
||||
}
|
||||
|
||||
function getSensorTypeName(type) {
|
||||
const nameMap = {
|
||||
smoke: '烟雾传感器',
|
||||
water: '水浸传感器',
|
||||
socket: '智能插座',
|
||||
door: '门禁状态',
|
||||
env: '环境传感器',
|
||||
tempHum: '温湿度传感器',
|
||||
toxicGas: '有毒气体传感器',
|
||||
hydrogenSulfide: '硫化氢传感器',
|
||||
sensor: '传感器'
|
||||
}
|
||||
return nameMap[type] || '传感器'
|
||||
}
|
||||
|
||||
function getLocationName(device, data) {
|
||||
return device.deptName || data.deptName || warehouseName.value || '仓库设备'
|
||||
}
|
||||
|
||||
function getSensorSummary(data, type) {
|
||||
if (!Object.keys(data).length) {
|
||||
return '等待上报'
|
||||
}
|
||||
|
||||
if (type === 'water') {
|
||||
return `水浸状态:${translateNormalAlarm(data.waterStatus ?? data.water_status ?? data.water)}`
|
||||
}
|
||||
|
||||
if (type === 'smoke') {
|
||||
return `烟雾状态:${translateSmokeStatus(data.event || data.statusDesc || data.eventDesc || data.concentration)}`
|
||||
}
|
||||
|
||||
if (type === 'socket') {
|
||||
return `插座状态:${translateSwitch(data.switchStatus ?? data.socket_status)}`
|
||||
}
|
||||
|
||||
if (type === 'door') {
|
||||
return `门禁状态:${translateSwitch(data.doorStatus)}`
|
||||
}
|
||||
|
||||
if (type === 'env') {
|
||||
const temperature = data.temperature ?? '--'
|
||||
const humidity = data.humidity ?? '--'
|
||||
return `${temperature}℃ / ${humidity}%`
|
||||
}
|
||||
|
||||
if (type === 'tempHum') {
|
||||
const temperature = data.temperature ?? '--'
|
||||
const humidity = data.humidity ?? '--'
|
||||
return `温湿度:${temperature}℃ / ${humidity}%`
|
||||
}
|
||||
|
||||
if (type === 'toxicGas') {
|
||||
const nh3 = data.nh3 ?? '--'
|
||||
return `氨气浓度:${nh3}ppm`
|
||||
}
|
||||
|
||||
if (type === 'hydrogenSulfide') {
|
||||
const h2s = data.h2s ?? '--'
|
||||
return `硫化氢浓度:${h2s}ppm`
|
||||
}
|
||||
|
||||
return '数据已上报'
|
||||
}
|
||||
|
||||
function buildMetrics(data, type) {
|
||||
if (type === 'tempHum') {
|
||||
return buildMetricItems(data, [
|
||||
['temperature', '温度', '℃'],
|
||||
['humidity', '湿度', '%'],
|
||||
['battery', '电量', '%']
|
||||
])
|
||||
}
|
||||
|
||||
if (type === 'toxicGas') {
|
||||
return buildMetricItems(data, [
|
||||
['nh3', '氨气', 'ppm'],
|
||||
['battery', '电量', '%']
|
||||
])
|
||||
}
|
||||
|
||||
if (type === 'hydrogenSulfide') {
|
||||
return buildMetricItems(data, [
|
||||
['h2s', '硫化氢', 'ppm'],
|
||||
['battery', '电量', '%']
|
||||
])
|
||||
}
|
||||
|
||||
const config = [
|
||||
['temperature', '温度', '℃'],
|
||||
['humidity', '湿度', '%'],
|
||||
['nh3', '氨气', 'ppm'],
|
||||
['h2s', '硫化氢', 'ppm'],
|
||||
['battery', '电量', '%'],
|
||||
['concentration', '烟雾浓度', ''],
|
||||
['voltage', '电压', 'V'],
|
||||
['current', '电流', 'A'],
|
||||
['active_power', '功率', 'W']
|
||||
]
|
||||
|
||||
const metrics = buildMetricItems(data, config)
|
||||
|
||||
if (type === 'water' && (data.waterStatus !== undefined || data.water_status !== undefined || data.water !== undefined)) {
|
||||
metrics.unshift({ label: '水浸状态', value: translateNormalAlarm(data.waterStatus ?? data.water_status ?? data.water) })
|
||||
}
|
||||
|
||||
if (type === 'socket' && (data.switchStatus !== undefined || data.socket_status !== undefined)) {
|
||||
metrics.unshift({ label: '插座状态', value: translateSwitch(data.switchStatus ?? data.socket_status) })
|
||||
}
|
||||
|
||||
return metrics.slice(0, 6)
|
||||
}
|
||||
|
||||
function buildMetricItems(data, config) {
|
||||
return config
|
||||
.filter(([key]) => data[key] !== undefined && data[key] !== null && data[key] !== '')
|
||||
.map(([key, label, unit]) => ({
|
||||
label,
|
||||
value: `${data[key]}${unit}`
|
||||
}))
|
||||
}
|
||||
|
||||
function translateNormalAlarm(value) {
|
||||
const normalized = String(value).toLowerCase()
|
||||
if (normalized === '0' || normalized === 'normal' || normalized === 'false') return '正常'
|
||||
if (normalized === '1' || normalized === 'alarm' || normalized === 'true') return '告警'
|
||||
return value || '正常'
|
||||
}
|
||||
|
||||
function translateSmokeStatus(value) {
|
||||
const normalized = String(value ?? '').trim().toLowerCase()
|
||||
if (!normalized || normalized === '0' || normalized === 'normal' || normalized === 'removed' || normalized.includes('normal battery')) {
|
||||
return '正常'
|
||||
}
|
||||
if (normalized === '1' || normalized === 'alarm' || normalized.includes('alarm')) {
|
||||
return '告警'
|
||||
}
|
||||
return value || '正常'
|
||||
}
|
||||
|
||||
function translateSwitch(value) {
|
||||
const normalized = String(value).toLowerCase()
|
||||
if (normalized === '0' || normalized === 'off' || normalized === 'false') return '关闭'
|
||||
if (normalized === '1' || normalized === 'on' || normalized === 'true') return '开启'
|
||||
return value || '--'
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return String(value).replace('T', ' ').slice(0, 19)
|
||||
}
|
||||
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDevices()
|
||||
connectWs(handleWsMessage)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
closeWs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.warehouse-page {
|
||||
min-height: calc(100vh - 84px);
|
||||
padding: 24px 32px 36px;
|
||||
color: #132033;
|
||||
background:
|
||||
radial-gradient(circle at 10% 0%, rgba(20, 184, 166, 0.18), transparent 28%),
|
||||
radial-gradient(circle at 90% 8%, rgba(14, 165, 233, 0.16), transparent 30%),
|
||||
linear-gradient(135deg, #eef8f7 0%, #f8fbff 52%, #edf4ff 100%);
|
||||
}
|
||||
|
||||
.warehouse-hero {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
padding: 24px 28px;
|
||||
margin-bottom: 18px;
|
||||
color: #fff;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(8, 47, 73, 0.95), rgba(15, 118, 110, 0.92)),
|
||||
radial-gradient(circle at 78% 24%, rgba(125, 211, 252, 0.44), transparent 34%);
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 24px 60px rgba(8, 47, 73, 0.22);
|
||||
|
||||
p {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.16em;
|
||||
color: rgba(204, 251, 241, 0.86);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
em {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
color: rgba(240, 253, 250, 0.82);
|
||||
}
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
padding: 0 14px;
|
||||
color: #ccfbf1;
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 999px;
|
||||
|
||||
span {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-bottom: 2px solid currentcolor;
|
||||
border-left: 2px solid currentcolor;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
.hero-meta {
|
||||
min-width: 150px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 22px;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: rgba(240, 253, 250, 0.78);
|
||||
}
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
padding: 18px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border: 1px solid rgba(255, 255, 255, 0.9);
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
|
||||
|
||||
span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 26px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&.online strong {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
&.offline strong {
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
.sensor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.sensor-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 250px;
|
||||
padding: 20px;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.98), rgba(247, 252, 253, 0.92)),
|
||||
radial-gradient(circle at 96% 96%, rgba(20, 184, 166, 0.14), transparent 22%);
|
||||
border: 1px solid rgba(221, 235, 238, 0.95);
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.07);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 6px;
|
||||
content: "";
|
||||
background: linear-gradient(180deg, #14b8a6, #38bdf8);
|
||||
}
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
right: -36px;
|
||||
bottom: -46px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
background: rgba(20, 184, 166, 0.08);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 24px 58px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
&.offline {
|
||||
filter: grayscale(0.28);
|
||||
|
||||
&::before {
|
||||
background: linear-gradient(180deg, #94a3b8, #cbd5e1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sensor-top,
|
||||
.sensor-footer {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sensor-top {
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.sensor-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(230px, 0.9fr) minmax(260px, 1.1fr);
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sensor-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
min-height: 238px;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.88), rgba(240, 253, 250, 0.45));
|
||||
border: 1px solid rgba(226, 232, 240, 0.58);
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.sensor-icon {
|
||||
display: grid;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
place-items: center;
|
||||
color: #0f766e;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(204, 251, 241, 0.95), rgba(240, 253, 250, 0.95));
|
||||
border: 1px solid rgba(20, 184, 166, 0.24);
|
||||
border-radius: 20px;
|
||||
box-shadow: inset 0 -10px 18px rgba(15, 118, 110, 0.08);
|
||||
|
||||
span {
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
&.smoke { color: #ea580c; background: linear-gradient(135deg, #ffedd5, #fff7ed); border-color: #fed7aa; }
|
||||
&.water { color: #0284c7; background: linear-gradient(135deg, #e0f2fe, #f0f9ff); border-color: #bae6fd; }
|
||||
&.socket { color: #16a34a; background: linear-gradient(135deg, #dcfce7, #f0fdf4); border-color: #bbf7d0; }
|
||||
&.door { color: #475569; background: linear-gradient(135deg, #e2e8f0, #f8fafc); border-color: #cbd5e1; }
|
||||
&.env { color: #0d9488; background: linear-gradient(135deg, #ccfbf1, #f0fdfa); border-color: #99f6e4; }
|
||||
&.tempHum { color: #0d9488; background: linear-gradient(135deg, #ccfbf1, #f0fdfa); border-color: #99f6e4; }
|
||||
&.toxicGas { color: #ca8a04; background: linear-gradient(135deg, #fef9c3, #fffbeb); border-color: #fde68a; }
|
||||
&.hydrogenSulfide { color: #7c3aed; background: linear-gradient(135deg, #ede9fe, #faf5ff); border-color: #ddd6fe; }
|
||||
}
|
||||
|
||||
.sensor-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: #059669;
|
||||
background: rgba(220, 252, 231, 0.86);
|
||||
border-radius: 999px;
|
||||
|
||||
i {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: #22c55e;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.sensor-card.offline .sensor-state {
|
||||
color: #64748b;
|
||||
background: #f1f5f9;
|
||||
|
||||
i {
|
||||
background: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.sensor-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-width: 0;
|
||||
padding-top: 18px;
|
||||
margin-top: 18px;
|
||||
text-align: left;
|
||||
border-top: 1px solid rgba(226, 232, 240, 0.7);
|
||||
|
||||
p {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
margin: 0 0 8px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.01em;
|
||||
color: #0f766e;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
margin: 0 0 12px;
|
||||
overflow: hidden;
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
line-height: 1.18;
|
||||
letter-spacing: -0.02em;
|
||||
color: #0f172a;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
padding: 7px 11px;
|
||||
margin-top: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
line-height: 1.3;
|
||||
color: #0f172a;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
background: rgba(248, 250, 252, 0.88);
|
||||
border: 1px solid rgba(226, 232, 240, 0.82);
|
||||
border-radius: 999px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
strong::before {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex: 0 0 auto;
|
||||
content: "";
|
||||
background: #14b8a6;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 4px rgba(20, 184, 166, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.metric-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
min-height: 238px;
|
||||
padding: 18px;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.92), rgba(248, 250, 252, 0.78));
|
||||
border: 1px solid rgba(226, 232, 240, 0.72);
|
||||
border-radius: 22px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
|
||||
|
||||
> p {
|
||||
margin: 0 0 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.12em;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.metric-list {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: auto;
|
||||
|
||||
span {
|
||||
display: grid;
|
||||
min-height: 62px;
|
||||
padding: 11px 12px;
|
||||
color: #0f172a;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(240, 253, 250, 0.88), rgba(255, 255, 255, 0.82));
|
||||
border: 1px solid rgba(153, 246, 228, 0.56);
|
||||
border-radius: 18px;
|
||||
|
||||
em {
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
strong {
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
font-size: 17px;
|
||||
font-weight: 900;
|
||||
color: #0f766e;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #94a3b8;
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
.sensor-footer {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
gap: 12px;
|
||||
padding: 10px 16px 0;
|
||||
margin-top: 14px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: grid;
|
||||
min-height: 240px;
|
||||
grid-column: 1 / -1;
|
||||
color: #94a3b8;
|
||||
place-items: center;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.summary-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sensor-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.warehouse-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.warehouse-hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sensor-grid,
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user