Browse Source

修改标题头,修改单个视频页面,修改视频随机播放

inspection_yanyuan
douyage 4 weeks ago
parent
commit
41e8ca7732
32 changed files with 589 additions and 184 deletions
  1. +0
    -12
      .env.agg
  2. +0
    -2
      .env.htjc6
  3. +0
    -1
      package.json
  4. BIN
      src/assets/images/header/baicaopo-title.png
  5. BIN
      src/assets/images/header/baicaopo-title1.png
  6. BIN
      src/assets/images/header/baiyangping-title.png
  7. BIN
      src/assets/images/header/baiyangping-title1.png
  8. BIN
      src/assets/images/header/dabaliangzi-title.png
  9. BIN
      src/assets/images/header/xfdh-title-navbar.png
  10. BIN
      src/assets/images/header/xfdh-title-navbar1.png
  11. BIN
      src/assets/images/header/xiaogaoshan-title.png
  12. BIN
      src/assets/images/header/xiaogaoshan-title1.png
  13. +10
    -0
      src/assets/styles/index.scss
  14. +1
    -1
      src/assets/styles/ruoyi.scss
  15. +11
    -4
      src/layout/components/Navbar.vue
  16. +10
    -10
      src/layout/components/Sidebar/index.vue
  17. +50
    -44
      src/layout/components/TagsView/index.vue
  18. +2
    -0
      src/main.js
  19. +20
    -20
      src/router/index.js
  20. +4
    -1
      src/store/modules/app.js
  21. +9
    -5
      src/store/modules/permission.js
  22. +9
    -0
      src/store/modules/tagsView.js
  23. +22
    -15
      src/utils/auth.js
  24. +2
    -5
      src/utils/homePath.js
  25. +1
    -1
      src/utils/request.js
  26. +43
    -9
      src/utils/stationTitle.js
  27. +112
    -0
      src/utils/viewportScale.js
  28. +12
    -5
      src/views/cards/taskSummary/inspectionTask.vue
  29. +8
    -1
      src/views/portal/components/PortalDeviceTree.vue
  30. +15
    -4
      src/views/portal/index.vue
  31. +243
    -43
      src/views/video/LivePreview.vue
  32. +5
    -1
      src/views/video/components/NvrDeviceTree.vue

+ 0
- 12
.env.agg View File

@ -1,12 +0,0 @@
NODE_ENV = 'production'
ENV = 'production'
VUE_APP_TITLE = 动环智能巡检系统
VUE_APP_BASE_API = '/app/htjc6/api'
VUE_APP_NVR_API = '/nvr-api'
VUE_APP_PUBLIC_PATH = '/app/htjc6/'
VUE_APP_OUTPUT_DIR = 'dist/app/htjc6'
VUE_APP_IMAGE_PATH_PREFIX = ''
VUE_APP_HOME_PATH = '/portal'
VUE_APP_AGG_VIDEO = true

+ 0
- 2
.env.htjc6 View File

@ -8,5 +8,3 @@ VUE_APP_NVR_API = '/nvr-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'
VUE_APP_IMAGE_PATH_PREFIX = '/app/htjc6/htjcImage' VUE_APP_IMAGE_PATH_PREFIX = '/app/htjc6/htjcImage'
VUE_APP_HOME_PATH = '/index'
VUE_APP_AGG_VIDEO = false

+ 0
- 1
package.json View File

@ -8,7 +8,6 @@
"dev": "vue-cli-service serve", "dev": "vue-cli-service serve",
"build:prod": "vue-cli-service build", "build:prod": "vue-cli-service build",
"build:htjc6": "vue-cli-service build --mode htjc6", "build:htjc6": "vue-cli-service build --mode htjc6",
"build:agg": "vue-cli-service build --mode agg",
"build:stage": "vue-cli-service build --mode staging", "build:stage": "vue-cli-service build --mode staging",
"preview": "node build/index.js --preview", "preview": "node build/index.js --preview",
"lint": "eslint --ext .js,.vue src" "lint": "eslint --ext .js,.vue src"


BIN
src/assets/images/header/baicaopo-title.png View File

Before After
Width: 1920  |  Height: 50  |  Size: 112 KiB Width: 1920  |  Height: 50  |  Size: 72 KiB

BIN
src/assets/images/header/baicaopo-title1.png View File

Before After
Width: 1920  |  Height: 50  |  Size: 112 KiB

BIN
src/assets/images/header/baiyangping-title.png View File

Before After
Width: 1920  |  Height: 50  |  Size: 114 KiB Width: 1920  |  Height: 50  |  Size: 73 KiB

BIN
src/assets/images/header/baiyangping-title1.png View File

Before After
Width: 1920  |  Height: 50  |  Size: 114 KiB

BIN
src/assets/images/header/dabaliangzi-title.png View File

Before After
Width: 1920  |  Height: 50  |  Size: 74 KiB

BIN
src/assets/images/header/xfdh-title-navbar.png View File

Before After
Width: 1920  |  Height: 50  |  Size: 115 KiB Width: 1920  |  Height: 50  |  Size: 71 KiB

BIN
src/assets/images/header/xfdh-title-navbar1.png View File

Before After
Width: 1920  |  Height: 50  |  Size: 115 KiB

BIN
src/assets/images/header/xiaogaoshan-title.png View File

Before After
Width: 1920  |  Height: 50  |  Size: 112 KiB Width: 1920  |  Height: 50  |  Size: 72 KiB

BIN
src/assets/images/header/xiaogaoshan-title1.png View File

Before After
Width: 1920  |  Height: 50  |  Size: 112 KiB

+ 10
- 0
src/assets/styles/index.scss View File

