feat:设备分组、设备分组查看监控

This commit is contained in:
dzc committed 2025-08-21 16:14:14 +08:00
1 parent 229c4c98a0
commit f5e1d63204
6 files changed
+578 -13

No files matched your search

+23
View File
@@ -1,5 +1,28 @@
import request from "/@/utils/request"
/**
* 房屋设施分组树
*/
export function getDeviceGroupTree(params: any) {
return request({
url: '/admin/device/deviceGroup/getDeviceGroupTree',
method: 'get',
params: params
})
}
/**
* 添加设施分组子项
*/
export function createGroupItem(data: any) {
return request({
url: '/admin/device/deviceGroup/createGroupItem',
method: 'post',
data: data
})
}
/**
* 删除设施分组子项
*/
@@ -0,0 +1,268 @@
<template>
<Splitpanes @resize="state.treeWidth = $event[0].size">
<pane :size="state.treeWidth">
<!-- 树 -->
<div v-loading="state.loading" class="h-full flex flex-col m-1">
<!-- 头部区域 -->
<div class="flex items-center pb-2 shadow-gray-400 z-10"
style="box-shadow: 0 8px 8px -8px rgba(0, 0, 0, 0.1)">
<div class="w-4/5">
<el-input v-model="state.searchName" suffix-icon="search" :placeholder="placeholder" clearable
@change="handleSearchInputChange" />
</div>
<div class="flex-1 ml-[5%] leading-none" v-if="showExpand">
<el-dropdown :hide-on-click="false">
<el-icon style="transform: rotate(90deg)">
<MoreFilled />
</el-icon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>
<el-button
class="!h-[20px] !text-gray-500 dark:!text-white dark:hover:!text-primary"
link :icon="state.isExpand ? 'expand' : 'fold'"
@click="toggleRowExpansionAll(state.isExpand ? false : true)">
{{ state.isExpand ? "折叠" : "展开" }}
</el-button>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 树形结构 -->
<div class="flex-1 overflow-hidden hover:overflow-auto">
<el-tree ref="treeRef" :props="props.props" :data="state.treeDataList" :expand-on-click-node="false"
node-key="id" :highlight-current="nodeClickable ? true : false" default-expand-all show-checkbox
@node-click="handleNodeClick" @check="handleCheck">
<template #default="{ node, data }">
<div class="flex items-center">
<el-icon class="flex justify-center align-center" v-if="data.deep == 1">
<Files />
</el-icon>
<el-icon class="flex justify-center align-center" v-if="data.deep == 2">
<VideoCamera />
</el-icon>
<span class="ml-1">{{ node.label }}</span>
</div>
</template>
</el-tree>
</div>
<!-- 底部区域 -->
<div class="flex justify-center items-center pt-2 z-50 sticky bottom-0 overflow-hidden"
style="box-shadow: 0 -8px 8px -8px rgba(0, 0, 0, 0.1)">
<el-switch v-model="state.switch" @change="handleSwitchChange" :inactive-text="switchInactiveText"
:active-text="switchActiveText"
:disabled="nodeClickable && state.checkedTreeNodes.length > 0" />
</div>
</div>
</pane>
<pane :size="100 - state.treeWidth" class="pl-1 relative">
<!-- 折叠按钮 -->
<div class="w-[12px] h-[56px] bg-gray-200 absolute left-0 top-[44vh] translate-y-[-50%] transition-all cursor-pointer rounded-tr-2xl rounded-br-2xl"
@click="handleClickTreeFold()">
<i class="text-gray-400 inline-block text-center leading-[62px] hover:opacity-50">
<el-icon>
<ArrowLeftBold v-if="state.treeWidth > 0" />
<ArrowRightBold v-else />
</el-icon>
</i>
</div>
<div class="w-full h-full p-3 overflow-auto">
<slot name="default" />
</div>
</pane>
</Splitpanes>
</template>
<script setup lang="ts" name="draggableDeviceGroup">
import { useMessage } from "/@/hooks/message"
import { getDeviceGroupTree } from "/@/api/device/deviceGroup"
const emit = defineEmits(["getSelectNode"])
const props = withDefaults(
defineProps<{
placeholder?: string // 输入框占位符
showExpand?: boolean // 折叠控制
query?: (searchName?: string) => Promise<any> // 查询函数
props?: object
nodeClickable?: boolean
switchInactiveText?: string | undefined
switchActiveText?: string | undefined
defaultTreeWidth?: number | string
}>(),
{
placeholder: "",
showExpand: true,
query: (name?: string) => getDeviceGroupTree({ orgName: name || "" }),
props: () => {
return {
label: "name",
children: "children",
value: "id",
disabled: "isLock",
}
},
nodeClickable: true,
switchInactiveText: "当前级",
switchActiveText: "当前级及子级",
defaultTreeWidth: 20,
},
)
const treeRef = ref() // 树组件ref
const state = reactive({
searchName: "", // 查询关键字
isExpand: true, // 是否展开树所有节点
treeWidth: Number(props.defaultTreeWidth), // 树组件宽度
treeWidthMemo: Number(props.defaultTreeWidth), // 树组件宽度记录
treeDataList: [],
curClickedNode: {},
checkedTreeNodes: [], // 被勾选的树节点
loading: false,
switch: false, // switch开关
})
// 是否折叠树组件
const treeVisible = computed(() => state.treeWidth > 0)
// 获取部门树形结构数据
const getTreeData = () => {
if (props.query instanceof Function) {
state.loading = true
const result = props.query(state.searchName)
// 如果查询结果为 Promise 类型,则进行后续处理
if ((typeof result === "object" || typeof result === "function") && typeof result.then === "function") {
result
.then((r: any) => {
if (r.data && r.data.length) {
r.data = filterVisibleNodes(r.data)
}
state.treeDataList = r.data
})
.catch((err) => {
useMessage().error(err.msg)
})
.finally(() => (state.loading = false))
}
}
}
// 过滤visible为false
const filterVisibleNodes = (nodes: Array<any>) => {
if (!Array.isArray(nodes)) return [];
return nodes.reduce((filtered, node) => {
if (node.visible) {
const filteredNode = { ...node };
if (node.children?.length) {
filteredNode.children = filterVisibleNodes(node.children);
}
filtered.push(filteredNode);
} else if (node.children?.length) {
// 如果当前节点不是visible,但是它的子节点可能是visible,所以需要继续检查
const filteredChildren = filterVisibleNodes(node.children);
if (filteredChildren.length > 0) {
filtered.push({ ...node, children: filteredChildren });
}
}
return filtered;
}, []);
}
// 点击折叠树组件
const handleClickTreeFold = () => {
if (treeVisible.value) {
state.treeWidthMemo = state.treeWidth
state.treeWidth = 0
} else {
state.treeWidth = state.treeWidthMemo
}
}
// 输入框改变
const handleSearchInputChange = () => {
treeRef.value!.setCheckedKeys([], false)
state.checkedTreeNodes = []
state.curClickedNode = {}
getTreeData()
}
// 点击树形结构节点
const handleNodeClick = (nodeData: any) => {
if (!props.nodeClickable) return
state.curClickedNode = nodeData
emit("getSelectNode", state.curClickedNode, state.checkedTreeNodes, state.switch)
}
// 勾选树节点时
const handleCheck = (data: any, checkStatus: any) => {
const { checkedNodes } = checkStatus
console.log("checkedNodes", checkedNodes)
state.checkedTreeNodes = checkedNodes
if (props.nodeClickable && state.checkedTreeNodes.length > 0) {
state.switch = false
}
emit("getSelectNode", state.curClickedNode, state.checkedTreeNodes, state.switch)
}
// switch改变时
const handleSwitchChange = () => emit("getSelectNode", state.curClickedNode, state.checkedTreeNodes, state.switch)
// 切换所有节点的展开/收起状态。status:true 为展开,false 为收起
const toggleRowExpansionAll = (status: boolean) => {
state.isExpand = status
const nodes = treeRef.value.store._getAllNodes()
for (let i = 0; i < nodes.length; i++) {
nodes[i].expanded = status
}
}
// 重置数据
const resetData = () => {
state.searchName = ""
state.checkedTreeNodes = []
state.curClickedNode = {}
state.switch = false
getTreeData()
}
onMounted(() => {
getTreeData()
})
defineExpose({
getTreeData,
resetData,
})
</script>
<style>
.splitpanes {
height: 100%;
}
.splitpanes>.splitpanes__pane {
height: 100%;
}
.splitpanes--vertical>.splitpanes__splitter {
background: var(--el-color-primary);
/* min-width: 2px; */
width: 2px;
transition: all 0.3s ease;
}
.splitpanes--vertical>.splitpanes__splitter:active {
background: var(--el-color-primary);
opacity: 0.6;
}
.splitpanes--vertical>.splitpanes__splitter:hover {
background: var(--el-color-primary);
opacity: 0.6;
}
</style>
+2
View File
@@ -14,6 +14,7 @@ import Sign from '/@/components/Sign/index.vue';
import ChinaArea from '/@/components/ChinaArea/index.vue';
import OrgSelector from '/@/components/OrgSelector/index.vue';
import DraggableOrgTree from "/@/components/DraggableOrgTree/index.vue"
import DraggableDeviceGroup from "/@/components/DraggableDeviceGroup/index.vue"
import PreviewImage from "/@/components/PreviewImage/index.vue"
@@ -73,6 +74,7 @@ export default {
app.component('PreviewImage', PreviewImage)
app.component('DraggableOrgTree', DraggableOrgTree)
app.component('DraggableDeviceGroup', DraggableDeviceGroup)
app.component('TagOnline', TagOnline);
app.component('TagSex', TagSex);
@@ -1,5 +1,5 @@
<template>
<el-dialog title="摄像头列表" v-model="visible" :close-on-click-modal="false" draggable>
<el-dialog title="摄像头列表" v-model="visible" :close-on-click-modal="false" width="60%" draggable>
<el-form :model="state.queryForm" ref="queryRef" :inline="true" @keyup.enter="getDataList">
<el-form-item style="width: 250px;" label="设备名" prop="deviceName">
<el-input v-model="state.queryForm.deviceName" placeholder="请输入设备名" />
@@ -14,6 +14,13 @@
<el-button icon="Refresh" @click="resetQuery">{{ $t('common.resetBtn') }}</el-button>
</el-form-item>
</el-form>
<el-row>
<div class="mb8" style="width: 100%">
<el-button icon="folder-add" type="primary" class="ml10" @click="addCamera">
新 增
</el-button>
</div>
</el-row>
<el-table :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
:header-cell-style="tableStyle.headerCellStyle" @selection-change="selectionChangHandle"
@sort-change="sortChangeHandle">
@@ -37,6 +44,24 @@
</el-table>
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
</el-dialog>
<el-dialog title="添加摄像头" v-model="cameraVisible" :close-on-click-modal="false" width="30%" draggable>
<el-form ref="dataFormRef" :model="cameraForm" :rules="cameraDataRules" formDialogRef label-width="90px">
<el-row :gutter="24">
<el-col :span="24" class="mb20">
<el-form-item label="摄像头" prop="deviceChannelCode">
<select-tree-channel-radio v-model="cameraForm.deviceChannelCode" placeholder="请选择摄像头" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="cameraVisible = false">取消</el-button>
<el-button type="primary" @click="onCameraSubmit" :disabled="cameraLoading">确认</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="CameraTableDialog">
@@ -46,7 +71,21 @@ import { useMessage, useMessageBox } from "/@/hooks/message";
import * as deviceGroupApi from "/@/api/device/deviceGroup";
// 定义变量内容
const dataFormRef = ref();
const visible = ref(false)
const cameraVisible = ref(false)
const cameraLoading = ref(false)
const cameraForm = reactive<any>({
groupId: "",
deviceChannelCode: "",
});
const cameraDataRules = ref({
deviceChannelCode: [{ required: true, message: '摄像头不能为空', trigger: 'change' }],
})
// 搜索变量
const queryRef = ref()
@@ -75,6 +114,8 @@ const {
const openDialog = async (id: string) => {
visible.value = true
if (!id) return
cameraForm.groupId = id
state.queryForm.groupId = id
getDataList()
}
@@ -109,6 +150,35 @@ async function handleDelete(row: any) {
}
}
/* ************************************* start 摄像头弹框 start ************************************* */
// 添加摄像头
function addCamera() {
dataFormRef.value?.resetFields();
cameraVisible.value = true
}
// 确定添加摄像头
async function onCameraSubmit() {
const valid = await dataFormRef.value.validate().catch(() => { });
if (!valid) return
cameraLoading.value = true
try {
const { data } = await deviceGroupApi.createGroupItem(cameraForm)
if (!data) throw new Error()
cameraLoading.value = false
cameraVisible.value = false
useMessage().success('添加成功');
getDataList();
} catch (error: any) {
console.error(error)
cameraLoading.value = false
useMessage().error(error.msg ?? "操作错误");
}
}
/* ************************************* end 摄像头弹框 end ************************************* */
// 暴露变量
defineExpose({
openDialog
+14 -12
View File
@@ -47,19 +47,21 @@
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" />
<el-table-column label="操作" width="150">
<el-table-column label="操作" width="210">
<template #default="{ row }">
<el-button icon="VideoPlay" text type="primary" @click="cameraTableDialogRef.openDialog(row.id)">
摄像头
</el-button>
<el-button v-if="row.bizType != 1" icon="edit-pen" text type="primary" v-auth="'device_deviceGroup_edit'"
@click="formDialogRef.openDialog(row.id)">
编辑
</el-button>
<el-button v-if="row.bizType != 1" icon="delete" text type="primary" v-auth="'device_deviceGroup_del'"
@click="handleDelete([row.id])">
删除
</el-button>
<div class="flex">
<el-button icon="VideoPlay" text type="primary" @click="cameraTableDialogRef.openDialog(row.id)">
摄像头
</el-button>
<el-button v-if="row.bizType != 1" icon="edit-pen" text type="primary" v-auth="'device_deviceGroup_edit'"
@click="formDialogRef.openDialog(row.id)">
编辑
</el-button>
<el-button v-if="row.bizType != 1" icon="delete" text type="primary" v-auth="'device_deviceGroup_del'"
@click="handleDelete([row.id])">
删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
+200
View File
@@ -0,0 +1,200 @@
<template>
<div class="layout-padding">
<div class="layout-padding-auto layout-padding-view">
<draggable-device-group ref="draggableDepTreeRef" placeholder="请输入设施分组" :show-expand="true"
@getSelectNode="handleGetSelectNode" switch-inactive-text="包含" switch-active-text="不包含"
:defaultTreeWidth="20" :nodeClickable="false">
<template #default>
<div id="ws-real-player" />
</template>
</draggable-device-group>
</div>
</div>
</template>
<script setup lang="ts" name="deviceGroupPlay">
import { BasicTableProps, useTable } from "/@/hooks/table";
import * as deviceGroupApi from "/@/api/device/deviceGroup";
import * as DeviceCameraApi from "/@/api/device/deviceCamera"
import * as DeviceChannelApi from "/@/api/device/deviceChannel"
import { useMessage, useMessageBox } from "/@/hooks/message";
// @ts-ignore
import PlayerManager from "../components/lib/icc/PlayerManager";
let realPlayer: any = null;
const videoState = reactive({
dialogVisible: false,
loading: false,
treeLoading: false,
videoNum: 0,
numList: [1, 4, 9, 16, 25],
})
const channel = reactive<any>({
playList: [],
deviceChannelList: []
})
onMounted(() => {
})
onUnmounted(() => {
destroy()
})
// 树节点选择时
const handleGetSelectNode = async (curClickedNode: any, checkedTreeNodes: any, isDeepTree: boolean) => {
destroy()
if (!checkedTreeNodes || !checkedTreeNodes.length) return
const list = checkedTreeNodes.filter((v: any) => v.deep == 2).map((v: any) => v.code)
if (!list || !list.length) return;
try {
// 查询设备通道
const { code: deviceChannelCode, data: deviceChannelList } = await DeviceChannelApi.list({ deviceChannelCodeList: list })
if (deviceChannelCode != 0) throw new Error()
if (deviceChannelList == null || deviceChannelList.length == 0) throw new Error()
channel.deviceChannelList = deviceChannelList
// 查询视频
const { code: cameraCode, data: channelList } = await DeviceCameraApi.getRtspVideoList({
channelIdList: list
})
if (cameraCode != 0) throw new Error()
if (channelList == null || channelList.length == 0) throw new Error()
channel.playList = channelList
initVideo()
} catch (error: any) {
console.error("查询设备通道错误", error);
}
}
/* ************************************* start 视频播放 start ************************************* */
// 初始化视频
function initVideo(channelCode?: string) {
if (realPlayer) realPlayer = null;
realPlayer = new PlayerManager({
el: "ws-real-player" /** 实时预览容器id,创建多个播放器,传入不同的容器id即可 **/,
type: "real" /** real - 实时预览 record - 录像回放 **/,
maxNum: 25 /** 一个播放器上限能播放的路数,可根据实际情况设置,支持 1 4 9 16 25 **/,
num: videoState.videoNum /** 初始化,页面显示的路数 **/,
showControl: true /** 是否显示工具栏,默认显示 **/,
prefixUrl: "lib" /** 解码库所在位置的前缀 **/,
isRTPStream: true /** true-标准码流拉流 false-私有码流拉流 **/,
receiveMessageFromWSPlayer: (methods: any, data: any, err: any) => {
/* 回调函数,可以在以下回调函数里面做监听 */
switch (methods) {
case "initializationCompleted":
console.log("初始化完成");
realPlayer.setPlayerAdapter("stretching");
// 初始化完成,可调用播放方法(适用于动态加载解码库)
// 若回调未触发时就使用实时预览/录像回放,则无法播放。
// 此时我们可以调用一个
playReal();
break;
case "changeStreamType": // 主/辅码流切换回调
console.log(data.channelData); // 通道数据
console.log(data.streamType); // 码流类型 0-主码流 1-辅码流1 2-辅码流2
console.log(data.selectIndex); // 窗口序号,从 0
break;
case "realSuccess": // 实时预览成功
console.log("实时预览成功");
break;
case "realError": // 实时预览失败
console.log("实时预览失败", err);
break;
case "notifyTalk":
// 点击窗口上的对讲按钮触发的事件回调
console.log("notifyTalk", data);
case "talkError": // 对讲失败
console.log("对讲失败");
break;
case "selectWindowChanged": // 选中的窗口发生改变
console.log(data, "返回窗口信息");
break;
case "windowNumChanged": // 播放器显示的路数发生改变
console.log(data, "返回显示的窗口数量");
break;
case "closeVideo": // 视频关闭回调
// 点击关闭按钮引发的视频关闭进行提示
// 切换视频引发的视频关闭不进行提示
if (!data.changeVideoFlag) {
console.log(`窗口${data.selectIndex}的视频已关闭`);
}
break;
case "statusChanged": // 视频状态发生改变
break;
case "errorInfo": // 错误信息汇总
console.log(data, "可打印查看错误消息");
}
},
});
}
// 预览
async function playReal() {
if (!channel.playList || !channel.playList.length) return
videoState.videoNum = findSuitableNum(channel.playList.length, videoState.numList)
try {
for (let index = 0; index < channel.playList.length; index++) {
const element = channel.playList[index]
const targetChannel = channel.deviceChannelList.find((v: any) => v.channelCode == element.channelId)
realPlayer.realByUrl({
rtspURL: element.fullUrl, // 必传
wsURL: element.wsURL, // 必传
selectIndex: index, // 必传, 播放窗口号
channelId: element.channelId, // 必传, 通道id
streamType: element.streamType, // 必传, 码流类型,
playerAdapter: "stretching", // 选传, selfAdaption 自适应 | stretching 拉伸
channelData: {
// 建议传, 通道树信息
id: targetChannel.channelCode, // {String} 通道编码
deviceCode: targetChannel.deviceCode, // {String} 设备编码
deviceType: targetChannel.deviceType, // {String} 设备类型
channelSeq: targetChannel.channelSeq, // {String|Number} 通道序号
}, // 选传
});
}
} catch (error: any) {
console.error(error);
useMessage().error(error?.msg ?? "视频播放错误");
}
}
// 销毁
function destroy() {
if (realPlayer) {
if (realPlayer) {
for (let index = 0; index < videoState.videoNum; index++) {
realPlayer.close(index)
}
}
realPlayer = null;
}
}
// 根据当前数量在numList中找到合适的值(大于等于当前值的最小值)
function findSuitableNum(currentNum: any, numList: any) {
const suitableNums = numList.filter((num: any) => num >= currentNum);
if (suitableNums.length > 0) {
return Math.min(...suitableNums);
} else {
return Math.max(...numList);
}
}
/* ************************************* end 视频播放 end ************************************* */
</script>
<style scoped lang="scss">
#ws-real-player {
width: 100%;
height: 100%;
}
</style>