Files
hazardousWaste_web/src/views/worn/warehouseDashboard/index.vue

1158 lines
31 KiB
Vue
Raw Normal View History

2026-04-15 14:23:34 +08:00
<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 v-if="item.type === 'socket'" class="socket-actions">
<button
type="button"
class="control-btn control-on"
:disabled="!!controlLoadingMap[item.cardKey]"
@click="handleSocketControl(item, 'on')"
>
{{ controlLoadingMap[item.cardKey] ? '发送中...' : '开启排风' }}
</button>
<button
type="button"
class="control-btn control-off"
:disabled="!!controlLoadingMap[item.cardKey]"
@click="handleSocketControl(item, 'off')"
>
{{ controlLoadingMap[item.cardKey] ? '发送中...' : '关闭排风' }}
</button>
</div>
2026-04-15 14:23:34 +08:00
</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 { ElMessage } from 'element-plus'
2026-04-15 14:23:34 +08:00
import { listMqttDevice } from '@/api/worn/mqttDevice'
import { listMqttData } from '@/api/worn/mqttData'
import { listMqttEvent } from '@/api/worn/mqttEvent'
import { controlSocket } from '@/api/worn/socket'
2026-04-15 14:23:34 +08:00
import { closeWs, connectWs } from '@/utils/ws'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const devices = ref([])
const latestDataMap = reactive({})
const controlLoadingMap = reactive({})
2026-04-15 14:23:34 +08:00
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()
}
const requestList = [
listMqttData({
pageNum: 1,
pageSize: 1,
deviceId: device.id
}).then((res) => {
const row = (res.rows || [])[0]
if (row) {
latestDataMap[getDeviceKey(device)] = normalizePayload({
...(latestDataMap[getDeviceKey(device)] || {}),
...row
})
}
}).catch(() => {})
]
if (isStateDevice(device)) {
requestList.push(
listMqttEvent({
pageNum: 1,
pageSize: 1,
deviceId: device.id
}).then((res) => {
const row = (res.rows || [])[0]
if (row) {
latestDataMap[getDeviceKey(device)] = normalizePayload({
...(latestDataMap[getDeviceKey(device)] || {}),
...row,
...deriveStateFromEvent(row)
})
}
}).catch(() => {})
)
}
return Promise.all(requestList)
2026-04-15 14:23:34 +08:00
})
return Promise.all(tasks)
}
function handleWsMessage(data) {
if (!data || String(data.deptId || '') !== String(deptId.value || '')) {
return
}
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) {
const device = devices.value[index]
const key = getDeviceKey(device)
const patchedData = patchRealtimeState(latestDataMap[key] || {}, data)
latestDataMap[key] = normalizePayload({
...(latestDataMap[key] || {}),
...patchedData
})
2026-04-15 14:23:34 +08:00
devices.value[index] = {
...device,
2026-04-15 14:23:34 +08:00
status: '0',
deviceName: data.deviceName || device.deviceName
2026-04-15 14:23:34 +08:00
}
return
}
const key = getDeviceKey(data)
const patchedData = patchRealtimeState(latestDataMap[key] || {}, data)
latestDataMap[key] = normalizePayload({
...(latestDataMap[key] || {}),
...patchedData
})
}
function patchRealtimeState(previous, incoming) {
const next = { ...incoming }
const waterStatus = deriveWaterStatus({ ...previous, ...incoming })
if (waterStatus !== undefined) {
next.waterStatus = waterStatus
next.water_status = waterStatus
}
const doorStatus = deriveDoorStatus({ ...previous, ...incoming })
if (doorStatus !== undefined) {
next.doorStatus = doorStatus
next.door_status = doorStatus
}
const socketStatus = deriveSocketStatus({ ...previous, ...incoming })
if (socketStatus !== undefined) {
next.switchStatus = socketStatus
next.socket_status = socketStatus
next.socketStatus = socketStatus
}
return next
}
async function handleSocketControl(item, action) {
const deviceId = item.id || item.deviceId
const devEui = item.devEui || item.devEUI || item.dev_eui
if (!devEui) {
ElMessage.error('未找到设备 DevEUI无法下发指令')
return
}
const loadingKey = item.cardKey || String(deviceId)
if (controlLoadingMap[loadingKey]) {
return
}
controlLoadingMap[loadingKey] = true
try {
const status = action === 'on' ? 1 : 0
await controlSocket({ devEui, deviceId, status })
ElMessage.success(action === 'on' ? '开启排风指令已发送' : '关闭排风指令已发送')
} finally {
controlLoadingMap[loadingKey] = false
2026-04-15 14:23:34 +08:00
}
}
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) || {}
const nestedData = typeof row.data === 'object'
? row.data
: (typeof row.data === 'string' ? parseJson(row.data) : null) || {}
const inferredWaterStatus = deriveWaterStatus({
...json,
...nestedData,
...row
})
2026-04-15 14:23:34 +08:00
return {
...json,
...nestedData,
2026-04-15 14:23:34 +08:00
...row,
devEUI: row.devEUI || row.devEui || json.devEUI || json.devEui,
deviceName: row.deviceName || json.deviceName || nestedData.deviceName,
waterStatus: row.waterStatus ?? row.water_status ?? nestedData.waterStatus ?? nestedData.water_status ?? json.waterStatus ?? json.water_status ?? inferredWaterStatus,
water_status: row.water_status ?? row.waterStatus ?? nestedData.water_status ?? nestedData.waterStatus ?? json.water_status ?? json.waterStatus ?? inferredWaterStatus,
socket_status: row.socket_status ?? row.socketStatus ?? nestedData.socket_status ?? nestedData.socketStatus ?? json.socket_status ?? json.socketStatus,
switchStatus: row.switchStatus ?? row.socket_status ?? row.socketStatus ?? nestedData.switchStatus ?? nestedData.socket_status ?? nestedData.socketStatus ?? json.switchStatus ?? json.socket_status ?? json.socketStatus,
doorStatus: row.doorStatus ?? row.door_status ?? nestedData.doorStatus ?? nestedData.door_status ?? json.doorStatus ?? json.door_status,
tamperStatus: row.tamperStatus ?? row.tamper_status ?? nestedData.tamperStatus ?? nestedData.tamper_status ?? json.tamperStatus ?? json.tamper_status
2026-04-15 14:23:34 +08:00
}
}
function parseJson(value) {
if (!value || typeof value !== 'string') {
return null
}
try {
return JSON.parse(value)
} catch (e) {
return null
}
}
function deriveStateFromEvent(row) {
const eventType = String(row.eventType || row.event_type || row.event || '').trim().toLowerCase()
const eventDesc = String(row.eventDesc || row.event_desc || row.statusDesc || row.status_desc || '').trim().toLowerCase()
const normalized = {
eventType: row.eventType || row.event_type,
eventDesc: row.eventDesc || row.event_desc,
statusDesc: row.statusDesc || row.status_desc
}
if (eventType === 'door_open' || eventDesc.includes('门已打开')) {
normalized.doorStatus = 1
} else if (eventType === 'door_close' || eventDesc.includes('门已关闭')) {
normalized.doorStatus = 0
}
if (eventType === 'socket_on' || eventDesc.includes('插座通电')) {
normalized.socket_status = 1
normalized.switchStatus = 1
} else if (eventType === 'socket_off' || eventDesc.includes('插座关闭') || eventDesc.includes('插座断电')) {
normalized.socket_status = 0
normalized.switchStatus = 0
}
const inferredWaterStatus = deriveWaterStatus(row)
if (inferredWaterStatus !== undefined) {
normalized.waterStatus = inferredWaterStatus
normalized.water_status = inferredWaterStatus
}
return normalized
}
function deriveWaterStatus(data) {
const eventType = String(data.eventType || data.event_type || data.event || data.status || '').trim().toLowerCase()
const eventDesc = String(data.eventDesc || data.event_desc || data.statusDesc || data.status_desc || '').trim().toLowerCase()
if (eventType === 'alarm' || eventDesc.includes('浸水预警') || eventDesc.includes('水浸预警') || eventDesc.includes('浸水报警') || eventDesc.includes('水浸报警')) {
return 1
}
if (eventType === 'recovery' || eventType === 'normal' || eventDesc.includes('水浸正常') || eventDesc.includes('浸水正常')) {
return 0
}
const directValue = data.waterStatus ?? data.water_status ?? data.water
if (directValue !== undefined && directValue !== null && directValue !== '') {
return directValue
}
return undefined
}
function deriveDoorStatus(data) {
const directValue = data.doorStatus ?? data.door_status
if (directValue !== undefined && directValue !== null && directValue !== '') {
return directValue
}
const eventType = String(data.eventType || data.event_type || data.event || data.status || '').trim().toLowerCase()
const eventDesc = String(data.eventDesc || data.event_desc || data.statusDesc || data.status_desc || '').trim().toLowerCase()
if (eventType === 'door_open' || eventDesc.includes('门已打开')) {
return 1
}
if (eventType === 'door_close' || eventDesc.includes('门已关闭')) {
return 0
}
return undefined
}
function deriveSocketStatus(data) {
const directValue = data.switchStatus ?? data.socket_status ?? data.socketStatus
if (directValue !== undefined && directValue !== null && directValue !== '') {
return directValue
}
const eventType = String(data.eventType || data.event_type || data.event || data.status || '').trim().toLowerCase()
const eventDesc = String(data.eventDesc || data.event_desc || data.statusDesc || data.status_desc || '').trim().toLowerCase()
if (eventType === 'socket_on' || eventDesc.includes('插座通电')) {
return 1
}
if (eventType === 'socket_off' || eventDesc.includes('插座关闭') || eventDesc.includes('插座断电')) {
return 0
}
return undefined
}
2026-04-15 14:23:34 +08:00
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 isStateDevice(device) {
const raw = `${device.deviceType || ''} ${device.deviceName || ''}`.toLowerCase()
return raw.includes('door') || raw.includes('socket')
}
2026-04-15 14:23:34 +08:00
function getSensorIcon(type) {
const iconMap = {
smoke: '烟',
water: '水',
socket: '电',
door: '门',
env: '温',
tempHum: '温',
toxicGas: '氨',
hydrogenSulfide: '硫',
sensor: '测'
}
return iconMap[type] || '测'
}
function getSensorTypeName(type) {
const nameMap = {
smoke: '烟雾传感器',
water: '水浸传感器',
2026-04-15 15:08:27 +08:00
socket: '智能排风',
2026-04-15 14:23:34 +08:00
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 ?? data.socketStatus ?? data.status)}`
2026-04-15 14:23:34 +08:00
}
if (type === 'door') {
return `门禁状态:${translateSwitch(data.doorStatus ?? data.door_status ?? data.status)}`
2026-04-15 14:23:34 +08:00
}
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 || data.socketStatus !== undefined || data.status !== undefined)) {
metrics.unshift({ label: '插座状态', value: translateSwitch(data.switchStatus ?? data.socket_status ?? data.socketStatus ?? data.status) })
}
if (type === 'door' && (data.doorStatus !== undefined || data.door_status !== undefined || data.status !== undefined)) {
metrics.unshift({ label: '门禁状态', value: translateSwitch(data.doorStatus ?? data.door_status ?? data.status) })
2026-04-15 14:23:34 +08:00
}
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 ?? '').trim().toLowerCase()
if (normalized === '0' || normalized === 'off' || normalized === 'false' || normalized === 'close' || normalized === 'closed') return '关闭'
if (normalized === '1' || normalized === 'on' || normalized === 'true' || normalized === 'open' || normalized === 'opened') return '开启'
2026-04-15 14:23:34 +08:00
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);
}
}
.socket-actions {
display: flex;
gap: 10px;
margin-top: 14px;
}
.control-btn {
min-width: 94px;
padding: 9px 14px;
font-size: 13px;
font-weight: 800;
line-height: 1;
cursor: pointer;
border: 1px solid transparent;
border-radius: 999px;
transition: transform 0.18s ease, box-shadow 0.18s ease, opacity 0.18s ease;
&:hover:not(:disabled) {
transform: translateY(-1px);
}
&:disabled {
cursor: not-allowed;
opacity: 0.68;
}
}
.control-on {
color: #065f46;
background: linear-gradient(135deg, #dcfce7, #f0fdf4);
border-color: #86efac;
box-shadow: 0 10px 18px rgba(34, 197, 94, 0.12);
}
.control-off {
color: #9a3412;
background: linear-gradient(135deg, #ffedd5, #fff7ed);
border-color: #fdba74;
box-shadow: 0 10px 18px rgba(249, 115, 22, 0.12);
}
2026-04-15 14:23:34 +08:00
.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>