Browse Source

增加视频预览、历史回放界面,设置路由打开指定的界面,补全导航的小图标

master
douyage 1 month ago
parent
commit
8d5056b1c0
40 changed files with 1596 additions and 30 deletions
  1. +2
    -0
      .env.development
  2. +2
    -0
      .env.production
  3. +2
    -0
      .env.staging
  4. +1
    -0
      package.json
  5. +80
    -0
      src/api/nvrVideo.js
  6. BIN
      src/assets/images/nav/nav-area.png
  7. BIN
      src/assets/images/nav/nav-base-data.png
  8. BIN
      src/assets/images/nav/nav-defect.png
  9. BIN
      src/assets/images/nav/nav-device.png
  10. BIN
      src/assets/images/nav/nav-home.png
  11. BIN
      src/assets/images/nav/nav-monitor.png
  12. BIN
      src/assets/images/nav/nav-patrol-plan.png
  13. BIN
      src/assets/images/nav/nav-patrol-report.png
  14. BIN
      src/assets/images/nav/nav-patrol-schedule.png
  15. BIN
      src/assets/images/nav/nav-patrol-task.png
  16. BIN
      src/assets/images/nav/nav-site-implementation.png
  17. BIN
      src/assets/images/nav/nav-site.png
  18. BIN
      src/assets/images/nav/nav-system.png
  19. BIN
      src/assets/images/nav/nav-task-management.png
  20. BIN
      src/assets/images/nav/nav-video-playback.png
  21. BIN
      src/assets/images/nav/nav-video-preview.png
  22. +4
    -1
      src/components/Breadcrumb/index.vue
  23. +8
    -0
      src/layout/components/AppMain.vue
  24. +21
    -12
      src/layout/components/Sidebar/Item.vue
  25. +5
    -1
      src/layout/components/Sidebar/SidebarItem.vue
  26. +37
    -1
      src/layout/components/Sidebar/index.vue
  27. +127
    -2
      src/layout/components/TagsView/index.vue
  28. +15
    -4
      src/layout/index.vue
  29. +91
    -0
      src/router/index.js
  30. +1
    -1
      src/views/cards/components/ImagePreviewZoomTemperature/index.vue
  31. +3
    -1
      src/views/cards/patrolReport/InspectionReporter.vue
  32. +9
    -3
      src/views/cards/patrolReport/patrolReportDetail.vue
  33. +1
    -1
      src/views/cards/site_management/site_management.vue
  34. +6
    -2
      src/views/cards/taskSummary/inspectionTask.vue
  35. +1
    -1
      src/views/point/site_management.vue
  36. +357
    -0
      src/views/video/HistoryPlayback.vue
  37. +475
    -0
      src/views/video/LivePreview.vue
  38. +186
    -0
      src/views/video/components/NvrDeviceTree.vue
  39. +154
    -0
      src/views/video/components/NvrStreamPlayer.vue
  40. +8
    -0
      vue.config.js

+ 2
- 0
.env.development View File

@ -7,5 +7,7 @@ ENV = 'development'
# 若依管理系统/开发环境
VUE_APP_BASE_API = '/dev-api'
VUE_APP_NVR_API = '/nvr-api'
# 路由懒加载
VUE_CLI_BABEL_TRANSPILE_MODULES = true

+ 2
- 0
.env.production View File

@ -6,3 +6,5 @@ ENV = 'production'
# 若依管理系统/生产环境
VUE_APP_BASE_API = '/prod-api'
VUE_APP_NVR_API = 'http://192.168.1.86:28081'

+ 2
- 0
.env.staging View File

@ -10,3 +10,5 @@ ENV = 'staging'
# 若依管理系统/测试环境
VUE_APP_BASE_API = '/stage-api'
VUE_APP_NVR_API = '/nvr-api'

+ 1
- 0
package.json View File

@ -47,6 +47,7 @@
"flv.js": "^1.6.2",
"fuse.js": "6.4.3",
"highlight.js": "9.18.5",
"hls.js": "^1.5.17",
"js-beautify": "1.13.0",
"js-cookie": "3.0.1",
"jsencrypt": "3.0.0-rc.1",


+ 80
- 0
src/api/nvrVideo.js View File

@ -0,0 +1,80 @@
import axios from 'axios'
const nvr = axios.create({
baseURL: process.env.VUE_APP_NVR_API || '/nvr-api',
timeout: 30000,
withCredentials: false
})
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)
}
)
const data = promise => promise.then(response => response.data)
const uiBase = '/hik/nvr/playback/hls/ui'
const playbackBase = '/hik/nvr/playback/hls'
export function getNvrConfig() {
return data(nvr.get(`${uiBase}/config`))
}
export function startLive(cameraCode) {
return data(nvr.post(`${uiBase}/actions/live/start`, { cameraCode, streamType: 1 }))
}
export function getLiveStatus(cameraCode) {
return data(nvr.get(`${uiBase}/actions/live/status`, { params: { cameraCode }}))
}
export function stopLive(cameraCode) {
return data(nvr.post(`${uiBase}/actions/live/stop`, { cameraCode }))
}
export function controlPtz(payload) {
return data(nvr.post('/device/ptzcontrol', payload))
}
export function getPresetList(deviceCode, domainCode) {
return data(nvr.get(`/device/ptzpresetlist/${encodeURIComponent(deviceCode)}/${encodeURIComponent(domainCode)}`))
}
export function createPreset(payload) {
return data(nvr.post(`${uiBase}/actions/preset/create`, payload))
}
export function createSnapshot(deviceCode, domainCode) {
return data(nvr.get(`/platform/platformSnapshot/${encodeURIComponent(deviceCode)}/${encodeURIComponent(domainCode)}`))
}
export function downloadSnapshot(taskId) {
return nvr.get(`/platform/snapshot/${encodeURIComponent(taskId)}`, { responseType: 'blob' })
}
export function createPlaybackSession(payload) {
return data(nvr.post(`${uiBase}/sessions`, payload))
}
export function getPlaybackSession(sessionId) {
return data(nvr.get(`${playbackBase}/sessions/${encodeURIComponent(sessionId)}`))
}
export function controlPlayback(sessionId, payload) {
return data(nvr.post(`${uiBase}/sessions/${encodeURIComponent(sessionId)}/controls`, payload))
}
export function deletePlaybackSession(sessionId) {
return data(nvr.delete(`${playbackBase}/sessions/${encodeURIComponent(sessionId)}`))
}
export function downloadPlaybackClip(payload) {
return nvr.post('/hik/nvr/playback/clips/download', payload, { responseType: 'blob', timeout: 120000 })
}

BIN
src/assets/images/nav/nav-area.png View File

Before After
Width: 15  |  Height: 15  |  Size: 490 B

BIN
src/assets/images/nav/nav-base-data.png View File

Before After
Width: 15  |  Height: 13  |  Size: 359 B

BIN
src/assets/images/nav/nav-defect.png View File

Before After
Width: 14  |  Height: 16  |  Size: 349 B

BIN
src/assets/images/nav/nav-device.png View File

Before After
Width: 15  |  Height: 13  |  Size: 542 B

BIN
src/assets/images/nav/nav-home.png View File

Before After
Width: 17  |  Height: 16  |  Size: 396 B

BIN
src/assets/images/nav/nav-monitor.png View File

Before After
Width: 15  |  Height: 15  |  Size: 472 B

BIN
src/assets/images/nav/nav-patrol-plan.png View File

Before After
Width: 14  |  Height: 16  |  Size: 379 B

BIN
src/assets/images/nav/nav-patrol-report.png View File

Before After
Width: 14  |  Height: 16  |  Size: 312 B

BIN
src/assets/images/nav/nav-patrol-schedule.png View File

Before After
Width: 15  |  Height: 15  |  Size: 450 B

BIN
src/assets/images/nav/nav-patrol-task.png View File

Before After
Width: 14  |  Height: 15  |  Size: 574 B

BIN
src/assets/images/nav/nav-site-implementation.png View File

Before After
Width: 15  |  Height: 15  |  Size: 385 B

BIN
src/assets/images/nav/nav-site.png View File

Before After
Width: 13  |  Height: 18  |  Size: 474 B

BIN
src/assets/images/nav/nav-system.png View File

Before After
Width: 16  |  Height: 16  |  Size: 575 B

BIN
src/assets/images/nav/nav-task-management.png View File

Before After
Width: 15  |  Height: 15  |  Size: 459 B

BIN
src/assets/images/nav/nav-video-playback.png View File

Before After
Width: 15  |  Height: 15  |  Size: 541 B

BIN
src/assets/images/nav/nav-video-preview.png View File

Before After
Width: 16  |  Height: 12  |  Size: 367 B

+ 4
- 1
src/components/Breadcrumb/index.vue View File

