feat:设备通道详情

This commit is contained in:
dzc committed 2026-03-18 16:09:43 +08:00
1 parent 0297f0e0ce
commit 135823c995
9 files changed
+428 -17

No files matched your search

+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>军航科技管理系统</title>
<!-- 监控播放器样式 -->
<link rel="stylesheet" href="/lib/WSPlayer/player.css">
<link rel="stylesheet" href="/lib/WSPlayer/window.division.css">
<!-- <link rel="stylesheet" href="/lib/WSPlayer/player.css">
<link rel="stylesheet" href="/lib/WSPlayer/window.division.css"> -->
</head>
<style>
html, body {
+29
View File
@@ -0,0 +1,29 @@
import request from "/@/utils/request"
// 云台方向控制
export const ytOperateDirect = (data: any) => {
return request({
url: '/admin/third/camera/yt/operateDirect',
method: 'post',
data,
})
}
// 云台镜头控制
export const ytOperateCamera = (data: any) => {
return request({
url: '/admin/third/camera/yt/operateCamera',
method: 'post',
data,
})
}
// 云台三维定位接口
export const ytSitPosition = (data: any) => {
return request({
url: '/admin/third/camera/yt/sitPosition',
method: 'post',
data,
})
}
@@ -52,7 +52,7 @@ let lastRealSignature = ""
let lastRealStartAt = 0
let lastPlaybackSignature = ""
let lastPlaybackStartAt = 0
let currentSnum = null
let currentSnum = 0
const ytRef = ref()
@@ -739,7 +739,7 @@ function emitSetting() {
}
function clickWindowCallback() {
if (props.visibleYt && currentWindowType == 0) {
if (props.visibleYt && currentWindowType.value == 0) {
updateYtChannel()
}
}
@@ -747,20 +747,42 @@ function clickWindowCallback() {
/* ************************************* start 云台相关 start ************************************* */
function visibleYt() {
console.log("visibleYt,currentSnum:", currentSnum);
console.log("VIDEO_PLAYER:", VIDEO_PLAYER.setting);
console.log("currentSnum:", currentSnum);
if (currentSnum !== null && currentSnum !== undefined) {
VIDEO_PLAYER.setting
const channelList = VIDEO_PLAYER.setting.channelList
const target = channelList.find((item) => item.snum === currentSnum)
if (!target || !target?.cameraType || !target?.capability) {
ytRef.value.open()
return
}
ytState.title = target?.channelName ?? ""
ytRef.value.showYt({
id: target?.channelId,
cameraType: target?.cameraType,
capability: target?.capability,
})
} else {
ytRef.value.open()
}
ytRef.value.open()
}
const updateYtChannel = () => {
if (!ytRef.value?.isVisible?.()) {
if (!ytRef.value?.isVisible) {
return
}
if (currentSnum !== null && currentSnum !== undefined) {
ytState.title = ""
ytRef.value.setChannel({})
const channelList = VIDEO_PLAYER.setting.channelList
const target = channelList.find((item) => item.snum === currentSnum)
if (!target || !target?.cameraType || !target?.capability) {
return
}
ytState.title = target?.channelName ?? ""
ytRef.value.setChannel({
id: target?.channelId,
cameraType: target?.cameraType,
capability: target?.capability,
})
}
}
+5 -4
View File
@@ -22,7 +22,7 @@
<script setup>
import { ref, defineExpose, onUnmounted, onMounted, onBeforeUnmount, defineProps, nextTick } from "vue";
import { ytApi } from "@/api/v2"
import * as ytApi from "/@/api/device/yt"
import "./lib/panTilt.css"
import PanTilt from './lib/panTilt';
@@ -36,7 +36,7 @@ const props = defineProps({
let YT = null
const dialogVisible = ref(false)
const minSize = { width: 350, height: 290 }
const minSize = { width: 350, height: 400 }
const sizeRatio = minSize.width / minSize.height
const size = ref({ width: minSize.width, height: minSize.height })
const position = ref({ x: 100, y: 100 })
@@ -273,10 +273,12 @@ onBeforeUnmount(() => {
})
</script>
<style lang="less" scoped>
<style lang="scss" scoped>
.yt-container {
width: 100%;
height: 100%;
flex: 1;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
@@ -357,7 +359,6 @@ onBeforeUnmount(() => {
height: 14px;
border-right: 2px solid rgba(255, 255, 255, 0.6);
border-bottom: 2px solid rgba(255, 255, 255, 0.6);
opacity: 0.8;
}
.resize_se {
+76
View File
@@ -35,3 +35,79 @@ export function timeDiff(enterTime: string, exitTime: string) {
if (diffMinutes === 0) return `${diffHours}小时`;
return `${diffHours}小时${diffMinutes}分钟`;
}
/**
* 设备通道能力位图(32 位,从右到左为第 1 位至第 32 位)
* - 输入:支持以下任意格式
* 1) 纯二进制字符串,例如 "1010..."; 最右侧为第 1 位
* 2) 逗号分隔的 0/1,例如 "1,0,0,..."; 第 1 项为第 1 位
* 3) 十进制字符串,例如 "12345";将自动转换为二进制位图
* - 输出:返回启用能力对应的中文名称数组(仅包含位为 1 的能力)
*/
export function parseDeviceCapability(capability: string): string[] {
if (!capability) return [];
const labels: string[] = [
"智能报警",
"鱼眼",
"电动聚焦",
"红外测温",
"热度图统计",
"过线统计",
"区域人数统计",
"人脸检测",
"人员识别",
"目标抓拍",
"主从跟踪",
"出入口抓拍",
"AR",
"多区域人数统计",
"雷球联动",
"行为分析",
"云台控制",
"保留位",
"扬声器",
"智能算法升级",
"MAC采集",
"自定义定时抓图",
"人脸开门",
"工装检测",
"人脸质量检测",
"人脸相似度比对",
"客流定时订阅",
"人脸分析",
"AR后端打标",
"AR广角180°",
"AR广角360°",
"废弃位",
];
const trimmed = capability.trim();
let getBitFromRight: (posFromRight: number) => number;
if (trimmed.includes(",")) {
const arr = trimmed.split(",").map((s) => s.trim());
getBitFromRight = (posFromRight: number) => {
const idx = posFromRight - 1;
const v = arr[idx];
return v === "1" ? 1 : 0;
};
} else if (/^[01]+$/.test(trimmed)) {
getBitFromRight = (posFromRight: number) => {
const idx = trimmed.length - posFromRight;
if (idx < 0) return 0;
return trimmed[idx] === "1" ? 1 : 0;
};
} else if (/^\d+$/.test(trimmed)) {
const bin = BigInt(trimmed).toString(2);
getBitFromRight = (posFromRight: number) => {
const idx = bin.length - posFromRight;
if (idx < 0) return 0;
return bin[idx] === "1" ? 1 : 0;
};
} else {
return [];
}
const result: string[] = [];
for (let i = 1; i <= labels.length; i++) {
if (getBitFromRight(i) === 1) result.push(labels[i - 1]);
}
return result;
}
+4
View File
@@ -6,6 +6,9 @@
<el-form-item style="width: 200px;" label="报警编号" prop="alarmCode">
<el-input v-model="state.queryForm.alarmCode" placeholder="请输入报警编号" />
</el-form-item>
<el-form-item style="width: 200px;" label="报警设备" prop="deviceName">
<el-input v-model="state.queryForm.deviceName" placeholder="请输入报警设备名" />
</el-form-item>
<el-form-item style="width: 350px;" label="报警类型" prop="alarmTypeList">
<dict-select v-model="state.queryForm.alarmTypeList" :options="alarm_type" multiple filterable
placeholder="请选择报警类型" />
@@ -36,6 +39,7 @@
@sort-change="sortChangeHandle">
<el-table-column type="index" label="#" width="60" />
<el-table-column prop="alarmCode" width="180" label="报警编号" show-overflow-tooltip />
<el-table-column prop="deviceName" width="180" label="报警设备" />
<el-table-column prop="alarmDate" width="180" label="报警时间" />
<el-table-column prop="alarmPosition" width="200" label="报警地点" show-overflow-tooltip />
<el-table-column prop="alarmType" width="180" label="报警类型">
@@ -0,0 +1,95 @@
<template>
<div class="containner">
<el-table :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
:header-cell-style="tableStyle.headerCellStyle">
<el-table-column type="index" label="#" width="80" />
<el-table-column prop="channelCode" align="left" width="150" label="通道编号" />
<el-table-column prop="channelName" align="left" width="250" label="通道名称" />
<el-table-column prop="cameraType" align="left" width="100" label="摄像头类型">
<template #default="{ row }">
<dict-tag v-if="row.cameraType" :options="camera_type" :value="row.cameraType" />
<el-tag type="danger" v-else>未知</el-tag>
</template>
</el-table-column>
<el-table-column prop="channelName" align="left" label="通道能力">
<template #default="{ row }">
<div class="tag-container">
<el-tag v-for="(v, i) in parseDeviceCapability(row.capability)" :key="i">
{{ v }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="installLocation" align="left" width="300" label="当前所在位置">
<template #default="{ row }">
<div class="color-success" v-if="row.installLocation">{{ row.installLocation }}</div>
<div class="color-danger" v-else>未安装</div>
</template>
</el-table-column>
</el-table>
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
<div style="height: 15px;"></div>
</div>
</template>
<script setup lang="ts" name="deviceChannelList">
import { ref, onMounted, reactive } from "vue"
import { BasicTableProps, useTable } from "/@/hooks/table";
import * as DeviceChannelApi from "/@/api/device/deviceChannel";
import { useDict } from '/@/hooks/dict';
import { parseDeviceCapability } from "/@/utils/dispose"
const props = withDefaults(defineProps<{
deviceCode: string
}>(), {
deviceCode: ""
})
const state: BasicTableProps = reactive<BasicTableProps>({
createdIsNeed: false,
queryForm: {
installLocation: true
},
pageList: DeviceChannelApi.fetchList
})
const { camera_type } = useDict("camera_type")
// table hook
const {
getDataList,
currentChangeHandle,
sizeChangeHandle,
sortChangeHandle,
downBlobFile,
tableStyle
} = useTable(state)
watch(
() => props.deviceCode,
(newVal, oldVal) => {
if (newVal !== oldVal) {
state.queryForm.deviceCode = newVal
getDataList()
}
}, { immediate: true }
)
</script>
<style lang="scss" scoped>
.tag-container {
display: flex;
align-items: center;
gap: 5px;
}
.containner {
width: 100%;
height: 100%;
}
</style>
+166
View File
@@ -0,0 +1,166 @@
<template>
<div class="layout-padding">
<div class="header">
<div class="title">基本信息</div>
<div class="info">
<div class="info-item">
<span>厂商:</span>
<span>
<dict-tag :options="dh_device_manufacturer" :value="state.deviceInfo.deviceManufacturer" />
</span>
</div>
<div class="info-item">
<span>设备类别:</span>
<dict-tag :options="device_category" :value="state.deviceInfo.deviceCategory" />
</div>
<div class="info-item">
<span>是否在线:</span>
<tag-online :isOnline="state.deviceInfo.isOnline == 1" />
</div>
<div class="info-item">
<span>设备编码:</span>
<span>{{ state.deviceInfo.deviceCode }}</span>
</div>
<div class="info-item">
<span>设备名称:</span>
<span>{{ state.deviceInfo.deviceName }}</span>
</div>
<div class="info-item">
<span>设备ip:</span>
<span>{{ state.deviceInfo.deviceIp }}</span>
</div>
<div class="info-item">
<span>设备端口:</span>
<span>{{ state.deviceInfo.devicePort }}</span>
</div>
</div>
</div>
<div class="main">
<div class="main-tab">
<el-tabs v-model="activeTab" class="demo-tabs">
<el-tab-pane label="视频通道" name="CHANNELS"></el-tab-pane>
</el-tabs>
</div>
<div class="main-content">
<device-channel-list :deviceCode="state?.deviceInfo?.deviceCode" v-if="activeTab == 'CHANNELS'" />
</div>
</div>
</div>
</template>
<script setup lang="ts" name="deviceDetail">
import { ref, onMounted, reactive } from "vue"
import { useRoute } from "vue-router"
import { useDict } from '/@/hooks/dict';
import * as DeviceManageApi from "/@/api/device/deviceManage";
const DeviceChannelList = defineAsyncComponent(() => import("./components/device-channer.vue"));
const { dh_device_manufacturer, device_category } = useDict("dh_device_manufacturer", "device_category")
const activeTab = ref("CHANNELS")
const state = reactive<any>({
deviceId: null,
deviceInfo: {}
})
onMounted(() => {
const route = useRoute();
state.deviceId = route.query.id;
getDeviceDetail()
})
// 获取设备详情
const getDeviceDetail = async () => {
if (!state.deviceId) {
return
}
try {
const { data, code } = await DeviceManageApi.getObj(state.deviceId)
if (code != 0) {
throw new Error()
}
state.deviceInfo = data
} catch (error) {
console.log("获取设备详情失败", error);
}
}
</script>
<style lang="scss" scoped>
.info {
padding: 10px 20px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
&-item {
display: flex;
align-items: center;
width: 100%;
>span:first-child {
width: 40%;
color: #333;
font-size: 13px;
font-weight: 500;
}
>span:last-child {
flex: 1;
color: #666;
font-size: 13px;
}
}
}
.title {
display: flex;
align-items: center;
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
&::before {
content: "";
display: block;
width: 4px;
height: 15px;
margin-right: 4px;
border-radius: 2px;
background: var(--el-color-primary);
}
}
.header {
width: 100%;
height: 20%;
background: var(--el-color-white);
border: 1px solid var(--el-border-color-light, #ebeef5);
border-radius: 4px;
}
.main {
display: flex;
flex-direction: column;
width: 100%;
height: 80%;
padding: 0 20px;
margin-top: 10px;
background: var(--el-color-white);
border: 1px solid var(--el-border-color-light, #ebeef5);
border-radius: 4px;
&-tab {
height: 10%;
}
&-content {
flex: 1;
overflow-y: scroll;
}
}
</style>
+21 -3
View File
@@ -74,11 +74,16 @@
{{ row.offlineReason }}
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="150">
<el-table-column label="操作" fixed="right" width="180">
<template #default="{ row }">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 justify-items-center">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2 justify-items-center">
<el-button icon="InfoFilled" text type="primary" @click="toDetail(row)">
详情
</el-button>
<el-button icon="VideoPlay" text type="primary"
@click="previewVideoRef.openDialog(row.id)">预览</el-button>
@click="previewVideoRef.openDialog(row.id)">
预览
</el-button>
<el-button icon="VideoCamera" text type="primary"
@click="replayVideoDialogRef.openDialog('device', row.deviceCode)">
回放
@@ -109,6 +114,7 @@
</template>
<script setup lang="ts" name="systemDeviceManage">
import { useRouter } from "vue-router"
import { BasicTableProps, useTable } from "/@/hooks/table";
import * as DeviceManageApi from "/@/api/device/deviceManage";
import { useMessage, useMessageBox } from "/@/hooks/message";
@@ -118,6 +124,9 @@ import { useDict } from '/@/hooks/dict';
const FormDialog = defineAsyncComponent(() => import("../components/deviceForm.vue"));
const PreviewVideoDialog = defineAsyncComponent(() => import("../components/preview-video/preview-video-dialog.vue"));
const ReplayVideoDialog = defineAsyncComponent(() => import("../components/replay-video/replay-video-dialog.vue"));
const { push } = useRouter()
// 定义查询字典
const { device_category, is_sync, is_nline, dh_device_manufacturer } = useDict("device_category", "is_sync", "is_nline", "dh_device_manufacturer")
// 定义变量内容
@@ -168,6 +177,15 @@ const selectionChangHandle = (objs: { id: string }[]) => {
multiple.value = !objs.length;
};
const toDetail = (row: any) => {
push({
path: "/device/detail/index",
query: {
id: row.id
}
})
}
// 树节点选择时
const handleGetSelectNode = (curClickedNode: any, checkedTreeNodes: any, isDeepTree: boolean) => {
state.queryForm.ownerCode = curClickedNode.id