Files
hazardousWaste_web/src/views/worn/warehouseDashboard/index.vue
2026-04-15 17:07:40 +08:00

1158 lines
31 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>
</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'
import { listMqttDevice } from '@/api/worn/mqttDevice'
import { listMqttData } from '@/api/worn/mqttData'
import { listMqttEvent } from '@/api/worn/mqttEvent'
import { controlSocket } from '@/api/worn/socket'
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({})
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)
})
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
})
devices.value[index] = {
...device,
status: '0',
deviceName: data.deviceName || device.deviceName
}
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
}
}
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
})
return {
...json,
...nestedData,
...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
}
}
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
}
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')
}
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 ?? data.socketStatus ?? data.status)}`
}
if (type === 'door') {
return `门禁状态:${translateSwitch(data.doorStatus ?? data.door_status ?? data.status)}`
}
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) })
}
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 '开启'
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);
}
.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>