@ -46,7 +46,7 @@ export default {
matched = router.matched.filter(item => item.meta && item.meta.title)
}
//
if (!this.isDashboard(matched[0])) {
if (!this.isPatrolWorkspace() && !this.isDashboard(matched[0])) {
matched = [{ path: "/index", meta: { title: "首页" } }].concat(matched)
}
this.levelList = matched.filter(item => item.meta && item.meta.title && item.meta.breadcrumb !== false)
@ -85,6 +85,9 @@ export default {
}
this.$router.push(path)
},
isPatrolWorkspace() {
return this.$route.matched.some(route => route.meta && route.meta.patrolWorkspace)
},
titleFn(item, children) {
let str = "";
let lang = localStorage.getItem("language");


+ 8
- 0
src/layout/components/AppMain.vue View File

@ -66,6 +66,14 @@ export default {
padding-top: 108px;
}
.app-main.patrolWorkspace.hasTagsView {
height: calc(100vh - 40px);
}
.fixed-header + .app-main.patrolWorkspace.hasTagsView {
padding-top: 40px;
}
</style>
<style lang="scss">


+ 21
- 12
src/layout/components/Sidebar/Item.vue View File

@ -1,7 +1,8 @@
<script>
const dataNavIcon = require('@/assets/images/nav/data.png')
const dataNavIcon = require('@/assets/images/nav/nav-base-data.png')
const reservedIconTitles = new Set([
'巡视任务',
'巡视方案',
'点位管理',
'巡视报告',
'巡视计划',
@ -9,17 +10,25 @@ const reservedIconTitles = new Set([
])
const navIconMap = {
'首页': require('@/assets/images/nav/home.png'),
'Home': require('@/assets/images/nav/home.png'),
'遥视任务管理': require('@/assets/images/nav/task.png'),
'巡检任务管理': require('@/assets/images/nav/inspection-task.png'),
'巡视任务管理': require('@/assets/images/nav/inspection-task.png'),
'巡检任务': require('@/assets/images/nav/task.png'),
'设备管理': require('@/assets/images/nav/monitor.png'),
'点位管理(实施用)': require('@/assets/images/nav/point.png'),
'系统管理': require('@/assets/images/nav/system.png'),
'区域管理': require('@/assets/images/nav/area.png'),
'系统监控': require('@/assets/images/nav/monitor.png'),
'首页': require('@/assets/images/nav/nav-home.png'),
'Home': require('@/assets/images/nav/nav-home.png'),
'巡视任务': require('@/assets/images/nav/nav-patrol-task.png'),
'巡视方案': require('@/assets/images/nav/nav-patrol-plan.png'),
'点位管理': require('@/assets/images/nav/nav-site.png'),
'巡视报告': require('@/assets/images/nav/nav-patrol-report.png'),
'巡视计划': require('@/assets/images/nav/nav-patrol-schedule.png'),
'缺陷记录': require('@/assets/images/nav/nav-defect.png'),
'视频预览': require('@/assets/images/nav/nav-video-preview.png'),
'历史回放': require('@/assets/images/nav/nav-video-playback.png'),
'遥视任务管理': require('@/assets/images/nav/nav-task-management.png'),
'巡检任务管理': require('@/assets/images/nav/nav-task-management.png'),
'巡视任务管理': require('@/assets/images/nav/nav-task-management.png'),
'巡检任务': require('@/assets/images/nav/nav-task-management.png'),
'设备管理': require('@/assets/images/nav/nav-device.png'),
'点位管理(实施用)': require('@/assets/images/nav/nav-site-implementation.png'),
'系统管理': require('@/assets/images/nav/nav-system.png'),
'区域管理': require('@/assets/images/nav/nav-area.png'),
'系统监控': require('@/assets/images/nav/nav-monitor.png'),
'基础数据': dataNavIcon
}


+ 5
- 1
src/layout/components/Sidebar/SidebarItem.vue View File

@ -159,7 +159,11 @@ export default {
str = "首页";
}
}
if (str === "点位管理" && this.basePath !== "/site-management") {
if (
str === "点位管理" &&
this.basePath !== "/site-management" &&
this.basePath !== "/inspection-workspace/points"
) {
str = "点位管理(实施用)";
}
return str;


+ 37
- 1
src/layout/components/Sidebar/index.vue View File

@ -13,7 +13,7 @@
mode="vertical"
>
<sidebar-item
v-for="(route, index) in sidebarRouters"
v-for="(route, index) in displayedRoutes"
:key="route.path + index"
:item="route"
:base-path="route.path"
@ -35,6 +35,42 @@ export default {
computed: {
...mapState(["settings"]),
...mapGetters(["sidebarRouters", "sidebar"]),
displayedRoutes() {
if (!this.$route.meta.patrolWorkspace) {
return this.sidebarRouters;
}
const workspacePaths = {
"/task-summary": "/inspection-workspace/task",
"/patrol-plan": "/inspection-workspace/plan",
"/site-management": "/inspection-workspace/points",
"/patrol-report": "/inspection-workspace/report",
"/patrol-schedule": "/inspection-workspace/schedule",
"/defect-record": "/inspection-workspace/defects",
"/video-preview": "/inspection-workspace/video-preview",
"/video-playback": "/inspection-workspace/video-playback",
};
return this.sidebarRouters
.filter((route) => workspacePaths[route.path])
.map((route) => {
const workspacePath = workspacePaths[route.path];
const child = (route.children || []).find(item => !item.hidden);
return {
...route,
path: workspacePath,
meta: { ...(route.meta || {}), icon: undefined },
children: child ? [{
...child,
path: "",
query: undefined,
meta: {
...(child.meta || {}),
icon: undefined,
activeMenu: workspacePath,
},
}] : [],
};
});
},
activeMenu() {
const route = this.$route;
const { meta, path } = route;


+ 127
- 2
src/layout/components/TagsView/index.vue View File

@ -1,7 +1,31 @@
<template>
<div id="tags-view-container" class="tags-view-container">
<breadcrumb class="module-breadcrumb" />
<div v-if="$route.name === 'PatrolSchedule'" class="schedule-view-switch">
<div v-if="isVideoLivePreview" class="video-layout-switch" aria-label="画面布局">
<el-tooltip
v-for="item in videoLayouts"
:key="item.value"
:content="item.label"
placement="bottom"
>
<button
type="button"
:aria-label="item.label"
:class="{ active: videoLayout === item.value }"
@click="setVideoLayout(item.value)"
>
<span
class="video-layout-icon"
:class="`video-layout-icon-${item.columns}`"
:style="{ gridTemplateColumns: `repeat(${item.columns}, auto)` }"
aria-hidden="true"
>
<i v-for="cell in item.value" :key="cell" />
</span>
</button>
</el-tooltip>
</div>
<div v-if="isPatrolSchedule" class="schedule-view-switch">
<button
v-for="view in scheduleViews"
:key="view.value"
@ -54,6 +78,13 @@ export default {
left: 0,
selectedTag: {},
affixTags: [],
videoLayout: 9,
videoLayouts: [
{ value: 1, columns: 1, label: '单画面' },
{ value: 4, columns: 2, label: '四画面' },
{ value: 9, columns: 3, label: '九画面' },
{ value: 16, columns: 4, label: '十六画面' }
],
scheduleViews: [
{ label: '年视图', value: 'year' },
{ label: '月视图', value: 'month' },
@ -73,6 +104,12 @@ export default {
},
scheduleView() {
return this.$route.query.view || 'year'
},
isPatrolSchedule() {
return ['PatrolSchedule', 'InspectionWorkspaceSchedule'].includes(this.$route.name)
},
isVideoLivePreview() {
return ['VideoLivePreview', 'InspectionWorkspaceVideoPreview'].includes(this.$route.name)
}
},
watch: {
@ -91,12 +128,26 @@ export default {
mounted() {
this.initTags()
this.addTags()
this.$root.$on('video-layout-updated', this.updateVideoLayout)
},
beforeDestroy() {
this.$root.$off('video-layout-updated', this.updateVideoLayout)
},
methods: {
setVideoLayout(layout) {
if (layout === this.videoLayout) return
this.videoLayout = layout
this.$root.$emit('video-layout-change', layout)
},
updateVideoLayout(layout) {
this.videoLayout = layout
},
setScheduleView(view) {
if (view === this.scheduleView) return
this.$router.replace({
name: 'PatrolSchedule',
name: this.$route.meta.patrolWorkspace
? 'InspectionWorkspaceSchedule'
: 'PatrolSchedule',
query: { ...this.$route.query, view }
})
},
@ -315,6 +366,80 @@ export default {
line-height: 40px !important;
}
.video-layout-switch {
position: absolute;
z-index: 2;
top: 0;
right: 18px;
height: 40px;
display: flex;
align-items: center;
gap: 8px;
button {
width: 36px;
height: 36px;
padding: 0;
display: grid;
place-items: center;
border: 0;
color: #398bf2;
background: transparent;
cursor: pointer;
opacity: 0.72;
&:hover,
&.active {
color: #54a3ff;
opacity: 1;
filter: drop-shadow(0 0 4px rgba(50, 136, 255, 0.7));
}
}
.video-layout-icon {
display: grid;
gap: 3px;
}
.video-layout-icon i {
width: 5px;
height: 5px;
border: 2px solid currentColor;
box-sizing: border-box;
}
.video-layout-icon-1 i {
width: 27px;
height: 27px;
border-width: 4px;
}
.video-layout-icon-2 {
gap: 3px;
}
.video-layout-icon-2 i {
width: 10px;
height: 10px;
border-width: 2px;
}
.video-layout-icon-3 i {
width: 6px;
height: 6px;
}
.video-layout-icon-4 {
gap: 2px;
}
.video-layout-icon-4 i {
width: 6px;
height: 6px;
border-width: 2px;
}
}
.schedule-view-switch {
position: absolute;
z-index: 2;


+ 15
- 4
src/layout/index.vue View File

@ -4,11 +4,11 @@
<sidebar v-if="!sidebar.hide" class="sidebar-container"/>
<div :class="{hasTagsView:needTagsView,sidebarHide:sidebar.hide}" class="main-container">
<div class="layout-header" :class="{'fixed-header':fixedHeader}">
<navbar/>
<navbar v-if="!isPatrolWorkspace"/>
<tags-view v-if="needTagsView"/>
</div>
<app-main :class="{hasTagsView: needTagsView}" />
<right-panel>
<app-main :class="{hasTagsView: needTagsView, patrolWorkspace: isPatrolWorkspace}" />
<right-panel v-if="!isPatrolWorkspace">
<settings/>
</right-panel>
</div>
@ -47,9 +47,13 @@ export default {
hideSidebar: !this.sidebar.opened,
openSidebar: this.sidebar.opened,
withoutAnimation: this.sidebar.withoutAnimation,
mobile: this.device === 'mobile'
mobile: this.device === 'mobile',
patrolWorkspace: this.isPatrolWorkspace
}
},
isPatrolWorkspace() {
return this.$route.matched.some(route => route.meta && route.meta.patrolWorkspace)
},
variables() {
return variables;
}
@ -137,4 +141,11 @@ export default {
}
}
.app-wrapper.patrolWorkspace {
::v-deep .sidebar-container {
top: 0 !important;
height: 100% !important;
}
}
</style>

+ 91
- 0
src/router/index.js View File

@ -61,6 +61,69 @@ export const constantRoutes = [
component: () => import('@/views/error/401'),
hidden: true
},
{
path: '/inspection-workspace',
component: Layout,
redirect: '/inspection-workspace/task',
hidden: true,
children: [
{
path: 'task',
component: () => import('@/views/cards/taskSummary/inspectionTask'),
name: 'InspectionWorkspaceTask',
meta: { title: '巡视任务', activeMenu: '/inspection-workspace/task', patrolWorkspace: true }
},
{
path: 'plan',
component: () => import('@/views/cards/patrolPlan/notstart'),
name: 'InspectionWorkspacePlan',
meta: { title: '巡视方案', activeMenu: '/inspection-workspace/plan', patrolWorkspace: true }
},
{
path: 'points',
component: () => import('@/views/cards/site_management/site_management'),
name: 'InspectionWorkspacePoints',
meta: { title: '点位管理', activeMenu: '/inspection-workspace/points', patrolWorkspace: true }
},
{
path: 'report',
component: () => import('@/views/cards/patrolReport/InspectionReporter'),
name: 'InspectionWorkspaceReport',
meta: { title: '巡视报告', activeMenu: '/inspection-workspace/report', patrolWorkspace: true }
},
{
path: 'report/detail',
component: () => import('@/views/cards/patrolReport/patrolReportDetail'),
name: 'InspectionWorkspaceReportDetail',
hidden: true,
meta: { title: '巡视报告详情', activeMenu: '/inspection-workspace/report', patrolWorkspace: true }
},
{
path: 'schedule',
component: () => import('@/views/cards/tasksShow/index'),
name: 'InspectionWorkspaceSchedule',
meta: { title: '巡视计划', activeMenu: '/inspection-workspace/schedule', patrolWorkspace: true }
},
{
path: 'defects',
component: () => import('@/views/cards/defectRecord/index'),
name: 'InspectionWorkspaceDefects',
meta: { title: '缺陷记录', activeMenu: '/inspection-workspace/defects', patrolWorkspace: true }
},
{
path: 'video-preview',
component: () => import('@/views/video/LivePreview'),
name: 'InspectionWorkspaceVideoPreview',
meta: { title: '视频预览', activeMenu: '/inspection-workspace/video-preview', patrolWorkspace: true }
},
{
path: 'video-playback',
component: () => import('@/views/video/HistoryPlayback'),
name: 'InspectionWorkspaceVideoPlayback',
meta: { title: '历史回放', activeMenu: '/inspection-workspace/video-playback', patrolWorkspace: true }
}
]
},
{
path: '',
component: Layout,
@ -181,6 +244,34 @@ export const constantRoutes = [
meta: { title: '缺陷记录', activeMenu: '/defect-record' }
}
]
},
{
path: '/video-preview',
component: Layout,
redirect: '/video-preview/index',
meta: { title: '视频预览' },
children: [
{
path: 'index',
component: () => import('@/views/video/LivePreview'),
name: 'VideoLivePreview',
meta: { title: '视频预览', activeMenu: '/video-preview' }
}
]
},
{
path: '/video-playback',
component: Layout,
redirect: '/video-playback/index',
meta: { title: '历史回放' },
children: [
{
path: 'index',
component: () => import('@/views/video/HistoryPlayback'),
name: 'VideoHistoryPlayback',
meta: { title: '历史回放', activeMenu: '/video-playback' }
}
]
}
]


+ 1
- 1
src/views/cards/components/ImagePreviewZoomTemperature/index.vue View File

@ -31,7 +31,7 @@
-->
<img
ref="previewImage"
:src="'/shaoxing/videoMonitor/xunshiht/htjcImage/'+selectImgUrl"
:src="'/app/htjc6/htjcImage/'+selectImgUrl"
style="width: 100%; height: 100%; object-fit: contain"
@load="handleImageLoad"
@error="handleImageError"


+ 3
- 1
src/views/cards/patrolReport/InspectionReporter.vue View File

@ -342,7 +342,9 @@ export default {
sessionStorage.setItem("reportLineId", JSON.stringify(info.lineId));
sessionStorage.setItem("reportType", JSON.stringify(info.filter));
this.$router.push({
name: "patrolReportDetail",
name: this.$route.meta.patrolWorkspace
? "InspectionWorkspaceReportDetail"
: "patrolReportDetail",
query: {
lineId: info.lineId,
filter: info.filter,


+ 9
- 3
src/views/cards/patrolReport/patrolReportDetail.vue View File

@ -509,17 +509,23 @@ export default {
if (fromComponent === "InspectionReporter") {
targetRoute = {
name: "PatrolReport",
name: this.$route.meta.patrolWorkspace
? "InspectionWorkspaceReport"
: "PatrolReport",
};
} else {
targetRoute = {
name: "InspectionTaskSummary",
name: this.$route.meta.patrolWorkspace
? "InspectionWorkspaceTask"
: "InspectionTaskSummary",
};
}
this.$router.replace({
...targetRoute,
query: { from: "back" },
query: {
from: "back",
},
});
sessionStorage.removeItem("reportLineId");


+ 1
- 1
src/views/cards/site_management/site_management.vue View File

@ -955,7 +955,7 @@
<div>
<ImageAnnotation
:image-src="
'/shaoxing/videoMonitor/xunshiht/htjcImage/' +
'/app/htjc6/htjcImage/' +
imageUrl
"
ref="checkItemSetRef"


+ 6
- 2
src/views/cards/taskSummary/inspectionTask.vue View File

@ -876,7 +876,9 @@ export default {
if (process.env.VUE_APP_DIALOG == "true") {
this.$router.push(
{
name: "patrolReportDetail",
name: this.$route.meta.patrolWorkspace
? "InspectionWorkspaceReportDetail"
: "patrolReportDetail",
query: {
lineId: row.lineId,
filter: 0,
@ -927,7 +929,9 @@ export default {
);
this.$router.push({
name: "patrolReportDetail",
name: this.$route.meta.patrolWorkspace
? "InspectionWorkspaceReportDetail"
: "patrolReportDetail",
query: {
lineId,
filter: reportType,


+ 1
- 1
src/views/point/site_management.vue View File

@ -983,7 +983,7 @@
<div>
<ImageAnnotation
:image-src="
'/lingzhou/videoMonitor/xunshiht/htjcImage' + imageUrl
'/app/htjc6/htjcImage' + imageUrl
"
ref="checkItemSetRef"
/>


+ 357
- 0
src/views/video/HistoryPlayback.vue View File

@ -0,0 +1,357 @@
<template>
<div class="playback-console">
<div class="playback-body">
<nvr-device-tree
:config="config"
:channels="channels"
:selected-code="selectedCode"
:loading="loading"
open-hint="单击通道选择历史回放设备"
@select="selectChannel"
@open="selectChannel"
/>
<main class="playback-workspace">
<div class="query-bar">
<label><span>日期</span><input v-model="queryDate" type="date"></label>
<label><span>开始时间</span><input v-model="startTime" type="time" step="1"></label>
<em></em>
<label><span>结束时间</span><input v-model="endTime" type="time" step="1"></label>
<label><span>下载时刻</span><input v-model="clipTime" type="time" step="1" title="留空时使用当前回放时刻"></label>
<div class="query-actions">
<button type="button" class="primary" :disabled="busy" @click="queryPlayback"><i class="el-icon-search" />查询录像</button>
<button type="button" :disabled="clipDownloading" @click="downloadClip"><i class="el-icon-download" />{{ clipDownloading ? '正在生成' : '下载1分钟' }}</button>
<button type="button" @click="reset"><i class="el-icon-refresh-left" />重置</button>
</div>
</div>
<section ref="videoStage" class="video-stage">
<video ref="video" playsinline muted />
<div class="stage-head">
<span><i :class="{ playing: sessionStatus === 'PLAYING' }" />{{ statusLabel }}</span>
<small>{{ selectedChannelName }}</small>
<em>HLS / BEIJING</em>
</div>
<div v-if="overlayVisible" class="stage-overlay">
<i :class="busy ? 'el-icon-loading' : 'el-icon-video-camera'" />
<strong>{{ overlayTitle }}</strong>
<span>{{ overlayText }}</span>
</div>
<div class="stage-clock">{{ currentClock }}</div>
</section>
<section class="timeline-panel">
<div class="timeline-head">
<span><i />录像轨道</span>
<small>{{ timelineRangeText }}</small>
</div>
<div class="timeline" @click="seekByTimeline">
<div class="timeline-track">
<div class="actual-range" :style="actualRangeStyle" />
<div class="timeline-marker" :style="markerStyle"><span>{{ currentClock }}</span></div>
</div>
<div v-if="!hasActualRange" class="timeline-empty">查询后显示实际录像区间</div>
<div class="timeline-labels"><span v-for="label in timelineLabels" :key="label">{{ label }}</span></div>
</div>
</section>
<section class="playback-controls">
<div class="transport">
<button type="button" title="跳到录像起点" @click="seekBoundary('start')"><i class="el-icon-d-arrow-left" /></button>
<button type="button" class="play" title="播放或暂停" @click="togglePlay"><i :class="sessionStatus === 'PLAYING' ? 'el-icon-video-pause' : 'el-icon-video-play'" /></button>
<button type="button" title="跳到录像末尾" @click="seekBoundary('end')"><i class="el-icon-d-arrow-right" /></button>
<div class="time-readout"><strong>{{ currentClock }}</strong><span>{{ timelineRangeText }}</span></div>
</div>
<div class="control-center">
<div class="volume"><i :class="volume ? 'el-icon-microphone' : 'el-icon-turn-off-microphone'" /><input v-model.number="volume" type="range" min="0" max="1" step="0.05" @input="syncVolume"></div>
<div class="speed-group">
<button v-for="speed in speeds" :key="speed" type="button" :class="{ active: playbackSpeed === speed }" @click="setSpeed(speed)">{{ speed }}x</button>
</div>
<div class="jump-control"><span>跳转</span><input v-model="jumpTime" type="time" step="1"><button type="button" @click="jumpToTime">定位</button></div>
</div>
<button type="button" class="fullscreen" title="全屏" @click="fullscreen"><i class="el-icon-full-screen" /></button>
</section>
<div class="status-line" :class="messageType"><i /><span>{{ statusMessage }}</span></div>
</main>
</div>
</div>
</template>
<script>
import Hls from 'hls.js'
import NvrDeviceTree from './components/NvrDeviceTree.vue'
import {
getNvrConfig, createPlaybackSession, getPlaybackSession,
controlPlayback, deletePlaybackSession, downloadPlaybackClip
} from '@/api/nvrVideo'
const pad = value => String(value).padStart(2, '0')
const compactToDate = value => {
if (!value || !/^\d{14}$/.test(value)) return null
return new Date(Date.UTC(+value.slice(0, 4), +value.slice(4, 6) - 1, +value.slice(6, 8), +value.slice(8, 10), +value.slice(10, 12), +value.slice(12, 14)))
}
const dateToCompact = date => `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`
const compactToInput = value => {
const date = compactToDate(value)
return date ? `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}` : ''
}
const compactToClock = value => {
const date = compactToDate(value)
return date ? `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}` : '--:--:--'
}
export default {
name: 'VideoHistoryPlayback',
components: { NvrDeviceTree },
data() {
const now = new Date(Date.now() + 8 * 60 * 60 * 1000)
return {
config: {}, channels: [], selectedCode: '', loading: false,
connectionText: '读取配置', connectionState: '',
queryDate: now.toISOString().slice(0, 10), startTime: '00:00:00', endTime: '23:59:59',
clipTime: '', jumpTime: '', volume: 0.7, speeds: [1, 2, 4, 8], playbackSpeed: 1,
session: null, pollTimer: null, hls: null, hlsRetryTimer: null, loadedUrl: '', busy: false, clipDownloading: false,
queryStart: null, queryEnd: null, mediaReady: false,
overlayTitle: '选择时间范围开始回放', overlayText: '页面会自动创建 HLS 会话并等待首个可播放切片', overlayVisible: true,
statusMessage: '正在读取服务端设备配置', messageType: 'info'
}
},
computed: {
selectedChannel() { return this.channels.find(item => item.cameraCode === this.selectedCode) || null },
selectedChannelName() { return this.selectedChannel ? (this.selectedChannel.name || `通道 ${this.selectedChannel.channel}`) : '未选择通道' },
sessionStatus() { return (this.session && this.session.status) || 'IDLE' },
statusLabel() {
return { IDLE: '待查询', STARTING: '等待首帧', PLAYING: '回放中', PAUSED: '已暂停', STOPPED: '已停止', FAILED: '播放失败' }[this.sessionStatus] || this.sessionStatus
},
currentClock() { return compactToClock(this.session && this.session.currentTime) },
actualRange() { return this.session && Array.isArray(this.session.actualTimeRange) ? this.session.actualTimeRange : null },
hasActualRange() { return Boolean(this.actualRange && this.actualRange.length === 2) },
timelineRangeText() { return this.hasActualRange ? `${compactToClock(this.actualRange[0])}${compactToClock(this.actualRange[1])}` : '等待查询范围' },
rangeMetrics() {
if (!this.queryStart || !this.queryEnd || !this.hasActualRange) return { left: 0, width: 0, marker: 0 }
const start = compactToDate(this.queryStart); const end = compactToDate(this.queryEnd)
const actualStart = compactToDate(this.actualRange[0]); const actualEnd = compactToDate(this.actualRange[1])
if (!start || !end || !actualStart || !actualEnd || end <= start) return { left: 0, width: 0, marker: 0 }
const total = end - start
const left = Math.max(0, Math.min(100, ((actualStart - start) / total) * 100))
const width = Math.max(1, Math.min(100 - left, ((actualEnd - actualStart) / total) * 100))
const current = compactToDate(this.session.currentTime) || actualStart
return { left, width, marker: Math.max(0, Math.min(100, ((current - start) / total) * 100)) }
},
actualRangeStyle() { return { left: `${this.rangeMetrics.left}%`, width: `${this.rangeMetrics.width}%` } },
markerStyle() { return { left: `${this.rangeMetrics.marker}%` } },
timelineLabels() {
if (!this.queryStart || !this.queryEnd) return ['00:00', '06:00', '12:00', '18:00', '24:00']
const start = compactToDate(this.queryStart); const end = compactToDate(this.queryEnd)
if (!start || !end) return []
return [0, 0.25, 0.5, 0.75, 1].map(value => {
const date = new Date(start.getTime() + (end - start) * value)
return `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`
})
}
},
created() { this.loadConfig() },
mounted() {
const video = this.$refs.video
video.volume = this.volume
video.addEventListener('playing', () => { this.mediaReady = true; this.overlayVisible = false })
video.addEventListener('ratechange', () => { if (video.playbackRate !== 1) video.playbackRate = 1 })
},
beforeDestroy() { this.stopSession() },
methods: {
setStatus(message, type = 'info') { this.statusMessage = message; this.messageType = type },
async loadConfig() {
this.loading = true
try {
const config = await getNvrConfig()
this.config = config || {}
this.channels = Array.isArray(config.channels) ? config.channels : []
const routeCode = this.$route.query.cameraCode
const selected = this.channels.find(item => item.cameraCode === routeCode) || this.channels.find(item => item.cameraCode === config.defaultCameraCode) || this.channels[0]
if (selected) this.selectChannel(selected, false)
this.connectionText = '设备服务在线'; this.connectionState = 'online'
this.setStatus('选择通道和时间范围后查询录像')
} catch (error) {
this.connectionText = '服务不可达'; this.connectionState = 'error'
this.overlayTitle = '无法读取设备配置'; this.overlayText = error.message
this.setStatus(error.message, 'error')
} finally { this.loading = false }
},
async selectChannel(channel, stop = true) {
if (!channel || channel.cameraCode === this.selectedCode) return
if (stop) await this.stopSession()
this.selectedCode = channel.cameraCode
this.$router.replace({ query: { ...this.$route.query, cameraCode: channel.cameraCode }}).catch(() => undefined)
this.overlayVisible = true; this.overlayTitle = '选择时间范围开始回放'; this.overlayText = '已切换通道,请查询录像'
},
values() {
if (!this.queryDate || !this.startTime || !this.endTime) throw new Error('请选择完整的日期、开始时间和结束时间')
const start = `${this.queryDate} ${this.startTime}`
const end = `${this.queryDate} ${this.endTime}`
const startCompact = start.replace(/[- :]/g, ''); const endCompact = end.replace(/[- :]/g, '')
if (startCompact.length !== 14 || endCompact.length !== 14 || endCompact <= startCompact) throw new Error('结束时间必须晚于开始时间')
return { start, end, startCompact, endCompact }
},
async queryPlayback() {
if (this.busy) return
if (!this.selectedCode) { this.setStatus('请先选择摄像机', 'warn'); return }
let values
try { values = this.values() } catch (error) { this.setStatus(error.message, 'error'); return }
await this.stopSession()
this.busy = true; this.queryStart = values.startCompact; this.queryEnd = values.endCompact
this.overlayVisible = true; this.overlayTitle = '正在检索录像'; this.overlayText = 'NVR 正在返回实际连续录像区间'
this.setStatus(`正在查询 ${values.start}${values.end}`)
try {
this.session = await createPlaybackSession({ cameraCode: this.selectedCode, streamType: 1, startTime: values.start, endTime: values.end })
this.applySession(this.session)
this.schedulePoll(0)
} catch (error) {
this.overlayTitle = error.status === 404 ? '这个时间段没有录像' : '回放创建失败'; this.overlayText = error.message
this.setStatus(error.message, error.status === 404 ? 'warn' : 'error')
} finally { this.busy = false }
},
schedulePoll(delay = 1000) {
clearTimeout(this.pollTimer)
if (!this.session || ['STOPPED', 'FAILED'].includes(this.session.status)) return
this.pollTimer = setTimeout(this.pollSession, delay)
},
async pollSession() {
if (!this.session) return
try {
this.session = await getPlaybackSession(this.session.sessionId)
this.applySession(this.session)
this.schedulePoll(this.session.status === 'STARTING' ? 650 : 1200)
} catch (error) {
this.setStatus(error.message, 'error'); this.overlayVisible = true; this.overlayTitle = '回放流启动失败'; this.overlayText = error.message
this.destroyHls(); this.session = null
}
},
applySession(session) {
this.playbackSpeed = Number(session.speed || 1)
if (session.hlsUrl && ['STARTING', 'PLAYING'].includes(session.status)) this.attachHls(session.hlsUrl)
if (session.status === 'PLAYING') {
if (this.mediaReady) this.overlayVisible = false
this.setStatus(`正在播放 ${this.timelineRangeText} · ${this.playbackSpeed}x`, 'success')
this.$refs.video.play().catch(() => undefined)
} else if (session.status === 'PAUSED') {
this.$refs.video.pause(); this.overlayVisible = true; this.overlayTitle = '回放已暂停'; this.overlayText = '点击播放按钮继续'
} else if (session.status === 'STARTING') {
this.overlayVisible = true; this.overlayTitle = '正在等待回放流'; this.overlayText = 'HLS 正在等待首个可播放切片'
}
},
attachHls(url) {
if (this.loadedUrl === url && this.hls) return
this.destroyHls(); this.loadedUrl = url; this.mediaReady = false
const video = this.$refs.video
if (Hls.isSupported()) {
const hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
backBufferLength: 30,
maxBufferLength: 16,
manifestLoadingMaxRetry: 8,
levelLoadingMaxRetry: 8,
fragLoadingMaxRetry: 8
})
this.hls = hls
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
if (this.hls === hls) hls.loadSource(`${url}${url.includes('?') ? '&' : '?'}uiGeneration=${this.session ? this.session.generation : Date.now()}`)
})
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => undefined))
hls.on(Hls.Events.ERROR, (_, detail) => {
if (this.hls !== hls || !detail || !detail.fatal) return
this.setStatus('HLS 播放异常,正在自动恢复', 'warn')
if (detail.type === Hls.ErrorTypes.NETWORK_ERROR) {
clearTimeout(this.hlsRetryTimer)
this.hlsRetryTimer = setTimeout(() => {
if (this.hls === hls) hls.startLoad()
}, 800)
} else if (detail.type === Hls.ErrorTypes.MEDIA_ERROR) {
hls.recoverMediaError()
}
})
hls.attachMedia(video)
} else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = url; video.play().catch(() => undefined) }
},
destroyHls() {
clearTimeout(this.hlsRetryTimer); this.hlsRetryTimer = null
if (this.hls) { this.hls.destroy(); this.hls = null }
this.loadedUrl = ''; this.mediaReady = false
const video = this.$refs.video
if (video) { video.pause(); video.removeAttribute('src'); video.load() }
},
async sendControl(action, payload = {}) {
if (!this.session || this.busy) return
this.busy = true
try {
this.session = await controlPlayback(this.session.sessionId, { cameraCode: this.selectedCode, action, ...payload })
this.applySession(this.session); this.schedulePoll(this.session.status === 'STARTING' ? 650 : 1200)
} catch (error) { this.setStatus(error.message, 'error') } finally { this.busy = false }
},
togglePlay() { if (!this.session) this.setStatus('请先查询录像', 'warn'); else this.sendControl(this.sessionStatus === 'PLAYING' ? 'PAUSE' : 'RESUME') },
setSpeed(speed) { this.sendControl('SET_SPEED', { speed }) },
seek(value) { if (value) this.sendControl('SEEK', { seekTime: compactToInput(value) }) },
seekBoundary(side) {
if (!this.hasActualRange) return
if (side === 'start') this.seek(this.actualRange[0])
else {
const end = compactToDate(this.actualRange[1]); end.setUTCSeconds(end.getUTCSeconds() - 1); this.seek(dateToCompact(end))
}
},
seekByTimeline(event) {
if (!this.session || !this.queryStart || !this.queryEnd) return
const rect = event.currentTarget.getBoundingClientRect(); const fraction = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width))
const start = compactToDate(this.queryStart); const end = compactToDate(this.queryEnd)
this.seek(dateToCompact(new Date(start.getTime() + (end - start) * fraction)))
},
jumpToTime() {
if (!this.jumpTime) { this.setStatus('请输入跳转时间', 'warn'); return }
const time = this.jumpTime.length === 5 ? `${this.jumpTime}:00` : this.jumpTime
this.seek(`${this.queryDate.replace(/-/g, '')}${time.replace(/:/g, '')}`)
},
syncVolume() { const video = this.$refs.video; video.volume = this.volume; video.muted = this.volume === 0 },
fullscreen() { const stage = this.$refs.videoStage; if (stage && stage.requestFullscreen) stage.requestFullscreen() },
async stopSession() {
clearTimeout(this.pollTimer)
const session = this.session
this.session = null
if (session && session.sessionId) {
try { await controlPlayback(session.sessionId, { cameraCode: session.cameraCode || this.selectedCode, action: 'STOP' }) } catch (_) { /* cleanup */ }
try { await deletePlaybackSession(session.sessionId) } catch (_) { /* cleanup */ }
}
this.destroyHls()
},
reset() {
this.stopSession(); this.startTime = '00:00:00'; this.endTime = '23:59:59'; this.clipTime = ''; this.jumpTime = ''
this.queryStart = null; this.queryEnd = null; this.overlayVisible = true; this.overlayTitle = '选择时间范围开始回放'; this.overlayText = '页面会自动创建 HLS 会话并等待首个可播放切片'; this.setStatus('选择通道和时间范围后查询录像')
},
async downloadClip() {
if (!this.selectedCode) { this.$message.warning('请先选择摄像机'); return }
const centerTime = this.clipTime ? `${this.queryDate} ${this.clipTime}` : compactToInput(this.session && this.session.currentTime)
if (!centerTime) { this.$message.warning('请输入下载时刻,或先开始回放'); return }
this.clipDownloading = true
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')
link.href = url; link.download = filename; link.click(); setTimeout(() => URL.revokeObjectURL(url), 2000)
this.$message.success('录像片段已下载')
} catch (error) { this.$message.error(error.message) } finally { this.clipDownloading = false }
}
}
}
</script>
<style lang="scss" scoped>
.playback-console { height: 100%; min-height: 0; display: flex; flex-direction: column; color: #dcecff; background: #0b1248; }
.playback-body { flex: 1; min-height: 0; display: flex; }.playback-workspace { flex: 1; min-width: 0; min-height: 0; padding: 10px 14px; display: flex; flex-direction: column; gap: 9px; }
.query-bar { min-height: 58px; display: flex; align-items: flex-end; gap: 9px; padding: 7px 10px; border: 1px solid rgba(42,133,230,.38); background: rgba(7,30,84,.68); }.query-bar label { display: flex; flex-direction: column; gap: 5px; color: #7fa8d1; font-size: 11px; }.query-bar input { width: 126px; height: 29px; padding: 0 8px; border: 1px solid rgba(55,151,247,.48); color: #d4eaff; background: #09265e; outline: none; }.query-bar label:first-child input { width: 140px; }.query-bar em { height: 29px; line-height: 29px; color: #6c97c2; font-style: normal; }.query-actions { margin-left: auto; display: flex; gap: 6px; }.query-actions button { height: 30px; padding: 0 11px; border: 1px solid rgba(50,147,244,.52); color: #a9d3ff; background: #0a2b67; cursor: pointer; }.query-actions button.primary { color: #fff; background: #176bc2; }.query-actions button i { margin-right: 5px; }.query-actions button:disabled { opacity: .55; cursor: not-allowed; }
.video-stage { position: relative; flex: 1; min-height: 260px; overflow: hidden; border: 1px solid #176bc2; background: #03081d; }.video-stage video { width: 100%; height: 100%; display: block; object-fit: contain; }.stage-head { position: absolute; z-index: 2; top: 0; left: 0; right: 0; height: 34px; padding: 0 10px; display: flex; align-items: center; gap: 12px; color: #c9e3ff; background: linear-gradient(180deg,rgba(2,9,31,.94),transparent); font-size: 11px; }.stage-head span { display: flex; align-items: center; gap: 6px; }.stage-head span i { width: 7px; height: 7px; border-radius: 50%; background: #d09231; }.stage-head span i.playing { background: #25dfa2; box-shadow: 0 0 8px #25dfa2; }.stage-head small { color: #7da8d4; }.stage-head em { margin-left: auto; color: #5e8dbb; font-style: normal; }.stage-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; color: #6d9bc8; background: radial-gradient(circle at center,rgba(10,45,96,.65),rgba(3,8,29,.92)); }.stage-overlay i { font-size: 36px; }.stage-overlay strong { color: #b9d8f7; font-size: 14px; }.stage-overlay span { font-size: 11px; }.stage-clock { position: absolute; right: 10px; bottom: 8px; color: #a7caec; font-family: Consolas,monospace; font-size: 12px; }
.timeline-panel { padding: 8px 10px 4px; border: 1px solid rgba(43,133,226,.38); background: rgba(7,29,80,.72); }.timeline-head { display: flex; align-items: center; justify-content: space-between; color: #bcd9f7; font-size: 11px; }.timeline-head span { display: flex; align-items: center; gap: 6px; }.timeline-head i { width: 6px; height: 6px; border-radius: 50%; background: #1a8cff; }.timeline-head small { color: #6d98c2; }.timeline { position: relative; height: 46px; margin-top: 4px; cursor: pointer; }.timeline-track { position: absolute; left: 0; right: 0; top: 12px; height: 7px; background: #071b44; border: 1px solid #183f73; }.actual-range { position: absolute; top: -1px; height: 7px; background: #1989ee; box-shadow: 0 0 7px rgba(25,137,238,.6); }.timeline-marker { position: absolute; z-index: 2; top: -5px; width: 1px; height: 17px; background: #54ffff; }.timeline-marker span { position: absolute; left: 50%; top: -17px; transform: translateX(-50%); color: #54ffff; font-size: 9px; white-space: nowrap; }.timeline-empty { position: absolute; top: 3px; left: 50%; transform: translateX(-50%); color: #587fa8; font-size: 10px; }.timeline-labels { position: absolute; left: 0; right: 0; bottom: 0; display: flex; justify-content: space-between; color: #557da7; font-size: 9px; }
.playback-controls { height: 48px; min-height: 48px; padding: 0 9px; display: flex; align-items: center; border: 1px solid rgba(43,133,226,.38); background: #081e53; }.transport { display: flex; align-items: center; gap: 5px; }.playback-controls button { border: 1px solid rgba(53,146,238,.5); color: #a8d2fa; background: #0a2b67; cursor: pointer; }.transport>button { width: 29px; height: 29px; }.transport button.play { width: 35px; height: 35px; border-radius: 50%; color: #fff; background: #176bc2; }.time-readout { width: 170px; display: flex; flex-direction: column; margin-left: 6px; }.time-readout strong { color: #54ffff; font: 13px Consolas,monospace; }.time-readout span { color: #638fb9; font-size: 9px; }.control-center { flex: 1; display: flex; align-items: center; justify-content: center; gap: 18px; }.volume { display: flex; align-items: center; gap: 5px; color: #71a7d7; }.volume input { width: 76px; accent-color: #228ef1; }.speed-group { display: flex; }.speed-group button { width: 34px; height: 27px; border-right: 0; }.speed-group button:last-child { border-right: 1px solid rgba(53,146,238,.5); }.speed-group button.active { color: #fff; background: #176bc2; }.jump-control { display: flex; align-items: center; gap: 5px; color: #729dcc; font-size: 10px; }.jump-control input { width: 100px; height: 27px; padding: 0 5px; border: 1px solid rgba(53,146,238,.5); color: #d1e7ff; background: #081f53; }.jump-control button { height: 27px; }.fullscreen { width: 30px; height: 30px; }
.status-line { min-height: 29px; padding: 0 10px; display: flex; align-items: center; gap: 7px; color: #7fa8d0; border: 1px solid rgba(43,133,226,.25); background: rgba(6,25,70,.6); font-size: 11px; }.status-line i { width: 6px; height: 6px; border-radius: 50%; background: #428fda; }.status-line.success i { background: #2bdb9f; }.status-line.warn i { background: #e5a83d; }.status-line.error i { background: #ff5c72; }
@media (max-width: 1250px) { .query-bar { flex-wrap: wrap; }.query-actions { margin-left: 0; }.control-center { gap: 8px; }.time-readout { width: 130px; } }
</style>

+ 475
- 0
src/views/video/LivePreview.vue View File

@ -0,0 +1,475 @@
<template>
<div class="video-console live-preview">
<div class="console-body">
<nvr-device-tree
:config="config"
:channels="channels"
:selected-code="selectedCode"
:loading="loading"
@select="selectChannel"
@open="openChannel"
/>
<main class="monitor-workspace">
<div class="monitor-head">
<span>监控画面</span>
<small>双击设备树通道打开单击画面切换控制对象</small>
</div>
<div class="camera-grid" :style="gridStyle">
<article
v-for="(slot, index) in visibleSlots"
:key="index"
ref="cameraCards"
class="camera-card"
:class="{ active: activeIndex === index, empty: !slot }"
@click="activeIndex = index"
@dblclick="slot && fullscreen(index)"
>
<template v-if="slot">
<nvr-stream-player
:source="slot.streamSource"
:loading="slot.loading"
:muted="muted"
@error="handleStreamError($event, index)"
/>
<div class="camera-label">
<span><i />{{ slot.name }}</span>
<small>CH{{ slot.channel }}</small>
</div>
<div class="camera-actions">
<button type="button" title="历史回放" @click.stop="openPlayback(slot)"><i class="el-icon-time" /></button>
<button type="button" title="全屏" @click.stop="fullscreen(index)"><i class="el-icon-full-screen" /></button>
<button type="button" title="关闭" @click.stop="closeSlot(index)"><i class="el-icon-close" /></button>
</div>
</template>
<div v-else class="empty-slot">
<i class="el-icon-video-camera" />
<span>双击左侧通道打开预览</span>
</div>
</article>
</div>
</main>
<aside class="control-panel">
<section class="control-section">
<div class="section-title"><span>云台控制</span><small>{{ selectedChannelName }}</small></div>
<div class="ptz-grid">
<button
v-for="button in ptzButtons"
:key="button.action"
type="button"
:title="button.label"
@mousedown.prevent="startPtz(button.action)"
@mouseup.prevent="stopPtz"
@mouseleave="stopPtz"
@touchstart.prevent="startPtz(button.action)"
@touchend.prevent="stopPtz"
>
<i :class="button.icon" />
</button>
</div>
<div class="lens-actions">
<div v-for="tool in lensTools" :key="tool.label" class="lens-tool" :title="tool.label">
<img :src="tool.icon" :alt="tool.label">
<button
type="button"
:aria-label="tool.leftLabel"
:title="tool.leftLabel"
@mousedown.prevent="startPtz(tool.leftAction)"
@mouseup.prevent="stopPtz"
@mouseleave="stopPtz"
@touchstart.prevent="startPtz(tool.leftAction)"
@touchend.prevent="stopPtz"
/>
<button
type="button"
:aria-label="tool.rightLabel"
:title="tool.rightLabel"
@mousedown.prevent="startPtz(tool.rightAction)"
@mouseup.prevent="stopPtz"
@mouseleave="stopPtz"
@touchstart.prevent="startPtz(tool.rightAction)"
@touchend.prevent="stopPtz"
/>
</div>
</div>
<div class="speed-row">
<span>速度</span>
<input v-model.number="ptzSpeed" type="range" min="1" max="10">
<el-input-number
v-model="ptzSpeed"
class="speed-input"
:min="1"
:max="10"
size="mini"
:controls="false"
/>
</div>
<div class="preset-quick-actions">
<button type="button" title="调用预置位" @click="callPreset">
<img :src="presetIcons.call" alt="调用预置位">
</button>
<button type="button" title="新增预置位" @click="prepareNewPreset">
<img :src="presetIcons.add" alt="新增预置位">
</button>
<button type="button" title="编辑预置位" @click="prepareEditPreset">
<img :src="presetIcons.edit" alt="编辑预置位">
</button>
</div>
</section>
<section class="control-section">
<div class="section-title"><span>预置位</span><button type="button" @click="loadPresets"><i class="el-icon-refresh" /></button></div>
<el-select v-model="presetId" size="small" placeholder="请选择预置位" style="width:100%">
<el-option v-for="item in presets" :key="item.id" :label="`${item.id} · ${item.name}`" :value="item.id" />
</el-select>
<div class="preset-create">
<el-input-number v-model="newPresetId" :min="1" :max="256" size="mini" :controls="false" placeholder="编号" />
<el-input v-model.trim="newPresetName" size="mini" maxlength="32" placeholder="名称" />
</div>
<div class="section-actions">
<button type="button" @click="callPreset">调用</button>
<button type="button" class="primary" @click="savePreset">保存</button>
</div>
</section>
<section class="control-section">
<div class="section-title"><span>操作</span></div>
<div class="operation-grid">
<button type="button" class="wide" @click="capture"><i class="el-icon-camera" />抓图</button>
<button type="button" @click="showPendingOperation('录像')"><i class="el-icon-video-camera" />录像</button>
<button type="button" :class="{ active: !muted }" @click="muted = !muted">
<i :class="muted ? 'el-icon-turn-off-microphone' : 'el-icon-microphone'" />静音
</button>
<button type="button" @click="showPendingOperation('雨刷器')"><i class="el-icon-umbrella" />雨刷器</button>
<button type="button" @click="showPendingOperation('3D定位')"><i class="el-icon-aim" />3D定位</button>
</div>
</section>
</aside>
</div>
</div>
</template>
<script>
import NvrDeviceTree from './components/NvrDeviceTree.vue'
import NvrStreamPlayer from './components/NvrStreamPlayer.vue'
import {
getNvrConfig, startLive, getLiveStatus, stopLive, controlPtz,
getPresetList, createPreset, createSnapshot, downloadSnapshot
} from '@/api/nvrVideo'
const PTZ_CODES = {
UP: 2, DOWN: 3, LEFT: 4, UP_LEFT: 5, DOWN_LEFT: 6,
RIGHT: 7, UP_RIGHT: 8, DOWN_RIGHT: 9,
IRIS_OPEN: 21, IRIS_CLOSE: 22,
ZOOM_IN: 23, ZOOM_OUT: 24, FOCUS_NEAR: 25, FOCUS_FAR: 26
}
export default {
name: 'VideoLivePreview',
components: { NvrDeviceTree, NvrStreamPlayer },
data() {
return {
config: {},
channels: [],
selectedCode: '',
layout: 9,
layouts: [
{ value: 1, label: '单画面' }, { value: 4, label: '四画面' },
{ value: 9, label: '九画面' }, { value: 16, label: '十六画面' }
],
slots: Array.from({ length: 16 }, () => null),
activeIndex: 0,
placementCursor: 0,
loading: false,
connectionText: '读取配置',
connectionState: '',
muted: true,
ptzSpeed: 4,
ptzActive: false,
presets: [],
presetId: null,
newPresetId: null,
newPresetName: '',
presetIcons: {
call: require('@/assets/images/r6.png'),
add: require('@/assets/images/r7.png'),
edit: require('@/assets/images/r8.png')
},
ptzButtons: [
{ action: 'UP_LEFT', icon: 'el-icon-top-left', label: '左上' },
{ action: 'UP', icon: 'el-icon-top', label: '向上' },
{ action: 'UP_RIGHT', icon: 'el-icon-top-right', label: '右上' },
{ action: 'LEFT', icon: 'el-icon-back', label: '向左' },
{ action: 'STOP', icon: 'el-icon-video-pause', label: '停止' },
{ action: 'RIGHT', icon: 'el-icon-right', label: '向右' },
{ action: 'DOWN_LEFT', icon: 'el-icon-bottom-left', label: '左下' },
{ action: 'DOWN', icon: 'el-icon-bottom', label: '向下' },
{ action: 'DOWN_RIGHT', icon: 'el-icon-bottom-right', label: '右下' }
],
lensTools: [
{
label: '变倍', icon: require('@/assets/images/r1.png'),
leftAction: 'ZOOM_OUT', leftLabel: '变倍缩小',
rightAction: 'ZOOM_IN', rightLabel: '变倍放大'
},
{
label: '聚焦', icon: require('@/assets/images/r2.png'),
leftAction: 'FOCUS_FAR', leftLabel: '远聚焦',
rightAction: 'FOCUS_NEAR', rightLabel: '近聚焦'
},
{
label: '光圈', icon: require('@/assets/images/r3.png'),
leftAction: 'IRIS_CLOSE', leftLabel: '关闭光圈',
rightAction: 'IRIS_OPEN', rightLabel: '打开光圈'
}
]
}
},
computed: {
capacity() { return this.layout },
visibleSlots() { return this.slots.slice(0, this.capacity) },
gridStyle() {
const columns = { 1: 1, 4: 2, 9: 3, 16: 4 }[this.layout]
return { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, gridTemplateRows: `repeat(${columns}, minmax(0, 1fr))` }
},
selectedChannel() { return this.channels.find(item => item.cameraCode === this.selectedCode) || null },
selectedChannelName() { return this.selectedChannel ? (this.selectedChannel.name || `通道 ${this.selectedChannel.channel}`) : '未选择' }
},
created() {
this.$root.$on('video-layout-change', this.changeLayout)
this.$root.$emit('video-layout-updated', this.layout)
this.loadConfig()
},
beforeDestroy() {
this.$root.$off('video-layout-change', this.changeLayout)
this.closeAll()
},
methods: {
async loadConfig() {
this.loading = true
this.connectionText = '读取配置'
try {
const config = await getNvrConfig()
this.config = config || {}
this.channels = Array.isArray(config.channels) ? config.channels : []
const selected = this.channels.find(item => item.cameraCode === config.defaultCameraCode) || this.channels[0]
if (selected) this.selectChannel(selected)
this.connectionText = '设备服务在线'
this.connectionState = 'online'
} catch (error) {
this.connectionText = '服务不可达'
this.connectionState = 'error'
this.$message.error(error.message)
} finally { this.loading = false }
},
selectChannel(channel) {
if (!channel) return
this.selectedCode = channel.cameraCode
this.loadPresets(true)
},
openChannel(channel) {
this.selectChannel(channel)
this.startChannel(channel)
},
async startChannel(channel) {
if (!channel || !channel.cameraCode) return
const existing = this.slots.findIndex(slot => slot && slot.cameraCode === channel.cameraCode)
if (existing >= 0) { this.activeIndex = existing; return }
let index = this.visibleSlots.findIndex(slot => !slot)
if (index < 0) index = this.placementCursor % this.capacity
if (this.slots[index]) await this.closeSlot(index, false)
const slot = {
...channel,
loading: true,
streamSource: { hlsUrl: '', flvUrl: '' },
timer: null
}
this.$set(this.slots, index, slot)
this.activeIndex = index
this.placementCursor = (index + 1) % this.capacity
try {
const result = await startLive(channel.cameraCode)
this.applyLiveResult(index, slot, result)
if (!result || result.status !== 'PLAYING') this.pollLive(index, slot, 0)
} catch (error) {
this.$set(this.slots, index, null)
this.$message.error(error.message)
}
},
applyLiveResult(index, slot, result) {
if (this.slots[index] !== slot || !result) return
if (result.hlsUrl) slot.streamSource.hlsUrl = result.hlsUrl
if (result.flvUrl) slot.streamSource.flvUrl = result.flvUrl
slot.loading = !(slot.streamSource.hlsUrl || slot.streamSource.flvUrl)
},
pollLive(index, slot, attempt) {
if (this.slots[index] !== slot) return
if (attempt >= 20) {
slot.loading = false
this.$message.error('视频流启动超时,请检查摄像机或流媒体服务')
return
}
slot.timer = setTimeout(async() => {
try {
const result = await getLiveStatus(slot.cameraCode)
this.applyLiveResult(index, slot, result)
if (!result || result.status !== 'PLAYING' || !(slot.streamSource.hlsUrl || slot.streamSource.flvUrl)) {
this.pollLive(index, slot, attempt + 1)
}
} catch (_) { this.pollLive(index, slot, attempt + 1) }
}, 800)
},
async closeSlot(index, notify = true) {
const slot = this.slots[index]
if (!slot) return
clearTimeout(slot.timer)
this.$set(this.slots, index, null)
try { await stopLive(slot.cameraCode) } catch (_) { /* release the UI slot first */ }
if (notify) this.$message.success('视频通道已关闭')
},
closeAll() {
this.slots.forEach((slot, index) => { if (slot) this.closeSlot(index, false) })
},
changeLayout(layout) {
if (layout === this.layout) return
if (layout < this.layout) {
this.slots.forEach((slot, index) => { if (slot && index >= layout) this.closeSlot(index, false) })
}
this.layout = layout
this.$root.$emit('video-layout-updated', layout)
if (this.activeIndex >= layout) this.activeIndex = 0
this.placementCursor %= layout
},
handleStreamError(error, index) {
const slot = this.slots[index]
if (slot) slot.loading = false
this.$message.error(error.message)
},
fullscreen(index) {
const card = this.$refs.cameraCards && this.$refs.cameraCards[index]
if (card && card.requestFullscreen) card.requestFullscreen()
},
openPlayback(slot) {
this.$router.push({ name: 'VideoHistoryPlayback', query: { cameraCode: slot.cameraCode }})
},
async sendPtz(controlCode, controlPara1 = '', controlPara2 = '') {
if (!this.selectedCode) { this.$message.warning('请先选择摄像机'); return }
const result = await controlPtz({ cameraCode: this.selectedCode, controlCode, controlPara1, controlPara2 })
if (!result || Number(result.resultCode) !== 0) throw new Error('云台控制失败')
},
startPtz(action) {
if (action === 'STOP') {
this.ptzActive = false
this.sendPtz(1).catch(error => this.$message.error(error.message))
return
}
const code = PTZ_CODES[action]
if (!code || this.ptzActive) return
this.ptzActive = true
this.sendPtz(code, '2', String(this.ptzSpeed)).catch(error => this.$message.error(error.message))
},
stopPtz() {
if (!this.ptzActive) return
this.ptzActive = false
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) {
if (!this.selectedCode) return
try {
const [deviceCode, domainCode] = this.splitCameraCode()
const result = await getPresetList(deviceCode, domainCode)
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)
if (this.presets.length && !this.presets.some(item => item.id === this.presetId)) this.presetId = this.presets[0].id
if (!silent) this.$message.success(`读取到 ${this.presets.length} 个预置位`)
} catch (error) { if (!silent) this.$message.error(error.message) }
},
async callPreset() {
if (!this.presetId) { this.$message.warning('请选择预置位'); return }
try { await this.sendPtz(11, String(this.presetId)); this.$message.success('预置位调用成功') } catch (error) { this.$message.error(error.message) }
},
prepareNewPreset() {
const used = new Set(this.presets.map(item => Number(item.id)))
let nextId = 1
while (used.has(nextId) && nextId < 256) nextId += 1
this.newPresetId = nextId
this.newPresetName = ''
this.$message.info(`请填写预置位 ${nextId} 的名称后保存`)
},
prepareEditPreset() {
const preset = this.presets.find(item => item.id === this.presetId)
if (!preset) { this.$message.warning('请先选择需要编辑的预置位'); return }
this.newPresetId = preset.id
this.newPresetName = preset.name || ''
this.$message.info(`正在编辑预置位 ${preset.id}`)
},
async savePreset() {
const channel = this.selectedChannel
if (!channel) { this.$message.warning('请先选择摄像机'); return }
if (!Number.isInteger(this.newPresetId) || this.newPresetId < 1 || this.newPresetId > 256) {
this.$message.warning('请输入 1-256 的预置位编号')
return
}
try {
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 this.loadPresets(true)
this.$message.success('预置位保存成功')
} catch (error) { if (error !== 'cancel' && error !== 'close') this.$message.error(error.message) }
},
async capture() {
if (!this.selectedCode) { this.$message.warning('请先选择摄像机'); return }
try {
const [deviceCode, domainCode] = this.splitCameraCode()
const command = await createSnapshot(deviceCode, domainCode)
if (!command || Number(command.resultCode) !== 0 || !command.taskID) throw new Error('抓图任务创建失败')
const response = await downloadSnapshot(command.taskID)
const url = URL.createObjectURL(response.data)
const link = document.createElement('a')
link.href = url
link.download = `camera-${this.selectedCode.replace(/[^A-Za-z0-9_-]/g, '_')}-${Date.now()}.jpg`
link.click()
setTimeout(() => URL.revokeObjectURL(url), 2000)
this.$message.success('抓图已下载')
} catch (error) { this.$message.error(error.message) }
},
showPendingOperation(name) {
this.$message.info(`${name}控制接口暂未接入`)
}
}
}
</script>
<style lang="scss" scoped>
.video-console { height: 100%; min-height: 0; display: flex; flex-direction: column; color: #dcecff; background: #0b1248; }
.console-body { flex: 1; min-height: 0; display: flex; }
.monitor-workspace { flex: 1; min-width: 0; display: flex; flex-direction: column; padding: 10px; }
.monitor-head { height: 32px; display: flex; align-items: center; justify-content: space-between; color: #cfe5ff; }.monitor-head small { color: #648fbc; }
.camera-grid { flex: 1; min-height: 0; display: grid; gap: 3px; background: rgba(14, 75, 146, .5); border: 1px solid #176bc2; }
.camera-card { position: relative; min-width: 0; min-height: 0; overflow: hidden; border: 1px solid transparent; background: #050b24; cursor: pointer; }.camera-card.active { border-color: #54ffff; box-shadow: inset 0 0 0 1px rgba(84,255,255,.35); }
.camera-label { position: absolute; left: 0; right: 0; top: 0; height: 28px; padding: 0 8px; display: flex; align-items: center; justify-content: space-between; color: #d8eaff; background: linear-gradient(180deg, rgba(0,10,36,.92), transparent); font-size: 11px; pointer-events: none; }.camera-label i { display: inline-block; width: 6px; height: 6px; margin-right: 5px; border-radius: 50%; background: #24e39f; box-shadow: 0 0 6px #24e39f; }
.camera-actions { position: absolute; right: 5px; bottom: 5px; display: none; gap: 3px; }.camera-card:hover .camera-actions { display: flex; }.camera-actions button { width: 26px; height: 25px; border: 1px solid rgba(66, 167, 255, .55); color: #bfe0ff; background: rgba(4,28,78,.88); cursor: pointer; }
.empty-slot { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: #466f9c; background: radial-gradient(circle at center, #0b2a61, #050b24 70%); }.empty-slot i { font-size: 28px; }.empty-slot span { font-size: 11px; }
.control-panel { width: 242px; min-width: 242px; padding: 10px; overflow-y: auto; border-left: 1px solid rgba(41, 139, 255, .45); background: rgba(7, 24, 78, .82); }
.control-section { padding-bottom: 12px; margin-bottom: 12px; border-bottom: 1px solid rgba(39, 128, 220, .28); }.section-title { height: 30px; display: flex; align-items: center; justify-content: space-between; color: #54ffff; }.section-title small { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #719bc8; }.section-title button { border: 0; color: #75bfff; background: transparent; cursor: pointer; }
.ptz-grid { width: 168px; margin: 6px auto 12px; display: grid; grid-template-columns: repeat(3, 50px); gap: 9px; }.ptz-grid button { width: 50px; height: 50px; border: 1px solid #b9d5fb; border-radius: 7px; color: #2874da; background: #e8f2ff; box-shadow: inset 0 0 0 1px rgba(255,255,255,.7); cursor: pointer; transition: color .15s, background .15s, transform .15s; }.ptz-grid button i { font-size: 18px; font-weight: 700; }.ptz-grid button:hover { color: #075cc6; background: #f6faff; }.ptz-grid button:active { color: #fff; background: #1670ca; transform: scale(.96); }
.speed-row { display: grid; grid-template-columns: 34px minmax(0, 1fr) 48px; align-items: center; gap: 7px; color: #8eb7df; font-size: 12px; }.speed-row > input { min-width: 0; accent-color: #2497ff; }.speed-input { width: 48px; }
.preset-quick-actions { display: flex; align-items: center; gap: 7px; margin: 9px 0 0 1px; }.preset-quick-actions button { display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 1px solid rgba(48, 151, 248, .5); color: #58bfff; background: transparent; cursor: pointer; }.preset-quick-actions button:hover { background: rgba(31, 126, 222, .25); }.preset-quick-actions img { width: 18px; height: 18px; object-fit: contain; }
.lens-actions { display: flex; align-items: center; justify-content: center; gap: 11px; min-height: 28px; margin: 0 0 10px; }.lens-tool { position: relative; width: 48px; height: 22px; overflow: hidden; }.lens-tool img { display: block; width: 100%; height: 100%; object-fit: fill; }.lens-tool button { position: absolute; top: 0; bottom: 0; width: 50%; padding: 0; border: 0; background: transparent; cursor: pointer; }.lens-tool button:first-of-type { left: 0; }.lens-tool button:last-of-type { right: 0; }.lens-tool button:active { background: rgba(73, 184, 255, .28); }
.operation-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 9px; }.operation-grid button, .section-actions button { min-height: 29px; border: 1px solid rgba(47, 145, 241, .5); color: #abd4ff; background: #0b2b67; cursor: pointer; }.operation-grid button i { margin-right: 5px; }.operation-grid button.active, .section-actions button.primary { color: #fff; background: #176bc2; }
.operation-grid .wide { grid-column: 1 / -1; }
::v-deep .el-select .el-input__inner, ::v-deep .preset-create .el-input__inner, ::v-deep .speed-input .el-input__inner { color: #cce6ff; border-color: rgba(52, 147, 241, .5); background: #09265e; }
::v-deep .speed-input .el-input__inner { height: 26px; padding: 0 7px; line-height: 26px; text-align: center; }
::v-deep .speed-input.el-input-number--mini { line-height: 24px; }
::v-deep .preset-create .el-input__inner { height: 28px; padding: 0 8px; line-height: 28px; }
::v-deep .preset-create .el-input-number .el-input__inner { text-align: center; }
::v-deep .preset-create .el-input-number--mini { line-height: 26px; }
.preset-create { display: grid; grid-template-columns: 68px minmax(0, 1fr); align-items: center; gap: 6px; margin-top: 8px; }.preset-create .el-input-number { width: 68px; }.preset-create .el-input { width: 100%; }.section-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 8px; }
@media (max-width: 1100px) { .control-panel { width: 218px; min-width: 218px; }.nvr-tree { width: 230px; min-width: 230px; } }
</style>

+ 186
- 0
src/views/video/components/NvrDeviceTree.vue View File

@ -0,0 +1,186 @@
<template>
<aside class="nvr-tree">
<div class="tree-title">
<span><i class="el-icon-s-platform" /> 设备通道</span>
<strong>{{ filteredCount }}</strong>
</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">
<i class="el-icon-video-camera-solid" />
<span>{{ loading ? '正在读取设备配置' : '没有匹配的设备通道' }}</span>
</div>
</div>
<div class="tree-tip">{{ openHint }}</div>
</aside>
</template>
<script>
export default {
name: 'NvrDeviceTree',
props: {
config: { type: Object, default: () => ({}) },
channels: { type: Array, default: () => [] },
selectedCode: { type: String, default: '' },
loading: { type: Boolean, default: false },
openHint: { type: String, default: '双击通道打开实时预览' }
},
data() {
return {
query: '',
expanded: {}
}
},
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)
}
},
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)
})
})
}
}
},
methods: {
toggle(key) {
this.$set(this.expanded, key, !this.isOpen(key))
},
isOpen(key) {
return this.expanded[key] !== false
}
}
}
</script>
<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; }
.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-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; }
</style>

+ 154
- 0
src/views/video/components/NvrStreamPlayer.vue View File

@ -0,0 +1,154 @@
<template>
<div class="stream-player" :class="{ ready }">
<video ref="video" autoplay playsinline :muted="muted" />
<div v-if="!ready" class="stream-placeholder">
<i :class="loading ? 'el-icon-loading' : 'el-icon-video-camera'" />
<span>{{ loading ? '正在连接视频流' : placeholder }}</span>
</div>
</div>
</template>
<script>
import Hls from 'hls.js'
import flvjs from 'flv.js'
export default {
name: 'NvrStreamPlayer',
props: {
source: { type: Object, default: () => ({}) },
muted: { type: Boolean, default: true },
autoplay: { type: Boolean, default: true },
loading: { type: Boolean, default: false },
placeholder: { type: String, default: '等待打开视频通道' }
},
data() {
return { hls: null, flv: null, ready: false, hlsRetryTimer: null }
},
watch: {
source: {
deep: true,
handler() { this.attach() }
},
muted(value) {
if (this.$refs.video) this.$refs.video.muted = value
}
},
mounted() {
this.attach()
},
beforeDestroy() {
this.destroyPlayer()
},
methods: {
play() {
const video = this.$refs.video
return video ? video.play() : Promise.resolve()
},
pause() {
if (this.$refs.video) this.$refs.video.pause()
},
destroyPlayer() {
clearTimeout(this.hlsRetryTimer)
this.hlsRetryTimer = null
if (this.hls) {
this.hls.destroy()
this.hls = null
}
if (this.flv) {
try { this.flv.destroy() } catch (_) { /* player already released */ }
this.flv = null
}
const video = this.$refs.video
if (video) {
video.pause()
video.removeAttribute('src')
video.load()
}
this.ready = false
},
attach() {
this.$nextTick(() => {
const video = this.$refs.video
if (!video) return
const flvUrl = this.source && this.source.flvUrl
const hlsUrl = this.source && this.source.hlsUrl
this.destroyPlayer()
video.muted = this.muted
const markReady = () => {
this.ready = true
this.$emit('ready')
}
video.onloadeddata = markReady
video.oncanplay = markReady
video.onplaying = markReady
video.onerror = () => this.$emit('error', new Error('视频播放失败'))
if (flvUrl && flvjs.isSupported()) {
this.flv = flvjs.createPlayer(
{ type: 'flv', url: flvUrl, isLive: true },
{ enableStashBuffer: false, lazyLoad: false, autoCleanupSourceBuffer: true }
)
this.flv.attachMediaElement(video)
this.flv.load()
if (this.autoplay) Promise.resolve(this.flv.play()).catch(() => undefined)
this.flv.on(flvjs.Events.ERROR, () => {
if (hlsUrl) this.attachHls(video, hlsUrl)
else this.$emit('error', new Error('HTTP-FLV 播放失败'))
})
return
}
if (hlsUrl) this.attachHls(video, hlsUrl)
})
},
attachHls(video, url) {
if (this.flv) {
try { this.flv.destroy() } catch (_) { /* ignore */ }
this.flv = null
}
if (Hls.isSupported()) {
const hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
liveDurationInfinity: true,
manifestLoadingMaxRetry: 8,
levelLoadingMaxRetry: 8,
fragLoadingMaxRetry: 8
})
this.hls = hls
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
if (this.hls === hls) hls.loadSource(url)
})
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (this.autoplay) video.play().catch(() => undefined)
})
hls.on(Hls.Events.ERROR, (_, detail) => {
if (this.hls !== hls || !detail || !detail.fatal) return
if (detail.type === Hls.ErrorTypes.NETWORK_ERROR) {
clearTimeout(this.hlsRetryTimer)
this.hlsRetryTimer = setTimeout(() => {
if (this.hls === hls) hls.startLoad()
}, 800)
} else if (detail.type === Hls.ErrorTypes.MEDIA_ERROR) {
hls.recoverMediaError()
} else {
this.$emit('error', new Error('HLS 播放失败'))
}
})
hls.attachMedia(video)
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url
if (this.autoplay) video.play().catch(() => undefined)
} else {
this.$emit('error', new Error('当前浏览器不支持 HLS 播放'))
}
}
}
}
</script>
<style lang="scss" scoped>
.stream-player { position: relative; width: 100%; height: 100%; overflow: hidden; background: #050b24; }
video { width: 100%; height: 100%; display: block; object-fit: contain; background: #050b24; }
.stream-placeholder { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; color: #527ca8; background: radial-gradient(circle at center, rgba(12, 47, 103, .45), rgba(3, 9, 32, .92)); }
.stream-placeholder i { font-size: 34px; }
.stream-placeholder span { font-size: 12px; }
</style>

+ 8
- 0
vue.config.js View File

@ -34,7 +34,15 @@ module.exports = {
host: '0.0.0.0',
port: port,
open: true,
historyApiFallback: true,
proxy: {
'/nvr-api': {
target: 'http://192.168.1.86:28081',
changeOrigin: true,
pathRewrite: {
'^/nvr-api': ''
}
},
// detail: https://cli.vuejs.org/config/#devserver-proxy
[process.env.VUE_APP_BASE_API]: {
// target: `http://localhost:8080`,


Loading…
Cancel
Save