首页,基础前端统一推送

中科电站备料库-规则引擎
LAPTOP-S9HJSOEB\昊天 3 months ago
parent 97aade93cb
commit 1b0c8567a1

@ -34,27 +34,27 @@ onMounted(() => {
connect(sseBase + '/admin-api/warehousealarm/alarm-records/subscribe') connect(sseBase + '/admin-api/warehousealarm/alarm-records/subscribe')
// 添加演示报警,用于查看效果 // 添加演示报警,用于查看效果
setTimeout(() => { // setTimeout(() => {
addAlarm({ // addAlarm({
alarm_id: 'ALM20260708143000', // alarm_id: 'ALM20260708143000',
alarm_level: '2', // alarm_level: '2',
alarm_level_name: '报警', // alarm_level_name: '报警',
alarm_time: '2026-07-08 14:30:00', // alarm_time: '2026-07-08 14:30:00',
description: '仓库#3 温湿度传感器触发高温报警', // description: '仓库#3 温湿度传感器触发高温报警',
cameraId: '2', // cameraId: '2',
pic: 'https://picsum.photos/400/300?random=1', // pic: 'https://picsum.photos/400/300?random=1',
sensor_data: [ // sensor_data: [
{ // {
sensor_code: 'st_001', // sensor_code: 'st_001',
sensor_name: '温湿度传感器', // sensor_name: '温湿度传感器',
data: [ // data: [
{ name: '温度', value: 38.5, sensor_threshold: 30, unit: '°C' }, // { name: '温度', value: 38.5, sensor_threshold: 30, unit: '°C' },
{ name: '湿度', value: 65.2, sensor_threshold: 80, unit: '%' }, // { name: '湿度', value: 65.2, sensor_threshold: 80, unit: '%' },
], // ],
}, // },
], // ],
}) // })
}, 2000) // }, 2000)
}) })
</script> </script>
<template> <template>

@ -3,37 +3,18 @@ import request from '@/config/axios'
// 相机 API // 相机 API
export const CameraControlApi = { export const CameraControlApi = {
// 查询相机分页 // 相机控制(POST)
cameraControl: async (urlStr: string,data) => { cameraControl: async (urlStr: string, data) => {
return await request.post({ url: urlStr, data }) return await request.post({ url: urlStr, data })
}, },
// //查询相机列表
// getCameraList: () => {
// return request.get({ url: `/logistics/camera/list` })
// },
// // 查询相机详情
// getCamera: async (id: number) => {
// return await request.get({ url: `/logistics/camera/get?id=` + id })
// },
// // 新增相机 // 查询录像文件列表(GET)
// createCamera: async (data: CameraVO) => { getRecordFiles: async (params: {
// return await request.post({ url: `/logistics/camera/create`, data }) cameraId: number
// }, channel?: number
startTime: string
// // 修改相机 endTime: string
// updateCamera: async (data: CameraVO) => { }) => {
// return await request.put({ url: `/logistics/camera/update`, data }) return await request.get({ url: `/camera/control/record-files`, params })
// }, },
// // 删除相机
// deleteCamera: async (id: number) => {
// return await request.delete({ url: `/logistics/camera/delete?id=` + id })
// },
// // 导出相机 Excel
// exportCamera: async (params) => {
// return await request.download({ url: `/logistics/camera/export-excel`, params })
// },
} }

@ -49,4 +49,32 @@ export const AlarmRecordsApi = {
exportAlarmRecords: async (params) => { exportAlarmRecords: async (params) => {
return await request.download({ url: `/warehousealarm/alarm-records/export-excel`, params }) return await request.download({ url: `/warehousealarm/alarm-records/export-excel`, params })
}, },
// 统计报警数量(返回 int)
getAlarmCount: async (params: {
status?: number
triggerTime?: string[]
}) => {
return await request.get({ url: `/warehousealarm/alarm-records/count`, params })
},
// 事件统计(横向柱状图)
getStatsDuration: async (params: {
dimension: string // year / month / week
ruleIds: string // 逗号分隔
startTime: string
endTime: string
}) => {
return await request.get({ url: `/warehousealarm/alarm-records/stats/duration`, params })
},
// 事件趋势(折线图)
getStatsTrend: async (params: {
dimension: string // year / month
ruleIds: string
startTime: string
endTime: string
}) => {
return await request.get({ url: `/warehousealarm/alarm-records/stats/trend`, params })
},
} }

