Browse Source

对接视频接口、修改图标,样式

master
douyage 1 month ago
parent
commit
d814622c8a
14 changed files with 277 additions and 224 deletions
  1. +1
    -0
      .env.development
  2. +1
    -0
      .env.htjc6
  3. +1
    -0
      .env.production
  4. BIN
      public/aa-logo.png
  5. +1
    -1
      public/index.html
  6. +86
    -37
      src/api/nvrVideo.js
  7. +12
    -3
      src/api/videosMonitor/index.js
  8. BIN
      src/assets/images/aa-logo.png
  9. +4
    -2
      src/layout/components/Navbar.vue
  10. +2
    -1
      src/store/modules/user.js
  11. +14
    -7
      src/views/cards/patrolPlan/notstart.vue
  12. +4
    -5
      src/views/video/HistoryPlayback.vue
  13. +21
    -21
      src/views/video/LivePreview.vue
  14. +130
    -147
      src/views/video/components/NvrDeviceTree.vue

+ 1
- 0
.env.development View File

@ -8,6 +8,7 @@ ENV = 'development'
VUE_APP_BASE_API = '/dev-api' VUE_APP_BASE_API = '/dev-api'
VUE_APP_NVR_API = '/nvr-api' VUE_APP_NVR_API = '/nvr-api'
VUE_APP_DEVICE_TREE_API = 'http://192.168.1.116:18891/app/htjc6/api'
# 路由懒加载 # 路由懒加载
VUE_CLI_BABEL_TRANSPILE_MODULES = true VUE_CLI_BABEL_TRANSPILE_MODULES = true

+ 1
- 0
.env.htjc6 View File

@ -4,6 +4,7 @@ ENV = 'production'
VUE_APP_TITLE = 智能巡视 VUE_APP_TITLE = 智能巡视
VUE_APP_BASE_API = '/prod-api' VUE_APP_BASE_API = '/prod-api'
VUE_APP_NVR_API = 'http://192.168.1.86:28081' VUE_APP_NVR_API = 'http://192.168.1.86:28081'
VUE_APP_DEVICE_TREE_API = 'http://192.168.1.116:18891/app/htjc6/api'
VUE_APP_PUBLIC_PATH = '/app/htjc6/' VUE_APP_PUBLIC_PATH = '/app/htjc6/'
VUE_APP_OUTPUT_DIR = 'dist/app/htjc6' VUE_APP_OUTPUT_DIR = 'dist/app/htjc6'


+ 1
- 0
.env.production View File

@ -8,3 +8,4 @@ ENV = 'production'
VUE_APP_BASE_API = '/prod-api' VUE_APP_BASE_API = '/prod-api'
VUE_APP_NVR_API = 'http://192.168.1.86:28081' VUE_APP_NVR_API = 'http://192.168.1.86:28081'
VUE_APP_DEVICE_TREE_API = 'http://192.168.1.116:18891/app/htjc6/api'

BIN
public/aa-logo.png View File

Before After
Width: 32  |  Height: 32  |  Size: 808 B

+ 1
- 1
public/index.html View File

@ -5,7 +5,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="renderer" content="webkit"> <meta name="renderer" content="webkit">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<link rel="icon" type="image/png" href="<%= BASE_URL %>aa-logo.png">
<title><%= webpackConfig.name %></title> <title><%= webpackConfig.name %></title>
<!--[if lt IE 11]><script>window.location.href='/html/ie.html';</script><![endif]--> <!--[if lt IE 11]><script>window.location.href='/html/ie.html';</script><![endif]-->
<script src="<%= BASE_URL %>js/liveplayer-lib.min.js"></script> <script src="<%= BASE_URL %>js/liveplayer-lib.min.js"></script>


+ 86
- 37
src/api/nvrVideo.js View File

