摄像头模块开发

This commit is contained in:
piratecaptain37
2026-04-28 17:20:38 +08:00
parent af168b96d8
commit 2f57847a3f
6 changed files with 784 additions and 20 deletions

View File

@@ -23,8 +23,10 @@
"clipboard": "2.0.11",
"echarts": "5.6.0",
"element-plus": "2.10.7",
"ezuikit-js": "^9.0.4",
"file-saver": "2.0.5",
"fuse.js": "6.6.2",
"hls.js": "^1.6.13",
"js-beautify": "1.14.11",
"js-cookie": "3.0.5",
"jsencrypt": "3.3.2",

62
src/api/worn/camera.js Normal file
View File

@@ -0,0 +1,62 @@
import request from '@/utils/request'
// 查询摄像头设备列表
export function listCamera(query) {
return request({
url: '/worn/camera/list',
method: 'get',
params: query
})
}
// 查询摄像头设备详情
export function getCamera(id) {
return request({
url: '/worn/camera/' + id,
method: 'get'
})
}
// 新增摄像头设备
export function addCamera(data) {
return request({
url: '/worn/camera',
method: 'post',
data
})
}
// 修改摄像头设备
export function updateCamera(data) {
return request({
url: '/worn/camera',
method: 'put',
data
})
}
// 删除摄像头设备
export function delCamera(id) {
return request({
url: '/worn/camera/' + id,
method: 'delete'
})
}
// 获取摄像头播放地址
export function getCameraPlayUrl(deviceSerial) {
return request({
url: '/worn/camera/play',
method: 'get',
params: { deviceSerial }
})
}
// 获取摄像头播放 token
export function getCameraPlayToken(deviceSerial) {
return request({
url: '/worn/camera/play/token',
method: 'get',
params: { deviceSerial }
})
}

View File