@ -17,6 +17,24 @@ export const DeviceLatestDataApi = {
getDeviceTypesPageAll: async (params: any) => { getDeviceTypesPageAll: async (params: any) => {
return await request.get({ url: `/warehousealarm/device-latest-data/pageAll`, params }) return await request.get({ url: `/warehousealarm/device-latest-data/pageAll`, params })
}, },
getDeviceTypesListAll: async () => {
return await request.get({ url: `/warehousealarm/device-latest-data/listAll` })
},
// 设备在线率统计
getOnlineRate: async () => {
return await request.get({ url: `/warehousealarm/device-latest-data/online-rate` })
},
// 各区域温度
getTemperatureByZone: async () => {
return await request.get({ url: `/warehousealarm/device-latest-data/temperature-by-zone` })
},
// 各区域湿度
getHumidityByZone: async () => {
return await request.get({ url: `/warehousealarm/device-latest-data/humidity-by-zone` })
},
// 查询设备最新数据详情 // 查询设备最新数据详情
getDeviceLatestData: async (id: number) => { getDeviceLatestData: async (id: number) => {

@ -58,4 +58,9 @@ export const RulesApi = {
getRuleConditionsListByRuleId: async (ruleId) => { getRuleConditionsListByRuleId: async (ruleId) => {
return await request.get({ url: `/warehousealarm/rules/rule-conditions/list-by-rule-id?ruleId=` + ruleId }) return await request.get({ url: `/warehousealarm/rules/rule-conditions/list-by-rule-id?ruleId=` + ruleId })
}, },
// 获取设备列表(无分页,支持 typeId 和 deviceName 过滤)
getDevicesPageByRules: async (params: { typeId?: number; deviceName?: string }) => {
return await request.get({ url: `/warehousealarm/rules/devicesPage`, params })
},
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

@ -6,7 +6,7 @@
<span>报警通知 ({{ alarmList.length }})</span> <span>报警通知 ({{ alarmList.length }})</span>
</div> </div>
<div class="alarm-header-right"> <div class="alarm-header-right">
<el-button text size="small" @click="clearAll" style="color:#fff">清空全部</el-button> <el-button text size="small" @click="clearAll" style="color:#fff">忽略全部</el-button>
<el-button text size="small" @click="closePanel" style="color:#fff"> <el-button text size="small" @click="closePanel" style="color:#fff">
<Icon icon="ep:fold" /> <Icon icon="ep:fold" />
</el-button> </el-button>
@ -76,25 +76,37 @@
<!-- 操作按钮 --> <!-- 操作按钮 -->
<div class="action-section"> <div class="action-section">
<el-button type="primary" size="small" @click="removeAlarm(index)">确认</el-button> <el-button link size="small" type="primary" @click="openAlarmRecord(alarm)">
<el-button type="info" size="small" @click="removeAlarm(index)">忽略</el-button> <Icon icon="ep:edit" style="margin-right:2px" />报警处理
</el-button>
<el-button link size="small" type="primary" @click="goLive(alarm.cameraId)"> <el-button link size="small" type="primary" @click="goLive(alarm.cameraId)">
<Icon icon="ep:video-camera" style="margin-right:2px" />实时视频 <Icon icon="ep:video-camera" style="margin-right:2px" />实时视频
</el-button> </el-button>
<el-button link size="small" type="info" @click="removeAlarm(index)">忽略</el-button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- 报警处理弹窗 -->
<AlarmRecordsForm ref="alarmRecordFormRef" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAlarmSSE } from '@/hooks/web/useAlarmSSE' import { useAlarmSSE } from '@/hooks/web/useAlarmSSE'
import camera from '@/components/camera/camera.vue' import camera from '@/components/camera/camera.vue'
import AlarmRecordsForm from '@/views/warehousealarm/alarmrecords/AlarmRecordsForm.vue'
const router = useRouter() const router = useRouter()
const { alarmList, alarmPanelVisible, removeAlarmByIndex, clearAllAlarms } = useAlarmSSE() const { alarmList, alarmPanelVisible, removeAlarmByIndex, clearAllAlarms } = useAlarmSSE()
const alarmRecordFormRef = ref()
const openAlarmRecord = (alarm: any) => {
alarmRecordFormRef.value?.open('update', alarm.alarm_id)
}
const alarmLevelTag = (level: string) => { const alarmLevelTag = (level: string) => {
const map: Record<string, string> = { '1': 'warning', '2': 'danger', '3': 'danger' } const map: Record<string, string> = { '1': 'warning', '2': 'danger', '3': 'danger' }

@ -24,7 +24,7 @@ const props = defineProps({
}, },
dark: propTypes.bool.def(false), dark: propTypes.bool.def(false),
width: propTypes.oneOfType([Number, String]).def(''), width: propTypes.oneOfType([Number, String]).def(''),
height: propTypes.oneOfType([Number, String]).def('500px') height: propTypes.oneOfType([Number, String]).def('100%')
}) })
const isDark = computed(() => props.dark|| appStore.getIsDark) const isDark = computed(() => props.dark|| appStore.getIsDark)

@ -41,6 +41,10 @@ export default {
ptzShow: { ptzShow: {
type: Boolean, type: Boolean,
default: true default: true
},
stream: {
type: String,
default: undefined
} }
}, },
@ -64,27 +68,24 @@ export default {
const recvOnly = ref(true) const recvOnly = ref(true)
const resolutions = ref([]) const resolutions = ref([])
const resolution = ref('') const resolution = ref('')
// 监听 cameraId 的变化 // 监听 cameraId 或 stream 的变化,重新拉流
watch(() => props.cameraId, (newCameraId, oldCameraId) => { const startOrRestart = () => {
// console.log(newCameraId);
if (newCameraId !== oldCameraId) {
ZLMRTCClient.GetAllScanResolution().forEach((r, i) => { ZLMRTCClient.GetAllScanResolution().forEach((r, i) => {
let text = r.label + '(' + r.width + 'x' + r.height + ')' let text = r.label + '(' + r.width + 'x' + r.height + ')'
resolutions.value.push({ resolutions.value.push({ text, value: r })
text: text,
value: r
})
}) })
resolution.value = resolutions.value[0]?.text
stopVideo()
startVideo()
}
resolution.value = resolutions.value[0].text watch(() => props.cameraId, (newVal, oldVal) => {
stopVideo() if (newVal !== oldVal) startOrRestart()
startVideo() })
// console.log(newCameraId);
// 释放资源 watch(() => props.stream, (newVal, oldVal) => {
// console.log(newCameraId+"54651561"); if (newVal !== oldVal) startOrRestart()
} })
}),
onMounted(() => { onMounted(() => {
@ -131,12 +132,13 @@ export default {
const urlIp = resCamera const urlIp = resCamera
console.log(urlIp); console.log(urlIp);
const streamName = props.stream || ('camera' + props.cameraId)
const streamUrl = ref( const streamUrl = ref(
document.location.protocol + document.location.protocol +
'//' + '//' +
urlIp.rtcServer + urlIp.rtcServer +
':'+urlIp.rtcServerPort+'/index/api/webrtc?app=live&stream=camera' + ':'+urlIp.rtcServerPort+'/index/api/webrtc?app=live&stream=' +
props.cameraId + streamName +
'&type=play' '&type=play'
) )
console.log(streamUrl.value); console.log(streamUrl.value);

@ -0,0 +1,482 @@
<template>
<div class="warehouse-bg" ref="containerRef">
<!-- 动态粒子背景 -->
<canvas ref="canvasRef" class="particle-canvas"></canvas>
<!-- 右上角:类型图标统计 -->
<div class="type-legend">
<div
v-for="t in typeStats"
:key="t.typeCode"
class="legend-item"
:class="{ 'has-alarm-legend': t.alarmCount > 0 }"
@click="toggleTypeFilter(t.typeId)"
>
<img :src="t.icon" class="legend-icon" />
<span class="legend-count">{{ t.count }}</span>
</div>
</div>
<div class="img-wrapper"
ref="imgWrapperRef"
@wheel.prevent="onWheel"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseUp"
>
<div class="img-content" :style="{ transform: `scale(${scale}) translate(${translateX}px, ${translateY}px)` }">
<img src="/src/assets/imgs/home/warehouse.png" class="bg-img" />
<div
v-for="device in filteredDeviceList"
:key="device.id"
class="device-marker"
:class="{
'has-alarm': device.alarmStatus === 1,
'is-offline': device.onlineStatus === 0,
'is-selected': selectedDevice?.id === device.id
}"
:data-device-uid="device.deviceId + '-' + (device.location || device.deviceName)"
:style="{ left: device.locationX + '%', top: device.locationY + '%' }"
@click.stop="toggleDevice(device)"
>
<img :src="getDeviceIcon(device)" class="marker-icon" />
</div>
</div>
</div>
<!-- 设备详情弹窗 - 用 fixed 直接定位在视口 -->
<Teleport to="body">
<div v-if="selectedDevice && popupPos" class="device-detail-fixed" :style="{ left: popupPos.x + 'px', top: popupPos.y + 'px' }">
<div class="detail-header" :class="headerClass(selectedDevice)">
<span class="detail-name">{{ selectedDevice.deviceName }}</span>
<el-tag :type="selectedDevice.alarmStatus === 1 ? 'danger' : 'success'" size="small">
{{ selectedDevice.alarmStatus === 1 ? '报警' : '正常' }}
</el-tag>
<el-tag :type="selectedDevice.onlineStatus === 1 ? 'success' : 'info'" size="small" effect="plain">
{{ selectedDevice.onlineStatus === 1 ? '在线' : '离线' }}
</el-tag>
<el-button class="close-btn" text size="small" @click="selectedDevice = null">✕</el-button>
</div>
<div class="detail-body">
<div class="detail-row"><span class="dl">设备类型</span><span class="dv">{{ selectedDevice.typeName }}</span></div>
<template v-if="hasData(selectedDevice)">
<div class="detail-section">
<div class="detail-section-title">最新数据</div>
<div v-for="(field, idx) in resolvedFields(selectedDevice)" :key="idx" class="detail-row">
<span class="dl">{{ field.name }}</span>
<span class="dv">{{ field.value }}{{ field.unit }}</span>
</div>
</div>
<div class="detail-row"><span class="dl">接收时间</span><span class="dv">{{ formatTime(selectedDevice.receiveTime) }}</span></div>
</template>
<div v-else class="no-data-text">暂无数据</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { DeviceLatestDataApi } from '@/api/warehousealarm/devicelatestdata'
import { DeviceTypesApi } from '@/api/warehousealarm/devicetypes'
defineOptions({ name: 'WarehouseBg' })
const containerRef = ref<HTMLElement>()
const canvasRef = ref<HTMLCanvasElement>()
const deviceList = ref<any[]>([])
const typeOptions = ref<any[]>([])
const typeFieldsMap = ref<Record<number, any[]>>({})
const selectedDevice = ref<any>(null)
const hiddenTypeIds = ref<Set<number>>(new Set())
const imgWrapperRef = ref<HTMLElement>()
const popupPos = ref<{ x: number; y: number } | null>(null)
const typeStats = computed(() => {
const map = new Map<number, { typeId: number; typeCode: string; count: number; alarmCount: number; icon: string }>()
for (const d of deviceList.value) {
const type = typeOptions.value.find((t: any) => t.id === d.typeId)
const typeCode = type?.typeCode || ''
if (!map.has(d.typeId)) {
const base = '/src/assets/imgs/device/'
map.set(d.typeId, { typeId: d.typeId, typeCode, count: 0, alarmCount: 0, icon: base + typeCode + '.png' })
}
const entry = map.get(d.typeId)!
entry.count++
if (d.alarmStatus === 1) entry.alarmCount++
}
return Array.from(map.values())
})
const toggleTypeFilter = (typeId: number) => {
if (hiddenTypeIds.value.has(typeId)) hiddenTypeIds.value.delete(typeId)
else hiddenTypeIds.value.add(typeId)
}
const filteredDeviceList = computed(() => {
if (hiddenTypeIds.value.size === 0) return deviceList.value
return deviceList.value.filter(d => !hiddenTypeIds.value.has(d.typeId))
})
/** 计算弹窗位置:在图标右侧,靠右时自动左移 */
const calcPopupPosition = (device: any) => {
if (!containerRef.value) return null
const uid = device.deviceId + '-' + (device.location || device.deviceName)
const el = containerRef.value.querySelector(`[data-device-uid="${uid}"]`) as HTMLElement | null
if (!el) return null
const rect = el.getBoundingClientRect()
const gap = 8
let left = rect.right + gap
let top = rect.top
if (left + 220 > window.innerWidth) left = rect.left - 220 - gap
if (top + 300 > window.innerHeight) top = window.innerHeight - 310
if (top < 10) top = 10
return { x: left, y: top }
}
const getDeviceIcon = (item: any) => {
const base = '/src/assets/imgs/device/'
const type = typeOptions.value.find((t: any) => t.id === item.typeId)
const typeCode = type?.typeCode || ''
if (!typeCode) return ''
if (item.alarmStatus === 1) return base + typeCode + '_e.png'
if (item.onlineStatus === 0) return base + typeCode + '_info.png'
return base + typeCode + '.png'
}
const hasData = (item: any) => item.dataJson && item.dataJson !== '{}' && item.dataJson !== 'null'
const resolvedFields = (item: any) => {
const fields = typeFieldsMap.value[item.typeId] || []
if (fields.length === 0) return []
let raw: Record<string, any> = {}
try { raw = typeof item.dataJson === 'string' ? JSON.parse(item.dataJson) : (item.dataJson || {}) } catch { return [] }
return fields.map((f: any) => ({
name: f.name || f.key,
value: raw[f.key] !== undefined ? raw[f.key] : '-',
unit: f.unit || '',
}))
}
const formatTime = (ts: any) => {
if (!ts) return '-'
const d = new Date(ts)
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`
}
const headerClass = (item: any) => {
if (item.alarmStatus === 1) return 'h-alarm'
if (item.onlineStatus === 0) return 'h-offline'
return 'h-normal'
}
// 缩放/拖动
const scale = ref(1)
const translateX = ref(0)
const translateY = ref(0)
let isDragging = false
let dragDistance = 0
let lastX = 0
let lastY = 0
const onWheel = (e: WheelEvent) => {
const delta = e.deltaY > 0 ? 0.9 : 1.1
const newScale = Math.max(0.5, Math.min(5, scale.value * delta))
// 以鼠标位置为中心缩放
if (imgWrapperRef.value) {
const rect = imgWrapperRef.value.getBoundingClientRect()
const mx = (e.clientX - rect.left) / rect.width
const my = (e.clientY - rect.top) / rect.height
translateX.value += (mx - 0.5) * rect.width * (1 - delta / (newScale / scale.value))
translateY.value += (my - 0.5) * rect.height * (1 - delta / (newScale / scale.value))
}
scale.value = newScale
}
const onMouseDown = (e: MouseEvent) => {
// 点击设备标记时不触发拖动
if ((e.target as HTMLElement).closest('.device-marker')) return
isDragging = true
dragDistance = 0
lastX = e.clientX
lastY = e.clientY
e.preventDefault()
}
const onMouseMove = (e: MouseEvent) => {
if (!isDragging) return
const dx = e.clientX - lastX
const dy = e.clientY - lastY
dragDistance += Math.abs(dx) + Math.abs(dy)
lastX = e.clientX
lastY = e.clientY
translateX.value += dx
translateY.value += dy
e.preventDefault()
}
const onMouseUp = () => { isDragging = false; dragDistance = 0 }
const toggleDevice = async (device: any) => {
// 拖动超过阈值时不触发点击
if (dragDistance > 5) { dragDistance = 0; return }
if (selectedDevice.value?.deviceId === device.deviceId && selectedDevice.value?.location === device.location) {
selectedDevice.value = null
popupPos.value = null
} else {
selectedDevice.value = device
await nextTick()
popupPos.value = calcPopupPosition(device)
}
}
onMounted(async () => {
try {
const typeData = await DeviceTypesApi.getDeviceTypesPage({ pageNo: 1, pageSize: 100 })
typeOptions.value = typeData.list ?? []
const map: Record<number, any[]> = {}
for (const type of typeOptions.value) {
if (type.dataFields) {
try { map[type.id] = typeof type.dataFields === 'string' ? JSON.parse(type.dataFields) : type.dataFields } catch { map[type.id] = [] }
} else { map[type.id] = [] }
}
typeFieldsMap.value = map
const devData = await DeviceLatestDataApi.getDeviceTypesListAll()
const parseLocation = (loc: string) => {
if (!loc) return null
const parts = loc.split(',').map(Number)
if (parts.length !== 2 || isNaN(parts[0]) || isNaN(parts[1])) return null
return { x: parts[0], y: parts[1] }
}
deviceList.value = (devData ?? []).filter((d: any) => {
const loc = parseLocation(d.location)
if (!loc) return false
d.locationX = loc.x
d.locationY = loc.y
return true
})
} catch {}
// 粒子动画
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const resize = () => {
canvas.width = containerRef.value?.clientWidth || 300
canvas.height = containerRef.value?.clientHeight || 300
}
resize()
window.addEventListener('resize', resize)
const particles: { x: number; y: number; vx: number; vy: number; r: number; alpha: number; speed: number }[] = []
const count = 40
for (let i = 0; i < count; i++) {
particles.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
vx: (Math.random() - 0.5) * 0.4,
vy: (Math.random() - 0.5) * 0.4,
r: Math.random() * 2 + 1,
alpha: Math.random() * 0.4 + 0.1,
speed: Math.random() * 0.3 + 0.1,
})
}
let animId = 0
const animate = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height)
for (const p of particles) {
p.x += p.vx * p.speed
p.y += p.vy * p.speed
if (p.x < 0 || p.x > canvas.width) p.vx *= -1
if (p.y < 0 || p.y > canvas.height) p.vy *= -1
ctx.beginPath()
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
ctx.fillStyle = `rgba(10, 255, 255, ${p.alpha})`
ctx.fill()
}
// 连线
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const dx = particles[i].x - particles[j].x
const dy = particles[i].y - particles[j].y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < 120) {
ctx.beginPath()
ctx.moveTo(particles[i].x, particles[i].y)
ctx.lineTo(particles[j].x, particles[j].y)
ctx.strokeStyle = `rgba(10, 255, 255, ${0.08 * (1 - dist / 120)})`
ctx.lineWidth = 0.5
ctx.stroke()
}
}
}
animId = requestAnimationFrame(animate)
}
animate()
})
</script>
<style scoped>
.warehouse-bg {
width: 100%;
height: 100%;
position: relative;
background: transparent;
}
.particle-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
}
.img-wrapper {
width: 100%;
height: 100%;
position: relative;
z-index: 1;
overflow: hidden;
cursor: grab;
}
.img-wrapper:active { cursor: grabbing; }
.img-wrapper { user-select: none; -webkit-user-select: none; }
.img-content {
width: 100%;
height: 100%;
transform-origin: center center;
position: relative;
}
.bg-img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
/* 右上角图例 */
.type-legend {
position: absolute;
top: 8px;
right: 8px;
z-index: 20;
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.legend-item {
background: rgba(10,30,60,0.7);
border: 1px solid rgba(10,255,255,0.15);
border-radius: 8px;
padding: 4px 8px;
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(4px);
}
.legend-item:hover { border-color: rgba(10,255,255,0.4); }
.legend-item.has-alarm-legend { border-color: rgba(255,0,0,0.5); animation: legendFlash 1.5s infinite; }
@keyframes legendFlash { 0%, 100% { box-shadow: 0 0 4px rgba(255,0,0,0.3); } 50% { box-shadow: 0 0 10px rgba(255,0,0,0.6); } }
.legend-icon { width: 20px; height: 20px; object-fit: contain; }
.legend-count { color: #0affff; font-size: 12px; font-weight: 600; }
/* 设备标记 */
.device-marker {
position: absolute;
transform: translate(-50%, -50%);
width: 32px;
height: 32px;
cursor: pointer;
z-index: 2;
transition: transform 0.2s;
}
.device-marker::before {
content: '';
position: absolute;
inset: -6px;
border-radius: 50%;
border: 2px solid rgba(10, 255, 255, 0.4);
animation: breathGlow 2s ease-in-out infinite;
pointer-events: none;
}
@keyframes breathGlow {
0%, 100% { opacity: 0.4; box-shadow: 0 0 10px rgba(10,255,255,0.3), 0 0 20px rgba(10,255,255,0.1); }
50% { opacity: 1; box-shadow: 0 0 25px rgba(10,255,255,0.6), 0 0 50px rgba(10,255,255,0.25); }
}
.device-marker.has-alarm::before {
border-color: rgba(255, 0, 0, 0.8);
animation: breathGlowRed 0.8s ease-in-out infinite;
}
@keyframes breathGlowRed {
0%, 100% { opacity: 0.5; box-shadow: 0 0 15px rgba(255,0,0,0.4), 0 0 35px rgba(255,0,0,0.15); }
50% { opacity: 1; box-shadow: 0 0 35px rgba(255,0,0,0.8), 0 0 70px rgba(255,0,0,0.35); }
}
.device-marker.is-offline::before {
border-color: rgba(255, 165, 0, 0.5);
animation: breathGlowOrange 2s ease-in-out infinite;
}
@keyframes breathGlowOrange {
0%, 100% { opacity: 0.3; box-shadow: 0 0 10px rgba(255,165,0,0.3), 0 0 20px rgba(255,165,0,0.1); }
50% { opacity: 0.8; box-shadow: 0 0 22px rgba(255,165,0,0.6), 0 0 44px rgba(255,165,0,0.25); }
}
.device-marker:hover { transform: translate(-50%, -50%) scale(1.3); z-index: 3; }
.device-marker.is-selected { transform: translate(-50%, -50%) scale(1.4); z-index: 4; }
.marker-icon {
width: 100%;
height: 100%;
object-fit: contain;
position: relative;
z-index: 1;
}
/* 固定定位弹窗 */
.device-detail-fixed {
position: fixed;
z-index: 9999;
width: 220px;
background: rgba(10,30,60,0.95);
border: 1px solid rgba(10,255,255,0.25);
border-radius: 10px;
overflow: hidden;
backdrop-filter: blur(10px);
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
.detail-header {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
color: #fff;
font-weight: 600;
font-size: 12px;
}
.h-alarm { background: linear-gradient(135deg, #f56c6c, #e6422e); }
.h-normal { background: linear-gradient(135deg, #67c23a, #409eff); }
.h-offline { background: linear-gradient(135deg, #909399, #606266); }
.detail-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.close-btn { color: #fff !important; }
.detail-body { padding: 8px 10px; font-size: 12px; }
.detail-row { display: flex; justify-content: space-between; padding: 3px 0; color: rgba(136,221,255,0.8); }
.dl { color: rgba(136,221,255,0.5); flex-shrink: 0; }
.dv { font-weight: 600; color: #0affff; }
.detail-section { margin: 4px 0; padding: 4px 6px; background: rgba(10,255,255,0.05); border-radius: 6px; }
.detail-section-title { font-weight: 600; font-size: 11px; color: #0affff; margin-bottom: 2px; }
.no-data-text { text-align: center; color: rgba(136,221,255,0.4); padding: 12px 0; }
</style>

@ -1,546 +1,328 @@
<template> <template>
<ContentWrap class="body" > <div class="dashboard">
<el-row :gutter="20"> <!-- 顶部标题栏 -->
<!-- 左侧区域 --> <div class="dashboard-header">
<el-col :span="7" > <div class="header-left">
<!-- 左上:统计信息 --> <span class="header-time">{{ currentTime }}</span>
<el-card class="box-card" style="height: 250px" shadow="always" > <span class="header-weekday">{{ currentWeekday }}</span>
<template #header> </div>
<div class="card-header" :body-style="{ borderTop: 'none' }"> <div class="header-title">
<span>统计信息</span> <span class="title-deco">✦</span>
智能安全管控系统
<span class="title-deco">✦</span>
</div>
<div class="header-right"></div>
</div>
<!-- 主体 -->
<el-row :gutter="10" class="main-row">
<!-- 左列 1/4 -->
<el-col :span="6">
<div class="left-col">
<div class="glass-panel">
<div class="card-title"><span class="title-badge">📊</span> 设备在线率<span class="title-en">DEVICE ONLINE</span></div>
<div class="rate-content">
<div class="ring-wrap">
<svg viewBox="0 0 100 100" class="rate-ring-svg">
<circle cx="50" cy="50" r="42" class="ring-bg" />
<circle cx="50" cy="50" r="42" class="ring-fg" :stroke-dasharray="`${onlineRate * 264} 264`" />
<circle cx="50" cy="50" r="42" class="ring-glow" :stroke-dasharray="`${onlineRate * 264} 264`" />
</svg>
<div class="ring-center">
<div class="ring-pct">{{ Math.round(onlineRate * 100) }}%</div>
</div>
</div>
<div class="rate-detail">
<div class="rate-item"><span class="dot dot-online"></span><span class="rate-label">在线</span><b>{{ onlineCount }}</b></div>
<div class="rate-item"><span class="dot dot-offline"></span><span class="rate-label">离线</span><b>{{ offlineCount }}</b></div>
<div class="rate-item rate-total"><span class="rate-label">总数</span><b>{{ onlineCount + offlineCount }}</b></div>
</div>
</div> </div>
</template>
<div>
<p class="colour-text">总盘点数: {{ statistics.checkLogCount }}</p>
<p class="colour-text">总随行数: {{ statistics.orderCount }}</p>
<p class="colour-text">月盘点数: {{ statistics.checkLogMonthCount }}</p>
<p class="colour-text">月随行数: {{ statistics.orderMonthCount }}</p>
</div> </div>
</el-card> <div class="glass-panel">
<div class="card-title"><span class="title-badge">🌡</span> 区域温度<span class="title-en">TEMPERATURE</span></div>
<!-- 左中:条状图 --> <div class="chart-wrap">
<el-card class="box-card" shadow="always"> <Echart :options="tempBarOption" />
<template #header>
<div class="card-header">
<span>盘点状态</span>
</div> </div>
</template>
<Echart dark="true" style="width: 100%; height: 200px" :options="deviceStatusChartOption" />
</el-card>
</el-col>
<!-- 中间区域 选择相机 -->
<el-col :span="10">
<el-card class="box-card" shadow="always" style="height: 590px">
<el-select v-model="selectedCamera" placeholder="请选择相机" style="width: 100%">
<el-option
v-for="camera in cameraList"
:key="camera.id"
:label="camera.name"
:value="camera.id"
/>
</el-select>
<div style="height: 500px; background-color: #555555" class="mt-20px">
<Camera v-if="selectedCamera" :cameraId="selectedCamera" />
</div> </div>
</el-card> <div class="glass-panel">
</el-col> <div class="card-title"><span class="title-badge">💧</span> 区域湿度<span class="title-en">HUMIDITY</span></div>
<!-- <el-col :span="10"> <div class="chart-wrap">
<el-card class="box-card" shadow="always" style="height: 590px"> <Echart :options="humidityLineOption" />
<template #header>
<div class="card-header">
<span>实时视频</span>
</div> </div>
</template>
<div style="height: 500px; background-color: #555555; display: flex; align-items: center; justify-content: center;">
<video
src="http://121.43.244.209:9010/record/live/camera16/2025-08-25/15-45-26-0_compressed.mp4"
autoplay
loop
muted
style="width: 100%; height: 100%; object-fit: contain;"
></video>
</div> </div>
</el-card> </div>
</el-col> -->
<!-- 右侧区域 -->
<el-col :span="7">
<!-- 右上:快捷方式 -->
<el-card class="box-card" style="height: 250px" shadow="always">
<template #header>
<div class="card-header">
<span>快捷方式</span>
</div>
</template>
<!-- <el-skeleton :loading="loading" animated>
</el-skeleton> -->
<el-row>
<p></p>
<el-col v-for="item in shortcut" :key="`team-${item.name}`" :span="8" class="mb-50px">
<div class="flex items-center">
<Icon :icon="item.icon" size="30" class="mr-3px" />
<router-link :to="item.url">
<el-link type="default" :underline="false">
<span class='white-text-icon'>{{ item.name }}</span>
</el-link>
</router-link>
</div>
</el-col>
</el-row>
</el-card>
<!-- 右中:另一个条状图 -->
<el-card class="box-card" shadow="always">
<template #header>
<div class="card-header">
<span>本月随行情况</span>
</div>
</template>
<Echart dark="true" style="width: 100%; height: 200px" :options="orderChartOption" />
</el-card>
</el-col> </el-col>
</el-row>
<!-- 底部区域 --> <!-- 中右列 3/4 -->
<el-row :gutter="20" class="mt-20px"> <el-col :span="18">
<el-col :span="7"> <div class="right-area">
<!-- 左下:实时滚动信息 --> <!-- 上面全部是 3D 仓库背景 -->
<el-card class="box-card" shadow="always"> <div class="bg-section">
<template #header> <WarehouseBg />
<div class="card-header"> </div>
<span>盘点饼状图</span> <!-- 下面一行:报警记录 + 按钮 -->
</div> <div class="bottom-row">
</template> <div class="bottom-left">
<Echart dark="true" style="width: 100%; height: 200px" :options="stockPieOptions" /> <div class="glass-panel">
</el-card> <div class="card-title"><span class="title-badge">📋</span> 报警记录<span class="title-en">RECORDS</span></div>
</el-col> <el-table :data="alarmRecords" size="small" class="dark-table" stripe>
<el-col :span="10"> <el-table-column label="时间" prop="time" min-width="80" />
<el-card class="box-card" shadow="always"> <el-table-column label="区域" prop="area" min-width="50" />
<template #header> <el-table-column label="类型" prop="type" min-width="80" />
<div class="card-header"> <el-table-column label="信息" prop="message" min-width="80" />
<span>随行折线图</span> </el-table>
</div> </div>
</template>
<Echart dark="true" style="width: 100%; height: 200px" :options="laneInventoryLineOptions" />
</el-card>
</el-col>
<el-col :span="7">
<!-- 左下:实时滚动信息 -->
<el-card class="box-card" shadow="always">
<template #header>
<div class="card-header">
<span>实时滚动信息</span>
</div> </div>
</template> <div class="bottom-right">
<el-scrollbar height="200px"> <div class="glass-panel nav-panel">
<div <div class="nav-bar">
v-for="(item, index) in realTimeLogs.slice().reverse()" <router-link to="/warehousealarm/deviceDashboard">
:key="index" <el-button class="nav-btn"><span class="nav-icon">📡</span> 设备看板</el-button>
class="log-item" </router-link>
> <router-link to="/warehousealarm/alarmStatistics">
<p class="colour-text"> <el-button class="nav-btn"><span class="nav-icon">📋</span> 报警记录</el-button>
</router-link>
{{ item }} </div>
</p> </div>
</div> </div>
</div>
</el-scrollbar> </div>
</el-card>
</el-col> </el-col>
</el-row> </el-row>
</ContentWrap> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { CameraApi, CameraVO } from '@/api/logistics/camera'
import { homeApi } from '@/api/logistics/home'
import Camera from '@/components/camera/camera.vue'
import { EChartsOption } from 'echarts' import { EChartsOption } from 'echarts'
import { StockApi } from '@/api/logistics/stock' import WarehouseBg from '@/components/warehouseBg/index.vue'
import type { Shortcut } from './types' import { DeviceLatestDataApi } from '@/api/warehousealarm/devicelatestdata'
import { AlarmRecordsApi } from '@/api/warehousealarm/alarmrecords'
// ----------------------------- 数据定义 -----------------------------
defineOptions({ name: 'HomeDashboard' })
const currentTime = ref('')
const currentWeekday = ref('')
let timer: any = null
const updateTime = () => {
const now = new Date()
const w = ['星期日','星期一','星期二','星期三','星期四','星期五','星期六'][now.getDay()]
currentTime.value = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`
currentWeekday.value = w
}
// 统计信息 const onlineCount = ref(0)
const statistics = ref({ const offlineCount = ref(0)
checkLogCount: 0, const onlineRate = computed(() => {
orderCount: 0, const total = onlineCount.value + offlineCount.value
checkLogMonthCount: 0, return total > 0 ? onlineCount.value / total : 0
orderMonthCount: 0
}) })
const laneInventoryStatistics = ref() const alarmRecords = ref<any[]>([])
const laneInventoryLine = ref()
const stockLaneInventoryStatistics = ref()
const buttonByStatusMap = ref(new Map<string, number>()) // 存储 row-column 到 statusString 的映射
// 选择相机直播
const selectedCamera = ref<number | null>(null)
const cameraList = ref<CameraVO[]>([])
// 快捷方式 const tempBarOption = ref<EChartsOption>({})
let shortcut = reactive<Shortcut[]>([]) const humidityLineOption = ref<EChartsOption>({})
// 实时滚动信息 const initCharts = () => {
const realTimeLogs = ref<string[]>([]) // 初始占位,加载数据后会更新
let logTimer: NodeJS.Timeout | null = null tempBarOption.value = {
tooltip: { trigger: 'axis' },
// ----------------------------- 数据获取 ----------------------------- grid: { left: 34, right: 8, bottom: 18, top: 6 },
xAxis: { type: 'category', data: [], axisLabel: { color: 'rgba(10,255,255,0.8)', fontSize: 9 } },
yAxis: { type: 'value', axisLabel: { color: 'rgba(10,255,255,0.6)', fontSize: 9 }, splitLine: { lineStyle: { color: 'rgba(10,255,255,0.06)' } } },
series: [{ type: 'bar', data: [], itemStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: '#0affff' }, { offset: 1, color: 'rgba(10,255,255,0.2)' }] }, borderRadius: [4,4,0,0] } }],
}
humidityLineOption.value = {
tooltip: { trigger: 'axis' },
grid: { left: 34, right: 8, bottom: 18, top: 6 },
xAxis: { type: 'category', data: [], axisLabel: { color: 'rgba(10,255,255,0.8)', fontSize: 9 } },
yAxis: { type: 'value', axisLabel: { color: 'rgba(10,255,255,0.6)', fontSize: 9 }, splitLine: { lineStyle: { color: 'rgba(10,255,255,0.06)' } } },
series: [{ type: 'line', smooth: true, data: [], lineStyle: { color: '#0affff', width: 2 }, areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(10,255,255,0.25)' }, { offset: 1, color: 'rgba(10,255,255,0)' }] } }, symbol: 'circle', symbolSize: 4 }],
}
}
// 获取所有数据 const loadOnlineRate = async () => {
const getAll = async () => {
try { try {
statistics.value = await homeApi.statistics() const res = await DeviceLatestDataApi.getOnlineRate()
laneInventoryStatistics.value = await homeApi.laneInventoryStatistics() const data = (res as any)
laneInventoryLine.value = await homeApi.laneInventoryLine() if (data) {
stockLaneInventoryStatistics.value = await homeApi.stockLaneInventoryStatistics() onlineCount.value = data.onlineCount ?? 0
offlineCount.value = data.offlineCount ?? 0
const chartData = stockLaneInventoryStatistics.value
// 盘点状态条状图选项
deviceStatusChartOption.value = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
legend: {
data: chartData.series.map((series) => series.name)
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: chartData.xaxis,
yAxis: chartData.yaxis,
series: chartData.series.map((series) => ({
name: series.name,
type: series.type,
stack: series.stack,
itemStyle: {
color: series.color
},
data: series.data
}))
} }
} catch {}
}
// 设置本月随行情况条状图选项 const loadTemperature = async () => {
orderChartOption.value = { try {
tooltip: { const res = await DeviceLatestDataApi.getTemperatureByZone()
trigger: 'axis', const data = (res as any)
axisPointer: { const zones = data.map((d: any) => d.zone)
type: 'shadow' const values = data.map((d: any) => d.value)
} if (tempBarOption.value.xAxis && 'data' in tempBarOption.value.xAxis) {
}, tempBarOption.value.xAxis.data = zones
grid: {
left: '3%',
right: '4%',
bottom: '10%', // 标签多时增加底部边距
containLabel: true
},
xAxis: laneInventoryStatistics.value.xaxis,
yAxis: {
type: 'value',
minInterval: 1
},
series: laneInventoryStatistics.value.series
} }
if (tempBarOption.value.series && Array.isArray(tempBarOption.value.series)) {
// 设置随行折线图选项 tempBarOption.value.series[0].data = values
laneInventoryLineOptions.value = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
legend: laneInventoryLine.value.legend,
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: laneInventoryLine.value.xaxis,
yAxis: {
type: 'value',
minInterval: 1
},
series: laneInventoryLine.value.series
} }
tempBarOption.value = { ...tempBarOption.value }
// 获取街道状态数据并更新饼状图 } catch {}
const data = await homeApi.stockLaneInventoryStatisticsPie()
stockPieOptions.series[0].data = data
} catch (error) {
console.error('获取数据时出错:', error)
}
} }
// 获取快捷入口 const loadHumidity = async () => {
const getShortcut = async () => { try {
const data = [ const res = await DeviceLatestDataApi.getHumidityByZone()
{ const data = (res as any)
name: '随行记录', const zones = data.map((d: any) => d.zone)
icon: 'ep:data-analysis', const values = data.map((d: any) => d.value)
url: 'logistics/order', if (humidityLineOption.value.xAxis && 'data' in humidityLineOption.value.xAxis) {
}, humidityLineOption.value.xAxis.data = zones
{
name: '盘点管理',
icon: 'ep:coin',
url: 'logistics/check-log',
},
{
name: '盘点信息',
icon: 'ep:pointer',
url: 'logistics/stock',
},
{
name: '实时视频',
icon: 'ep:magic-stick',
url: 'cameraTree/cameraStreet',
},
{
name: '巷道管理',
icon: 'fa-solid:rainbow',
url: 'system/street',
},
{
name: '相机管理',
icon: 'ep:camera-filled',
url: 'system/camera',
} }
] if (humidityLineOption.value.series && Array.isArray(humidityLineOption.value.series)) {
shortcut = Object.assign(shortcut, data) humidityLineOption.value.series[0].data = values
} }
humidityLineOption.value = { ...humidityLineOption.value }
// ----------------------------- 数据处理 ----------------------------- } catch {}
// 构造 ECharts 数据,使用 statusString 作为 name
const echartData = (): { name: string; value: number }[] => {
return Array.from(buttonByStatusMap.value, ([name, value]) => ({ name, value }))
} }
// ----------------------------- 图表选项 ----------------------------- const loadAlarmRecords = async () => {
try {
// 条状图数据 const res = await AlarmRecordsApi.getAlarmRecordsPage({ pageNo: 1, pageSize: 7 })
const deviceStatusChartOption = ref<EChartsOption>({}) const list = res.list ?? []
alarmRecords.value = list.map((item: any) => ({
const laneInventoryLineOptions = ref<EChartsOption>({}) time: formatShortTime(item.triggerTime),
area: item.deviceName?.slice(0, 2) || '-',
const orderChartOption = ref<EChartsOption>({}) type: item.ruleName?.slice(0, 2) || '-',
message: item.ruleName || '-',
let eventSource: any = null }))
const sseUid = ref(`${Date.now()}-${Math.floor(Math.random() * 10000)}`) } catch {}
const sse = ()=>{
// 创建一个 EventSource 实例,连接到后端的 SSE 路径
eventSource = new EventSource(window.location.protocol + '//' + window.location.hostname+":"+import.meta.env.VITE_BASE_PORT+'/app-api/sse/createSse?uid=' + sseUid.value );
// 监听来自后端的消息
eventSource.onmessage = (event) => {
console.log(event.data);
realTimeLogs.value.push(event.data)
};
// 处理错误
eventSource.onerror = (error) => {
console.error('SSE 连接发生错误:', error);
};
} }
const colorListHex = {'未盘点':'#ebc26e','盘点异常':'#ed6d6d','盘点正确':'#6cc238','人工核对正确':'#5061c2'}; const formatShortTime = (ts: any) => {
// 盘点饼状图选项 if (!ts) return '-'
const stockPieOptions = reactive({ const d = new Date(ts)
tooltip: { return `${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`
trigger: 'item'
},
legend: {
top: '4%',
left: 'center'
},
series: [
{
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderWidth: 2,
color: function (params) {
// 使用 params.dataIndex 获取当前数据项的索引
return colorListHex[params.name];
}
},
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: 20,
fontWeight: 'bold'
}
},
labelLine: {
show: false
},
data: []
}
]
})
// ----------------------------- 快捷方式处理 -----------------------------
// 快捷方式处理
const handleShortcut = (action: string) => {
switch (action) {
case 'setting':
ElMessage.info('打开设置')
break
case 'help':
ElMessageBox.alert('帮助信息', '帮助', {
confirmButtonText: '确定'
})
break
case 'logout':
ElMessageBox.confirm('确认退出?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
ElMessage.success('退出成功')
})
.catch(() => {
ElMessage.info('已取消')
})
break
default:
ElMessage.warning('未知操作')
}
} }
// ----------------------------- 实时滚动信息 -----------------------------
// 启动日志定时器
// const startLogTimer = () => {
// logTimer = setInterval(() => {
// const newLog = `日志 ${realTimeLogs.value.length + 1}: ${new Date().toLocaleTimeString()}`
// realTimeLogs.value.push(newLog)
// if (realTimeLogs.value.length > 10) {
// realTimeLogs.value.shift()
// }
// }, 2000)
// }
// // 清除日志定时器
// const clearLogTimer = () => {
// if (logTimer) {
// clearInterval(logTimer)
// logTimer = null
// }
// }
// ----------------------------- 生命周期钩子 -----------------------------
// 初始化
onMounted(() => { onMounted(() => {
getShortcut() updateTime()
getAll() // 获取所有数据 timer = setInterval(updateTime, 60000)
// 获取相机列表 initCharts()
CameraApi.getCameraList().then((res) => { loadOnlineRate()
cameraList.value = res loadTemperature()
}) loadHumidity()
sse() loadAlarmRecords()
})
onUnmounted(() => {
// 在组件销毁时关闭 EventSource 连接
if (eventSource) {
eventSource.close();
}
}) })
onUnmounted(() => { if (timer) clearInterval(timer) })
</script> </script>
<style scoped>
.body{
background-image:url(/public/pic02.jpg) ;
background-size:cover;
}
.card-header {
display: flex;
justify-content: center;
align-items: center;
padding: 10px;
color: white;
background: url('/public/pic01.png') no-repeat center;
background-size: cover;
}
.colour-text {
color: white;
margin:10px;
}
.card-header::v-deep .el-card__header {
border-bottom: none !important;
}
.el-card {
border-color: rgba(5, 2, 53, 0.87); /* 深蓝色透明边框 */
background-color: rgba(5, 2, 53, 0.87); /* 深蓝色透明背景 */
}
.log-item {
padding: 5px 0;
border-bottom: 1px solid #ebeef5;
}
.log-item:last-child { <style scoped>
border-bottom: none; .dashboard {
background: url('/src/assets/imgs/home/background.png') no-repeat center;
background-size: 100% 100%;
background-color: #0a1628;
min-height: 85vh; padding: 4px 8px;
color: #0affff; font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif;
} }
.dashboard-header {
.mt-20px { display: flex; align-items: center; justify-content: space-between;
margin-top: 20px; height: 36px;
background: linear-gradient(90deg, transparent, rgba(10,255,255,0.08), transparent);
border-bottom: 1px solid rgba(10,255,255,0.12);
padding: 0 16px; margin-bottom: 6px;
} }
.header-left, .header-right { width: 180px; }
.mb-20px { .header-time { color: #0affff; font-size: 14px; font-weight: 600; letter-spacing: 1px; }
margin-bottom: 30px; .header-weekday { color: rgba(136,221,255,0.7); font-size: 11px; margin-left: 6px; }
.header-title {
font-size: 18px; font-weight: 700;
background: linear-gradient(180deg, #ffffff 0%, #0affff 80%);
-webkit-background-clip: text; background-clip: text; color: transparent;
letter-spacing: 6px;
} }
.title-deco { color: rgba(10,255,255,0.5); font-size: 12px; margin: 0 8px; }
.box-card { .main-row { height: calc(82vh); }
margin-bottom: 20px; .left-col { display: flex; flex-direction: column; gap: 8px; height: 100%; }
.left-col .glass-panel { flex: 1; }
.right-area { display: flex; flex-direction: column; height: 100%; gap: 8px; }
.bg-section { flex: 2; overflow: hidden; }
.bottom-row { display: flex; gap: 8px; flex-shrink: 0; height: 35%; }
.bottom-left { flex: 3; overflow: hidden; }
.bottom-left .glass-panel { height: 100%; }
.bottom-right { width: 24%; overflow: hidden; }
.bottom-right .glass-panel { height: 100%; display: flex; align-items: center; justify-content: center; }
.glass-panel {
background: linear-gradient(135deg, rgba(10,30,60,0.4) 0%, rgba(10,30,60,0.08) 100%);
border: 1px solid rgba(10,255,255,0.12);
border-radius: 10px;
padding: 8px 12px;
display: flex; flex-direction: column;
overflow: hidden;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: 0 0 15px rgba(10,255,255,0.05) inset;
} }
.box-card ::v-deep .el-card__header { .card-title {
border-bottom: none !important; font-size: 16px;
background: linear-gradient(90deg, #ffffff 0%, #0affff 60%, rgba(10,255,255,0.6) 100%);
-webkit-background-clip: text; background-clip: text; color: transparent;
font-weight: 700; letter-spacing: 3px; font-style: italic;
padding: 2px 0 3px 10px; margin: -8px -12px 4px -12px; flex-shrink: 0;
display: flex; align-items: center; gap: 6px;
text-shadow: 0 0 10px rgba(10,255,255,0.3);
border-bottom: 1px solid rgba(10,255,255,0.12);
position: relative;
} }
.card-title::before {
.box-card ::v-deep .el-card__body { content: ''; position: absolute; left: 0; top: 0; bottom: 0;
background-color: #070225; width: 3px; background: linear-gradient(180deg, #0affff, transparent);
color: #7d6ea3ee; /* 接近黑色*/ box-shadow: 0 0 6px rgba(10,255,255,0.4);
} }
.title-badge { font-size: 14px; background: none !important; -webkit-background-clip: unset !important; background-clip: unset !important; color: initial !important; }
.white-text-icon, .el-icon { .title-en { color: rgba(10,255,255,0.3); font-size: 9px; letter-spacing: 2px; font-weight: normal; margin-left: auto; font-style: normal; }
color: white !important; .chart-wrap { flex: 1; min-height: 0; }
.rate-content { display: flex; align-items: center; gap: 6px; flex: 1; padding: 4px; }
.ring-wrap { width: 60%; height: 60%; aspect-ratio: 1; position: relative; flex-shrink: 0; }
.rate-ring-svg { width: 100%; height: 100%; transform: rotate(-90deg); }
.ring-bg { fill: none; stroke: rgba(10,255,255,0.08); stroke-width: 6; }
.ring-fg { fill: none; stroke: #0affff; stroke-width: 6; filter: drop-shadow(0 0 4px rgba(10,255,255,0.5)); }
.ring-glow { fill: none; stroke: rgba(10,255,255,0.12); stroke-width: 10; }
.ring-center { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.ring-pct { color: #0affff; font-size: 16px; font-weight: 700; text-shadow: 0 0 6px rgba(10,255,255,0.4); }
.rate-detail { flex: 1; }
.rate-item { color: rgba(136,221,255,0.7); font-size: 12px; margin-bottom: 4px; display: flex; align-items: center; gap: 4px; }
.rate-item b { color: #0affff; margin-left: auto; font-size: 14px; }
.rate-label { flex: 1; }
.rate-total { border-top: 1px solid rgba(10,255,255,0.08); padding-top: 3px; margin-top: 3px; }
.dot { display: inline-block; width: 5px; height: 5px; border-radius: 50%; }
.dot-online { background: #0affff; box-shadow: 0 0 4px rgba(10,255,255,0.6); }
.dot-offline { background: #ffa500; box-shadow: 0 0 4px rgba(255,165,0,0.6); }
.nav-bar { display: flex; flex-direction: column; align-items: center; gap: 14px; width: 100%; padding: 8px; }
.nav-btn {
background: linear-gradient(135deg, rgba(10,255,255,0.1), rgba(10,255,255,0.03)) !important;
border: 1px solid rgba(10,255,255,0.2) !important;
color: #0affff !important;
border-radius: 8px !important;
padding: 10px 16px !important;
font-size: 13px !important;
letter-spacing: 1px;
width: 90%;
transition: all 0.3s ease;
} }
.nav-bar a { text-decoration: none; display: block; width: 90%; }
/*.box-card { box-shadow: 0 0 10px rgba(0, 0, 255, 0.5); } */ .nav-btn:hover { background: rgba(10,255,255,0.2) !important; border-color: #0affff !important; box-shadow: 0 0 20px rgba(10,255,255,0.15); transform: scale(1.02); }
.nav-icon { margin-right: 4px; font-size: 14px; }
.box-card { border-radius: 10px; } /* 加圆角*/
.dark-table { background: transparent !important; }
</style> .dark-table :deep(.el-table__row), .dark-table :deep(tr) { background: transparent !important; }
.dark-table :deep(td) { background: transparent !important; color: rgba(136,221,255,0.7) !important; border-bottom: 1px solid rgba(10,255,255,0.04) !important; font-size: 11px !important; padding: 3px 0 !important; }
.dark-table :deep(th.el-table__cell) { background: transparent !important; color: #0affff !important; border-bottom: 1px solid rgba(10,255,255,0.08) !important; font-size: 11px !important; padding: 3px 0 !important; }
.dark-table :deep(.el-table__inner-wrapper::before) { background: transparent !important; }
.dark-table :deep(.el-table--striped .el-table__body tr.el-table__row--striped td) { background: rgba(10,255,255,0.02) !important; }
</style>

@ -0,0 +1,165 @@
# 首页接口文档
## 1. 设备在线率统计
**接口**: `GET /warehousealarm/device-latest-data/online-rate`
**返回**:
```json
{
"code": 200,
"data": {
"onlineCount": 830,
"offlineCount": 170
}
}
```
---
## 2. 各区域温度
**接口**: `GET /warehousealarm/device-latest-data/temperature-by-zone`
**返回**:
```json
{
"code": 200,
"data": [
{ "zone": "A区", "value": 22.5 },
{ "zone": "B区", "value": 24.1 },
{ "zone": "C区", "value": 19.8 },
{ "zone": "D区", "value": 21.3 },
{ "zone": "E区", "value": 23.6 },
{ "zone": "F区", "value": 20.2 }
]
}
```
---
## 3. 各区域湿度
**接口**: `GET /warehousealarm/device-latest-data/humidity-by-zone`
**返回**:
```json
{
"code": 200,
"data": [
{ "zone": "A区", "value": 65 },
{ "zone": "B区", "value": 72 },
{ "zone": "C区", "value": 58 },
{ "zone": "D区", "value": 61 },
{ "zone": "E区", "value": 68 },
{ "zone": "F区", "value": 55 }
]
}
```
---
## 4. 传感器总览(各类型报警/正常数)
**接口**: `GET /warehousealarm/device-latest-data/sensor-overview`
**返回**:
```json
{
"code": 200,
"data": [
{ "name": "温湿度传感器", "alarm": 3, "normal": 47 },
{ "name": "烟雾传感器", "alarm": 1, "normal": 29 },
{ "name": "水浸传感器", "alarm": 2, "normal": 18 },
{ "name": "倾斜传感器", "alarm": 0, "normal": 12 },
{ "name": "报警灯", "alarm": 5, "normal": 15 },
{ "name": "边缘服务器", "alarm": 0, "normal": 8 }
]
}
```
---
## 5. 报警类型分布(饼图)
**接口**: `GET /warehousealarm/alarm-records/stats/by-type`
**返回**:
```json
{
"code": 200,
"data": [
{ "name": "温度告警", "value": 45 },
{ "name": "烟雾告警", "value": 28 },
{ "name": "水浸告警", "value": 18 },
{ "name": "倾斜告警", "value": 9 }
]
}
```
---
## 6. 报警时长统计
**接口**: `GET /warehousealarm/alarm-records/stats/duration-by-type`
**返回**:
```json
{
"code": 200,
"data": [
{ "name": "温度", "value": 32 },
{ "name": "烟雾", "value": 18 },
{ "name": "水浸", "value": 25 },
{ "name": "倾斜", "value": 12 }
]
}
```
---
## 7. 报警记录列表
**接口**: `GET /warehousealarm/alarm-records/page`
**参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| pageNo | int | 否 | 页码,默认1 |
| pageSize | int | 否 | 每页条数,默认6 |
| status | int | 否 | 状态筛选,1=待处理 |
**返回**:
```json
{
"code": 200,
"data": {
"list": [
{
"triggerTime": "03-25 14:14",
"deviceName": "B区-设备037",
"ruleName": "网络异常告警",
"message": "网络异常"
}
],
"total": 100
}
}
```
---
## 8. 相机列表
**接口**: `GET /logistics/camera/list`
**返回**:
```json
{
"code": 200,
"data": [
{ "id": 1, "name": "摄像头-01" },
{ "id": 2, "name": "摄像头-02" }
]
}
```

@ -41,7 +41,7 @@
end-placeholder="结束" end-placeholder="结束"
value-format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss"
size="small" size="small"
style="width:100%" style="width:95%"
@change="onRangeChange" @change="onRangeChange"
/> />
</div> </div>
@ -52,24 +52,24 @@
<el-col :span="18"> <el-col :span="18">
<div class="video-wrapper"> <div class="video-wrapper">
<div class="video-container"> <div class="video-container">
<camera :cameraId="Number(selectedCameraId)" :ptzShow="false" v-if="selectedCameraId" /> <camera :cameraId="Number(selectedCameraId)" :ptzShow="false" v-if="selectedCameraId && currentStream" :stream="currentStream" />
<div v-else class="video-placeholder"> <div v-else class="video-placeholder">
<Icon icon="ep:video-camera" style="font-size:48px;color:#ccc" /> <Icon icon="ep:video-camera" style="font-size:48px;color:#ccc" />
<p>请选择相机</p> <p>请选择相机并开始回放</p>
</div> </div>
</div> </div>
<div v-if="selectedCameraId" class="video-controls"> <div v-if="selectedCameraId" class="video-controls">
<div class="ctrl-left"> <div class="ctrl-left">
<el-button size="small" @click="togglePlay" type="primary" circle> <el-button size="small" @click="doCommand('play')" type="primary" circle>
<Icon :icon="isPlaying ? 'ep:video-pause' : 'ep:video-play'" /> <Icon :icon="isPlaying ? 'ep:video-pause' : 'ep:video-play'" />
</el-button> </el-button>
<el-button size="small" @click="stopPlayback" :disabled="!isPlaying" circle> <el-button size="small" @click="doCommand('stop')" :disabled="!currentStream" circle>
<Icon icon="ep:video-pause" /> <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
</el-button> </el-button>
<el-button size="small" @click="skipFrame(-1)" :disabled="!isPlaying" circle> <el-button size="small" @click="doCommand('seekBackward', 30)" :disabled="!isPlaying" circle>
<Icon icon="ep:back" /> <Icon icon="ep:back" />
</el-button> </el-button>
<el-button size="small" @click="skipFrame(1)" :disabled="!isPlaying" circle> <el-button size="small" @click="doCommand('seekForward', 30)" :disabled="!isPlaying" circle>
<Icon icon="ep:right" /> <Icon icon="ep:right" />
</el-button> </el-button>
<el-select v-model="playSpeed" size="small" style="width:70px" @change="onSpeedChange"> <el-select v-model="playSpeed" size="small" style="width:70px" @change="onSpeedChange">
@ -79,14 +79,11 @@
</div> </div>
<div class="ctrl-right"> <div class="ctrl-right">
<el-button size="small" @click="captureSnapshot" circle><Icon icon="ep:camera" /></el-button> <el-button size="small" @click="captureSnapshot" circle><Icon icon="ep:camera" /></el-button>
<el-button size="small" @click="toggleRecording" circle>
<Icon :icon="isRecording ? 'ep:video-pause' : 'ep:video-play'" />
</el-button>
</div> </div>
</div> </div>
</div> </div>
<!-- 时间轴:根据起止时间动态刻度 --> <!-- 时间轴 -->
<div class="timeline-wrapper"> <div class="timeline-wrapper">
<div class="timeline-bar" ref="timelineRef" @mousedown="onTimelineMouseDown" @mousemove="onTimelineMouseMove" @mouseup="onTimelineMouseUp" @mouseleave="onTimelineMouseUp"> <div class="timeline-bar" ref="timelineRef" @mousedown="onTimelineMouseDown" @mousemove="onTimelineMouseMove" @mouseup="onTimelineMouseUp" @mouseleave="onTimelineMouseUp">
<div class="timeline-date-layer"> <div class="timeline-date-layer">
@ -100,7 +97,7 @@
</div> </div>
</div> </div>
<div class="timeline-track"> <div class="timeline-track">
<div v-for="(seg, idx) in playbackSegments" :key="idx" class="segment" :style="{ left: seg.left+'%', width: seg.width+'%' }"></div> <div v-for="(seg, idx) in recordSegments" :key="idx" class="segment" :style="{ left: seg.left+'%', width: seg.width+'%' }"></div>
<div class="playhead" :style="{ left: playheadPercent + '%' }" @mousedown.stop="onPlayheadDrag"> <div class="playhead" :style="{ left: playheadPercent + '%' }" @mousedown.stop="onPlayheadDrag">
<div class="playhead-dot"></div> <div class="playhead-dot"></div>
</div> </div>
@ -116,15 +113,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { CameraApi } from '@/api/logistics/camera' import { CameraApi } from '@/api/logistics/camera'
import { CameraControlApi } from '@/api/cameraControl/camera'
import camera from '@/components/camera/camera.vue' import camera from '@/components/camera/camera.vue'
defineOptions({ name: 'CameraPlayback' }) defineOptions({ name: 'CameraPlayback' })
// 相机列表
const cameraOptions = ref<any[]>([]) const cameraOptions = ref<any[]>([])
const cameraTotal = ref(0) const cameraTotal = ref(0)
const cameraPageNo = ref(1) const cameraPageNo = ref(1)
const cameraKeyword = ref('') const cameraKeyword = ref('')
const selectedCameraId = ref<number | undefined>(undefined) const selectedCameraId = ref<number | undefined>(undefined)
const channel = ref(1)
const loadCameras = async () => { const loadCameras = async () => {
try { try {
@ -134,29 +134,38 @@ const loadCameras = async () => {
} catch {} } catch {}
} }
const playbackRange = ref<string[]>(['2026-01-01 00:00:00', '2026-01-01 23:59:00']) // 回放时间
const getDefaultRange = () => {
// 直接用完整时间戳计算(支持跨天) const now = new Date()
const rangeStartMs = computed(() => { const todayStart = new Date(now)
if (!playbackRange.value?.[0]) return 0 todayStart.setHours(0, 0, 0, 0)
return new Date(playbackRange.value[0]).getTime() const todayEnd = new Date(now)
}) todayEnd.setHours(23, 59, 59, 999)
const rangeEndMs = computed(() => { const fmt = (d: Date) => {
if (!playbackRange.value?.[1]) return 0 const pad = (n: number) => String(n).padStart(2, '0')
return new Date(playbackRange.value[1]).getTime() return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}) }
return [fmt(todayStart), fmt(todayEnd)]
}
const playbackRange = ref<string[]>(getDefaultRange())
const rangeStartMs = computed(() => playbackRange.value?.[0] ? new Date(playbackRange.value[0]).getTime() : 0)
const rangeEndMs = computed(() => playbackRange.value?.[1] ? new Date(playbackRange.value[1]).getTime() : 0)
const totalDurationMs = computed(() => rangeEndMs.value - rangeStartMs.value) const totalDurationMs = computed(() => rangeEndMs.value - rangeStartMs.value)
// 播放状态
const isPlaying = ref(false) const isPlaying = ref(false)
const playSpeed = ref(1) const playSpeed = ref(1)
const speedOptions = [0.25, 0.5, 0.75, 1, 1.5, 2, 4, 8] const speedOptions = [0.25, 0.5, 0.75, 1, 1.5, 2, 4, 8]
const playheadPercent = ref(0) const playheadPercent = ref(0)
const currentStream = ref<string | undefined>(undefined)
const recordSegments = ref<any[]>([])
const currentPlayTime = computed(() => { const currentPlayTime = computed(() => {
const offset = (playheadPercent.value / 100) * totalDurationMs.value const offset = (playheadPercent.value / 100) * totalDurationMs.value
return msToFullTimeStr(rangeStartMs.value + offset) return msToFullTimeStr(rangeStartMs.value + offset)
}) })
/** 时间刻度:等分20份 */ // 时间刻度
const timeTicks = computed(() => { const timeTicks = computed(() => {
const totalMs = totalDurationMs.value const totalMs = totalDurationMs.value
if (totalMs <= 0) return [] if (totalMs <= 0) return []
@ -170,18 +179,15 @@ const timeTicks = computed(() => {
return ticks return ticks
}) })
/** 日期刻度:跨天时在每天 00:00 位置标注日期(显示在时间刻度上方) */
const dateTicks = computed(() => { const dateTicks = computed(() => {
const endMs = rangeEndMs.value
const startMs = rangeStartMs.value
const totalMs = totalDurationMs.value const totalMs = totalDurationMs.value
if (totalMs <= 0 || endMs - startMs <= 86400000) return [] if (totalMs <= 0 || rangeEndMs.value - rangeStartMs.value <= 86400000) return []
const ticks: { left: number; label: string }[] = [] const ticks: { left: number; label: string }[] = []
const dayStart = new Date(startMs) const dayStart = new Date(rangeStartMs.value)
dayStart.setHours(0, 0, 0, 0) dayStart.setHours(0, 0, 0, 0)
let dayMs = dayStart.getTime() let dayMs = dayStart.getTime()
while (dayMs <= endMs) { while (dayMs <= rangeEndMs.value) {
const pct = ((dayMs - startMs) / totalMs) * 100 const pct = ((dayMs - rangeStartMs.value) / totalMs) * 100
if (pct >= 0 && pct <= 100) { if (pct >= 0 && pct <= 100) {
const d = new Date(dayMs) const d = new Date(dayMs)
ticks.push({ left: pct, label: `${d.getMonth() + 1}/${d.getDate()}` }) ticks.push({ left: pct, label: `${d.getMonth() + 1}/${d.getDate()}` })
@ -191,55 +197,264 @@ const dateTicks = computed(() => {
return ticks return ticks
}) })
// 录像状态
const isRecording = ref(false) const isRecording = ref(false)
let mediaRecorder: MediaRecorder | null = null let mediaRecorder: MediaRecorder | null = null
let recordedChunks: Blob[] = [] let recordedChunks: Blob[] = []
const playbackSegments = ref<any[]>([])
let playTimer: ReturnType<typeof setInterval> | null = null
const onCameraChange = () => { generateMockSegments(); stopPlayback() } // 当前播放的录像文件索引
const onRangeChange = () => { playheadPercent.value = 0; generateMockSegments() } const currentSegmentIndex = ref(0)
const recordFileList = ref<any[]>([])
const generateMockSegments = () => { // 播放进度定时器
const totalMs = totalDurationMs.value let playProgressTimer: ReturnType<typeof setInterval> | null = null
if (totalMs <= 0) { playbackSegments.value = []; return }
const segs: any[] = [] /** 开始播放进度更新 */
const segCount = Math.floor(Math.random() * 4) + 2 const startProgressTimer = () => {
for (let i = 0; i < segCount; i++) { stopProgressTimer()
const segStart = rangeStartMs.value + Math.random() * totalMs * 0.8 playProgressTimer = setInterval(() => {
const segEnd = segStart + Math.random() * (totalMs * 0.3) + 60000 // 1x = 每秒走 (1/总秒数)*100%
if (segEnd > rangeStartMs.value + totalMs) continue const totalSec = totalDurationMs.value / 1000
segs.push({ left: ((segStart - rangeStartMs.value) / totalMs) * 100, width: ((segEnd - segStart) / totalMs) * 100 }) if (totalSec <= 0) return
const step = (playSpeed.value / totalSec) * 100
if (playheadPercent.value >= 100) {
playheadPercent.value = 100
isPlaying.value = false
stopProgressTimer()
return
}
playheadPercent.value += step
}, 1000)
}
/** 停止播放进度更新 */
const stopProgressTimer = () => {
if (playProgressTimer) {
clearInterval(playProgressTimer)
playProgressTimer = null
}
}
// ---- 后端 API 交互 ----
/** 查询录像文件列表 */
const fetchRecordFiles = async () => {
if (!selectedCameraId.value || !playbackRange.value?.[0] || !playbackRange.value?.[1]) return
try {
const res = await CameraControlApi.cameraControl('/camera/control/record-files', {
cameraId: selectedCameraId.value,
channel: channel.value,
startTime: playbackRange.value[0],
endTime: playbackRange.value[1],
})
const list = (res as any) ?? []
recordFileList.value = list
recordSegments.value = list.map((f: any) => ({
left: Math.max(0, ((f.startTime - rangeStartMs.value) / totalDurationMs.value) * 100),
width: ((Math.min(f.endTime, rangeEndMs.value) - Math.max(f.startTime, rangeStartMs.value)) / totalDurationMs.value) * 100,
}))
} catch {
recordSegments.value = []
recordFileList.value = []
} }
playbackSegments.value = segs
} }
const togglePlay = () => { isPlaying.value ? pausePlayback() : startPlayback() } /** 根据当前红标时间找到对应的录像片段 */
const startPlayback = () => { const findSegmentAtPlayhead = () => {
const currentMs = rangeStartMs.value + (playheadPercent.value / 100) * totalDurationMs.value
const seg = recordFileList.value.find((f: any) => currentMs >= f.startTime && currentMs < f.endTime)
console.log('findSegment:', { currentMs, startTime: seg?.startTime, endTime: seg?.endTime, list: recordFileList.value })
return seg
}
/** 播放(从红标位置开始,播到所在片段的结尾) */
const playFromPlayhead = async () => {
const files = recordFileList.value
if (!files.length) return
const seg = findSegmentAtPlayhead()
if (!seg) {
console.log('红标不在任何片段内,从第一个开始')
return playSegment(0)
}
const currentMs = rangeStartMs.value + (playheadPercent.value / 100) * totalDurationMs.value
currentStream.value = `record_${selectedCameraId.value}_${channel.value}_${Date.now()}`
const startStr = fmtLocal(currentMs)
const endStr = fmtLocal(seg.endTime)
console.log('playFromPlayhead:', { startStr, endStr })
try {
await CameraControlApi.cameraControl('/camera/control/playbackControl', {
cameraId: selectedCameraId.value,
command: 'play',
channel: channel.value,
stream: currentStream.value,
startTime: startStr,
endTime: endStr,
speed: Math.round(playSpeed.value * 100),
})
isPlaying.value = true
startProgressTimer()
} catch {}
}
/** 播放指定索引的录像片段(从头播到片段结束) */
const playSegment = async (index: number) => {
const files = recordFileList.value
if (index < 0 || index >= files.length) {
doCommand('stop')
return
}
currentSegmentIndex.value = index
const seg = files[index]
currentStream.value = `record_${selectedCameraId.value}_${channel.value}_${Date.now()}_${index}`
try {
await CameraControlApi.cameraControl('/camera/control/playbackControl', {
cameraId: selectedCameraId.value,
command: 'play',
channel: channel.value,
stream: currentStream.value,
startTime: fmtLocal(seg.startTime),
endTime: fmtLocal(seg.endTime),
speed: Math.round(playSpeed.value * 100),
})
isPlaying.value = true
startProgressTimer()
playheadPercent.value = ((seg.startTime - rangeStartMs.value) / totalDurationMs.value) * 100
} catch {}
}
/** 发送播放控制命令 */
const doCommand = async (command: string, extra?: number) => {
if (!selectedCameraId.value) return if (!selectedCameraId.value) return
isPlaying.value = true
playTimer = setInterval(() => { if (command === 'play') {
playheadPercent.value = Math.min(100, playheadPercent.value + 0.1 * playSpeed.value) if (isPlaying.value) {
if (playheadPercent.value >= 100) pausePlayback() // 播放中 → 停后端 + 停前端进度(画面保留最后一帧)
}, 200) isPlaying.value = false
stopProgressTimer()
try {
await CameraControlApi.cameraControl('/camera/control/playbackControl', {
cameraId: selectedCameraId.value, command: 'stop', stream: currentStream.value,
})
} catch {}
} else {
// 未播放 → 先停旧流再重新 play
if (currentStream.value) {
try {
await CameraControlApi.cameraControl('/camera/control/playbackControl', {
cameraId: selectedCameraId.value, command: 'stop', stream: currentStream.value,
})
} catch {}
currentStream.value = undefined
}
currentStream.value = `record_${selectedCameraId.value}_${channel.value}_${Date.now()}`
playFromPlayhead()
}
return
}
if (command === 'stop') {
isPlaying.value = false
stopProgressTimer()
playheadPercent.value = 0
const stream = currentStream.value
currentStream.value = undefined
try {
await CameraControlApi.cameraControl('/camera/control/playbackControl', {
cameraId: selectedCameraId.value, command: 'stop', stream,
})
} catch {}
return
}
const params: Record<string, any> = {
cameraId: selectedCameraId.value,
command,
channel: channel.value,
}
if (currentStream.value) params.stream = currentStream.value
if (command === 'speed') {
params.speed = Math.round(playSpeed.value * 100)
} else if (command === 'seekForward' || command === 'seekBackward') {
params.seekSeconds = extra || 30
}
try {
await CameraControlApi.cameraControl('/camera/control/playbackControl', params)
} catch {}
}
// ---- 事件处理 ----
const onCameraChange = () => {
stopProgressTimer()
currentStream.value = undefined
isPlaying.value = false
playheadPercent.value = 0
recordSegments.value = []
fetchRecordFiles()
} }
const pausePlayback = () => { isPlaying.value = false; if (playTimer) { clearInterval(playTimer); playTimer = null } }
const stopPlayback = () => { pausePlayback(); playheadPercent.value = 0 }
const onSpeedChange = () => { if (isPlaying.value) { pausePlayback(); startPlayback() } }
const skipFrame = (dir: number) => { playheadPercent.value = Math.max(0, Math.min(100, playheadPercent.value + dir * 0.5)) }
const onRangeChange = () => {
stopProgressTimer()
playheadPercent.value = 0
currentStream.value = undefined
isPlaying.value = false
fetchRecordFiles()
}
const togglePlay = () => {
if (isPlaying.value) {
doCommand('stop')
} else {
doCommand('play')
}
}
const onSpeedChange = () => {
if (currentStream.value) doCommand('speed')
}
// 时间轴拖动
const timelineRef = ref<HTMLElement | null>(null) const timelineRef = ref<HTMLElement | null>(null)
let isDragging = false let isDragging = false
const onTimelineMouseDown = (e: MouseEvent) => { isDragging = true; updatePlayheadFromEvent(e) } const onTimelineMouseDown = (e: MouseEvent) => { isDragging = true; updatePlayheadFromEvent(e) }
const onTimelineMouseMove = (e: MouseEvent) => { if (isDragging) updatePlayheadFromEvent(e) } const onTimelineMouseMove = (e: MouseEvent) => { if (isDragging) updatePlayheadFromEvent(e) }
const onTimelineMouseUp = () => { isDragging = false } const onTimelineMouseUp = () => {
if (!isDragging) return
isDragging = false
// 松开鼠标时如果之前正在播放,停止旧流并自动重新播放
if (isPlaying.value) {
isPlaying.value = false
stopProgressTimer()
const stream = currentStream.value
currentStream.value = undefined
try {
CameraControlApi.cameraControl('/camera/control/playbackControl', {
cameraId: selectedCameraId.value, command: 'stop', stream,
})
} catch {}
// 等1秒后自动重新播放
setTimeout(() => {
if (!isPlaying.value) doCommand('play')
}, 1000)
}
}
const onPlayheadDrag = () => { isDragging = true } const onPlayheadDrag = () => { isDragging = true }
const updatePlayheadFromEvent = (e: MouseEvent) => { const updatePlayheadFromEvent = (e: MouseEvent) => {
if (!timelineRef.value) return if (!timelineRef.value) return
const rect = timelineRef.value.getBoundingClientRect() playheadPercent.value = Math.max(0, Math.min(100, ((e.clientX - timelineRef.value.getBoundingClientRect().left) / timelineRef.value.getBoundingClientRect().width) * 100))
playheadPercent.value = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100))
} }
// 截图/录像
const captureSnapshot = () => { const captureSnapshot = () => {
const video = document.querySelector('video') const video = document.querySelector('video')
if (!video) return if (!video) return
@ -248,7 +463,7 @@ const captureSnapshot = () => {
canvas.height = video.videoHeight || 720 canvas.height = video.videoHeight || 720
canvas.getContext('2d')!.drawImage(video, 0, 0) canvas.getContext('2d')!.drawImage(video, 0, 0)
const link = document.createElement('a') const link = document.createElement('a')
link.download = `snapshot_${currentPlayTime.value.replace(/:/g, '')}.png` link.download = `snapshot_${Date.now()}.png`
link.href = canvas.toDataURL('image/png') link.href = canvas.toDataURL('image/png')
link.click() link.click()
} }
@ -273,31 +488,32 @@ const stopRecording = () => { if (mediaRecorder && mediaRecorder.state !== 'inac
const msToFullTimeStr = (ms: number) => { const msToFullTimeStr = (ms: number) => {
const d = new Date(ms) const d = new Date(ms)
const M = String(d.getMonth() + 1).padStart(2, '0') return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
const D = String(d.getDate()).padStart(2, '0')
const h = String(d.getHours()).padStart(2, '0')
const m = String(d.getMinutes()).padStart(2, '0')
const s = String(d.getSeconds()).padStart(2, '0')
return `${M}-${D} ${h}:${m}:${s}`
} }
onMounted(() => { loadCameras() }) const fmtLocal = (ms: number) => {
const d = new Date(ms)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
onMounted(() => {
loadCameras().then(() => {
// 默认选中第一个相机
if (cameraOptions.value.length > 0 && !selectedCameraId.value) {
selectedCameraId.value = cameraOptions.value[0].id
}
if (selectedCameraId.value) fetchRecordFiles()
})
})
</script> </script>
<style scoped> <style scoped>
.search-panel { .search-panel { border: 1px solid #ebeef5; border-radius: 10px; padding: 16px; background: #fafafa; }
border: 1px solid #ebeef5;
border-radius: 10px;
padding: 16px;
background: #fafafa;
}
.panel-section { margin-bottom: 16px; } .panel-section { margin-bottom: 16px; }
.panel-title { font-weight: 600; font-size: 14px; color: #303133; margin-bottom: 10px; } .panel-title { font-weight: 600; font-size: 14px; color: #303133; margin-bottom: 10px; }
.time-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.time-lbl { font-size: 13px; color: #909399; flex-shrink: 0; width: 36px; }
.video-wrapper { position: relative; border: 1px solid #dcdfe6; border-radius: 8px; overflow: hidden; background: #000; } .video-wrapper { position: relative; border: 1px solid #dcdfe6; border-radius: 8px; overflow: hidden; background: #000; }
.video-container { width: 100%; aspect-ratio: 16/9; max-height: 55vh; } .video-container { width: 100%; aspect-ratio: 16/9; max-height: 75vh; }
.video-placeholder { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: #999; } .video-placeholder { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: #999; }
.video-controls { .video-controls {
position: absolute; bottom: 0; left: 0; right: 0; position: absolute; bottom: 0; left: 0; right: 0;
@ -307,15 +523,14 @@ onMounted(() => { loadCameras() })
} }
.ctrl-left, .ctrl-right { display: flex; align-items: center; gap: 6px; } .ctrl-left, .ctrl-right { display: flex; align-items: center; gap: 6px; }
.ctrl-time { color: #fff; font-size: 13px; font-weight: 500; min-width: 65px; } .ctrl-time { color: #fff; font-size: 13px; font-weight: 500; min-width: 65px; }
.timeline-wrapper { margin-top: 12px; padding: 4px 0; } .timeline-wrapper { margin-top: 12px; padding: 4px 0; }
.timeline-bar { position: relative; height: 48px; background: #f5f7fa; border-radius: 6px; border: 1px solid #e4e7ed; cursor: pointer; user-select: none; } .timeline-bar { position: relative; height: 48px; background: #f5f7fa; border-radius: 6px; border: 1px solid #e4e7ed; cursor: pointer; user-select: none; padding: 0 8px; }
.timeline-ticks { position: relative; height: 20px; } .timeline-ticks { position: relative; height: 20px; }
.tick-hour { position: absolute; top: 0; } .tick-hour { position: absolute; top: 0; }
.tick-label { font-size: 10px; color: #909399; transform: translateX(-50%); white-space: nowrap; } .tick-label { font-size: 10px; color: #909399; transform: translateX(-50%); white-space: nowrap; }
.tick-date-label { font-size: 11px; color: #409eff; font-weight: 600; transform: translateX(-50%); white-space: nowrap; } .tick-date-label { font-size: 11px; color: #409eff; font-weight: 600; transform: translateX(-50%); white-space: nowrap; }
.timeline-date-layer { position: relative; height: 16px; } .timeline-date-layer { position: relative; height: 16px; }
.timeline-track { position: relative; height: 18px; margin: 0 4px; } .timeline-track { position: relative; height: 18px; }
.segment { position: absolute; top: 2px; height: 12px; background: #409eff; border-radius: 3px; opacity: 0.6; } .segment { position: absolute; top: 2px; height: 12px; background: #409eff; border-radius: 3px; opacity: 0.6; }
.playhead { position: absolute; top: -4px; width: 2px; height: 22px; background: #f56c6c; z-index: 2; cursor: ew-resize; transform: translateX(-50%); } .playhead { position: absolute; top: -4px; width: 2px; height: 22px; background: #f56c6c; z-index: 2; cursor: ew-resize; transform: translateX(-50%); }
.playhead-dot { width: 10px; height: 10px; background: #f56c6c; border-radius: 50%; position: absolute; top: -5px; left: -4px; } .playhead-dot { width: 10px; height: 10px; background: #f56c6c; border-radius: 50%; position: absolute; top: -5px; left: -4px; }

@ -0,0 +1,265 @@
<template>
<ContentWrap>
<!-- 四个统计卡片 -->
<el-row :gutter="30" justify="space-between" class="mb-20px">
<el-col :span="6" v-for="card in statCards" :key="card.key">
<div class="stat-card" :style="{ borderTopColor: card.color }">
<div class="stat-card-top">
<div>
<div class="stat-label">{{ card.label }}</div>
<div class="stat-value" :style="{ color: card.color }">{{ card.value }}</div>
</div>
<img :src="card.icon" class="stat-icon" />
</div>
</div>
</el-col>
</el-row>
<!-- 两个图表 -->
<el-row :gutter="16">
<!-- 事件统计(横向柱状图) -->
<el-col :span="12">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">事件统计</span>
<div class="chart-actions">
<el-select
v-model="durationRuleIds"
placeholder="报警类型"
multiple clearable collapse-tags collapse-tags-tooltip
style="width:180px"
@change="fetchDurationStats"
>
<el-option v-for="r in ruleOptions" :key="r.id" :label="r.ruleName" :value="r.id" />
</el-select>
<el-select v-model="durationDimension" style="width:90px" @change="onDurationDimensionChange">
<el-option label="按周" value="week" />
<el-option label="按月" value="month" />
<el-option label="按年" value="year" />
</el-select>
<el-date-picker
v-if="durationDimension === 'year'"
v-model="durationYear"
type="year" placeholder="年份" value-format="YYYY" style="width:120px"
@change="fetchDurationStats"
/>
<el-date-picker
v-else-if="durationDimension === 'month'"
v-model="durationMonth"
type="month" placeholder="月份" value-format="YYYY-MM" style="width:120px"
@change="fetchDurationStats"
/>
<el-date-picker
v-else
v-model="durationWeek"
type="week" placeholder="周" value-format="YYYY-MM-DD" style="width:120px"
@change="fetchDurationStats"
/>
</div>
</div>
<Echart :options="durationOption" :height="350" />
</div>
</el-col>
<!-- 事件趋势(折线图) -->
<el-col :span="12">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">事件趋势</span>
<div class="chart-actions">
<el-select
v-model="trendRuleIds"
placeholder="报警类型"
multiple clearable collapse-tags collapse-tags-tooltip
style="width:180px"
@change="fetchTrendStats"
>
<el-option v-for="r in ruleOptions" :key="r.id" :label="r.ruleName" :value="r.id" />
</el-select>
<el-select v-model="trendDimension" style="width:90px" @change="onTrendDimensionChange">
<el-option label="按周" value="week" />
<el-option label="按月" value="month" />
<el-option label="按年" value="year" />
</el-select>
<el-date-picker
v-if="trendDimension === 'year'"
v-model="trendYear"
type="year" placeholder="年份" value-format="YYYY" style="width:120px"
@change="fetchTrendStats"
/>
<el-date-picker
v-else-if="trendDimension === 'month'"
v-model="trendMonth"
type="month" placeholder="月份" value-format="YYYY-MM" style="width:120px"
@change="fetchTrendStats"
/>
<el-date-picker
v-else
v-model="trendWeek"
type="week" placeholder="周" value-format="YYYY-MM-DD" style="width:120px"
@change="fetchTrendStats"
/>
</div>
</div>
<Echart :options="trendOption" :height="350" />
</div>
</el-col>
</el-row>
</ContentWrap>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { EChartsOption } from 'echarts'
import { AlarmRecordsApi } from '@/api/warehousealarm/alarmrecords'
import { RulesApi } from '@/api/warehousealarm/rules'
defineOptions({ name: 'AlarmStatistics' })
// ===== 四个统计卡片 =====
const statCards = ref([
{ key: 'pending', label: '待处理报警数', color: '#f56c6c', value: 0, icon: '/src/assets/imgs/icon/error.png' },
{ key: 'today', label: '本日报警数', color: '#e6a23c', value: 0, icon: '/src/assets/imgs/icon/day.png' },
{ key: 'week', label: '本周报警数', color: '#409eff', value: 0, icon: '/src/assets/imgs/icon/work.png' },
{ key: 'month', label: '本月报警数', color: '#67c23a', value: 0, icon: '/src/assets/imgs/icon/mother.png' },
])
const dayStart = (date: Date) => { const d = new Date(date); d.setHours(0,0,0,0); return d }
const dayEnd = (date: Date) => { const d = new Date(date); d.setHours(23,59,59,999); return d }
const fetchCounts = async () => {
const now = new Date()
const fmt = (d: Date) => d.toISOString().slice(0, 19).replace('T', ' ')
const getCount = async (params: any) => {
try {
const res = await AlarmRecordsApi.getAlarmCount(params)
return (res as any) ?? 0
} catch { return 0 }
}
statCards.value[0].value = await getCount({ status: 1 })
statCards.value[1].value = await getCount({ triggerTime: [fmt(dayStart(now)), fmt(dayEnd(now))] })
const weekStart = new Date(now); weekStart.setDate(weekStart.getDate() - (weekStart.getDay() === 0 ? 6 : weekStart.getDay() - 1))
statCards.value[2].value = await getCount({ triggerTime: [fmt(dayStart(weekStart)), fmt(dayEnd(now))] })
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
statCards.value[3].value = await getCount({ triggerTime: [fmt(monthStart), fmt(dayEnd(now))] })
}
// ===== 报警类型选项 =====
const ruleOptions = ref<any[]>([])
const loadRules = async () => {
try {
const res = await RulesApi.getRulesPage({ pageNo: 1, pageSize: 100 })
ruleOptions.value = res.list ?? []
} catch {}
}
// ===== 工具 =====
const calcRange = (dimension: string, val: string): [string, string] => {
const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`
if (dimension === 'year') {
const start = new Date(parseInt(val), 0, 1); const end = new Date(parseInt(val), 11, 31, 23, 59, 59)
return [fmt(start), fmt(end)]
}
if (dimension === 'month') {
const [y, m] = val.split('-').map(Number); const start = new Date(y, m - 1, 1); const end = new Date(y, m, 0, 23, 59, 59)
return [fmt(start), fmt(end)]
}
const monday = new Date(val); const sunday = new Date(monday); sunday.setDate(monday.getDate() + 6)
return [fmt(dayStart(monday)), fmt(dayEnd(sunday))]
}
// ===== 事件统计 =====
const durationRuleIds = ref<number[]>([])
const durationDimension = ref('month')
const durationYear = ref(new Date().getFullYear().toString())
const durationMonth = ref(`${new Date().getFullYear()}-${String(new Date().getMonth()+1).padStart(2,'0')}`)
const durationWeek = ref('')
const durationOption = ref<EChartsOption>({})
const onDurationDimensionChange = () => {
const now = new Date()
if (durationDimension.value === 'year') durationYear.value = now.getFullYear().toString()
else if (durationDimension.value === 'month') durationMonth.value = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}`
else { const monday = new Date(now); monday.setDate(now.getDate() - (now.getDay() === 0 ? 6 : now.getDay() - 1)); durationWeek.value = monday.toISOString().slice(0, 10) }
fetchDurationStats()
}
const fetchDurationStats = async () => {
if (durationRuleIds.value.length === 0) return
let startTime: string, endTime: string
if (durationDimension.value === 'year') [startTime, endTime] = calcRange('year', durationYear.value)
else if (durationDimension.value === 'month') [startTime, endTime] = calcRange('month', durationMonth.value)
else [startTime, endTime] = calcRange('week', durationWeek.value)
try {
const res = await AlarmRecordsApi.getStatsDuration({
dimension: durationDimension.value === 'year' ? 'year' : durationDimension.value === 'month' ? 'month' : 'week',
ruleIds: durationRuleIds.value.join(','), startTime, endTime,
})
const data = (res as any)
if (data) {
durationOption.value = {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
xAxis: { type: 'value' },
yAxis: { type: 'category', data: data.yAxis?.data || [] },
series: data.series || [],
grid: { left: 120, right: 30, top: 20, bottom: 30 },
}
}
} catch {}
}
// ===== 事件趋势 =====
const trendRuleIds = ref<number[]>([])
const trendDimension = ref('month')
const trendYear = ref(new Date().getFullYear().toString())
const trendMonth = ref(`${new Date().getFullYear()}-${String(new Date().getMonth()+1).padStart(2,'0')}`)
const trendWeek = ref('')
const trendOption = ref<EChartsOption>({})
const onTrendDimensionChange = () => {
const now = new Date()
if (trendDimension.value === 'year') trendYear.value = now.getFullYear().toString()
else if (trendDimension.value === 'month') trendMonth.value = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}`
else { const monday = new Date(now); monday.setDate(now.getDate() - (now.getDay() === 0 ? 6 : now.getDay() - 1)); trendWeek.value = monday.toISOString().slice(0, 10) }
fetchTrendStats()
}
const fetchTrendStats = async () => {
if (trendRuleIds.value.length === 0) return
let startTime: string, endTime: string
if (trendDimension.value === 'year') [startTime, endTime] = calcRange('year', trendYear.value)
else if (trendDimension.value === 'month') [startTime, endTime] = calcRange('month', trendMonth.value)
else [startTime, endTime] = calcRange('week', trendWeek.value)
try {
const res = await AlarmRecordsApi.getStatsTrend({
dimension: trendDimension.value === 'year' ? 'year' : 'month',
ruleIds: trendRuleIds.value.join(','), startTime, endTime,
})
const data = (res as any)
if (data) trendOption.value = data
} catch {}
}
onMounted(async () => {
fetchCounts()
await loadRules()
if (ruleOptions.value.length > 0) {
durationRuleIds.value = ruleOptions.value.map((r: any) => r.id)
trendRuleIds.value = ruleOptions.value.map((r: any) => r.id)
}
fetchDurationStats()
fetchTrendStats()
})
</script>
<style scoped>
.stat-card { background: #fff; border-radius: 10px; padding: 20px; border: 1px solid #ebeef5; border-top: 3px solid; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
.stat-card-top { display: flex; justify-content: space-between; align-items: flex-start; }
.stat-label { font-size: 13px; color: #909399; margin-bottom: 8px; }
.stat-value { font-size: 28px; font-weight: 700; }
.stat-icon { width: 100px; height: 100px; object-fit: contain; }
.chart-card { background: #fff; border-radius: 10px; border: 1px solid #ebeef5; padding: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
.chart-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-wrap: wrap; gap: 8px; }
.chart-title { font-weight: 600; font-size: 15px; color: #303133; }
.chart-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
</style>

@ -8,10 +8,10 @@
v-loading="formLoading" v-loading="formLoading"
> >
<el-form-item label="规则名称" prop="ruleName"> <el-form-item label="规则名称" prop="ruleName">
<el-input v-model="formData.ruleName" placeholder="请输入规则名称" /> <el-input disabled v-model="formData.ruleName" placeholder="请输入规则名称" />
</el-form-item> </el-form-item>
<el-form-item label="严重级别" prop="severity"> <el-form-item label="严重级别" prop="severity">
<el-select v-model="formData.severity" placeholder="请选择严重级别"> <el-select disabled v-model="formData.severity" placeholder="请选择严重级别">
<el-option <el-option
v-for="dict in getIntDictOptions(DICT_TYPE.ALARM_SEVERITY)" v-for="dict in getIntDictOptions(DICT_TYPE.ALARM_SEVERITY)"
:key="dict.value" :key="dict.value"
@ -21,7 +21,7 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="状态" prop="status"> <el-form-item label="状态" prop="status">
<el-select v-model="formData.status" placeholder="请选择状态"> <el-select v-model="formData.status" placeholder="请选择状态">
<el-option <el-option
v-for="dict in getIntDictOptions(DICT_TYPE.ALARM_RECORD_STATUS)" v-for="dict in getIntDictOptions(DICT_TYPE.ALARM_RECORD_STATUS)"
:key="dict.value" :key="dict.value"
@ -43,12 +43,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
import { AlarmRecordsApi, AlarmRecordsVO } from '@/api/warehousealarm/alarmrecords' import { AlarmRecordsApi, AlarmRecordsVO } from '@/api/warehousealarm/alarmrecords'
import { useUserStore } from '@/store/modules/user'
/** 报警记录 表单 */ /** 报警记录 表单 */
defineOptions({ name: 'AlarmRecordsForm' }) defineOptions({ name: 'AlarmRecordsForm' })
const { t } = useI18n() // 国际化 const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗 const message = useMessage() // 消息弹窗
const userStore = useUserStore()
const dialogVisible = ref(false) // 弹窗的是否展示 const dialogVisible = ref(false) // 弹窗的是否展示
const dialogTitle = ref('') // 弹窗的标题 const dialogTitle = ref('') // 弹窗的标题
@ -94,6 +96,8 @@ const open = async (type: string, id?: number) => {
formLoading.value = false formLoading.value = false
} }
} }
// 设置 handler 为当前用户 ID
formData.value.handler = userStore.getUser.id
} }
defineExpose({ open }) // 提供 open 方法,用于打开弹窗 defineExpose({ open }) // 提供 open 方法,用于打开弹窗

@ -113,7 +113,7 @@
<dict-tag :type="DICT_TYPE.ALARM_RECORD_STATUS" :value="scope.row.status" /> <dict-tag :type="DICT_TYPE.ALARM_RECORD_STATUS" :value="scope.row.status" />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="处理人" align="center" prop="handler" /> <!-- <el-table-column label="处理人" align="center" prop="handler" /> -->
<el-table-column <el-table-column
label="处理时间" label="处理时间"
align="center" align="center"
@ -122,13 +122,13 @@
width="180px" width="180px"
/> />
<el-table-column label="处理备注" align="center" prop="handleRemark" /> <el-table-column label="处理备注" align="center" prop="handleRemark" />
<el-table-column <!-- <el-table-column
label="创建时间" label="创建时间"
align="center" align="center"
prop="createTime" prop="createTime"
:formatter="dateFormatter" :formatter="dateFormatter"
width="180px" width="180px"
/> /> -->
<el-table-column label="操作" align="center"> <el-table-column label="操作" align="center">
<template #default="scope"> <template #default="scope">
<el-button <el-button

@ -61,28 +61,41 @@
</div> </div>
</div> </div>
<!-- 内容 -->
<div class="card-body"> <div class="card-body">
<div class="info-line"> <div class="info-line">
<span class="info-label">设备类型</span> <span class="info-label">设备类型</span>
<span class="info-value">{{ item.typeName }}</span> <span class="info-value">{{ item.typeName }}</span>
</div> </div>
<div class="data-section"> <!-- 有数据时才显示 -->
<div class="section-title">最新数据</div> <template v-if="hasData(item)">
<div <div class="data-section">
v-for="(field, idx) in resolvedFields(item)" <div class="section-title">最新数据</div>
:key="idx" <div class="data-content-row">
class="data-row" <div class="data-fields">
> <div
<span class="data-key">{{ field.name }}</span> v-for="(field, idx) in resolvedFields(item)"
<span class="data-val">{{ field.value }}{{ field.unit }}</span> :key="idx"
class="data-row"
>
<span class="data-key">{{ field.name }}</span>
<span class="data-val">{{ field.value }}{{ field.unit }}</span>
</div>
</div>
<div v-if="getDeviceIcon(item)" class="card-icon-wrapper">
<img :src="getDeviceIcon(item)" class="device-icon" />
</div>
</div>
</div> </div>
</div>
<div class="info-line time-line"> <div class="info-line time-line">
<span class="info-label">接收时间</span> <span class="info-label">接收时间</span>
<span class="info-value">{{ formatTime(item.receiveTime) }}</span> <span class="info-value">{{ formatTime(item.receiveTime) }}</span>
</div>
</template>
<!-- 无数据时,图标居中 -->
<div v-else-if="getDeviceIcon(item)" class="no-data-icon">
<img :src="getDeviceIcon(item)" class="device-icon" />
</div> </div>
</div> </div>
</div> </div>
@ -110,8 +123,8 @@ defineOptions({ name: 'DeviceDashboard' })
const loading = ref(true) const loading = ref(true)
const list = ref<any[]>([]) const list = ref<any[]>([])
const total = ref(0) const total = ref(0)
const typeOptions = ref<any[]>([]) // 设备类型选项 const typeOptions = ref<any[]>([])
const typeFieldsMap = ref<Record<number, any[]>>({}) // typeId → 字段定义 const typeFieldsMap = ref<Record<number, any[]>>({})
const queryParams = reactive({ const queryParams = reactive({
pageNo: 1, pageNo: 1,
@ -144,6 +157,26 @@ const buildTypeFieldsMap = async () => {
} catch {} } catch {}
} }
/** 获取设备图标路径(根据 typeId 查 typeCode) */
const getDeviceIcon = (item: any) => {
const base = '/src/assets/imgs/device/'
const type = typeOptions.value.find((t: any) => t.id === item.typeId)
const typeCode = type?.typeCode || ''
if (!typeCode) return ''
if (item.alarmStatus === 1) {
return base + typeCode + '_e.png'
}
if (item.onlineStatus === 0) {
return base + typeCode + '_info.png'
}
return base + typeCode + '.png'
}
/** 判断是否有数据 */
const hasData = (item: any) => {
return item.dataJson && item.dataJson !== '{}' && item.dataJson !== 'null'
}
/** 解析当前设备的 dataJson,对照 typeFieldsMap 展示 name: value unit */ /** 解析当前设备的 dataJson,对照 typeFieldsMap 展示 name: value unit */
const resolvedFields = (item: any) => { const resolvedFields = (item: any) => {
const fields = typeFieldsMap.value[item.typeId] || [] const fields = typeFieldsMap.value[item.typeId] || []
@ -214,10 +247,39 @@ onMounted(() => {
background: #fff; background: #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.06); box-shadow: 0 2px 8px rgba(0,0,0,0.06);
transition: box-shadow 0.2s; transition: box-shadow 0.2s;
position: relative;
} }
.device-card:hover { .device-card:hover {
box-shadow: 0 4px 16px rgba(0,0,0,0.12); box-shadow: 0 4px 16px rgba(0,0,0,0.12);
} }
.card-body {
padding: 12px 14px;
font-size: 13px;
}
.data-content-row {
display: flex;
gap: 10px;
}
.data-fields {
flex: 1;
}
.card-icon-wrapper {
width: 80px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.no-data-icon {
display: flex;
justify-content: center;
padding: 16px 0;
}
.device-icon {
width: 72px;
height: 72px;
object-fit: contain;
}
.card-header { .card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

@ -7,61 +7,53 @@
label-width="100px" label-width="100px"
v-loading="formLoading" v-loading="formLoading"
> >
<el-form-item label="规则名称" prop="ruleName"> <el-form-item label="检测设备" prop="deviceId">
<el-input v-model="formData.ruleName" placeholder="请输入规则名称" />
</el-form-item>
<el-form-item label="规则描述" prop="ruleDesc">
<el-input v-model="formData.ruleDesc" placeholder="请输入规则描述" />
</el-form-item>
<el-form-item label="是否报警" prop="isAlarm">
<el-switch
v-model="formData.isAlarm"
:active-value="true"
:inactive-value="false"
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949"
/>
</el-form-item>
<el-form-item label="严重级别" prop="severity">
<el-select v-model="formData.severity" placeholder="请选择严重级别">
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.ALARM_SEVERITY)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="联动球机" prop="cameraDeviceId">
<el-select <el-select
v-model="formData.cameraDeviceId" v-model="formData.deviceId"
:disabled="isDeviceDisabled"
filterable filterable
remote remote
:remote-method="(q) => { cameraKeyword = q; cameraPageNo = 1; fetchCameras() }" :remote-method="(q) => { deviceKeyword = q; devicePageNo = 1; searchDevices() }"
placeholder="请选择联动球机" placeholder="请选择检测设备"
@visible-change="(v) => { if (v) { cameraKeyword = ''; cameraPageNo = 1; fetchCameras() } }" @visible-change="(v) => { if (v) { deviceKeyword = ''; devicePageNo = 1; searchDevices() } }"
@change="onDeviceChange"
style="width: 100%" style="width: 100%"
> >
<el-option <el-option
v-for="item in cameraList" v-for="item in deviceList"
:key="item.id" :key="item.id"
:label="item.name" :label="item.deviceName"
:value="item.id" :value="item.id"
/> />
<template #footer> <template #footer>
<div @mousedown.stop> <div @mousedown.stop>
<el-pagination <el-pagination
v-model:current-page="cameraPageNo" v-model:current-page="devicePageNo"
:page-size="100" :page-size="100"
:total="cameraTotal" :total="deviceTotal"
layout="prev, pager, next" layout="prev, pager, next"
small small
background background
@current-change="fetchCameras" @current-change="searchDevices"
/> />
</div> </div>
</template> </template>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="规则描述" prop="ruleDesc">
<el-input v-model="formData.ruleDesc" placeholder="请输入规则描述" />
</el-form-item>
<el-form-item label="是否报警" prop="isAlarm">
<el-switch
v-model="formData.isAlarm"
:active-value="true"
:inactive-value="false"
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949"
/>
</el-form-item>
<el-form-item label="冷却时间" prop="cooldownSec">
<el-input-number v-model="formData.cooldownSec" :min="0" placeholder="请输入冷却时间(秒)" />
</el-form-item>
<el-form-item label="状态" prop="enabled"> <el-form-item label="状态" prop="enabled">
<el-select v-model="formData.enabled" placeholder="请选择状态"> <el-select v-model="formData.enabled" placeholder="请选择状态">
<el-option <el-option
@ -72,16 +64,13 @@
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="冷却时间" prop="cooldownSec">
<el-input-number v-model="formData.cooldownSec" placeholder="请输入冷却时间(秒)" />
</el-form-item>
</el-form> </el-form>
<!-- 子表的表单 --> <!-- 子表的表单 -->
<el-tabs v-model="subTabsName"> <el-tabs v-model="subTabsName">
<el-tab-pane label="规则条件" name="ruleConditions"> <el-tab-pane label="规则条件" name="ruleConditions">
<RuleConditionsForm ref="ruleConditionsFormRef" :rule-id="formData.id" /> <RuleConditionsForm ref="ruleConditionsFormRef" :rule-id="formData.id" :parent-device-id="formData.deviceId" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="规则联动动作" name="ruleActions"> <el-tab-pane label="规则联动动作" name="ruleActions">
<RuleActionsForm ref="ruleActionsFormRef" :rule-id="formData.id" /> <RuleActionsForm ref="ruleActionsFormRef" :rule-id="formData.id" />
@ -96,7 +85,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
import { RulesApi, RulesVO } from '@/api/warehousealarm/rules' import { RulesApi, RulesVO } from '@/api/warehousealarm/rules'
import { CameraApi } from '@/api/logistics/camera' import { DevicesApi } from '@/api/warehousealarm/devices'
import RuleActionsForm from './components/RuleActionsForm.vue' import RuleActionsForm from './components/RuleActionsForm.vue'
import RuleConditionsForm from './components/RuleConditionsForm.vue' import RuleConditionsForm from './components/RuleConditionsForm.vue'
@ -112,16 +101,16 @@ const formLoading = ref(false) // 表单的加载中:1)修改时的数据加
const formType = ref('') // 表单的类型:create - 新增;update - 修改 const formType = ref('') // 表单的类型:create - 新增;update - 修改
const formData = ref({ const formData = ref({
id: undefined, id: undefined,
deviceId: undefined,
ruleName: undefined, ruleName: undefined,
ruleDesc: undefined, ruleDesc: undefined,
isAlarm: undefined, isAlarm: true,
severity: undefined, enabled: 1,
cameraDeviceId: undefined, cooldownSec: 0,
enabled: undefined,
cooldownSec: undefined,
lastTrigger: undefined, lastTrigger: undefined,
}) })
const formRules = reactive({ const formRules = reactive({
deviceId: [{ required: true, message: '请选择检测设备', trigger: 'change' }],
ruleName: [{ required: true, message: '规则名称不能为空', trigger: 'blur' }], ruleName: [{ required: true, message: '规则名称不能为空', trigger: 'blur' }],
isAlarm: [{ required: true, message: '是否报警不能为空', trigger: 'change' }], isAlarm: [{ required: true, message: '是否报警不能为空', trigger: 'change' }],
enabled: [{ required: true, message: '状态不能为空', trigger: 'change' }], enabled: [{ required: true, message: '状态不能为空', trigger: 'change' }],
@ -129,33 +118,49 @@ const formRules = reactive({
}) })
const formRef = ref() // 表单 Ref const formRef = ref() // 表单 Ref
// 相机搜索状态 // 检测设备搜索状态
const cameraList = ref<any[]>([]) const deviceList = ref<any[]>([])
const cameraTotal = ref(0) const deviceTotal = ref(0)
const cameraPageNo = ref(1) const devicePageNo = ref(1)
const cameraKeyword = ref('') const deviceKeyword = ref('')
/** 检测设备是否禁用:修改模式 或 已有规则条件时禁用 */
const isDeviceDisabled = computed(() => {
return !!formData.value.id || (ruleConditionsFormRef.value?.getDataRaw()?.length > 0)
})
/** 获取相机列表 */ /** 搜索检测设备 */
const fetchCameras = async () => { const searchDevices = async () => {
try { try {
const res = await CameraApi.getCameraPage({ const res = await RulesApi.getDevicesPageByRules({
pageNo: cameraPageNo.value, deviceName: deviceKeyword.value || '',
pageSize: 100,
name: cameraKeyword.value || undefined,
}) })
cameraList.value = res.list ?? [] deviceList.value = Array.isArray(res) ? res : (res.data ?? res.list ?? res ?? [])
cameraTotal.value = res.total ?? 0 deviceTotal.value = deviceList.value.length
} catch {} } catch {}
} }
/** 编辑时将 cameraDeviceId 对应的相机名加载到列表中,保证 el-select 能显示名称 */ /** 检测设备变化时,设置规则名称、更新规则条件中的 deviceId */
const ensureCameraInList = async (cameraId: number) => { const onDeviceChange = (deviceId: number) => {
if (!cameraId) return const device = deviceList.value.find((d: any) => d.id === deviceId)
const exist = cameraList.value.find((c: any) => c.id === cameraId) if (device) {
formData.value.ruleName = device.deviceName
}
if (!ruleConditionsFormRef.value) return
const conditions = ruleConditionsFormRef.value.getDataRaw()
if (conditions && conditions.length > 0) {
conditions.forEach((row: any) => { row.deviceId = deviceId })
}
}
/** 编辑时将 deviceId 对应的设备加载到列表中 */
const ensureDeviceInList = async (deviceId: number) => {
if (!deviceId) return
const exist = deviceList.value.find((d: any) => d.id === deviceId)
if (exist) return if (exist) return
try { try {
const camera = await CameraApi.getCamera(cameraId) const device = await DevicesApi.getDevices(deviceId)
if (camera) cameraList.value.unshift(camera) if (device) deviceList.value.unshift(device)
} catch {} } catch {}
} }
@ -170,17 +175,27 @@ const open = async (type: string, id?: number) => {
dialogTitle.value = t('action.' + type) dialogTitle.value = t('action.' + type)
formType.value = type formType.value = type
resetForm() resetForm()
// 加载相机列表 // 加载设备列表
fetchCameras() searchDevices()
// 修改时,设置数据 // 修改时,设置数据
if (id) { if (id) {
formLoading.value = true formLoading.value = true
try { try {
formData.value = await RulesApi.getRules(id) formData.value = await RulesApi.getRules(id)
// 将已有的 cameraDeviceId 加载到列表中,让 select 显示名称 // 如果后端没有返回 deviceId,从规则条件中取第一个
if (formData.value.cameraDeviceId) { if (!formData.value.deviceId) {
await ensureCameraInList(formData.value.cameraDeviceId) const conditions = await RulesApi.getRuleConditionsListByRuleId(id)
if (conditions && conditions.length > 0 && conditions[0].deviceId) {
formData.value.deviceId = conditions[0].deviceId
}
}
// 将已有的 deviceId 和 cameraDeviceId 加载到列表中
if (formData.value.deviceId) {
await ensureDeviceInList(formData.value.deviceId)
} }
// 默认值
if (formData.value.enabled === undefined || formData.value.enabled === null) formData.value.enabled = 0
if (formData.value.cooldownSec === undefined || formData.value.cooldownSec === null) formData.value.cooldownSec = 0
} finally { } finally {
formLoading.value = false formLoading.value = false
} }
@ -232,13 +247,12 @@ const submitForm = async () => {
const resetForm = () => { const resetForm = () => {
formData.value = { formData.value = {
id: undefined, id: undefined,
deviceId: undefined,
ruleName: undefined, ruleName: undefined,
ruleDesc: undefined, ruleDesc: undefined,
isAlarm: undefined, isAlarm: true,
severity: undefined, enabled: 1,
cameraDeviceId: undefined, cooldownSec: 0,
enabled: undefined,
cooldownSec: undefined,
lastTrigger: undefined, lastTrigger: undefined,
} }
formRef.value?.resetFields() formRef.value?.resetFields()

@ -8,7 +8,7 @@
:inline-message="true" :inline-message="true"
> >
<el-table :data="formData" class="-mt-10px"> <el-table :data="formData" class="-mt-10px">
<el-table-column label="检测设备" min-width="200"> <!-- <el-table-column label="检测设备" min-width="200">
<template #default="{ row, $index }"> <template #default="{ row, $index }">
<el-form-item :prop="`${$index}.deviceId`" :rules="formRules.deviceId" class="mb-0px!"> <el-form-item :prop="`${$index}.deviceId`" :rules="formRules.deviceId" class="mb-0px!">
<el-select <el-select
@ -43,7 +43,7 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
</template> </template>
</el-table-column> </el-table-column> -->
<el-table-column label="监测字段" min-width="160"> <el-table-column label="监测字段" min-width="160">
<template #default="{ row, $index }"> <template #default="{ row, $index }">
<el-form-item :prop="`${$index}.fieldKey`" :rules="formRules.fieldKey" class="mb-0px!"> <el-form-item :prop="`${$index}.fieldKey`" :rules="formRules.fieldKey" class="mb-0px!">
@ -79,6 +79,20 @@
</el-form-item> </el-form-item>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="严重级别" min-width="130">
<template #default="{ row, $index }">
<el-form-item :prop="`${$index}.sortOrder`" :rules="formRules.sortOrder" class="mb-0px!">
<el-select v-model="row.sortOrder" placeholder="请选择严重级别" style="width: 100%">
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.ALARM_SEVERITY)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
</template>
</el-table-column>
<el-table-column label="持续时间(s)" min-width="150"> <el-table-column label="持续时间(s)" min-width="150">
<template #default="{ row, $index }"> <template #default="{ row, $index }">
<el-form-item :prop="`${$index}.durationSec`" :rules="formRules.durationSec" class="mb-0px!"> <el-form-item :prop="`${$index}.durationSec`" :rules="formRules.durationSec" class="mb-0px!">
@ -93,13 +107,7 @@
</el-form-item> </el-form-item>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="组内排序" min-width="120">
<template #default="{ row, $index }">
<el-form-item :prop="`${$index}.sortOrder`" :rules="formRules.sortOrder" class="mb-0px!">
<el-input-number v-model="row.sortOrder" :min="1" placeholder="组内排序" class="!w-full" />
</el-form-item>
</template>
</el-table-column>
<el-table-column align="center" fixed="right" label="操作" width="60"> <el-table-column align="center" fixed="right" label="操作" width="60">
<template #default="{ $index }"> <template #default="{ $index }">
<el-button @click="handleDelete($index)" link>—</el-button> <el-button @click="handleDelete($index)" link>—</el-button>
@ -110,15 +118,17 @@
<el-row justify="center" class="mt-3"> <el-row justify="center" class="mt-3">
<el-button @click="handleAdd" round>+ 添加规则条件</el-button> <el-button @click="handleAdd" round>+ 添加规则条件</el-button>
</el-row> </el-row>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getStrDictOptions, DICT_TYPE } from '@/utils/dict' import { getStrDictOptions, getIntDictOptions, DICT_TYPE } from '@/utils/dict'
import { RulesApi } from '@/api/warehousealarm/rules' import { RulesApi } from '@/api/warehousealarm/rules'
import { DevicesApi } from '@/api/warehousealarm/devices' import { DevicesApi } from '@/api/warehousealarm/devices'
import { DeviceTypesApi } from '@/api/warehousealarm/devicetypes' import { DeviceTypesApi } from '@/api/warehousealarm/devicetypes'
const props = defineProps<{ const props = defineProps<{
ruleId?: number | undefined // 规则ID(主表的关联字段) ruleId?: number | undefined // 规则ID(主表的关联字段)
parentDeviceId?: number | undefined // 主表单选中的检测设备ID
}>() }>()
const formLoading = ref(false) // 表单的加载中 const formLoading = ref(false) // 表单的加载中
const formData = ref<any[]>([]) const formData = ref<any[]>([])
@ -137,6 +147,15 @@ const formRef = ref() // 表单 Ref
// 设备搜索状态(全局共享,便于编辑时显示设备名称) // 设备搜索状态(全局共享,便于编辑时显示设备名称)
const deviceList = ref<any[]>([]) const deviceList = ref<any[]>([])
const deviceTotal = ref(0) const deviceTotal = ref(0)
const typeOptions = ref<any[]>([])
// 加载设备类型列表(用于监测字段)
const loadTypeOptions = async () => {
try {
const res = await DeviceTypesApi.getDeviceTypesPage({ pageNo: 1, pageSize: 100 })
typeOptions.value = res.list ?? []
} catch {}
}
const devicePageNo = ref(1) const devicePageNo = ref(1)
const deviceKeyword = ref('') const deviceKeyword = ref('')
@ -146,13 +165,11 @@ const lastGroupId = ref(1)
/** 搜索设备 */ /** 搜索设备 */
const searchDevices = async () => { const searchDevices = async () => {
try { try {
const res = await DevicesApi.getDevicesPage({ const res = await RulesApi.getDevicesPageByRules({
pageNo: devicePageNo.value, deviceName: deviceKeyword.value || '',
pageSize: 100,
deviceName: deviceKeyword.value || undefined,
}) })
deviceList.value = res.list ?? [] deviceList.value = Array.isArray(res) ? res : (res.data ?? res.list ?? res ?? [])
deviceTotal.value = res.total ?? 0 deviceTotal.value = deviceList.value.length
} catch {} } catch {}
} }
@ -183,29 +200,54 @@ const handleGroupIdChange = (row: any) => {
row.sortOrder = 1 row.sortOrder = 1
} }
/** 根据设备ID加载监测字段选项 */ /** 根据设备ID加载监测字段选项(从 deviceList 和 typeOptions 中获取,不调额外接口) */
const loadFieldOptionsByDeviceId = async (row: any) => { const loadFieldOptionsByDeviceId = async (row: any) => {
if (!row.deviceId) { if (!row.deviceId) {
row.fieldOptions = [] row.fieldOptions = []
row.fieldKey = undefined row.fieldKey = undefined
return return
} }
try { // 从 deviceList 中查找设备,拿到 typeId
const device = await DevicesApi.getDevices(row.deviceId) const device = deviceList.value.find((d: any) => d.id === row.deviceId)
if (!device) return if (!device || !device.typeId) return
const deviceType = await DeviceTypesApi.getDeviceTypes(device.typeId) // 从 typeOptions 中查找设备类型字段定义(已在外侧加载)
if (deviceType?.dataFields) { const deviceType = typeOptions.value.find((t: any) => t.id === device.typeId)
const fields = typeof deviceType.dataFields === 'string' if (deviceType?.dataFields) {
? JSON.parse(deviceType.dataFields) const fields = typeof deviceType.dataFields === 'string'
: deviceType.dataFields ? JSON.parse(deviceType.dataFields)
row.fieldOptions = fields : deviceType.dataFields
if (!row.fieldKey || !fields.find((f: any) => f.key === row.fieldKey)) { row.fieldOptions = fields
row.fieldKey = fields.length > 0 ? fields[0].key : undefined if (!row.fieldKey || !fields.find((f: any) => f.key === row.fieldKey)) {
} row.fieldKey = fields.length > 0 ? fields[0].key : undefined
} }
} catch {} }
} }
/** 监听外部检测设备变化,同步更新所有行的 deviceId 和监测字段 */
watch(
() => props.parentDeviceId,
async (val) => {
if (!val) return
// 确保设备列表和设备类型列表已加载
if (deviceList.value.length === 0) await searchDevices()
if (typeOptions.value.length === 0) await loadTypeOptions()
await ensureDeviceInList(val)
// 更新已有行
if (formData.value.length > 0) {
for (const row of formData.value) {
row.deviceId = val
await loadFieldOptionsByDeviceId(row)
}
// 强制触发响应式更新
formData.value = [...formData.value]
}
// 如果没有任何行,添加一个默认行
if (formData.value.length === 0) {
handleAdd()
}
}
)
/** 监听主表的关联字段的变化,加载对应的子表数据 */ /** 监听主表的关联字段的变化,加载对应的子表数据 */
watch( watch(
() => props.ruleId, () => props.ruleId,
@ -223,8 +265,9 @@ watch(
...item, ...item,
fieldOptions: [], fieldOptions: [],
})) }))
// 加载设备列表 + 每行的监测字段选项 // 加载设备列表 + 设备类型列表 + 每行的监测字段选项
await searchDevices() await searchDevices()
await loadTypeOptions()
for (const row of formData.value) { for (const row of formData.value) {
await ensureDeviceInList(row.deviceId) await ensureDeviceInList(row.deviceId)
await loadFieldOptionsByDeviceId(row) await loadFieldOptionsByDeviceId(row)
@ -237,23 +280,34 @@ watch(
) )
/** 新增按钮操作 */ /** 新增按钮操作 */
const handleAdd = () => { const handleAdd = async () => {
const currentGroupRows = formData.value.filter((r: any) => r.groupId === lastGroupId.value) // 条件分组自动加1
const nextSort = currentGroupRows.length > 0 const newGroupId = formData.value.length > 0
? Math.max(...currentGroupRows.map((r: any) => r.sortOrder || 0)) + 1 ? Math.max(...formData.value.map((r: any) => r.groupId || 0)) + 1
: 1 : 1
// 获取最后一行作为参考(如果存在)
const lastRow = formData.value.length > 0 ? formData.value[formData.value.length - 1] : null
// 严重级别:默认1,如果上一行存在则取上一行的值
const defaultSortOrder = lastRow ? lastRow.sortOrder : 1
formData.value.push({ formData.value.push({
id: undefined, id: undefined,
ruleId: props.ruleId, ruleId: props.ruleId,
deviceId: undefined, deviceId: props.parentDeviceId,
fieldKey: undefined, fieldKey: undefined,
fieldOptions: [], fieldOptions: [],
opCode: undefined, opCode: undefined,
threshold: undefined, threshold: undefined,
durationSec: 0, durationSec: 0,
groupId: lastGroupId.value, groupId: newGroupId,
sortOrder: nextSort, sortOrder: defaultSortOrder,
}) })
lastGroupId.value = newGroupId
const newRow = formData.value[formData.value.length - 1]
if (props.parentDeviceId) {
newRow.deviceId = props.parentDeviceId
await loadFieldOptionsByDeviceId(newRow)
formData.value = [...formData.value]
}
} }
/** 删除按钮操作 */ /** 删除按钮操作 */
@ -271,5 +325,8 @@ const getData = () => {
return formData.value.map(({ fieldOptions, ...rest }) => rest) return formData.value.map(({ fieldOptions, ...rest }) => rest)
} }
defineExpose({ validate, getData }) /** 获取原始数据(不剥离 fieldOptions) */
const getDataRaw = () => formData.value
defineExpose({ validate, getData, getDataRaw })
</script> </script>

@ -109,11 +109,7 @@
<span>{{ scope.row.isAlarm ? '报警' : '不报警' }}</span> <span>{{ scope.row.isAlarm ? '报警' : '不报警' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="级别" align="center" prop="severity">
<template #default="scope">
<dict-tag :type="DICT_TYPE.ALARM_SEVERITY" :value="scope.row.severity" />
</template>
</el-table-column>
<el-table-column label="状态" align="center" prop="enabled"> <el-table-column label="状态" align="center" prop="enabled">
<template #default="scope"> <template #default="scope">
<dict-tag :type="DICT_TYPE.ENABLE_STATUS" :value="scope.row.enabled" /> <dict-tag :type="DICT_TYPE.ENABLE_STATUS" :value="scope.row.enabled" />
@ -153,6 +149,13 @@
> >
删除 删除
</el-button> </el-button>
<el-button
link
type="warning"
@click="openCopyDialog(scope.row)"
>
复制
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -167,6 +170,24 @@
<!-- 表单弹窗:添加/修改 --> <!-- 表单弹窗:添加/修改 -->
<RulesForm ref="formRef" @success="getList" /> <RulesForm ref="formRef" @success="getList" />
<!-- 复制到同类型设备穿梭框 -->
<el-dialog v-model="copyDialogVisible" title="选择要复制到的设备" width="600px" :close-on-click-modal="false">
<el-transfer
v-model="copySelectedDevices"
:data="copyTransferData"
:titles="['可选设备', '已选设备']"
filterable
:filter-method="(q, item) => item.label.includes(q)"
filter-placeholder="搜索设备"
style="text-align: left"
:props="{ disabled: (item) => item.disabled }"
/>
<template #footer>
<el-button @click="copyDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="copyLoading" @click="confirmCopy">确定复制</el-button>
</template>
</el-dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -174,6 +195,7 @@ import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
import { dateFormatter } from '@/utils/formatTime' import { dateFormatter } from '@/utils/formatTime'
import download from '@/utils/download' import download from '@/utils/download'
import { RulesApi, RulesVO } from '@/api/warehousealarm/rules' import { RulesApi, RulesVO } from '@/api/warehousealarm/rules'
import { DevicesApi } from '@/api/warehousealarm/devices'
import RulesForm from './RulesForm.vue' import RulesForm from './RulesForm.vue'
import RuleActionsList from './components/RuleActionsList.vue' import RuleActionsList from './components/RuleActionsList.vue'
import RuleConditionsList from './components/RuleConditionsList.vue' import RuleConditionsList from './components/RuleConditionsList.vue'
@ -261,6 +283,107 @@ const handleExport = async () => {
} }
} }
// ===== 复制规则 =====
const copyDialogVisible = ref(false)
const copyDeviceList = ref<any[]>([])
const copySelectedDevices = ref<number[]>([])
const copySourceRule = ref<any>(null)
const copyLoading = ref(false)
const copyTransferData = computed(() => {
const sourceDeviceId = copySourceRule.value?.deviceId
return copyDeviceList.value.map((d: any) => ({
key: d.id,
label: `${d.deviceName} (${d.region || ''})`,
disabled: d.id === sourceDeviceId,
}))
})
/** 打开复制穿梭框 */
const openCopyDialog = async (row: any) => {
copySourceRule.value = row
copySelectedDevices.value = []
copyLoading.value = true
copyDialogVisible.value = true
try {
// 获取当前规则的规则条件,取第一个条件的 deviceId
const conditions = await RulesApi.getRuleConditionsListByRuleId(row.id)
if (!conditions || conditions.length === 0 || !conditions[0].deviceId) {
ElMessage.warning('该规则没有检测设备,无法复制')
copyDialogVisible.value = false
return
}
const deviceId = conditions[0].deviceId
const device = await DevicesApi.getDevices(deviceId)
if (!device || !device.typeId) {
ElMessage.warning('无法获取设备类型')
copyDialogVisible.value = false
return
}
// 查询同类型所有设备
const res = await RulesApi.getDevicesPageByRules({ typeId: device.typeId })
const rawList = Array.isArray(res) ? res : (res.data ?? res.list ?? res ?? [])
copyDeviceList.value = rawList.filter((d: any) => d.id !== deviceId)
} catch {
ElMessage.error('查询设备失败')
} finally {
copyLoading.value = false
}
}
/** 确认复制 */
const confirmCopy = async () => {
if (copySelectedDevices.value.length === 0) {
ElMessage.warning('请选择要复制到的设备')
return
}
copyLoading.value = true
try {
const sourceId = copySourceRule.value.id
// 获取源规则的所有数据
const sourceRule = await RulesApi.getRules(sourceId)
const sourceConditions = await RulesApi.getRuleConditionsListByRuleId(sourceId)
const sourceActions = await RulesApi.getRuleActionsListByRuleId(sourceId)
for (const targetDeviceId of copySelectedDevices.value) {
const targetDevice = copyDeviceList.value.find((d: any) => d.id === targetDeviceId)
// 创建新规则(携带子表数据一起提交)
const newRule: any = {
...sourceRule,
id: undefined,
deviceId: targetDeviceId,
ruleName: targetDevice?.deviceName || sourceRule.ruleName,
triggerCount: 0,
lastTrigger: undefined,
}
// 拼接子表数据
if (sourceConditions && sourceConditions.length > 0) {
newRule.ruleConditionss = sourceConditions.map((c: any) => ({
...c,
id: undefined,
ruleId: undefined,
deviceId: targetDeviceId,
}))
}
if (sourceActions && sourceActions.length > 0) {
newRule.ruleActionss = sourceActions.map((a: any) => ({
...a,
id: undefined,
ruleId: undefined,
}))
}
await RulesApi.createRules(newRule)
}
ElMessage.success(`已复制到 ${copySelectedDevices.value.length} 个设备`)
copyDialogVisible.value = false
getList()
} catch {
ElMessage.error('复制失败')
} finally {
copyLoading.value = false
}
}
/** 初始化 **/ /** 初始化 **/
onMounted(() => { onMounted(() => {
getList() getList()

Loading…
Cancel
Save