@ -1,80 +1,129 @@
import axios from 'axios'
import request from '@/utils/request'
const nvr = axios.create({
baseURL: process.env.VUE_APP_NVR_API || '/nvr-api',
timeout: 30000,
withCredentials: false
const unwrap = promise => promise.then(result => {
if (result && Object.prototype.hasOwnProperty.call(result, 'data')) return result.data
return result
}) })
nvr.interceptors.response.use(
response => response,
error => {
const response = error.response
const data = response && response.data
const message = data && (data.message || data.msg)
const normalized = new Error(message || error.message || 'NVR 服务请求失败')
normalized.status = response && response.status
normalized.response = response
return Promise.reject(normalized)
function encodeCameraCodeSegments(cameraCode) {
const value = String(cameraCode || '')
const separatorIndex = value.indexOf('#')
if (separatorIndex <= 0 || separatorIndex === value.length - 1) {
throw new Error('摄像机编码格式不正确')
} }
)
const data = promise => promise.then(response => response.data)
const uiBase = '/hik/nvr/playback/hls/ui'
const playbackBase = '/hik/nvr/playback/hls'
return [
encodeURIComponent(value.slice(0, separatorIndex)),
encodeURIComponent(value.slice(separatorIndex + 1))
]
}
export function getNvrConfig() { export function getNvrConfig() {
return data(nvr.get(`${uiBase}/config`))
return unwrap(request({ url: '/patrol/video/config', method: 'get' }))
} }
export function startLive(cameraCode) {
return data(nvr.post(`${uiBase}/actions/live/start`, { cameraCode, streamType: 1 }))
export function startLive(channel) {
return unwrap(request({
url: '/patrol/video/realtime/start',
method: 'post',
data: {
cameraCode: channel.cameraCode || channel.channelCode,
streamType: 1
}
}))
} }
export function getLiveStatus(cameraCode) { export function getLiveStatus(cameraCode) {
return data(nvr.get(`${uiBase}/actions/live/status`, { params: { cameraCode }}))
return unwrap(request({
url: '/patrol/video/realtime/status',
method: 'get',
params: { cameraCode }
}))
} }
export function stopLive(cameraCode) { export function stopLive(cameraCode) {
return data(nvr.post(`${uiBase}/actions/live/stop`, { cameraCode }))
return unwrap(request({
url: '/patrol/video/realtime/stop',
method: 'post',
data: { cameraCode }
}))
} }
export function controlPtz(payload) { export function controlPtz(payload) {
return data(nvr.post('/device/ptzcontrol', payload))
return unwrap(request({
url: '/patrol/preset/ptzControl',
method: 'post',
data: payload
}))
} }
export function getPresetList(deviceCode, domainCode) {
return data(nvr.get(`/device/ptzpresetlist/${encodeURIComponent(deviceCode)}/${encodeURIComponent(domainCode)}`))
export function getPresetList(cameraCode) {
const [device, domain] = encodeCameraCodeSegments(cameraCode)
return unwrap(request({
url: `/patrol/preset/presetList/${device}/${domain}`,
method: 'get'
}))
} }
export function createPreset(payload) { export function createPreset(payload) {
return data(nvr.post(`${uiBase}/actions/preset/create`, payload))
return unwrap(request({
url: '/patrol/preset/presetCreate',
method: 'post',
data: payload
}))
} }
export function createSnapshot(deviceCode, domainCode) {
return data(nvr.get(`/platform/platformSnapshot/${encodeURIComponent(deviceCode)}/${encodeURIComponent(domainCode)}`))
export function createSnapshot(cameraCode) {
const [device, domain] = encodeCameraCodeSegments(cameraCode)
return unwrap(request({
url: `/patrol/videotape/snapshot/task/${device}/${domain}`,
method: 'get'
}))
} }
export function downloadSnapshot(taskId) { export function downloadSnapshot(taskId) {
return nvr.get(`/platform/snapshot/${encodeURIComponent(taskId)}`, { responseType: 'blob' })
return request({
url: `/patrol/videotape/snapshot/${encodeURIComponent(taskId)}`,
method: 'get',
responseType: 'blob'
})
} }
export function createPlaybackSession(payload) { export function createPlaybackSession(payload) {
return data(nvr.post(`${uiBase}/sessions`, payload))
return unwrap(request({
url: '/patrol/videotape/hls/ui/session/create',
method: 'post',
data: payload
}))
} }
export function getPlaybackSession(sessionId) { export function getPlaybackSession(sessionId) {
return data(nvr.get(`${playbackBase}/sessions/${encodeURIComponent(sessionId)}`))
return unwrap(request({
url: `/patrol/videotape/hls/session/query/${encodeURIComponent(sessionId)}`,
method: 'get'
}))
} }
export function controlPlayback(sessionId, payload) { export function controlPlayback(sessionId, payload) {
return data(nvr.post(`${uiBase}/sessions/${encodeURIComponent(sessionId)}/controls`, payload))
return unwrap(request({
url: `/patrol/videotape/hls/ui/session/control/${encodeURIComponent(sessionId)}`,
method: 'post',
data: payload
}))
} }
export function deletePlaybackSession(sessionId) { export function deletePlaybackSession(sessionId) {
return data(nvr.delete(`${playbackBase}/sessions/${encodeURIComponent(sessionId)}`))
return unwrap(request({
url: `/patrol/videotape/hls/session/delete/${encodeURIComponent(sessionId)}`,
method: 'delete'
}))
} }
export function downloadPlaybackClip(payload) { export function downloadPlaybackClip(payload) {
return nvr.post('/hik/nvr/playback/clips/download', payload, { responseType: 'blob', timeout: 120000 })
return request({
url: '/patrol/videotape/clip/download',
method: 'post',
data: payload,
responseType: 'blob',
timeout: 120000
})
} }

+ 12
- 3
src/api/videosMonitor/index.js View File