@@ -272,16 +272,18 @@ function handleWsMessage(data) {
}
function loadHomeData() {
loadHomeStat()
loadDeviceStat()
loadAlarmStat()
loadAlarmTrend()
loadAlarmType()
loadProjectPage()
return Promise.all([
loadHomeStat(),
loadDeviceStat(),
loadAlarmStat(),
loadAlarmTrend(),
loadAlarmType(),
loadProjectPage()
])
}
function loadHomeStat() {
getHomeStat().then((res) => {
return getHomeStat().then((res) => {
const data = res.data || {}
overview.projectTotal = toNumber(data.projectCount, overview.projectTotal)
overview.warehouseTotal = toNumber(data.warehouseCount, overview.warehouseTotal)
@@ -289,7 +291,7 @@ function loadHomeStat() {
}
function loadDeviceStat() {
getHomeDeviceStat().then((res) => {
return getHomeDeviceStat().then((res) => {
const data = res.data || {}
overview.deviceTotal = toNumber(data.total, overview.deviceTotal)
overview.onlineCount = toNumber(data.online, overview.onlineCount)
@@ -299,7 +301,7 @@ function loadDeviceStat() {
}
function loadAlarmStat() {
getHomeAlarmStat().then((res) => {
return getHomeAlarmStat().then((res) => {
const data = res.data || {}
overview.alarmToday = toNumber(data.today, overview.alarmToday)
overview.alarmUnhandled = toNumber(data.unhandled, overview.alarmUnhandled)
@@ -307,7 +309,7 @@ function loadAlarmStat() {
}
function loadAlarmTrend() {
getHomeAlarmTrend().then((res) => {
return getHomeAlarmTrend().then((res) => {
const list = Array.isArray(res.data) ? res.data : []
const maxCount = Math.max(...list.map((item) => toNumber(item.count, 0)), 1)
@@ -319,14 +321,14 @@ function loadAlarmTrend() {
}
function loadAlarmType() {
getHomeAlarmType().then((res) => {
return getHomeAlarmType().then((res) => {
const list = Array.isArray(res.data) ? res.data : []
exceptionStats.total = list.reduce((sum, item) => sum + toNumber(item.value, 0), 0)
})
}
function loadProjectPage() {
getHomeProjectList().then((res) => {
return getHomeProjectList().then((res) => {
const list = Array.isArray(res.data) ? res.data : []
projectList.value = list.map(normalizeProject).sort(sortProjectWarehouse)
if (projectPage.value > projectPageTotal.value) {
@@ -354,7 +356,8 @@ function openWarehouseDetail(item) {
query: {
deptId: item.deptId,
warehouseName: item.warehouseName,
projectName: item.name
projectName: item.name,
deviceSerial: item.deviceSerial || ''
}
})
}
@@ -369,6 +372,7 @@ function normalizeProject(item, index) {
deptId: item.deptId || item.warehouseId || item.id,
name,
warehouseName,
deviceSerial: item.deviceSerial || '',
onlineRate: `${onlineRate}%`,
status: onlineRate >= 95 ? 'normal' : 'warning'
}
@@ -751,9 +755,7 @@ function pushRealtimeAlarm(event) {
realtimeAlarms.value = realtimeAlarms.value.slice(0, 5)
}
onMounted(() => {
loadHomeData()
onMounted(async () => {
const token = getToken()
if (!token || token === 'undefined' || token === 'null' || token.length < 30) {
@@ -761,7 +763,12 @@ onMounted(() => {
return
}
connectWs(handleWsMessage)
try {
await loadHomeData()
connectWs(handleWsMessage)
} catch (error) {
console.warn('[Home] 棣栭〉鍒濆鍖栧け璐ワ紝璺宠繃 WebSocket 杩炴帴', error)
}
})
onBeforeUnmount(() => {

View File

@@ -0,0 +1,328 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="68px">
<el-form-item label="所属部门ID" prop="deptId">
<el-input
v-model="queryParams.deptId"
placeholder="请输入所属部门ID"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="设备序列号" prop="deviceSerial">
<el-input
v-model="queryParams.deviceSerial"
placeholder="请输入设备序列号"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="设备名称" prop="deviceName">
<el-input
v-model="queryParams.deviceName"
placeholder="请输入设备名称"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="布防状态" prop="defence">
<el-input
v-model="queryParams.defence"
placeholder="请输入布防状态"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="设备IP" prop="netAddress">
<el-input
v-model="queryParams.netAddress"
placeholder="请输入设备IP"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="是否删除" prop="isDelete">
<el-input
v-model="queryParams.isDelete"
placeholder="请输入是否删除"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
type="primary"
plain
icon="Plus"
@click="handleAdd"
v-hasPermi="['worn:camera:add']"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="Edit"
:disabled="single"
@click="handleUpdate"
v-hasPermi="['worn:camera:edit']"
>修改</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="Delete"
:disabled="multiple"
@click="handleDelete"
v-hasPermi="['worn:camera:remove']"
>删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="Download"
@click="handleExport"
v-hasPermi="['worn:camera:export']"
>导出</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="info"
plain
icon="Refresh"
@click="handleSyncEzviz"
>同步萤石设备</el-button>
</el-col>
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<el-table v-loading="loading" :data="cameraList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="主键" align="center" prop="id" />
<el-table-column label="所属部门ID" align="center" prop="deptId" />
<el-table-column label="设备序列号" align="center" prop="deviceSerial" />
<el-table-column label="设备名称" align="center" prop="deviceName" />
<el-table-column label="设备型号" align="center" prop="deviceType" />
<el-table-column label="设备状态" align="center" prop="status" />
<el-table-column label="布防状态" align="center" prop="defence" />
<el-table-column label="设备IP" align="center" prop="netAddress" />
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column label="是否删除" align="center" prop="isDelete" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template #default="scope">
<el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['worn:camera:edit']">修改</el-button>
<el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['worn:camera:remove']">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total>0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
<!-- 添加或修改摄像头设备对话框 -->
<el-dialog :title="title" v-model="open" width="500px" append-to-body>
<el-form ref="cameraRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="所属部门ID" prop="deptId">
<el-input v-model="form.deptId" placeholder="请输入所属部门ID" />
</el-form-item>
<el-form-item label="设备序列号" prop="deviceSerial">
<el-input v-model="form.deviceSerial" placeholder="请输入设备序列号" />
</el-form-item>
<el-form-item label="设备名称" prop="deviceName">
<el-input v-model="form.deviceName" placeholder="请输入设备名称" />
</el-form-item>
<el-form-item label="布防状态" prop="defence">
<el-input v-model="form.defence" placeholder="请输入布防状态" />
</el-form-item>
<el-form-item label="设备IP" prop="netAddress">
<el-input v-model="form.netAddress" placeholder="请输入设备IP" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" placeholder="请输入内容" />
</el-form-item>
<el-form-item label="是否删除" prop="isDelete">
<el-input v-model="form.isDelete" placeholder="请输入是否删除" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitForm"> </el-button>
<el-button @click="cancel"> </el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup name="Camera">
import { listCamera, getCamera, delCamera, addCamera, updateCamera } from "@/api/worn/camera"
const { proxy } = getCurrentInstance()
const cameraList = ref([])
const open = ref(false)
const loading = ref(true)
const showSearch = ref(true)
const ids = ref([])
const single = ref(true)
const multiple = ref(true)
const total = ref(0)
const title = ref("")
const data = reactive({
form: {},
queryParams: {
pageNum: 1,
pageSize: 10,
deptId: null,
deviceSerial: null,
deviceName: null,
deviceType: null,
status: null,
defence: null,
netAddress: null,
isDelete: null
},
rules: {
deviceSerial: [
{ required: true, message: "设备序列号不能为空", trigger: "blur" }
],
}
})
const { queryParams, form, rules } = toRefs(data)
/** 查询摄像头设备列表 */
function getList() {
loading.value = true
listCamera(queryParams.value).then(response => {
cameraList.value = response.rows
total.value = response.total
loading.value = false
})
}
// 取消按钮
function cancel() {
open.value = false
reset()
}
// 表单重置
function reset() {
form.value = {
id: null,
deptId: null,
deviceSerial: null,
deviceName: null,
deviceType: null,
status: null,
defence: null,
netAddress: null,
createBy: null,
createTime: null,
updateBy: null,
updateTime: null,
remark: null,
isDelete: null
}
proxy.resetForm("cameraRef")
}
/** 搜索按钮操作 */
function handleQuery() {
queryParams.value.pageNum = 1
getList()
}
/** 重置按钮操作 */
function resetQuery() {
proxy.resetForm("queryRef")
handleQuery()
}
// 多选框选中数据
function handleSelectionChange(selection) {
ids.value = selection.map(item => item.id)
single.value = selection.length != 1
multiple.value = !selection.length
}
/** 新增按钮操作 */
function handleAdd() {
reset()
open.value = true
title.value = "添加摄像头设备"
}
/** 修改按钮操作 */
function handleUpdate(row) {
reset()
const _id = row.id || ids.value
getCamera(_id).then(response => {
form.value = response.data
open.value = true
title.value = "修改摄像头设备"
})
}
/** 提交按钮 */
function submitForm() {
proxy.$refs["cameraRef"].validate(valid => {
if (valid) {
if (form.value.id != null) {
updateCamera(form.value).then(response => {
proxy.$modal.msgSuccess("修改成功")
open.value = false
getList()
})
} else {
addCamera(form.value).then(response => {
proxy.$modal.msgSuccess("新增成功")
open.value = false
getList()
})
}
}
})
}
/** 删除按钮操作 */
function handleDelete(row) {
const _ids = row.id || ids.value
proxy.$modal.confirm('是否确认删除摄像头设备编号为"' + _ids + '"的数据项?').then(function() {
return delCamera(_ids)
}).then(() => {
getList()
proxy.$modal.msgSuccess("删除成功")
}).catch(() => {})
}
/** 导出按钮操作 */
function handleExport() {
proxy.download('worn/camera/export', {
...queryParams.value
}, `camera_${new Date().getTime()}.xlsx`)
}
/** 同步萤石设备 */
function handleSyncEzviz() {
proxy.$modal.msgSuccess("已触发同步萤石设备")
}
getList()
</script>

View File

@@ -0,0 +1,177 @@
<template>
<div class="camera-player">
<div
:id="playerId"
ref="playerShellRef"
class="camera-player-shell"
@click="handleShellClick"
>
<div v-if="!ready" class="camera-player-placeholder">
<strong>{{ loading ? '正在连接摄像头...' : '点击开始播放摄像头画面' }}</strong>
<span>{{ error || statusText }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onBeforeUnmount, nextTick, watch } from 'vue'
import EZUIKit from 'ezuikit-js'
import { getCameraPlayToken } from '@/api/worn/camera'
const props = defineProps({
deviceSerial: String
})
const emit = defineEmits(['state-change'])
const playerShellRef = ref(null)
const playerId = `player-${Date.now()}`
const loading = ref(false)
const ready = ref(false)
const playing = ref(false)
const error = ref('')
const statusText = ref('等待播放')
let playerInstance = null
async function getToken() {
const res = await getCameraPlayToken(props.deviceSerial)
const data = res.data || {}
if (!data.accessToken) {
throw new Error('未获取到accessToken')
}
return data.accessToken
}
async function play() {
if (!props.deviceSerial || loading.value) return
loading.value = true
error.value = ''
statusText.value = '获取播放授权...'
emitState()
try {
const token = await getToken()
await nextTick()
destroy()
statusText.value = '连接摄像头...'
emitState()
const shell = playerShellRef.value
const rect = shell.getBoundingClientRect()
playerInstance = new EZUIKit.EZUIKitPlayer({
id: playerId,
width: rect.width || 600,
height: rect.height || 400,
template: 'pcLive',
url: `ezopen://open.ys7.com/${props.deviceSerial}/1.live`,
accessToken: token,
autoplay: true,
handleSuccess: () => {
loading.value = false
ready.value = true
playing.value = true
statusText.value = '播放中'
emitState()
},
handleError: (err) => {
console.error('播放失败:', err)
loading.value = false
ready.value = false
playing.value = false
error.value = '摄像头播放失败'
statusText.value = '播放失败'
emitState()
}
})
} catch (e) {
console.error('异常:', e)
loading.value = false
ready.value = false
playing.value = false
error.value = e.message
statusText.value = '播放异常'
emitState()
}
}
function emitState() {
emit('state-change', {
loading: loading.value,
ready: ready.value,
playing: playing.value,
error: error.value,
statusText: statusText.value
})
}
function destroy() {
if (playerInstance) {
try { playerInstance.stop?.() } catch {}
try { playerInstance.destroy?.() } catch {}
playerInstance = null
}
ready.value = false
playing.value = false
}
function handleShellClick() {
if (!ready.value && !loading.value) {
play()
}
}
watch(() => props.deviceSerial, () => {
destroy()
error.value = ''
statusText.value = '点击开始播放摄像头'
emitState()
})
onBeforeUnmount(() => {
destroy()
})
defineExpose({
play,
destroy
})
</script>
<style scoped>
.camera-player {
display: flex;
flex-direction: column;
gap: 10px;
}
.camera-player-shell {
height: 260px;
border-radius: 16px;
background: #000;
overflow: hidden;
position: relative;
cursor: pointer;
}
.camera-player-placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: #fff;
}
</style>

View File

@@ -36,7 +36,7 @@
</section>
<section v-loading="loading" class="sensor-grid">
<div v-if="!loading && sensorCards.length === 0" class="empty-state">
<div v-if="!loading && sensorCards.length === 0 && !hasCameraCard" class="empty-state">
当前仓库暂无传感器设备
</div>
@@ -123,12 +123,60 @@
<span>{{ item.reportTime || '暂无上报' }}</span>
</div>
</article>
<article v-if="hasCameraCard" :class="['sensor-card', 'camera-card', cameraReady ? 'camera-active' : '']">
<div class="sensor-content">
<div class="sensor-main">
<div class="sensor-top">
<div class="sensor-icon camera">
<span></span>
</div>
<div class="sensor-state">
<i></i>
{{ cameraReady ? '播放中' : (cameraLoading ? '加载中' : '已绑定') }}
</div>
</div>
<div class="sensor-body">
<p>{{ warehouseName || '仓库摄像头' }}</p>
<h2>仓库摄像头</h2>
<strong>{{ cameraSummaryText }}</strong>
<div class="socket-actions">
<button
type="button"
class="control-btn control-on"
:disabled="cameraLoading"
@click="handleCameraPlay"
>
{{ cameraLoading ? '加载中...' : (cameraPlaying ? '重新播放' : '开始播放') }}
</button>
</div>
</div>
</div>
<div class="metric-panel camera-panel">
<p>实时画面</p>
<CameraPlayer
ref="cameraPlayerRef"
:device-serial="cameraDeviceSerial"
@state-change="handleCameraStateChange"
/>
<div v-if="cameraError" class="camera-error">{{ cameraError }}</div>
<div v-else-if="cameraExpireTime" class="camera-expire">地址有效期至 {{ cameraExpireTime }}</div>
</div>
</div>
<div class="sensor-footer">
<span>DeviceSerial{{ cameraDeviceSerial }}</span>
<span>{{ cameraReady ? '视频已开始播放' : (cameraLoading ? '正在建立视频连接' : cameraStatusText) }}</span>
</div>
</article>
</section>
</div>
</template>
<script setup name="WarehouseDashboard">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { listMqttDevice } from '@/api/worn/mqttDevice'
@@ -136,6 +184,7 @@ import { listMqttData } from '@/api/worn/mqttData'
import { listMqttEvent } from '@/api/worn/mqttEvent'
import { controlLightSwitch, controlSocket } from '@/api/worn/socket'
import { closeWs, connectWs } from '@/utils/ws'
import CameraPlayer from './components/CameraPlayer.vue'
const route = useRoute()
const router = useRouter()
@@ -145,10 +194,31 @@ const devices = ref([])
const latestDataMap = reactive({})
const controlLoadingMap = reactive({})
const ENV_CARD_TYPES = ['tempHum', 'toxicGas', 'hydrogenSulfide']
const cameraPlayerRef = ref(null)
const cameraLoading = ref(false)
const cameraPlaying = ref(false)
const cameraExpireTime = ref('')
const cameraError = ref('')
const cameraReady = ref(false)
const cameraStatusText = ref('点击开始播放摄像头')
const deptId = computed(() => route.query.deptId)
const projectName = computed(() => route.query.projectName || '')
const warehouseName = computed(() => route.query.warehouseName || '')
const cameraDeviceSerial = computed(() => String(route.query.deviceSerial || '').trim())
const hasCameraCard = computed(() => !!cameraDeviceSerial.value)
const cameraSummaryText = computed(() => {
if (cameraError.value) {
return '播放失败,请重新尝试'
}
if (cameraLoading.value) {
return '正在获取摄像头视频流'
}
if (cameraReady.value || cameraPlaying.value) {
return '摄像头画面播放中'
}
return '点击卡片开始播放摄像头画面'
})
const sensorCards = computed(() => devices.value.flatMap((device) => expandDeviceCards(device)))
const onlineCount = computed(() => sensorCards.value.filter((item) => item.isOnline).length)
@@ -157,11 +227,43 @@ const latestReportTime = computed(() => {
const times = sensorCards.value.map((item) => item.reportTime).filter(Boolean)
return times[0] || ''
})
function goBack() {
router.push('/index')
}
async function handleCameraPlay() {
if (!cameraDeviceSerial.value || cameraLoading.value) {
return
}
cameraPlayerRef.value?.play?.().catch((error) => {
const message = error?.message || '摄像头播放失败'
cameraError.value = message
cameraStatusText.value = message
ElMessage.error(message)
})
}
function handleCameraFullscreen() {
cameraPlayerRef.value?.fullscreen?.()
}
function handleCameraStateChange(state = {}) {
cameraLoading.value = !!state.loading
cameraReady.value = !!state.ready
cameraPlaying.value = !!state.playing
cameraError.value = state.error || ''
cameraExpireTime.value = state.expireTime || ''
cameraStatusText.value = state.statusText || '点击开始播放摄像头'
}
function resetCameraState() {
cameraLoading.value = false
cameraReady.value = false
cameraPlaying.value = false
cameraStatusText.value = '点击开始播放摄像头'
}
function loadDevices() {
if (!deptId.value) {
devices.value = []
@@ -893,8 +995,15 @@ onMounted(() => {
})
onBeforeUnmount(() => {
cameraPlayerRef.value?.destroy?.()
closeWs(handleWsMessage)
})
watch(cameraDeviceSerial, () => {
resetCameraState()
cameraPlayerRef.value?.destroy?.()
cameraStatusText.value = '点击开始播放摄像头'
})
</script>
<style lang="scss" scoped>
@@ -1139,6 +1248,7 @@ onBeforeUnmount(() => {
&.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; }
&.camera { color: #2563eb; background: linear-gradient(135deg, #dbeafe, #eff6ff); border-color: #bfdbfe; }
}
.sensor-state {
@@ -1322,6 +1432,84 @@ onBeforeUnmount(() => {
}
}
.camera-card {
.sensor-state {
color: #2563eb;
background: rgba(219, 234, 254, 0.86);
i {
background: #3b82f6;
}
}
&.camera-active .sensor-state {
color: #059669;
background: rgba(220, 252, 231, 0.86);
i {
background: #22c55e;
}
}
}
.camera-panel {
gap: 12px;
}
.camera-stage {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 238px;
overflow: hidden;
cursor: pointer;
background:
linear-gradient(145deg, rgba(15, 23, 42, 0.92), rgba(30, 41, 59, 0.95)),
radial-gradient(circle at 15% 20%, rgba(59, 130, 246, 0.28), transparent 28%);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 18px;
}
.camera-placeholder {
display: grid;
gap: 8px;
padding: 0 18px;
text-align: center;
strong {
font-size: 18px;
color: #f8fafc;
}
span {
font-size: 12px;
color: rgba(226, 232, 240, 0.78);
}
}
.camera-video {
width: 100%;
height: 100%;
min-height: 238px;
object-fit: cover;
background: #020617;
}
.camera-expire,
.camera-error {
font-size: 12px;
line-height: 1.4;
}
.camera-expire {
color: #64748b;
}
.camera-error {
color: #dc2626;
}
.metric-list {
position: relative;
z-index: 1;