@ -35,6 +35,15 @@ label {
font-weight: 700; font-weight: 700;
} }
html:has(.portal-screen),
html:has(.portal-screen) body,
html:has(.portal-screen) #app {
overflow: hidden;
background:
radial-gradient(1100px 480px at 50% -8%, rgba(38, 68, 160, 0.5), transparent 62%),
linear-gradient(160deg, #0d1348 0%, #101b58 46%, #0c1140 100%);
}
html { html {
height: 100%; height: 100%;
background-color: $base-page-background; background-color: $base-page-background;
@ -44,6 +53,7 @@ html {
} }
#app { #app {
width: 100%;
height: 100%; height: 100%;
background-color: $base-page-background; background-color: $base-page-background;
font-family: var(--app-font-family); font-family: var(--app-font-family);


+ 1
- 1
src/assets/styles/ruoyi.scss View File

@ -670,7 +670,7 @@ table tbody tr > td {
.el-tree { .el-tree {
color: #dcecff; color: #dcecff;
background-color: #0F124A !important;
// background-color: #0F124A !important;
} }
.el-tree-node__content { .el-tree-node__content {


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

@ -58,7 +58,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 { getEmbeddedUser } from "@/utils/embeddedAuth"; import { getEmbeddedUser } from "@/utils/embeddedAuth";
import { getStationTitleImage } from "@/utils/stationTitle";
import { applyStationTitle, getStationTitleImage } from "@/utils/stationTitle";
import headerLogo from "@/assets/images/header/logo.png"; import headerLogo from "@/assets/images/header/logo.png";
import xfdhBrand from "@/assets/images/header/xfdh-brand.png"; import xfdhBrand from "@/assets/images/header/xfdh-brand.png";
import loginAvatar from "@/assets/images/header/framework-avatar.png"; import loginAvatar from "@/assets/images/header/framework-avatar.png";
@ -70,8 +70,6 @@ export default {
data() { data() {
return { return {
headerLogo, headerLogo,
// 标题头横幅按访问 IP 切换(见 utils/stationTitle.js),默认用消防动环这张
xfdhTitleNavbar: getStationTitleImage(),
xfdhBrand, xfdhBrand,
loginAvatar, loginAvatar,
frameworkUser: null, frameworkUser: null,
@ -79,7 +77,10 @@ export default {
}; };
}, },
computed: { computed: {
...mapGetters(["name", "device", "inSite", "siteName"]),
...mapGetters(["name", "device", "inSite", "siteName", "systemParams"]),
xfdhTitleNavbar() {
return getStationTitleImage(this.systemParams && this.systemParams.STATION_CODE);
},
topNav: { topNav: {
get() { get() {
return this.$store.state.settings.topNav; return this.$store.state.settings.topNav;
@ -102,7 +103,13 @@ export default {
return this.loginAvatar return this.loginAvatar
} }
}, },
watch: {
"systemParams.STATION_CODE"(code) {
applyStationTitle(code);
},
},
mounted() { mounted() {
applyStationTitle(this.systemParams && this.systemParams.STATION_CODE);
this.loadFrameworkUser() this.loadFrameworkUser()
this.frameworkUserPoller = window.setInterval(this.loadFrameworkUser, 500) this.frameworkUserPoller = window.setInterval(this.loadFrameworkUser, 500)
}, },


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

@ -96,17 +96,17 @@ export default {
const visibleRoutes = filterHiddenNavigation(this.sidebarRouters); const visibleRoutes = filterHiddenNavigation(this.sidebarRouters);
if (!this.$route.meta.patrolWorkspace) { if (!this.$route.meta.patrolWorkspace) {
const inspectionPaths = new Set([
"/task-summary", "/patrol-plan", "/site-management",
"/patrol-report", "/patrol-schedule", "/defect-record",
]);
const videoPaths = new Set(["/video-preview", "/video-playback"]);
const inspectionRoutes = visibleRoutes.filter(route => inspectionPaths.has(route.path));
const videoRoutes = visibleRoutes.filter(route => videoPaths.has(route.path));
const groupedPaths = new Set([...inspectionPaths, ...videoPaths]);
const otherRoutes = visibleRoutes.filter(route => !groupedPaths.has(route.path));
const inspectionOrder = [
"/task-summary", "/video-preview", "/patrol-plan", "/site-management",
"/patrol-report", "/patrol-schedule", "/defect-record", "/video-playback",
];
const inspectionPaths = new Set(inspectionOrder);
const inspectionRoutes = inspectionOrder
.map(path => visibleRoutes.find(route => route.path === path))
.filter(Boolean);
const otherRoutes = visibleRoutes.filter(route => !inspectionPaths.has(route.path));
const groups = []; const groups = [];
const patrolRoutes = [...inspectionRoutes, ...videoRoutes];
const patrolRoutes = inspectionRoutes;
if (patrolRoutes.length) groups.push({ if (patrolRoutes.length) groups.push({
path: "/inspection-menu", path: "/inspection-menu",
meta: { title: "动环巡检", icon: "dashboard" }, meta: { title: "动环巡检", icon: "dashboard" },


+ 50
- 44
src/layout/components/TagsView/index.vue View File

@ -16,24 +16,13 @@
<span <span
class="video-layout-icon" class="video-layout-icon"
:class="`video-layout-icon-${item.columns}`" :class="`video-layout-icon-${item.columns}`"
:style="{ gridTemplateColumns: `repeat(${item.columns}, auto)` }"
:style="{ gridTemplateColumns: `repeat(${item.columns}, minmax(0, 1fr))`, gridTemplateRows: `repeat(${item.columns}, minmax(0, 1fr))` }"
aria-hidden="true" aria-hidden="true"
> >
<i v-for="cell in item.value" :key="cell" /> <i v-for="cell in item.value" :key="cell" />
</span> </span>
</button> </button>
</el-tooltip> </el-tooltip>
<el-tooltip :content="videoControlPanelExpanded ? '收起云台控制' : '展开云台控制'" placement="bottom">
<button
type="button"
class="video-control-toggle"
:class="{ active: videoControlPanelExpanded }"
:aria-label="videoControlPanelExpanded ? '收起云台控制' : '展开云台控制'"
@click="toggleVideoControlPanel"
>
<img :src="ptzControlIcon" alt="" aria-hidden="true">
</button>
</el-tooltip>
</div> </div>
<div v-if="isPatrolSchedule" class="schedule-view-switch"> <div v-if="isPatrolSchedule" class="schedule-view-switch">
<button <button
@ -51,10 +40,15 @@
v-for="tag in visitedViews" v-for="tag in visitedViews"
ref="tag" ref="tag"
:key="tag.path" :key="tag.path"
:class="isActive(tag)?'active':''"
:class="[isActive(tag) ? 'active' : '', { 'is-dragging': dragTag && dragTag.path === tag.path }]"
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }" :to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
tag="span" tag="span"
class="tags-view-item" class="tags-view-item"
draggable="true"
@dragstart.native="onTagDragStart(tag, $event)"
@dragover.native.prevent="onTagDragOver(tag, $event)"
@drop.native.prevent="onTagDrop(tag)"
@dragend.native="onTagDragEnd"
@click.middle.native="!isAffix(tag)?closeSelectedTag(tag):''" @click.middle.native="!isAffix(tag)?closeSelectedTag(tag):''"
@contextmenu.prevent.native="openMenu(tag,$event)" @contextmenu.prevent.native="openMenu(tag,$event)"
> >
@ -85,6 +79,7 @@ export default {
top: 0, top: 0,
left: 0, left: 0,
selectedTag: {}, selectedTag: {},
dragTag: null,
affixTags: [], affixTags: [],
videoLayout: 9, videoLayout: 9,
videoControlPanelExpanded: false, videoControlPanelExpanded: false,
@ -319,6 +314,32 @@ export default {
handleScroll() { handleScroll() {
this.closeMenu() this.closeMenu()
}, },
onTagDragStart(tag, event) {
this.dragTag = tag
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', tag.path)
}
},
onTagDragOver(tag, event) {
if (!this.dragTag || this.dragTag.path === tag.path) return
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
},
onTagDrop(tag) {
const from = this.dragTag
this.dragTag = null
if (!from || from.path === tag.path) return
const views = this.visitedViews
const fromIndex = views.findIndex(item => item.path === from.path)
const toIndex = views.findIndex(item => item.path === tag.path)
if (fromIndex < 0 || toIndex < 0) return
const affixCount = views.filter(item => item.meta && item.meta.affix).length
if (fromIndex < affixCount || toIndex < affixCount) return
this.$store.dispatch('tagsView/moveVisitedView', { from: fromIndex, to: toIndex })
},
onTagDragEnd() {
this.dragTag = null
},
titleFn(item, children) { titleFn(item, children) {
let str = ""; let str = "";
let lang = localStorage.getItem("language"); let lang = localStorage.getItem("language");
@ -413,45 +434,25 @@ export default {
.video-layout-icon { .video-layout-icon {
display: grid; display: grid;
gap: 3px;
width: 27px;
height: 27px;
gap: 2px;
box-sizing: border-box;
} }
.video-layout-icon i { .video-layout-icon i {
width: 5px;
height: 5px;
width: auto;
height: auto;
border: 2px solid currentColor; border: 2px solid currentColor;
box-sizing: border-box; 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-1 {
gap: 0;
} }
.video-layout-icon-4 {
gap: 2px;
}
.video-layout-icon-4 i {
width: 6px;
height: 6px;
border-width: 2px;
.video-layout-icon-1 i {
border-width: 3px;
} }
} }
@ -496,7 +497,7 @@ export default {
.tags-view-item { .tags-view-item {
display: inline-block; display: inline-block;
position: relative; position: relative;
cursor: pointer;
cursor: grab;
height: 40px; height: 40px;
line-height: 40px; line-height: 40px;
border: 0; border: 0;
@ -530,6 +531,11 @@ export default {
background: transparent; background: transparent;
} }
&.is-dragging {
opacity: 0.45;
cursor: grabbing;
}
&.active { &.active {
color: #54ffff; color: #54ffff;
background: url('~@/assets/images/nav/menu-item-active-glow.png') center / 100% 100% no-repeat; background: url('~@/assets/images/nav/menu-item-active-glow.png') center / 100% 100% no-repeat;


+ 2
- 0
src/main.js View File

@ -52,7 +52,9 @@ import 'vue-video-player/src/custom-theme.css'
import i18n from './lang' import i18n from './lang'
// 站点配置:按访问 IP 切换标题头横幅与浏览器标签页站名 // 站点配置:按访问 IP 切换标题头横幅与浏览器标签页站名
import { applyStationTitle } from '@/utils/stationTitle' import { applyStationTitle } from '@/utils/stationTitle'
import { bindViewportScale } from '@/utils/viewportScale'
applyStationTitle() applyStationTitle()
bindViewportScale()
// 全局方法挂载 // 全局方法挂载
Vue.prototype.getDicts = getDicts Vue.prototype.getDicts = getDicts


+ 20
- 20
src/router/index.js View File

@ -102,6 +102,12 @@ export const constantRoutes = [
name: 'InspectionWorkspaceTask', name: 'InspectionWorkspaceTask',
meta: { title: '巡检任务', activeMenu: '/inspection-workspace/task', patrolWorkspace: true } meta: { title: '巡检任务', activeMenu: '/inspection-workspace/task', patrolWorkspace: true }
}, },
{
path: 'video-preview',
component: () => import('@/views/video/LivePreview'),
name: 'InspectionWorkspaceVideoPreview',
meta: { title: '实时监控', activeMenu: '/inspection-workspace/video-preview', patrolWorkspace: true }
},
{ {
path: 'plan', path: 'plan',
component: () => import('@/views/cards/patrolPlan/notstart'), component: () => import('@/views/cards/patrolPlan/notstart'),
@ -139,12 +145,6 @@ export const constantRoutes = [
name: 'InspectionWorkspaceDefects', name: 'InspectionWorkspaceDefects',
meta: { title: '缺陷记录', activeMenu: '/inspection-workspace/defects', patrolWorkspace: true } 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', path: 'video-playback',
component: () => import('@/views/video/HistoryPlayback'), component: () => import('@/views/video/HistoryPlayback'),
@ -196,6 +196,20 @@ export const constantRoutes = [
} }
] ]
}, },
{
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/index' }
}
]
},
{ {
path: '/patrol-plan', path: '/patrol-plan',
component: Layout, component: Layout,
@ -276,20 +290,6 @@ export const constantRoutes = [
} }
] ]
}, },
{
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/index' }
}
]
},
{ {
path: '/video-playback', path: '/video-playback',
component: Layout, component: Layout,


+ 4
- 1
src/store/modules/app.js View File

@ -1,5 +1,6 @@
import Cookies from 'js-cookie' import Cookies from 'js-cookie'
import { getConfigKey, getConfigByKeys } from "@/api/system/config"; import { getConfigKey, getConfigByKeys } from "@/api/system/config";
import { applyStationTitle } from "@/utils/stationTitle";
const savedLanguage = Cookies.get('language') const savedLanguage = Cookies.get('language')
const state = { const state = {
sidebar: { sidebar: {
@ -69,11 +70,13 @@ const actions = {
// 获取系统参数配置 // 获取系统参数配置
getSystemParams({ commit }) { getSystemParams({ commit }) {
return new Promise((resolve) => { return new Promise((resolve) => {
getConfigByKeys(["EXTRANET_ADDRESS", "EXTRANET_FTP_VIEW_ADDRESS", "INTRANET_FTP_VIEW_ADDRESS", "VIRTUAL_RTC_MEDIAURL", "EXTRANET_MEDIA_URL", "INTRANET_MEDIA_URL", "VIRTUAL_FLV_MEDIAURL", "INTRANET_FLV_MEDIA_URL", "EXTRANET_FLV_MEDIA_URL"]).then((res) => {
getConfigByKeys(["EXTRANET_ADDRESS", "EXTRANET_FTP_VIEW_ADDRESS", "INTRANET_FTP_VIEW_ADDRESS", "VIRTUAL_RTC_MEDIAURL", "EXTRANET_MEDIA_URL", "INTRANET_MEDIA_URL", "VIRTUAL_FLV_MEDIAURL", "INTRANET_FLV_MEDIA_URL", "EXTRANET_FLV_MEDIA_URL", "STATION_CODE"]).then((res) => {
const systemParams = {} const systemParams = {}
res.data.forEach((x) => { res.data.forEach((x) => {
systemParams[x.configKey] = x.configValue; systemParams[x.configKey] = x.configValue;
}); });
// 共用内网 IP 的风电场,IP 对不上时按站点编码换标题和浏览器标签
applyStationTitle(systemParams.STATION_CODE);
systemParams.PREFIX = location.hostname === systemParams.EXTRANET_ADDRESS ? 'EXTRANET' : 'INTRANET'; systemParams.PREFIX = location.hostname === systemParams.EXTRANET_ADDRESS ? 'EXTRANET' : 'INTRANET';
systemParams.ftpViewAddress = systemParams[systemParams.PREFIX + '_FTP_VIEW_ADDRESS'] systemParams.ftpViewAddress = systemParams[systemParams.PREFIX + '_FTP_VIEW_ADDRESS']
systemParams.MEDIA_URL = systemParams[systemParams.PREFIX + '_MEDIA_URL'] systemParams.MEDIA_URL = systemParams[systemParams.PREFIX + '_MEDIA_URL']


+ 9
- 5
src/store/modules/permission.js View File

@ -136,13 +136,17 @@ export function filterDynamicRoutes(routes) {
return res return res
} }
// 后台菜单的 component 是 views 下的相对路径。
// 生产构建会把 import() 转成无法按变量分包的 require,所以这里用带静态目录的 require,
// 让 views 下的页面都能打进包里。
const viewModules = require.context('@/views', true, /\.vue$/)
export const loadView = (view) => { export const loadView = (view) => {
if (process.env.NODE_ENV === 'development') {
return (resolve) => require([`@/views/${view}`], resolve)
} else {
// 使用 import 实现生产环境的路由懒加载
return () => import(`@/views/${view}`)
const key = `./${String(view || '').replace(/^\//, '').replace(/\.vue$/, '')}.vue`
if (!viewModules.keys().includes(key)) {
return null
} }
return (resolve) => resolve(viewModules(key).default)
} }
export default permission export default permission

+ 9
- 0
src/store/modules/tagsView.js View File

@ -75,6 +75,12 @@ const mutations = {
} }
} }
}, },
MOVE_VISITED_VIEW: (state, { from, to }) => {
const views = state.visitedViews
if (from === to || from < 0 || to < 0 || from >= views.length || to >= views.length) return
const [moved] = views.splice(from, 1)
views.splice(to, 0, moved)
},
DEL_RIGHT_VIEWS: (state, view) => { DEL_RIGHT_VIEWS: (state, view) => {
const index = state.visitedViews.findIndex(v => v.path === view.path) const index = state.visitedViews.findIndex(v => v.path === view.path)
if (index === -1) { if (index === -1) {
@ -206,6 +212,9 @@ const actions = {
updateVisitedView({ commit }, view) { updateVisitedView({ commit }, view) {
commit('UPDATE_VISITED_VIEW', view) commit('UPDATE_VISITED_VIEW', view)
}, },
moveVisitedView({ commit }, payload) {
commit('MOVE_VISITED_VIEW', payload)
},
delRightTags({ commit }, view) { delRightTags({ commit }, view) {
return new Promise(resolve => { return new Promise(resolve => {
commit('DEL_RIGHT_VIEWS', view) commit('DEL_RIGHT_VIEWS', view)


+ 22
- 15
src/utils/auth.js View File

@ -1,32 +1,39 @@
import Cookies from 'js-cookie'
const TokenKey = 'Htjc-Admin-Token'
const ExpiresInKey = 'Htjc-Admin-Expires-In'
const TokenKey = 'Admin-Token'
// 集成商平台把假登录态写在同一个地址的 Admin-Token 里。
// 不能再读取或迁移这个名字,否则会把 auto-login-mock-token 复制到我们自己的登录态。
const REJECTED_TOKENS = ['auto-login-mock-token']
const ExpiresInKey = "Admin-Expires-In";
// Cookie 不区分端口,同一 IP 下不同端口会串登录态。
// localStorage 按源(协议 + 主机 + 端口)隔离,因此登录信息改存这里。
function clearLegacyCookies() {
Cookies.remove(TokenKey)
Cookies.remove(ExpiresInKey)
function readValid(key) {
const value = localStorage.getItem(key)
if (!value || REJECTED_TOKENS.indexOf(value) !== -1) {
if (value) localStorage.removeItem(key)
return null
}
return value
} }
export function getToken() { export function getToken() {
clearLegacyCookies()
return localStorage.getItem(TokenKey)
return readValid(TokenKey)
} }
export function setToken(token) { export function setToken(token) {
clearLegacyCookies()
if (!token || REJECTED_TOKENS.indexOf(token) !== -1) {
localStorage.removeItem(TokenKey)
return
}
return localStorage.setItem(TokenKey, token) return localStorage.setItem(TokenKey, token)
} }
export function removeToken() { export function removeToken() {
clearLegacyCookies()
return localStorage.removeItem(TokenKey) return localStorage.removeItem(TokenKey)
} }
export function setExpiresIn(time) { export function setExpiresIn(time) {
clearLegacyCookies()
return localStorage.setItem(ExpiresInKey, time);
return localStorage.setItem(ExpiresInKey, time)
}
export function getExpiresIn() {
return readValid(ExpiresInKey)
} }

+ 2
- 5
src/utils/homePath.js View File

@ -1,5 +1,2 @@
const configuredHomePath = process.env.VUE_APP_HOME_PATH
const homePaths = ['/portal', '/index']
// 登录后的首页由打包模式决定,未配置或配置非法时进入门户
export const homePath = homePaths.indexOf(configuredHomePath) >= 0 ? configuredHomePath : '/portal'
// 没有来源地址时的默认页。登录本身按进来时的地址返回,不再按打包模式区分门户和站端。
export const homePath = '/index'

+ 1
- 1
src/utils/request.js View File

@ -157,7 +157,7 @@ service.interceptors.response.use((res) => {
return Promise.reject(new Error('无效的会话,或者会话已过期,请重新登录。')) return Promise.reject(new Error('无效的会话,或者会话已过期,请重新登录。'))
} }
// 登录页会同时请求验证码。/code 不带 token,401 不能触发登出、静默登录或跳转, // 登录页会同时请求验证码。/code 不带 token,401 不能触发登出、静默登录或跳转,
// 否则会在登录成功跳往 /portal 之后把地址改掉。
// 否则会在登录成功跳回原地址之后把地址改掉。
const requestUrl = (res.config && res.config.url) || "" const requestUrl = (res.config && res.config.url) || ""
if (requestUrl === "/code" || requestUrl.indexOf("/code?") === 0) { if (requestUrl === "/code" || requestUrl.indexOf("/code?") === 0) {
return Promise.reject(new Error('验证码获取失败')) return Promise.reject(new Error('验证码获取失败'))


+ 43
- 9
src/utils/stationTitle.js View File

@ -1,4 +1,6 @@
// 站点配置:按访问地址的 IP/域名(hostname)及端口切换导航栏标题头横幅和浏览器标签页站名。
// 站点配置:先按访问地址的 IP/端口切换导航栏标题和浏览器标签。
// 多个风电场共用同一个内网 IP 时,IP 对不上,登录后再用
// /system/config/configKeys 返回的 STATION_CODE 区分。
// 后续接入新站点:把切图放到 src/assets/images/header/ 下, // 后续接入新站点:把切图放到 src/assets/images/header/ 下,
// 在 stationConfigMap 里加一行 "<该站IP或域名>: { titleImage, name }" 即可; // 在 stationConfigMap 里加一行 "<该站IP或域名>: { titleImage, name }" 即可;
// 同一 IP 不同端口是不同站点时,key 写成 "IP:端口"(如 "192.168.129.2:32799"); // 同一 IP 不同端口是不同站点时,key 写成 "IP:端口"(如 "192.168.129.2:32799");
@ -7,23 +9,30 @@ import defaultTitleImage from "@/assets/images/header/xfdh-title-navbar.png";
import baiyangpingTitleImage from "@/assets/images/header/baiyangping-title.png"; import baiyangpingTitleImage from "@/assets/images/header/baiyangping-title.png";
import baicaopoTitleImage from "@/assets/images/header/baicaopo-title.png"; import baicaopoTitleImage from "@/assets/images/header/baicaopo-title.png";
import xiaogaoshanTitleImage from "@/assets/images/header/xiaogaoshan-title.png"; import xiaogaoshanTitleImage from "@/assets/images/header/xiaogaoshan-title.png";
import dabaliangziTitleImage from "@/assets/images/header/dabaliangzi-title.png";
// 一个站点多个 IP 时逐个列出,指向同一份配置 // 一个站点多个 IP 时逐个列出,指向同一份配置
const stationConfigMap = { const stationConfigMap = {
// 白杨坪站 // 白杨坪站
"192.168.20.210": { titleImage: baiyangpingTitleImage, name: "白杨坪动环智能巡检系统" },
// "192.168.20.210": { titleImage: baiyangpingTitleImage, name: "白杨坪动环智能巡检系统" },
"192.168.134.10": { titleImage: baiyangpingTitleImage, name: "白杨坪动环智能巡检系统" }, "192.168.134.10": { titleImage: baiyangpingTitleImage, name: "白杨坪动环智能巡检系统" },
"192.168.129.2:20001": { titleImage: baiyangpingTitleImage, name: "白杨坪动环智能巡检系统" }, "192.168.129.2:20001": { titleImage: baiyangpingTitleImage, name: "白杨坪动环智能巡检系统" },
// "192.168.1.90": { titleImage: xiaogaoshanTitleImage, name: "白杨坪智能巡检系统" }, // "192.168.1.90": { titleImage: xiaogaoshanTitleImage, name: "白杨坪智能巡检系统" },
// 白草坡站 // 白草坡站
"192.168.20.201": { titleImage: baicaopoTitleImage, name: "白草坡动环智能巡检系统" },
// "192.168.20.210": { titleImage: baicaopoTitleImage, name: "白草坡动环智能巡检系统" },
"192.168.147.13": { titleImage: baicaopoTitleImage, name: "白草坡动环智能巡检系统" }, "192.168.147.13": { titleImage: baicaopoTitleImage, name: "白草坡动环智能巡检系统" },
"192.168.129.2:20002": { titleImage: baicaopoTitleImage, name: "白草坡动环智能巡检系统" }, "192.168.129.2:20002": { titleImage: baicaopoTitleImage, name: "白草坡动环智能巡检系统" },
// 小高山站 // 小高山站
"192.168.20.222": { titleImage: xiaogaoshanTitleImage, name: "小高山动环智能巡检系统" }, "192.168.20.222": { titleImage: xiaogaoshanTitleImage, name: "小高山动环智能巡检系统" },
"192.168.132.10": { titleImage: xiaogaoshanTitleImage, name: "小高山动环智能巡检系统" }, "192.168.132.10": { titleImage: xiaogaoshanTitleImage, name: "小高山动环智能巡检系统" },
"192.168.129.2:20003": { titleImage: xiaogaoshanTitleImage, name: "小高山动环智能巡检系统" },
// 大坝梁子站
// "192.168.20.210": { titleImage: dabaliangziTitleImage, name: "大坝梁子动环智能巡检系统" },
"192.168.136.13": { titleImage: dabaliangziTitleImage, name: "大坝梁子动环智能巡检系统" },
"192.168.129.2:20004": { titleImage: dabaliangziTitleImage, name: "大坝梁子动环智能巡检系统" },
}; };
const defaultStationConfig = { const defaultStationConfig = {
@ -32,7 +41,32 @@ const defaultStationConfig = {
name: "动环智能巡检系统", name: "动环智能巡检系统",
}; };
export function getStationConfig() {
// 共用内网 IP 的风电场,按后端 STATION_CODE 区分。没有单独切图的先用默认横幅。
const stationCodeConfigMap = {
CZ000000001: { titleImage: defaultTitleImage, name: "凉风坳动环智能巡检系统" },
CZ000000002: { titleImage: xiaogaoshanTitleImage, name: "小高山动环智能巡检系统" },
CZ000000003: { titleImage: defaultTitleImage, name: "大河动环智能巡检系统" },
CZ000000004: { titleImage: baiyangpingTitleImage, name: "白杨坪动环智能巡检系统" },
CZ000000005: { titleImage: defaultTitleImage, name: "后龙山动环智能巡检系统" },
CZ000000006: { titleImage: defaultTitleImage, name: "沃底动环智能巡检系统" },
CZ000000007: { titleImage: dabaliangziTitleImage, name: "大坝梁子动环智能巡检系统" },
CZ000000008: { titleImage: defaultTitleImage, name: "白乌动环智能巡检系统" },
CZ000000009: { titleImage: baicaopoTitleImage, name: "白草坡动环智能巡检系统" },
};
function readStationCode(value) {
if (!value) return "";
if (typeof value === "string") return value.trim();
if (Array.isArray(value)) {
const matched = value.find(item => item && (item.configKey === "STATION_CODE" || item.configKey === "STATION_CODE"));
return matched && matched.configValue ? String(matched.configValue).trim() : "";
}
return String(value.STATION_CODE || value.STATION_CODE || "").trim();
}
export function getStationConfig(stationCode) {
const code = readStationCode(stationCode);
if (code && stationCodeConfigMap[code]) return stationCodeConfigMap[code];
if (typeof window === "undefined") return defaultStationConfig; if (typeof window === "undefined") return defaultStationConfig;
const { hostname, port } = window.location; const { hostname, port } = window.location;
// hostname 不含端口,端口要单独取;先按 "IP:端口" 精确匹配,再退回只按 IP 匹配 // hostname 不含端口,端口要单独取;先按 "IP:端口" 精确匹配,再退回只按 IP 匹配
@ -43,13 +77,13 @@ export function getStationConfig() {
); );
} }
export function getStationTitleImage() {
return getStationConfig().titleImage;
export function getStationTitleImage(stationCode) {
return getStationConfig(stationCode).titleImage;
} }
// 应用启动时调用:按 IP 设置浏览器标签页标题(如"白杨坪智能巡检系统")
export function applyStationTitle() {
// 应用启动时按 IP 设置浏览器标签页标题。IP 匹配不上时,登录后传入 STATION_CODE 再换一次。
export function applyStationTitle(stationCode) {
if (typeof document !== "undefined") { if (typeof document !== "undefined") {
document.title = getStationConfig().name;
document.title = getStationConfig(stationCode).name;
} }
} }

+ 112
- 0
src/utils/viewportScale.js View File

@ -0,0 +1,112 @@
// 1920×1080 是页面原来的基准,这个尺寸保持原样铺满。
// 只有窗口明显更大时才整体缩小。缩小后把布局盒按比例拉大,视觉上仍然铺满窗口。
const DESIGN_WIDTH = 1920
const DESIGN_HEIGHT = 1080
const MIN_SCALE = 0.72
function readWindowBox() {
const rawWidth = window.innerWidth || DESIGN_WIDTH
const rawHeight = window.innerHeight || DESIGN_HEIGHT
const applied = Number(document.documentElement.dataset.appScale || 1) || 1
// 已经比设计尺寸大,读到的是缩放前的窗口,不能再除一次。
if (rawWidth > DESIGN_WIDTH + 40 || rawHeight > DESIGN_HEIGHT + 40) {
return { width: rawWidth, height: rawHeight }
}
return { width: rawWidth / applied, height: rawHeight / applied }
}
export function computeViewportScale() {
const { width, height } = readWindowBox()
// 1920×1080 附近(含浏览器边框吃掉的几十像素)保持 1,页面按原来的方式铺满。
if (width <= DESIGN_WIDTH + 40 && height <= DESIGN_HEIGHT + 40) return 1
const scale = Math.min(width / DESIGN_WIDTH, height / DESIGN_HEIGHT, 1)
return Math.max(MIN_SCALE, Math.round(scale * 1000) / 1000)
}
function ensureScaleStyle() {
let style = document.getElementById("app-viewport-scale")
if (style) return style
style = document.createElement("style")
style.id = "app-viewport-scale"
document.head.appendChild(style)
return style
}
function clearPortalCanvas() {
const screen = document.querySelector(".portal-screen")
if (!screen) return
;["width", "height", "minWidth", "minHeight", "transform", "transformOrigin", "position", "left", "top", "zoom", "marginTop", "marginBottom", "overflow"].forEach(name => {
screen.style[name] = ""
})
const body = screen.querySelector(".portal-body")
if (body) body.style.columnGap = ""
}
// 汇聚首页铺满窗口,同时按能放下 1920×1080 的比例整体放大。
// 大屏上字号和模块一起变大,不会再出现字很小、卡片和视频被单独拉得很开。
function fitPortalCanvas() {
const screen = document.querySelector(".portal-screen")
if (!screen) return false
const width = window.innerWidth || DESIGN_WIDTH
const height = window.innerHeight || DESIGN_HEIGHT
const scale = Math.min(width / DESIGN_WIDTH, height / DESIGN_HEIGHT) || 1
const body = screen.querySelector(".portal-body")
screen.style.zoom = String(scale)
screen.style.width = `${width / scale}px`
screen.style.height = `${height / scale}px`
screen.style.minWidth = "0px"
screen.style.minHeight = "0px"
screen.style.position = "absolute"
screen.style.transform = "none"
screen.style.transformOrigin = "left top"
screen.style.left = "0px"
screen.style.top = "0px"
screen.style.overflow = "hidden"
screen.style.marginTop = "0px"
screen.style.marginBottom = "0px"
if (body) body.style.columnGap = ""
return true
}
export function applyViewportScale() {
const portalHome = /\/portal\/?$/.test(window.location.pathname || "")
if (portalHome && fitPortalCanvas()) {
const root = document.documentElement
root.dataset.appScale = "1"
root.style.zoom = ""
ensureScaleStyle().textContent = ""
return 1
}
clearPortalCanvas()
const scale = computeViewportScale()
const root = document.documentElement
root.dataset.appScale = String(scale)
root.style.zoom = ""
ensureScaleStyle().textContent = scale === 1
? ""
: [
`html { zoom: ${scale}; }`,
`#app { width: ${(100 / scale).toFixed(4)}%; height: ${(100 / scale).toFixed(4)}%; }`
].join("\n")
return scale
}
export function bindViewportScale() {
let frame = 0
const update = () => {
frame = 0
applyViewportScale()
}
const schedule = () => {
if (frame) return
frame = window.requestAnimationFrame(update)
}
schedule()
window.addEventListener("resize", schedule)
window.addEventListener("orientationchange", schedule)
return () => {
if (frame) window.cancelAnimationFrame(frame)
window.removeEventListener("resize", schedule)
window.removeEventListener("orientationchange", schedule)
}
}

+ 12
- 5
src/views/cards/taskSummary/inspectionTask.vue View File

@ -160,14 +160,16 @@
label="执行时间" label="执行时间"
align="center" align="center"
prop="startTime" prop="startTime"
min-width="130"
width="188"
class-name="time-cell"
:resizable="true" :resizable="true"
/> />
<el-table-column <el-table-column
label="结束时间" label="结束时间"
align="center" align="center"
prop="endTime" prop="endTime"
min-width="130"
width="188"
class-name="time-cell"
:resizable="true" :resizable="true"
/> />
<el-table-column <el-table-column
@ -211,7 +213,7 @@
/> />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="详情" align="center" min-width="80">
<el-table-column label="详情" align="center" width="64">
<template slot-scope="scope"> <template slot-scope="scope">
<span class="table-action-link" @click="lookDetail(scope.row)"> <span class="table-action-link" @click="lookDetail(scope.row)">
<img <img
@ -223,7 +225,7 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="缺陷" align="center" min-width="80">
<el-table-column label="缺陷" align="center" width="64">
<template slot-scope="scope"> <template slot-scope="scope">
<template v-if="scope.row.taskProgress == 100"> <template v-if="scope.row.taskProgress == 100">
<span <span
@ -237,7 +239,7 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="巡检报告" align="center" min-width="100">
<el-table-column label="巡检报告" align="center" width="88">
<template slot-scope="scope"> <template slot-scope="scope">
<span <span
v-if="scope.row.fileStatus == 1 && scope.row.lineId" v-if="scope.row.fileStatus == 1 && scope.row.lineId"
@ -1199,6 +1201,11 @@ export default {
white-space: nowrap; white-space: nowrap;
} }
.card-page ::v-deep td.time-cell .cell,
.card-page ::v-deep th.time-cell .cell {
white-space: nowrap;
}
.card-page ::v-deep .card-table, .card-page ::v-deep .card-table,
.card-page ::v-deep .el-table { .card-page ::v-deep .el-table {
width: 100% !important; width: 100% !important;


+ 8
- 1
src/views/portal/components/PortalDeviceTree.vue View File

@ -40,7 +40,7 @@
popper-class="portal-tree-tooltip" popper-class="portal-tree-tooltip"
effect="dark" effect="dark"
> >
<span class="custom-tree-node">
<span class="custom-tree-node" :class="{ 'is-offline': isOffline(data) }">
<i :class="nodeIcon(data)" /> <i :class="nodeIcon(data)" />
<span class="node-label">{{ data.label || data.name }}</span> <span class="node-label">{{ data.label || data.name }}</span>
</span> </span>
@ -181,6 +181,9 @@ export default {
if (!node.channelFlag) return; if (!node.channelFlag) return;
this.$emit("open", this.normalizeChannel(node)); this.$emit("open", this.normalizeChannel(node));
}, },
isOffline(node) {
return !!node.channelFlag && String(node.patroldeviceOnlineStatus) === "0";
},
nodeIcon(node) { nodeIcon(node) {
if (node.channelFlag) return "el-icon-video-camera"; if (node.channelFlag) return "el-icon-video-camera";
if (node.patroldeviceType === 13) return "el-icon-position"; if (node.patroldeviceType === 13) return "el-icon-position";
@ -336,6 +339,10 @@ export default {
color: #54ffff; color: #54ffff;
background: transparent; background: transparent;
} }
.custom-tree-node.is-offline {
color: #7f8da8;
}
</style> </style>
<style lang="scss"> <style lang="scss">


+ 15
- 4
src/views/portal/index.vue View File

@ -275,6 +275,7 @@ import {
import Hls from "hls.js"; import Hls from "hls.js";
import flvjs from "flv.js"; import flvjs from "flv.js";
import axios from "axios"; import axios from "axios";
import { applyViewportScale } from "@/utils/viewportScale";
import DefectTrendChart from "./components/DefectTrendChart"; import DefectTrendChart from "./components/DefectTrendChart";
import PortalDeviceTree from "./components/PortalDeviceTree.vue"; import PortalDeviceTree from "./components/PortalDeviceTree.vue";
import Navbar from "@/layout/components/Navbar"; import Navbar from "@/layout/components/Navbar";
@ -445,13 +446,12 @@ export default {
}, },
filteredVideos() { filteredVideos() {
if (this.videoStationId === "" || this.videoStationId === null || this.videoStationId === undefined) { if (this.videoStationId === "" || this.videoStationId === null || this.videoStationId === undefined) {
return this.portalChannels.slice(0, 4);
return this.portalChannels.slice();
} }
const station = this.stationList.find(item => String(item.id) === String(this.videoStationId)); const station = this.stationList.find(item => String(item.id) === String(this.videoStationId));
if (!station) return []; if (!station) return [];
return this.portalChannels return this.portalChannels
.filter(video => String(video.stationId) === String(this.videoStationId))
.slice(0, 4);
.filter(video => String(video.stationId) === String(this.videoStationId));
}, },
portalTreeStationCode() { portalTreeStationCode() {
const station = this.stationList.find(item => String(item.id) === String(this.videoStationId)); const station = this.stationList.find(item => String(item.id) === String(this.videoStationId));
@ -481,6 +481,7 @@ export default {
mounted() { mounted() {
this.startDefectCarousel(); this.startDefectCarousel();
this.loadPortalData(); this.loadPortalData();
this.$nextTick(() => applyViewportScale());
}, },
beforeDestroy() { beforeDestroy() {
this.releasePortalPage(); this.releasePortalPage();
@ -586,11 +587,21 @@ export default {
status: String(node.patroldeviceOnlineStatus || "1") === "1" ? "normal" : "abnormal" status: String(node.patroldeviceOnlineStatus || "1") === "1" ? "normal" : "abnormal"
}; };
}, },
pickRandomVideos(videos, count = 4) {
const list = (videos || []).filter(item => !item || item.status !== "abnormal");
for (let index = list.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(Math.random() * (index + 1));
const current = list[index];
list[index] = list[swapIndex];
list[swapIndex] = current;
}
return list.slice(0, count);
},
replacePortalVideos(videos) { replacePortalVideos(videos) {
return this.stopPortalVideos().then(() => { return this.stopPortalVideos().then(() => {
// 切页后停流才返回时,不能再把播放器挂回去。 // 切页后停流才返回时,不能再把播放器挂回去。
if (!this.portalAlive) return; if (!this.portalAlive) return;
this.videoList = (videos || []).slice(0, 4);
this.videoList = this.pickRandomVideos(videos, 4);
this.activeVideoIndex = 0; this.activeVideoIndex = 0;
this.$nextTick(() => this.startDefaultPortalVideos()); this.$nextTick(() => this.startDefaultPortalVideos());
}); });


+ 243
- 43
src/views/video/LivePreview.vue View File

@ -1,7 +1,12 @@
<template> <template>
<div class="video-console live-preview" :class="{ 'is-embed': embed }"> <div class="video-console live-preview" :class="{ 'is-embed': embed }">
<div class="console-body"> <div class="console-body">
<div v-show="!embed || embedTreeVisible" class="tree-panel" :style="{ width: treePanelWidth + 'px' }">
<div
v-show="!embed || embedTreeVisible"
class="tree-panel"
:style="embedTreeStyle"
@mousedown="startEmbedPanelDrag('tree', $event)"
>
<nvr-device-tree <nvr-device-tree
v-if="!embed || sessionReady" v-if="!embed || sessionReady"
:config="config" :config="config"
@ -26,19 +31,25 @@
<span /> <span />
<span /> <span />
</div> </div>
<button
v-if="embed"
type="button"
class="embed-toggle embed-toggle-left"
:title="embedTreeVisible ? '隐藏设备列表' : '显示设备列表'"
@mousedown.stop
@click.stop="embedTreeVisible = !embedTreeVisible"
>
<img :src="treeToggleIcon" alt="">
</button>
</div> </div>
<button <button
v-if="embed"
v-if="embed && !embedTreeVisible"
type="button" type="button"
class="embed-toggle embed-toggle-left"
:class="{ open: embedTreeVisible }"
:style="embedTreeVisible ? { left: treePanelWidth + 'px' } : null"
:title="embedTreeVisible ? '隐藏设备列表' : '显示设备列表'"
@click="embedTreeVisible = !embedTreeVisible"
style="transform: rotate(180deg) translateY(50%)"
class="embed-toggle embed-toggle-left is-collapsed"
title="显示设备列表"
@click="embedTreeVisible = true"
> >
<!-- <img :src="embedTreeVisible ? treeCollapseIcon : treeToggleIcon" alt=""> -->
<img :src="embedTreeVisible ? treeToggleIcon : treeCollapseIcon" alt="">
<img :src="treeCollapseIcon" alt="">
</button> </button>
<main class="monitor-workspace"> <main class="monitor-workspace">
@ -80,7 +91,13 @@
</div> </div>
</main> </main>
<aside v-show="embed ? embedControlVisible : controlPanelExpanded" class="control-panel">
<aside
v-show="embed ? embedControlVisible : controlPanelExpanded"
class="control-panel"
:style="embedControlStyle"
@mousedown="startEmbedPanelDrag('control', $event)"
>
<div class="control-panel-body">
<section v-if="embed" class="control-section embed-layout-section"> <section v-if="embed" class="control-section embed-layout-section">
<div class="section-title"><span>画面布局</span></div> <div class="section-title"><span>画面布局</span></div>
<div class="embed-layout-switch" aria-label="画面布局"> <div class="embed-layout-switch" aria-label="画面布局">
@ -96,7 +113,7 @@
<span <span
class="embed-layout-icon" class="embed-layout-icon"
:class="`embed-layout-icon-${item.columns}`" :class="`embed-layout-icon-${item.columns}`"
:style="{ gridTemplateColumns: `repeat(${item.columns}, auto)` }"
:style="{ gridTemplateColumns: `repeat(${item.columns}, minmax(0, 1fr))`, gridTemplateRows: `repeat(${item.columns}, minmax(0, 1fr))` }"
> >
<i v-for="cell in item.value" :key="cell" /> <i v-for="cell in item.value" :key="cell" />
</span> </span>
@ -199,16 +216,26 @@
<button type="button" @click="showPendingOperation('3D定位')"><i class="el-icon-aim" />3D定位</button> <button type="button" @click="showPendingOperation('3D定位')"><i class="el-icon-aim" />3D定位</button>
</div> </div>
</section> </section>
</div>
<button
type="button"
class="embed-toggle embed-toggle-right"
:class="{ 'is-page': !embed }"
title="隐藏云台控制"
@mousedown.stop
@click.stop="toggleControlPanel(false)"
>
<img :src="treeToggleIcon" alt="">
</button>
</aside> </aside>
<button <button
v-if="embed"
v-if="!(embed ? embedControlVisible : controlPanelExpanded)"
type="button" type="button"
class="embed-toggle embed-toggle-right"
:class="{ open: embedControlVisible }"
:title="embedControlVisible ? '隐藏云台控制' : '显示云台控制'"
@click="embedControlVisible = !embedControlVisible"
class="embed-toggle embed-toggle-right is-collapsed"
title="显示云台控制"
@click="toggleControlPanel(true)"
> >
<img :src="embedControlVisible ? treeToggleIcon : treeCollapseIcon" alt="">
<img :src="treeCollapseIcon" alt="">
</button> </button>
</div> </div>
</div> </div>
@ -257,9 +284,15 @@ const PTZ_CODES = {
controlPanelExpanded: false, controlPanelExpanded: false,
embedTreeVisible: false, embedTreeVisible: false,
embedControlVisible: false, embedControlVisible: false,
embedPanelDrag: {
tree: { x: 0, y: 0, moved: false },
control: { x: 0, y: 0, moved: false }
},
embedDragState: null,
treeToggleIcon, treeToggleIcon,
treeCollapseIcon, treeCollapseIcon,
treePanelWidth: 400,
treePanelWidth: 242,
controlPanelWidth: 242,
isTreeResizing: false, isTreeResizing: false,
treeResizeStartX: 0, treeResizeStartX: 0,
treeResizeStartWidth: 0, treeResizeStartWidth: 0,
@ -275,6 +308,8 @@ const PTZ_CODES = {
{ value: 9, columns: 3, label: '九画面' }, { value: 16, columns: 4, label: '十六画面' } { value: 9, columns: 3, label: '九画面' }, { value: 16, columns: 4, label: '十六画面' }
], ],
slots: Array.from({ length: 16 }, () => null), slots: Array.from({ length: 16 }, () => null),
// 实际开过的视频,按开播顺序记录。宫格变少时从末尾往前留。
playbackHistory: [],
activeIndex: 0, activeIndex: 0,
controlTarget: null, controlTarget: null,
placementCursor: 0, placementCursor: 0,
@ -343,7 +378,19 @@ const PTZ_CODES = {
return { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, gridTemplateRows: `repeat(${columns}, minmax(0, 1fr))` } return { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, gridTemplateRows: `repeat(${columns}, minmax(0, 1fr))` }
}, },
selectedChannel() { return this.controlTarget }, selectedChannel() { return this.controlTarget },
selectedChannelName() { return this.controlTarget ? (this.controlTarget.name || `通道 ${this.controlTarget.channel}`) : '未选择画面' }
selectedChannelName() { return this.controlTarget ? (this.controlTarget.name || `通道 ${this.controlTarget.channel}`) : '未选择画面' },
embedTreeStyle() {
if (!this.embed) return { width: this.treePanelWidth + 'px' }
const drag = this.embedPanelDrag.tree
return drag.moved
? { width: this.treePanelWidth + 'px', left: drag.x + 'px' }
: { width: this.treePanelWidth + 'px' }
},
embedControlStyle() {
const width = { width: this.controlPanelWidth + 'px' }
const drag = this.embedPanelDrag.control
return drag.moved ? { ...width, left: drag.x + 'px', right: 'auto' } : width
}
}, },
watch: { watch: {
sessionReady(ready) { sessionReady(ready) {
@ -383,6 +430,7 @@ const PTZ_CODES = {
}, },
beforeDestroy() { beforeDestroy() {
this.stopTreeResize() this.stopTreeResize()
this.stopEmbedPanelDrag()
this.lifecycleId += 1 this.lifecycleId += 1
this.pageActive = false this.pageActive = false
document.removeEventListener('fullscreenchange', this.handleFullscreenChange) document.removeEventListener('fullscreenchange', this.handleFullscreenChange)
@ -406,7 +454,7 @@ const PTZ_CODES = {
onTreeResize(event) { onTreeResize(event) {
if (!this.isTreeResizing) return if (!this.isTreeResizing) return
const width = this.treeResizeStartWidth + event.clientX - this.treeResizeStartX const width = this.treeResizeStartWidth + event.clientX - this.treeResizeStartX
this.treePanelWidth = Math.max(260, Math.min(620, width))
this.treePanelWidth = Math.max(218, Math.min(620, width))
}, },
stopTreeResize() { stopTreeResize() {
if (!this.isTreeResizing) return if (!this.isTreeResizing) return
@ -416,6 +464,45 @@ const PTZ_CODES = {
document.body.style.cursor = '' document.body.style.cursor = ''
document.body.style.userSelect = '' document.body.style.userSelect = ''
}, },
startEmbedPanelDrag(panel, event) {
if (event.button !== 0) return
if (!this.embed && panel !== 'control') return
if (event.target.closest('button, a, input, textarea, .el-input, .el-select, .el-input-number, .el-tree-node, .tree-content, .embed-toggle')) return
const bounds = event.currentTarget.getBoundingClientRect()
const parent = event.currentTarget.offsetParent
? event.currentTarget.offsetParent.getBoundingClientRect()
: { left: 0, top: 0 }
this.embedDragState = {
panel,
startX: event.clientX,
startY: event.clientY,
originX: bounds.left - parent.left,
originY: bounds.top - parent.top
}
window.getSelection && window.getSelection().removeAllRanges()
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', this.onEmbedPanelDrag)
document.addEventListener('mouseup', this.stopEmbedPanelDrag)
},
onEmbedPanelDrag(event) {
const drag = this.embedDragState
if (!drag) return
const panel = this.embedPanelDrag[drag.panel]
const parent = this.$el.querySelector('.console-body')
const box = parent ? parent.getBoundingClientRect() : { width: window.innerWidth, height: window.innerHeight }
const node = parent ? parent.querySelector(drag.panel === 'tree' ? '.tree-panel' : '.control-panel') : null
const width = node ? node.offsetWidth : 0
panel.x = Math.max(0, Math.min(box.width - width, drag.originX + event.clientX - drag.startX))
panel.moved = true
},
stopEmbedPanelDrag() {
if (!this.embedDragState) return
this.embedDragState = null
document.body.style.userSelect = ''
window.getSelection && window.getSelection().removeAllRanges()
document.removeEventListener('mousemove', this.onEmbedPanelDrag)
document.removeEventListener('mouseup', this.stopEmbedPanelDrag)
},
async loadConfig() { async loadConfig() {
this.loading = true this.loading = true
this.connectionText = '读取配置' this.connectionText = '读取配置'
@ -496,19 +583,75 @@ const PTZ_CODES = {
if (treeCodes.size) this.channels = this.channels.filter(item => treeCodes.has(item.cameraCode)) if (treeCodes.size) this.channels = this.channels.filter(item => treeCodes.has(item.cameraCode))
this.setAvailableChannels(channels, true) this.setAvailableChannels(channels, true)
}, },
async populateDefaultLayout() {
if (!this.pageActive) return
// 几宫格就按频道列表顺序默认播放前几个,不做随机洗牌
const channels = this.channels.slice(0, this.capacity)
if (!channels.length) return
isChannelOnline(channel) {
if (!channel) return false
const status = channel.patroldeviceOnlineStatus
if (status === undefined || status === null || status === '') return true
return String(status) === '1'
},
onlineChannels(channels) {
return (channels || []).filter(item => this.isChannelOnline(item))
},
shuffleChannels(channels) {
const list = (channels || []).slice()
for (let index = list.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(Math.random() * (index + 1))
const current = list[index]
list[index] = list[swapIndex]
list[swapIndex] = current
}
return list
},
playbackSnapshot(channel) {
if (!channel || !channel.cameraCode) return null
return {
cameraCode: channel.cameraCode,
stationCode: channel.stationCode,
name: channel.name || channel.cameraName,
channel: channel.channel,
channelCode: channel.channelCode,
nvrIp: channel.nvrIp || ''
}
},
rememberPlayback(channel, replacedCode) {
const snapshot = this.playbackSnapshot(channel)
if (!snapshot) return
if (replacedCode && replacedCode !== snapshot.cameraCode) {
const stillPlaying = this.slots.some(slot => slot && slot.cameraCode === replacedCode)
if (!stillPlaying) {
this.playbackHistory = this.playbackHistory.filter(item => item.cameraCode !== replacedCode)
}
}
this.playbackHistory = this.playbackHistory.filter(item => item.cameraCode !== snapshot.cameraCode)
this.playbackHistory.push(snapshot)
},
forgetPlayback(cameraCode) {
if (!cameraCode) return
const stillPlaying = this.slots.some(slot => slot && slot.cameraCode === cameraCode)
if (stillPlaying) return
this.playbackHistory = this.playbackHistory.filter(item => item.cameraCode !== cameraCode)
},
async playChannels(channels, highlightCode) {
await Promise.all(this.slots.map((slot, index) => slot ? this.closeSlot(index, false) : null)) await Promise.all(this.slots.map((slot, index) => slot ? this.closeSlot(index, false) : null))
if (!this.pageActive) return if (!this.pageActive) return
this.playbackHistory = []
this.activeIndex = 0 this.activeIndex = 0
this.controlTarget = null this.controlTarget = null
for (let index = 0; index < channels.length; index += 1) { for (let index = 0; index < channels.length; index += 1) {
if (!this.pageActive) return if (!this.pageActive) return
await this.startChannel(channels[index], index) await this.startChannel(channels[index], index)
} }
if (highlightCode) {
const highlightIndex = channels.findIndex(item => item && item.cameraCode === highlightCode)
if (highlightIndex >= 0) this.selectSlot(highlightIndex)
}
},
async populateDefaultLayout() {
if (!this.pageActive) return
// 初次打开按宫格数量随机抽在线通道,离线的不进默认画面
const channels = this.shuffleChannels(this.onlineChannels(this.channels)).slice(0, this.capacity)
if (!channels.length) return
await this.playChannels(channels)
}, },
openChannel(channel) { openChannel(channel) {
this.selectChannel(channel) this.selectChannel(channel)
@ -520,8 +663,10 @@ const PTZ_CODES = {
const current = this.slots[index] const current = this.slots[index]
if (current && current.cameraCode === channel.cameraCode && current.streamType === streamType) { if (current && current.cameraCode === channel.cameraCode && current.streamType === streamType) {
this.controlTarget = current this.controlTarget = current
this.rememberPlayback(current)
return return
} }
const replacedCode = current && current.cameraCode
if (this.slots[index]) await this.closeSlot(index, false) if (this.slots[index]) await this.closeSlot(index, false)
if (!this.pageActive) return if (!this.pageActive) return
const slot = { const slot = {
@ -536,6 +681,7 @@ const PTZ_CODES = {
streamType streamType
} }
this.$set(this.slots, index, slot) this.$set(this.slots, index, slot)
this.rememberPlayback(channel, replacedCode)
if (index === this.activeIndex) { if (index === this.activeIndex) {
this.controlTarget = slot this.controlTarget = slot
this.selectedCode = channel.cameraCode this.selectedCode = channel.cameraCode
@ -598,9 +744,11 @@ const PTZ_CODES = {
async closeSlot(index, notify = true) { async closeSlot(index, notify = true) {
const slot = this.slots[index] const slot = this.slots[index]
if (!slot) return if (!slot) return
const cameraCode = slot.cameraCode
if (index === this.activeIndex && this.ptzActive) this.stopPtz() if (index === this.activeIndex && this.ptzActive) this.stopPtz()
clearTimeout(slot.timer) clearTimeout(slot.timer)
this.$set(this.slots, index, null) this.$set(this.slots, index, null)
this.forgetPlayback(cameraCode)
if (this.controlTarget === slot) this.controlTarget = null if (this.controlTarget === slot) this.controlTarget = null
if (index === this.activeIndex) { if (index === this.activeIndex) {
this.presets = [] this.presets = []
@ -643,14 +791,51 @@ const PTZ_CODES = {
changeLayout(layout) { changeLayout(layout) {
const nextLayout = this.normalizeLayout(layout) const nextLayout = this.normalizeLayout(layout)
if (nextLayout === this.layout) return if (nextLayout === this.layout) return
const previousCapacity = this.capacity
const highlighted = this.playbackSnapshot(this.slots[this.activeIndex])
const history = this.playbackHistory.map(item => this.playbackSnapshot(item)).filter(Boolean)
const playing = this.slots.slice(0, previousCapacity).map(slot => this.playbackSnapshot(slot))
this.layout = nextLayout this.layout = nextLayout
this.$root.$emit('video-layout-updated', layout) this.$root.$emit('video-layout-updated', layout)
this.populateDefaultLayout()
if (nextLayout < previousCapacity) {
this.playReducedLayout(nextLayout, highlighted, history)
return
}
this.expandLayout(playing, nextLayout)
},
playReducedLayout(capacity, highlighted, history) {
let channels
if (capacity === 1) {
// 一宫格只保留当前高亮的那一路
channels = highlighted ? [highlighted] : history.slice(-1)
} else {
// 其余少宫格按开播记录从后往前留,中途换过的画面排在最后
channels = history.slice(-capacity)
}
if (!channels.length) {
this.populateDefaultLayout()
return
}
this.playChannels(channels, highlighted && highlighted.cameraCode)
},
async expandLayout(playing, capacity) {
const kept = playing.filter(Boolean)
const used = new Set(kept.map(item => item.cameraCode))
const extras = this.shuffleChannels(this.onlineChannels(this.channels).filter(item => item && !used.has(item.cameraCode)))
const channels = kept.concat(extras).slice(0, capacity)
await this.playChannels(channels, kept[0] && kept[0].cameraCode)
}, },
changeControlPanel(expanded) { changeControlPanel(expanded) {
this.controlPanelExpanded = Boolean(expanded) this.controlPanelExpanded = Boolean(expanded)
this.$root.$emit('video-control-panel-updated', this.controlPanelExpanded) this.$root.$emit('video-control-panel-updated', this.controlPanelExpanded)
}, },
toggleControlPanel(expanded) {
if (this.embed) {
this.embedControlVisible = expanded
return
}
this.changeControlPanel(expanded)
},
selectSlot(index) { selectSlot(index) {
if (index === this.activeIndex) { if (index === this.activeIndex) {
const current = this.slots[index] const current = this.slots[index]
@ -963,9 +1148,9 @@ const PTZ_CODES = {
<style lang="scss" scoped> <style lang="scss" scoped>
.video-console { height: 100%; min-height: 0; display: flex; flex-direction: column; color: #dcecff; background: #0b1248; } .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; gap: 10px; }
.tree-panel { position: relative; flex: 0 0 auto; min-width: 260px; max-width: 620px; height: 100%; }
.tree-panel .nvr-tree { width: 100%; min-width: 0; }
.console-body { position: relative; flex: 1; min-height: 0; display: flex; gap: 10px; }
.tree-panel { position: relative; flex: 0 0 auto; width: 242px; min-width: 242px; max-width: 620px; height: 100%; }
.tree-panel .nvr-tree { width: 100%; min-width: 0; max-width: 100%; }
.tree-resize-handle { position: absolute; z-index: 3; top: 0; right: -6px; width: 12px; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px; cursor: col-resize; } .tree-resize-handle { position: absolute; z-index: 3; top: 0; right: -6px; width: 12px; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px; cursor: col-resize; }
.tree-resize-handle span { display: block; width: 3px; height: 3px; border-radius: 50%; background: #4b9eff; box-shadow: 0 0 4px rgba(75,158,255,.8); } .tree-resize-handle span { display: block; width: 3px; height: 3px; border-radius: 50%; background: #4b9eff; box-shadow: 0 0 4px rgba(75,158,255,.8); }
.tree-resize-handle:hover span, .tree-resize-handle.resizing span { background: #54ffff; } .tree-resize-handle:hover span, .tree-resize-handle.resizing span { background: #54ffff; }
@ -973,10 +1158,11 @@ const PTZ_CODES = {
.monitor-head { height: 32px; display: flex; align-items: center; justify-content: space-between; color: #cfe5ff; }.monitor-head small { color: #648fbc; } .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-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-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-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; user-select: 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; } .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; height: 100%; align-self: stretch; margin: 0; padding: 10px; overflow-y: auto; border: 1px solid rgba(63,127,216,.72); border-radius: 2px; background: rgba(7, 24, 78, .82); box-sizing: border-box; }
.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%); user-select: none; }.empty-slot i { font-size: 28px; }.empty-slot span { font-size: 11px; }
.control-panel { position: absolute; top: 0; right: 0; bottom: 0; z-index: 4; width: 242px; min-width: 242px; height: auto; margin: 0; overflow: visible; border: 1px solid rgba(63,127,216,.72); border-radius: 2px; background: rgba(7, 24, 78, .82); box-sizing: border-box; cursor: move; user-select: none; }
.control-panel-body { height: 100%; padding: 10px; overflow-x: hidden; overflow-y: auto; box-sizing: border-box; }
.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; } .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 #3988ff; border-radius: 7px; color: #43c0ff; background: #01016b; box-shadow: none; cursor: pointer; transition: color .15s, background .15s, transform .15s; }.ptz-grid button i { font-size: 18px; font-weight: 700; }.ptz-grid button:hover { color: #43c0ff; background: #01016b; }.ptz-grid button:active { color: #ffffff; background: #01016b; transform: scale(.96); } .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 #3988ff; border-radius: 7px; color: #43c0ff; background: #01016b; box-shadow: none; cursor: pointer; transition: color .15s, background .15s, transform .15s; }.ptz-grid button i { font-size: 18px; font-weight: 700; }.ptz-grid button:hover { color: #43c0ff; background: #01016b; }.ptz-grid button:active { color: #ffffff; background: #01016b; 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[type="range"] { min-width: 0; height: 8px; box-sizing: border-box; margin: 0; appearance: none; border: 1px solid #2787ed; border-radius: 4px; outline: none; cursor: pointer; }.speed-row > input[type="range"]::-webkit-slider-runnable-track { height: 6px; border: 0; border-radius: 3px; background: transparent; }.speed-row > input[type="range"]::-webkit-slider-thumb { width: 14px; height: 14px; margin-top: -4px; appearance: none; border: 0; border-radius: 50%; background: #2787ed; }.speed-row > input[type="range"]::-moz-range-track { height: 6px; border: 0; border-radius: 3px; background: transparent; }.speed-row > input[type="range"]::-moz-range-progress { background: transparent; }.speed-row > input[type="range"]::-moz-range-thumb { width: 14px; height: 14px; border: 0; border-radius: 50%; background: #2787ed; }.speed-input { width: 48px; } .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[type="range"] { min-width: 0; height: 8px; box-sizing: border-box; margin: 0; appearance: none; border: 1px solid #2787ed; border-radius: 4px; outline: none; cursor: pointer; }.speed-row > input[type="range"]::-webkit-slider-runnable-track { height: 6px; border: 0; border-radius: 3px; background: transparent; }.speed-row > input[type="range"]::-webkit-slider-thumb { width: 14px; height: 14px; margin-top: -4px; appearance: none; border: 0; border-radius: 50%; background: #2787ed; }.speed-row > input[type="range"]::-moz-range-track { height: 6px; border: 0; border-radius: 3px; background: transparent; }.speed-row > input[type="range"]::-moz-range-progress { background: transparent; }.speed-row > input[type="range"]::-moz-range-thumb { width: 14px; height: 14px; border: 0; border-radius: 50%; background: #2787ed; }.speed-input { width: 48px; }
@ -992,21 +1178,35 @@ const PTZ_CODES = {
::v-deep .preset-create .el-input-number .el-input__inner { text-align: center; } ::v-deep .preset-create .el-input-number .el-input__inner { text-align: center; }
::v-deep .preset-create .el-input-number--mini { line-height: 26px; } ::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; } .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; }.tree-panel { min-width: 230px; }.tree-panel .nvr-tree { width: 100%; } .embed-toggle-right.open { right: 218px; } }
@media (max-width: 1100px) { .video-console:not(.is-embed) .control-panel { width: 218px; min-width: 218px; } .video-console:not(.is-embed) .tree-panel { width: 218px; min-width: 218px; } .tree-panel .nvr-tree { width: 100%; } }
.is-embed .console-body { position: relative; } .is-embed .console-body { position: relative; }
.is-embed .monitor-workspace { position: absolute; inset: 0; z-index: 1; }
.is-embed .tree-panel,
.is-embed .control-panel { position: absolute; top: 0; bottom: 0; z-index: 4; overflow: visible; background: rgba(7, 24, 78, .8); }
.is-embed .tree-panel { left: 0; }
.is-embed .tree-panel .nvr-tree { background: transparent; }
.is-embed .control-panel { right: 0; }
.is-embed .tree-panel button,
.is-embed .tree-panel input,
.is-embed .tree-panel .tree-content,
.control-panel button,
.control-panel input,
.control-panel .el-input,
.control-panel .el-select,
.is-embed .tree-panel button,
.is-embed .tree-panel input,
.is-embed .tree-panel .tree-content { cursor: auto; }
.embed-toggle { position: absolute; top: 50%; z-index: 6; width: 16px; height: 70px; padding: 0; border: 0; outline: 0; background: transparent; cursor: pointer; transform: translateY(-50%); } .embed-toggle { position: absolute; top: 50%; z-index: 6; width: 16px; height: 70px; padding: 0; border: 0; outline: 0; background: transparent; cursor: pointer; transform: translateY(-50%); }
.embed-toggle img { display: block; width: 100%; height: 100%; object-fit: fill; } .embed-toggle img { display: block; width: 100%; height: 100%; object-fit: fill; }
.embed-toggle:hover img, .embed-toggle:focus img { filter: brightness(1.2) drop-shadow(0 0 6px rgba(32, 218, 255, .65)); } .embed-toggle:hover img, .embed-toggle:focus img { filter: brightness(1.2) drop-shadow(0 0 6px rgba(32, 218, 255, .65)); }
.embed-toggle-left { left: 0; }
.embed-toggle-right { right: 0; }
.embed-toggle-right.open { right: 242px; }
.embed-toggle-left { right: -17px; left: auto; transform: translateY(-50%) rotate(180deg); }
.embed-toggle-left.is-collapsed { left: 0; right: auto; transform: translateY(-50%) rotate(180deg); }
.embed-toggle-right { left: -17px; right: auto; transform: translateY(-50%); }
.embed-toggle-right.is-page { left: -16px; }
.embed-toggle-right.is-collapsed { right: 0; left: auto; transform: translateY(-50%); }
.embed-layout-switch { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; } .embed-layout-switch { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
.embed-layout-switch button { height: 36px; padding: 0; display: flex; align-items: center; justify-content: center; border: 1px solid rgba(39,135,237,.55); color: #7eb6ef; background: rgba(10,43,103,.55); cursor: pointer; } .embed-layout-switch button { height: 36px; padding: 0; display: flex; align-items: center; justify-content: center; border: 1px solid rgba(39,135,237,.55); color: #7eb6ef; background: rgba(10,43,103,.55); cursor: pointer; }
.embed-layout-switch button:hover, .embed-layout-switch button.active { color: #54ffff; border-color: #54ffff; background: rgba(23,107,194,.72); } .embed-layout-switch button:hover, .embed-layout-switch button.active { color: #54ffff; border-color: #54ffff; background: rgba(23,107,194,.72); }
.embed-layout-icon { display: grid; gap: 1px; }
.embed-layout-icon i { width: 4px; height: 4px; border: 1px solid currentColor; box-sizing: border-box; }
.embed-layout-icon-1 i { width: 14px; height: 14px; }
.embed-layout-icon-2 i { width: 6px; height: 6px; }
.embed-layout-icon-4 { gap: 1px; }
.embed-layout-icon-4 i { width: 3px; height: 3px; }
.embed-layout-icon { display: grid; width: 16px; height: 16px; gap: 1px; box-sizing: border-box; }
.embed-layout-icon i { width: auto; height: auto; border: 1px solid currentColor; box-sizing: border-box; }
</style> </style>

+ 5
- 1
src/views/video/components/NvrDeviceTree.vue View File

@ -40,7 +40,7 @@
popper-class="nvr-tree-tooltip" popper-class="nvr-tree-tooltip"
effect="dark" effect="dark"
> >
<span class="custom-tree-node">
<span class="custom-tree-node" :class="{ 'is-offline': isOffline(data) }">
<i :class="nodeIcon(data)" /> <i :class="nodeIcon(data)" />
<span class="node-label">{{ data.label || data.name }}</span> <span class="node-label">{{ data.label || data.name }}</span>
</span> </span>
@ -149,6 +149,9 @@ export default {
const channel = this.normalizeChannel(node) const channel = this.normalizeChannel(node)
this.$emit('open', channel) this.$emit('open', channel)
}, },
isOffline(node) {
return !!node.channelFlag && String(node.patroldeviceOnlineStatus) === '0'
},
nodeIcon(node) { nodeIcon(node) {
if (node.channelFlag) return 'el-icon-video-camera' if (node.channelFlag) return 'el-icon-video-camera'
if (node.patroldeviceType === 13) return 'el-icon-position' if (node.patroldeviceType === 13) return 'el-icon-position'
@ -190,6 +193,7 @@ export default {
::v-deep .el-tree-node__expand-icon.is-leaf { display: inline-block; width: 12px; } ::v-deep .el-tree-node__expand-icon.is-leaf { display: inline-block; width: 12px; }
::v-deep .el-tree-node__content:hover, ::v-deep .el-tree-node:focus > .el-tree-node__content { background: transparent; } ::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; } ::v-deep .el-tree-node.is-current > .el-tree-node__content { color: #54ffff; background: transparent; }
.custom-tree-node.is-offline { color: #7f8da8; }
</style> </style>
<style lang="scss"> <style lang="scss">


Loading…
Cancel
Save