@ -7,11 +7,20 @@ import { defectList } from "../home/index";
* @returns * @returns
*/ */
export function getAreaEqubookTreeSelect(params) { export function getAreaEqubookTreeSelect(params) {
const query = params || {};
const patroldeviceName = encodeURIComponent(query.patroldeviceName || "");
const onlineStatus = encodeURIComponent(query.onlineStatus || "");
const patroldeviceTypeFlag = encodeURIComponent(
query.patroldeviceTypeFlag || "ipc"
);
return request({ return request({
// url: '/basedata/eqpbook/getAreaEqubookTreeSelect',
url: "/basedata/eqpbook/getAreaEqubookTreeSelectForFilter",
baseURL: process.env.VUE_APP_DEVICE_TREE_API,
url:
"/basedata/eqpbook/getAreaEqubookTreeSelectForFilter" +
`?patroldeviceName=${patroldeviceName}` +
`&onlineStatus=${onlineStatus}` +
`&patroldeviceTypeFlag=${patroldeviceTypeFlag}`,
method: "get", method: "get",
params,
}); });
} }
/** /**


BIN
src/assets/images/aa-logo.png View File

Before After
Width: 32  |  Height: 32  |  Size: 808 B

+ 4
- 2
src/layout/components/Navbar.vue View File

@ -47,7 +47,7 @@
trigger="click" trigger="click"
> >
<div class="avatar-wrapper"> <div class="avatar-wrapper">
<img :src="avatar" class="user-avatar" />
<img :src="loginAvatar" class="user-avatar" />
<i class="el-icon-caret-bottom" /> <i class="el-icon-caret-bottom" />
</div> </div>
<el-dropdown-menu slot="dropdown"> <el-dropdown-menu slot="dropdown">
@ -71,6 +71,7 @@ import { mapGetters } from "vuex";
import TopNav from "@/components/TopNav"; import TopNav from "@/components/TopNav";
import Screenfull from "@/components/Screenfull"; import Screenfull from "@/components/Screenfull";
import headerLogo from "@/assets/images/header/logo.png"; import headerLogo from "@/assets/images/header/logo.png";
import loginAvatar from "@/assets/images/aa-logo.png";
// import { changeLanguage } from "@/api/login"; // import { changeLanguage } from "@/api/login";
export default { export default {
components: { components: {
@ -79,7 +80,8 @@ export default {
}, },
data() { data() {
return { return {
headerLogo
headerLogo,
loginAvatar
}; };
}, },
computed: { computed: {


+ 2
- 1
src/store/modules/user.js View File

@ -1,5 +1,6 @@
import { login, logout, getInfo, refreshToken } from "@/api/login"; import { login, logout, getInfo, refreshToken } from "@/api/login";
import { getToken, setToken, setExpiresIn, removeToken } from "@/utils/auth"; import { getToken, setToken, setExpiresIn, removeToken } from "@/utils/auth";
import defaultAvatar from "@/assets/images/aa-logo.png";
const user = { const user = {
state: { state: {
@ -84,7 +85,7 @@ const user = {
const user = res.user; const user = res.user;
const avatar = const avatar =
user.avatar == "" || user.avatar == null user.avatar == "" || user.avatar == null
? require("@/assets/images/profile.jpg")
? defaultAvatar
: user.avatar; : user.avatar;
if (res.roles && res.roles.length > 0) { if (res.roles && res.roles.length > 0) {
// 验证返回的roles是否是一个非空数组 // 验证返回的roles是否是一个非空数组


+ 14
- 7
src/views/cards/patrolPlan/notstart.vue View File

@ -106,14 +106,21 @@
align="center" align="center"
:index="table_index" :index="table_index"
/> />
<el-table-column label="方案名称" align="left" prop="taskName" width="700" header-align="center" />
<el-table-column label="点位数量" align="center" prop="totalPoints" />
<el-table-column label="创建时间" align="center" prop="createTime" />
<el-table-column label="区域名称" align="center" prop="areaName" />
<el-table-column label="方案名称" align="left" prop="taskName" min-width="360" header-align="center" show-overflow-tooltip />
<el-table-column label="点位数" align="center" prop="totalPoints" width="90" />
<el-table-column label="时间" align="center" prop="createTime" width="170" show-overflow-tooltip />
<el-table-column <el-table-column
label="任务策略"
label="区域名称"
align="center"
prop="areaName"
min-width="180"
show-overflow-tooltip
/>
<el-table-column
label="策略"
align="center" align="center"
prop="executionStatus" prop="executionStatus"
width="100"
:formatter="stateFormat" :formatter="stateFormat"
> >
</el-table-column> </el-table-column>
@ -123,7 +130,7 @@
prop="taskCycle" prop="taskCycle"
:formatter="stateTask" :formatter="stateTask"
/> --> /> -->
<el-table-column label="操作" align="center">
<el-table-column label="操作" align="center" width="150">
<template slot-scope="scope"> <template slot-scope="scope">
<el-tooltip content="立即执行" placement="bottom"> <el-tooltip content="立即执行" placement="bottom">
<i <i
@ -466,7 +473,7 @@ export default {
.fss { .fss {
font-size: 20px; font-size: 20px;
margin: 0 15px;
margin: 0 10px;
cursor: pointer; cursor: pointer;
} }


+ 4
- 5
src/views/video/HistoryPlayback.vue View File

@ -181,6 +181,7 @@ export default {
async selectChannel(channel, stop = true) { async selectChannel(channel, stop = true) {
if (!channel || channel.cameraCode === this.selectedCode) return if (!channel || channel.cameraCode === this.selectedCode) return
if (stop) await this.stopSession() if (stop) await this.stopSession()
if (!this.channels.some(item => item.cameraCode === channel.cameraCode)) this.channels.push(channel)
this.selectedCode = channel.cameraCode this.selectedCode = channel.cameraCode
this.$router.replace({ query: { ...this.$route.query, cameraCode: channel.cameraCode }}).catch(() => undefined) this.$router.replace({ query: { ...this.$route.query, cameraCode: channel.cameraCode }}).catch(() => undefined)
this.overlayVisible = true; this.overlayTitle = '选择时间范围开始回放'; this.overlayText = '已切换通道,请查询录像' this.overlayVisible = true; this.overlayTitle = '选择时间范围开始回放'; this.overlayText = '已切换通道,请查询录像'
@ -332,11 +333,9 @@ export default {
if (!centerTime) { this.$message.warning('请输入下载时刻,或先开始回放'); return } if (!centerTime) { this.$message.warning('请输入下载时刻,或先开始回放'); return }
this.clipDownloading = true this.clipDownloading = true
try { try {
const response = await downloadPlaybackClip({ cameraCode: this.selectedCode, streamType: 1, centerTime, beforeSeconds: 30, afterSeconds: 30 })
const disposition = response.headers['content-disposition'] || ''
const match = disposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';]+)/i)
const filename = match ? decodeURIComponent(match[1]) : `playback-${centerTime.replace(/[- :]/g, '')}.mp4`
const url = URL.createObjectURL(response.data); const link = document.createElement('a')
const blob = await downloadPlaybackClip({ cameraCode: this.selectedCode, streamType: 1, centerTime, beforeSeconds: 30, afterSeconds: 30 })
const filename = `playback-${centerTime.replace(/[- :]/g, '')}.mp4`
const url = URL.createObjectURL(blob); const link = document.createElement('a')
link.href = url; link.download = filename; link.click(); setTimeout(() => URL.revokeObjectURL(url), 2000) link.href = url; link.download = filename; link.click(); setTimeout(() => URL.revokeObjectURL(url), 2000)
this.$message.success('录像片段已下载') this.$message.success('录像片段已下载')
} catch (error) { this.$message.error(error.message) } finally { this.clipDownloading = false } } catch (error) { this.$message.error(error.message) } finally { this.clipDownloading = false }


+ 21
- 21
src/views/video/LivePreview.vue View File

@ -95,12 +95,12 @@
</div> </div>
<div class="speed-row"> <div class="speed-row">
<span>速度</span> <span>速度</span>
<input v-model.number="ptzSpeed" type="range" min="1" max="10">
<input v-model.number="ptzSpeed" type="range" min="1" max="7">
<el-input-number <el-input-number
v-model="ptzSpeed" v-model="ptzSpeed"
class="speed-input" class="speed-input"
:min="1" :min="1"
:max="10"
:max="7"
size="mini" size="mini"
:controls="false" :controls="false"
/> />
@ -124,7 +124,7 @@
<el-option v-for="item in presets" :key="item.id" :label="`${item.id} · ${item.name}`" :value="item.id" /> <el-option v-for="item in presets" :key="item.id" :label="`${item.id} · ${item.name}`" :value="item.id" />
</el-select> </el-select>
<div class="preset-create"> <div class="preset-create">
<el-input-number v-model="newPresetId" :min="1" :max="256" size="mini" :controls="false" placeholder="编号" />
<el-input-number v-model="newPresetId" :min="1" :max="255" size="mini" :controls="false" placeholder="编号" />
<el-input v-model.trim="newPresetName" size="mini" maxlength="32" placeholder="名称" /> <el-input v-model.trim="newPresetName" size="mini" maxlength="32" placeholder="名称" />
</div> </div>
<div class="section-actions"> <div class="section-actions">
@ -265,6 +265,9 @@ export default {
}, },
selectChannel(channel) { selectChannel(channel) {
if (!channel) return if (!channel) return
if (!this.channels.some(item => item.cameraCode === channel.cameraCode)) {
this.channels.push(channel)
}
this.selectedCode = channel.cameraCode this.selectedCode = channel.cameraCode
this.loadPresets(true) this.loadPresets(true)
}, },
@ -289,9 +292,11 @@ export default {
this.activeIndex = index this.activeIndex = index
this.placementCursor = (index + 1) % this.capacity this.placementCursor = (index + 1) % this.capacity
try { try {
const result = await startLive(channel.cameraCode)
const result = await startLive(channel)
this.applyLiveResult(index, slot, result) this.applyLiveResult(index, slot, result)
if (!result || result.status !== 'PLAYING') this.pollLive(index, slot, 0)
if (!slot.streamSource.hlsUrl && !slot.streamSource.flvUrl) {
throw new Error('实时视频接口未返回可播放地址')
}
} catch (error) { } catch (error) {
this.$set(this.slots, index, null) this.$set(this.slots, index, null)
this.$message.error(error.message) this.$message.error(error.message)
@ -299,8 +304,10 @@ export default {
}, },
applyLiveResult(index, slot, result) { applyLiveResult(index, slot, result) {
if (this.slots[index] !== slot || !result) return if (this.slots[index] !== slot || !result) return
if (result.hlsUrl) slot.streamSource.hlsUrl = result.hlsUrl
if (result.flvUrl) slot.streamSource.flvUrl = result.flvUrl
const payload = result.data || result
const mediaUrl = payload.url || payload.playUrl || payload.mediaUrl || payload.address || ''
slot.streamSource.hlsUrl = payload.hlsUrl || payload.hls || (/\.m3u8(?:\?|$)/i.test(mediaUrl) ? mediaUrl : '')
slot.streamSource.flvUrl = payload.flvUrl || payload.flv || (/\.flv(?:\?|$)/i.test(mediaUrl) ? mediaUrl : '')
slot.loading = !(slot.streamSource.hlsUrl || slot.streamSource.flvUrl) slot.loading = !(slot.streamSource.hlsUrl || slot.streamSource.flvUrl)
}, },
pollLive(index, slot, attempt) { pollLive(index, slot, attempt) {
@ -374,16 +381,10 @@ export default {
this.ptzActive = false this.ptzActive = false
this.sendPtz(1).catch(error => this.$message.error(error.message)) this.sendPtz(1).catch(error => this.$message.error(error.message))
}, },
splitCameraCode() {
const parts = String(this.selectedCode || '').split('#')
if (parts.length !== 2 || !parts[0] || !parts[1]) throw new Error('摄像机编码格式不正确')
return parts
},
async loadPresets(silent = false) { async loadPresets(silent = false) {
if (!this.selectedCode) return if (!this.selectedCode) return
try { try {
const [deviceCode, domainCode] = this.splitCameraCode()
const result = await getPresetList(deviceCode, domainCode)
const result = await getPresetList(this.selectedCode)
const source = result && result.ptzPresetInfoList && result.ptzPresetInfoList.ptzPresetInfo const source = result && result.ptzPresetInfoList && result.ptzPresetInfoList.ptzPresetInfo
this.presets = (Array.isArray(source) ? source : []).map(item => ({ id: Number(item.presetIndex), name: item.presetName || `预置位 ${item.presetIndex}` })).filter(item => item.id > 0) this.presets = (Array.isArray(source) ? source : []).map(item => ({ id: Number(item.presetIndex), name: item.presetName || `预置位 ${item.presetIndex}` })).filter(item => item.id > 0)
if (this.presets.length && !this.presets.some(item => item.id === this.presetId)) this.presetId = this.presets[0].id if (this.presets.length && !this.presets.some(item => item.id === this.presetId)) this.presetId = this.presets[0].id
@ -397,7 +398,7 @@ export default {
prepareNewPreset() { prepareNewPreset() {
const used = new Set(this.presets.map(item => Number(item.id))) const used = new Set(this.presets.map(item => Number(item.id)))
let nextId = 1 let nextId = 1
while (used.has(nextId) && nextId < 256) nextId += 1
while (used.has(nextId) && nextId < 255) nextId += 1
this.newPresetId = nextId this.newPresetId = nextId
this.newPresetName = '' this.newPresetName = ''
this.$message.info(`请填写预置位 ${nextId} 的名称后保存`) this.$message.info(`请填写预置位 ${nextId} 的名称后保存`)
@ -412,13 +413,13 @@ export default {
async savePreset() { async savePreset() {
const channel = this.selectedChannel const channel = this.selectedChannel
if (!channel) { this.$message.warning('请先选择摄像机'); return } if (!channel) { this.$message.warning('请先选择摄像机'); return }
if (!Number.isInteger(this.newPresetId) || this.newPresetId < 1 || this.newPresetId > 256) {
if (!Number.isInteger(this.newPresetId) || this.newPresetId < 1 || this.newPresetId > 255) {
this.$message.warning('请输入 1-256 的预置位编号') this.$message.warning('请输入 1-256 的预置位编号')
return return
} }
try { try {
await this.$confirm(`确认保存预置位 ${this.newPresetId}${this.newPresetName ? ` · ${this.newPresetName}` : ''}`, '保存预置位') await this.$confirm(`确认保存预置位 ${this.newPresetId}${this.newPresetName ? ` · ${this.newPresetName}` : ''}`, '保存预置位')
await createPreset({ nvrIp: channel.nvrIp, channel: Number(channel.channel), preset: this.newPresetId, presetName: this.newPresetName || null })
await createPreset({ cameraCode: channel.cameraCode, nvrIp: channel.nvrIp, channel: Number(channel.channel), preset: this.newPresetId, presetName: this.newPresetName || null })
await this.loadPresets(true) await this.loadPresets(true)
this.$message.success('预置位保存成功') this.$message.success('预置位保存成功')
} catch (error) { if (error !== 'cancel' && error !== 'close') this.$message.error(error.message) } } catch (error) { if (error !== 'cancel' && error !== 'close') this.$message.error(error.message) }
@ -426,11 +427,10 @@ export default {
async capture() { async capture() {
if (!this.selectedCode) { this.$message.warning('请先选择摄像机'); return } if (!this.selectedCode) { this.$message.warning('请先选择摄像机'); return }
try { try {
const [deviceCode, domainCode] = this.splitCameraCode()
const command = await createSnapshot(deviceCode, domainCode)
const command = await createSnapshot(this.selectedCode)
if (!command || Number(command.resultCode) !== 0 || !command.taskID) throw new Error('抓图任务创建失败') if (!command || Number(command.resultCode) !== 0 || !command.taskID) throw new Error('抓图任务创建失败')
const response = await downloadSnapshot(command.taskID)
const url = URL.createObjectURL(response.data)
const blob = await downloadSnapshot(command.taskID)
const url = URL.createObjectURL(blob)
const link = document.createElement('a') const link = document.createElement('a')
link.href = url link.href = url
link.download = `camera-${this.selectedCode.replace(/[^A-Za-z0-9_-]/g, '_')}-${Date.now()}.jpg` link.download = `camera-${this.selectedCode.replace(/[^A-Za-z0-9_-]/g, '_')}-${Date.now()}.jpg`


+ 130
- 147
src/views/video/components/NvrDeviceTree.vue View File

@ -1,60 +1,80 @@
<template> <template>
<aside class="nvr-tree"> <aside class="nvr-tree">
<div class="tree-title"> <div class="tree-title">
<span><i class="el-icon-s-platform" /> 设备通道</span>
<strong>{{ filteredCount }}</strong>
<span><i class="el-icon-s-platform" /> 设备列表</span>
<strong>{{ channelNodes.length }}</strong>
</div> </div>
<el-input
v-model.trim="query"
clearable
size="small"
prefix-icon="el-icon-search"
placeholder="搜索通道 / 分组"
/>
<div v-loading="loading" class="tree-content">
<template v-if="visibleNvrs.length">
<section v-for="nvr in visibleNvrs" :key="nvr.key" class="nvr-node">
<button type="button" class="nvr-row" @click="toggle(nvr.key)">
<i :class="isOpen(nvr.key) ? 'el-icon-arrow-down' : 'el-icon-arrow-right'" />
<i class="el-icon-connection" />
<span>{{ nvr.name }}</span>
<small>{{ nvr.channels.length }}</small>
</button>
<div v-show="isOpen(nvr.key)" class="nvr-groups">
<section v-for="group in nvr.groups" :key="group.key" class="channel-group">
<button type="button" class="group-row" @click="toggle(group.key)">
<i :class="isOpen(group.key) ? 'el-icon-caret-bottom' : 'el-icon-caret-right'" />
<span>{{ group.name }}</span>
<small>{{ group.channels.length }}</small>
</button>
<button
v-for="channel in group.channels"
v-show="isOpen(group.key)"
:key="channel.cameraCode"
type="button"
class="channel-row"
:class="{ active: channel.cameraCode === selectedCode }"
@click="$emit('select', channel)"
@dblclick="$emit('open', channel)"
>
<i class="el-icon-video-camera" />
<span>{{ channel.name || `通道 ${channel.channel}` }}</span>
<small>CH{{ channel.channel }}</small>
</button>
</section>
</div>
</section>
</template>
<div v-else class="tree-empty">
<div class="tree-filters">
<el-select v-model="onlineStatus" size="small" placeholder="状态" clearable>
<el-option label="全部" value="" />
<el-option label="在线" value="1" />
<el-option label="离线" value="0" />
</el-select>
<el-input
v-model.trim="keywords"
size="small"
clearable
prefix-icon="el-icon-search"
placeholder="请输入内容"
@input="refreshTree"
/>
<el-button size="small" icon="el-icon-refresh" title="刷新列表" @click="loadTree" />
</div>
<div v-loading="isLoading" class="tree-content">
<el-tree
ref="treeRef"
:data="treeData"
:props="treeProps"
node-key="id"
highlight-current
:default-expanded-keys="expandedKeys"
@node-click="handleNodeClick"
>
<el-tooltip
slot-scope="{ data }"
:content="data.label || data.name || ''"
placement="right"
:open-delay="300"
effect="dark"
>
<span class="custom-tree-node">
<i :class="nodeIcon(data)" />
<span class="node-label">{{ data.label || data.name }}</span>
<i v-if="data.channelFlag" :class="data.patroldeviceOnlineStatus == 1 ? 'online-dot' : 'offline-dot'" />
</span>
</el-tooltip>
</el-tree>
<div v-if="!loading && !treeData.length" class="tree-empty">
<i class="el-icon-video-camera-solid" /> <i class="el-icon-video-camera-solid" />
<span>{{ loading ? '正在读取设备配置' : '没有匹配的设备通道' }}</span>
<span>没有匹配的设备通道</span>
</div> </div>
</div> </div>
<div class="tree-tip">{{ openHint }}</div>
<div class="tree-tip">单击通道打开预览或切换当前控制对象</div>
</aside> </aside>
</template> </template>
<script> <script>
import { getAreaEqubookTreeSelect } from '@/api/videosMonitor/index'
function flatten(nodes, result = []) {
;(nodes || []).forEach(node => {
result.push(node)
if (Array.isArray(node.children)) flatten(node.children, result)
})
return result
}
function filterTree(nodes, keyword, onlineStatus) {
return (nodes || []).reduce((result, node) => {
const children = filterTree(node.children, keyword, onlineStatus)
const text = `${node.label || ''} ${node.name || ''} ${node.patroldeviceCode || ''} ${node.channelCode || ''}`.toLowerCase()
const matches = !keyword || text.includes(keyword.toLowerCase())
const onlineMatches = node.areaFlag === 'Yes' || !onlineStatus || String(node.patroldeviceOnlineStatus) === onlineStatus
if (matches && onlineMatches || children.length) result.push({ ...node, children })
return result
}, [])
}
export default { export default {
name: 'NvrDeviceTree', name: 'NvrDeviceTree',
props: { props: {
@ -62,125 +82,88 @@ export default {
channels: { type: Array, default: () => [] }, channels: { type: Array, default: () => [] },
selectedCode: { type: String, default: '' }, selectedCode: { type: String, default: '' },
loading: { type: Boolean, default: false }, loading: { type: Boolean, default: false },
openHint: { type: String, default: '击通道打开实时预览' }
openHint: { type: String, default: '击通道打开实时预览' }
}, },
data() { data() {
return { return {
query: '',
expanded: {}
treeNodes: [],
treeData: [],
expandedKeys: [],
keywords: '',
onlineStatus: '',
internalLoading: false,
treeProps: { children: 'children', label: 'label' }
} }
}, },
computed: { computed: {
visibleNvrs() {
const query = this.query.toLowerCase()
const configured = Array.isArray(this.config.nvrs) ? this.config.nvrs : []
const source = configured.length ? configured : this.inferredNvrs
return source.reduce((result, item, index) => {
const nvrIp = String(item.nvrIp || item.ip || '')
const ownChannels = Array.isArray(item.channels) && item.channels.length
? item.channels
: this.channels.filter(channel => String(channel.nvrIp || '') === nvrIp)
const nvrName = item.name || item.nvrName || nvrIp || `NVR ${index + 1}`
const nvrMatches = [nvrName, nvrIp, item.nvrId].filter(Boolean).join(' ').toLowerCase().includes(query)
const channels = !query || nvrMatches ? ownChannels : ownChannels.filter(channel => {
return [channel.name, channel.group, channel.channel, channel.cameraCode]
.filter(Boolean).join(' ').toLowerCase().includes(query)
})
if (query && !nvrMatches && !channels.length) return result
const key = `nvr:${nvrIp || index}`
const grouped = channels.reduce((groups, channel) => {
const name = String(channel.group || '未分组')
if (!groups[name]) groups[name] = []
groups[name].push(channel)
return groups
}, {})
result.push({
...item,
key,
name: nvrName,
channels,
groups: Object.keys(grouped).map(name => ({
key: `${key}:group:${name}`,
name,
channels: grouped[name]
}))
})
return result
}, [])
},
inferredNvrs() {
const map = new Map()
this.channels.forEach(channel => {
const key = String(channel.nvrIp || channel.nvrName || 'default')
if (!map.has(key)) map.set(key, { nvrIp: channel.nvrIp, name: channel.nvrName || channel.nvrIp, channels: [] })
map.get(key).channels.push(channel)
})
return Array.from(map.values())
},
filteredCount() {
return this.visibleNvrs.reduce((count, nvr) => count + nvr.channels.length, 0)
}
isLoading() { return this.loading || this.internalLoading },
channelNodes() { return flatten(this.treeNodes).filter(node => node.channelFlag) }
}, },
watch: { watch: {
visibleNvrs: {
immediate: true,
handler(nvrs) {
nvrs.forEach(nvr => {
if (this.expanded[nvr.key] === undefined) this.$set(this.expanded, nvr.key, true)
nvr.groups.forEach(group => {
if (this.expanded[group.key] === undefined) this.$set(this.expanded, group.key, true)
})
})
}
onlineStatus: 'refreshTree',
selectedCode(value) {
const node = this.channelNodes.find(item => this.toCameraCode(item) === value)
if (node && this.$refs.treeRef) this.$refs.treeRef.setCurrentKey(node.id)
} }
}, },
mounted() { this.loadTree() },
methods: { methods: {
toggle(key) {
this.$set(this.expanded, key, !this.isOpen(key))
async loadTree() {
this.internalLoading = true
try {
const response = await getAreaEqubookTreeSelect({ patroldeviceName: '', onlineStatus: '', patroldeviceTypeFlag: 'ipc' })
this.treeNodes = Array.isArray(response.data) ? response.data : []
this.expandedKeys = []
const walk = nodes => (nodes || []).forEach(node => { if (node.children && node.children.length) { this.expandedKeys.push(node.id); walk(node.children) } })
walk(this.treeNodes)
this.refreshTree()
const first = this.channelNodes[0]
if (first && !this.selectedCode) this.$emit('open', this.normalizeChannel(first))
} catch (error) {
this.treeNodes = []
this.$message.error(error.message || '设备树加载失败')
} finally { this.internalLoading = false }
},
refreshTree() { this.treeData = filterTree(this.treeNodes, this.keywords, this.onlineStatus) },
toCameraCode(node) { return node.cameraCode || node.channelCode || '' },
normalizeChannel(node) {
return { ...node, cameraCode: this.toCameraCode(node), name: node.label || node.name, channel: node.channelCode, nvrIp: node.nvrIp || '' }
}, },
isOpen(key) {
return this.expanded[key] !== false
handleNodeClick(node) {
if (!node.channelFlag) return
const channel = this.normalizeChannel(node)
this.$emit('open', channel)
},
nodeIcon(node) {
if (node.channelFlag) return 'el-icon-video-camera'
if (node.patroldeviceType == 13) return 'el-icon-position'
if (node.areaFlag === 'Yes') return 'el-icon-location-outline'
return 'el-icon-connection'
} }
} }
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.nvr-tree {
width: 258px;
min-width: 258px;
height: 100%;
display: flex;
flex-direction: column;
border-right: 1px solid rgba(41, 139, 255, .55);
background: rgba(7, 24, 78, .82);
color: #d7e9ff;
}
.tree-title { height: 46px; padding: 0 14px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(41, 139, 255, .35); color: #54ffff; font-size: 14px; }
.nvr-tree { width: 280px; min-width: 280px; height: 100%; display: flex; flex-direction: column; border-right: 1px solid rgba(41,139,255,.55); background: transparent; color: #d7e9ff; }
.tree-title { height: 46px; padding: 0 14px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(41,139,255,.35); color: #54ffff; font-size: 14px; }
.tree-title i { margin-right: 6px; } .tree-title i { margin-right: 6px; }
.tree-title strong { min-width: 28px; height: 20px; padding: 0 7px; line-height: 20px; text-align: center; color: #b7d8ff; border: 1px solid rgba(55, 151, 255, .4); background: #0d3472; font-size: 12px; }
::v-deep .el-input { margin: 10px 12px; width: calc(100% - 24px); }
::v-deep .el-input__inner { color: #d7e9ff; border-color: rgba(56, 151, 255, .45); background: rgba(7, 30, 88, .75); }
.tree-content {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 8px 8px;
scrollbar-width: none;
-ms-overflow-style: none;
}
.tree-title strong { min-width: 28px; text-align: center; color: #b7d8ff; }
.tree-filters { display: flex; gap: 6px; padding: 10px 8px; }
.tree-filters .el-select { width: 78px; }
.tree-filters .el-input { flex: 1; min-width: 0; }
.tree-filters .el-button { flex: 0 0 32px; padding: 0; }
.tree-content { flex: 1; min-height: 0; overflow: auto; padding: 0 8px 8px; scrollbar-width: none; -ms-overflow-style: none; }
.tree-content::-webkit-scrollbar { display: none; } .tree-content::-webkit-scrollbar { display: none; }
.nvr-row, .group-row, .channel-row { width: 100%; border: 0; color: inherit; background: transparent; cursor: pointer; text-align: left; letter-spacing: 0; }
.nvr-row { height: 36px; display: flex; align-items: center; gap: 7px; color: #cce4ff; font-weight: 600; }
.nvr-row span, .channel-row span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nvr-row small, .group-row small { color: #78a6d8; font-weight: 400; }
.group-row { height: 30px; padding-left: 17px; color: #8ebce8; }
.group-row i { margin-right: 5px; }
.channel-row { height: 34px; padding: 0 7px 0 34px; display: flex; align-items: center; gap: 8px; border-left: 2px solid transparent; color: #b9d8f8; }
.channel-row:hover { background: rgba(25, 105, 194, .22); }
.channel-row.active { color: #54ffff; border-left-color: #26d8ff; background: rgba(19, 100, 196, .38); }
.channel-row small { color: #668fbd; font-size: 10px; }
.tree-empty { height: 180px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: #7298c3; }
.tree-empty i { font-size: 32px; }
.tree-tip { min-height: 35px; padding: 9px 12px; border-top: 1px solid rgba(41, 139, 255, .3); color: #6f9ecc; font-size: 11px; line-height: 16px; }
.custom-tree-node { display: flex; align-items: center; width: 100%; gap: 6px; font-size: 14px; }
.node-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.online-dot, .offline-dot { width: 7px; height: 7px; border-radius: 50%; background: #20d47a; }
.offline-dot { background: #f05b68; }
.tree-empty { padding: 40px 0; text-align: center; color: #7298c3; }
.tree-empty i { display: block; margin-bottom: 10px; font-size: 28px; }
.tree-tip { padding: 9px 12px; border-top: 1px solid rgba(41,139,255,.3); color: #6f9ecc; font-size: 11px; }
::v-deep .el-tree { background: transparent; color: inherit; font-size: 14px; }
::v-deep .el-tree-node__content { height: 34px; }
::v-deep .el-tree-node__content:hover, ::v-deep .el-tree-node:focus > .el-tree-node__content { background: transparent; }
::v-deep .el-tree-node.is-current > .el-tree-node__content { color: #54ffff; background: transparent; }
</style> </style>

Loading…
Cancel
Save