Browse Source

修改登录后重定向,增加单独视频页video-preview-embed

inspection_yanyuan
douyage 1 month ago
parent
commit
4a24c08d54
20 changed files with 285 additions and 64 deletions
  1. +12
    -0
      .env.agg
  2. +2
    -0
      .env.htjc6
  3. +1
    -0
      package.json
  4. +14
    -0
      public/index.html
  5. +12
    -0
      src/api/login.js
  6. +1
    -0
      src/components/AsyncImageFrame/index.vue
  7. +1
    -1
      src/layout/components/Navbar.vue
  8. +1
    -2
      src/layout/index.vue
  9. +9
    -2
      src/permission.js
  10. +7
    -0
      src/router/index.js
  11. +18
    -5
      src/utils/auth.js
  12. +5
    -0
      src/utils/homePath.js
  13. +18
    -1
      src/utils/request.js
  14. +2
    -2
      src/utils/stationTitle.js
  15. +7
    -6
      src/views/cards/patrolReport/InspectionReporter.vue
  16. +3
    -3
      src/views/cards/site_management/site_management.vue
  17. +2
    -1
      src/views/login.vue
  18. +20
    -17
      src/views/portal/index.vue
  19. +76
    -24
      src/views/video/LivePreview.vue
  20. +74
    -0
      src/views/video/LivePreviewEmbed.vue

+ 12
- 0
.env.agg View File

@ -0,0 +1,12 @@
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

+ 2
- 0
.env.htjc6 View File

@ -8,3 +8,5 @@ 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

+ 1
- 0
package.json View File

@ -8,6 +8,7 @@
"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"


+ 14
- 0
public/index.html View File

@ -2,6 +2,11 @@
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<script>
if (location.pathname.indexOf('/video-preview-embed') !== -1) {
document.documentElement.className += ' embed-preview';
}
</script>
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="renderer" content="webkit"> <meta name="renderer" content="webkit">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
@ -26,6 +31,15 @@
padding: 0.2em 0; padding: 0.2em 0;
} }
html.embed-preview,
html.embed-preview body {
background: #0b1248;
}
html.embed-preview #loader-wrapper {
display: none;
}
#loader-wrapper { #loader-wrapper {
position: fixed; position: fixed;
top: 0; top: 0;


+ 12
- 0
src/api/login.js View File

@ -1,5 +1,17 @@
import request from '@/utils/request' import request from '@/utils/request'
// 嵌入页换取访问 token,不走登录页
export function getAuthToken(username, password) {
return request({
url: '/auth/getToken',
headers: {
isToken: false
},
method: 'get',
params: { username, password }
})
}
// 登录方法 // 登录方法
export function login(username, password, code, uuid) { export function login(username, password, code, uuid) {
console.log('login', username, password, code, uuid) console.log('login', username, password, code, uuid)


+ 1
- 0
src/components/AsyncImageFrame/index.vue View File

@ -538,6 +538,7 @@ export default {
// ==================== FTP和URL处理 ==================== // ==================== FTP和URL处理 ====================
async ftpImageViewUrl(url) { async ftpImageViewUrl(url) {
debugger;
const ftpViewAddress = this.systemParams && this.systemParams.ftpViewAddress; const ftpViewAddress = this.systemParams && this.systemParams.ftpViewAddress;
if (!ftpViewAddress) { if (!ftpViewAddress) {
const systemParams = {}; const systemParams = {};


+ 1
- 1
src/layout/components/Navbar.vue View File

@ -188,7 +188,7 @@ export default {
pointer-events: none; pointer-events: none;
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: center center; background-position: center center;
background-size: 100% 100%;
background-size: auto 100%;
background-color: #11194f; background-color: #11194f;
box-sizing: border-box; box-sizing: border-box;
} }


+ 1
- 2
src/layout/index.vue View File

@ -123,8 +123,7 @@ export default {
{ key: 'home', label: '首页', path: '/portal' }, { key: 'home', label: '首页', path: '/portal' },
{ key: 'patrol', label: '动环巡检', path: '/index' }, { key: 'patrol', label: '动环巡检', path: '/index' },
// { key: 'system', label: '系统管理', path: '/system/user' } // { key: 'system', label: '系统管理', path: '/system/user' }
{ key: 'system', label: '系统管理', path: '' }
// { key: 'system', label: '系统管理', path: '' }
] ]
}, },
activeModule() { activeModule() {


+ 9
- 2
src/permission.js View File

@ -6,11 +6,12 @@ import "nprogress/nprogress.css";
import { getToken } from "@/utils/auth"; import { getToken } from "@/utils/auth";
import { isEmbeddedSession } from "@/utils/embeddedAuth"; import { isEmbeddedSession } from "@/utils/embeddedAuth";
import { isRelogin } from "@/utils/request"; import { isRelogin } from "@/utils/request";
import { homePath } from "@/utils/homePath";
import md5 from 'md5.js' import md5 from 'md5.js'
NProgress.configure({ showSpinner: false }); NProgress.configure({ showSpinner: false });
const whiteList = ["/login", "/auth-redirect", "/bind", "/register"];
const whiteList = ["/login", "/auth-redirect", "/bind", "/register", "/video-preview-embed"];
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
// NProgress.start(); // NProgress.start();
@ -20,8 +21,10 @@ router.beforeEach(async (to, from, next) => {
/* has token*/ /* has token*/
if (to.path === "/login") { if (to.path === "/login") {
console.info("[GAB] to next"); console.info("[GAB] to next");
next({ path: "/xfdh/index", replace: true });
next({ path: homePath, replace: true });
// NProgress.done(); // NProgress.done();
} else if (to.path === "/video-preview-embed") {
next();
} else if (embeddedSession) { } else if (embeddedSession) {
// 外部系统跳转进入:宿主框架已提供登录用户,无需再走登录页, // 外部系统跳转进入:宿主框架已提供登录用户,无需再走登录页,
// 默认只挂载本地菜单(即非 admin 账号看到的默认内容)。 // 默认只挂载本地菜单(即非 admin 账号看到的默认内容)。
@ -50,6 +53,10 @@ router.beforeEach(async (to, from, next) => {
.catch(async (err) => { .catch(async (err) => {
isRelogin.show = false; isRelogin.show = false;
await store.dispatch("FedLogOut"); await store.dispatch("FedLogOut");
if (to.path === "/video-preview-embed") {
next();
return;
}
Message.error((err && err.message) || "登录状态已失效,请重新登录"); Message.error((err && err.message) || "登录状态已失效,请重新登录");
next({ path: "/login", query: { redirect: to.fullPath }, replace: true }); next({ path: "/login", query: { redirect: to.fullPath }, replace: true });
}); });


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

@ -37,6 +37,13 @@ export const constantRoutes = [
hidden: true, hidden: true,
meta: { title: '动环智能巡检系统' } meta: { title: '动环智能巡检系统' }
}, },
{
// 供外部系统 iframe 嵌入:无后台外壳,页面内静默登录
path: '/video-preview-embed',
component: () => import('@/views/video/LivePreviewEmbed'),
hidden: true,
meta: { title: '实时监控' }
},
{ {
path: '/xfdh', path: '/xfdh',
component: Layout, component: Layout,


+ 18
- 5
src/utils/auth.js View File

@ -3,17 +3,30 @@ import Cookies from 'js-cookie'
const TokenKey = 'Admin-Token' const TokenKey = 'Admin-Token'
const ExpiresInKey = "Admin-Expires-In"; const ExpiresInKey = "Admin-Expires-In";
// Cookie 不区分端口,同一 IP 下不同端口会串登录态。
// localStorage 按源(协议 + 主机 + 端口)隔离,因此登录信息改存这里。
function clearLegacyCookies() {
Cookies.remove(TokenKey)
Cookies.remove(ExpiresInKey)
}
export function getToken() { export function getToken() {
return Cookies.get(TokenKey)
clearLegacyCookies()
return localStorage.getItem(TokenKey)
} }
export function setToken(token) { export function setToken(token) {
return Cookies.set(TokenKey, token)
clearLegacyCookies()
return localStorage.setItem(TokenKey, token)
} }
export function removeToken() { export function removeToken() {
return Cookies.remove(TokenKey)
clearLegacyCookies()
return localStorage.removeItem(TokenKey)
} }
export function setExpiresIn(time) { export function setExpiresIn(time) {
return Cookies.set(ExpiresInKey, time);
}
clearLegacyCookies()
return localStorage.setItem(ExpiresInKey, time);
}

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

@ -0,0 +1,5 @@
const configuredHomePath = process.env.VUE_APP_HOME_PATH
const homePaths = ['/portal', '/index']
// 登录后的首页由打包模式决定,未配置或配置非法时进入门户
export const homePath = homePaths.indexOf(configuredHomePath) >= 0 ? configuredHomePath : '/portal'

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

@ -10,6 +10,7 @@ import { saveAs } from 'file-saver'
import { urlWhiteList } from '@/settings' import { urlWhiteList } from '@/settings'
import md5 from 'md5.js' import md5 from 'md5.js'
import { isEmbeddedSession } from '@/utils/embeddedAuth' import { isEmbeddedSession } from '@/utils/embeddedAuth'
import { homePath } from '@/utils/homePath'
import Vue from 'vue' import Vue from 'vue'
@ -149,6 +150,17 @@ service.interceptors.response.use((res) => {
console.log('[GAB] code: ' + code) console.log('[GAB] code: ' + code)
if (code === 401) { if (code === 401) {
isRelogin.show = false; isRelogin.show = false;
const currentPath = router.currentRoute && router.currentRoute.path
// iframe 嵌入页自己静默登录,401 不能把外层画面带到登录页或门户
if (currentPath === "/video-preview-embed") {
return Promise.reject(new Error('无效的会话,或者会话已过期,请重新登录。'))
}
// 登录页会同时请求验证码。/code 不带 token,401 不能触发登出、静默登录或跳转,
// 否则会在登录成功跳往 /portal 之后把地址改掉。
const requestUrl = (res.config && res.config.url) || ""
if (requestUrl === "/code" || requestUrl.indexOf("/code?") === 0) {
return Promise.reject(new Error('验证码获取失败'))
}
if (isEmbeddedSession()) { if (isEmbeddedSession()) {
// 外部系统跳转进入:宿主框架未提供本系统token,静默登录(默认普通账号)保证页面数据可用 // 外部系统跳转进入:宿主框架未提供本系统token,静默登录(默认普通账号)保证页面数据可用
const postData = new LoginEntity(); const postData = new LoginEntity();
@ -156,7 +168,12 @@ service.interceptors.response.use((res) => {
postData.password = new md5().update("htjc@123").digest('hex') postData.password = new md5().update("htjc@123").digest('hex')
store.dispatch("Login", postData).then(() => { store.dispatch("Login", postData).then(() => {
console.info("[GAB] Login then"); console.info("[GAB] Login then");
router.push({ path: "/" }).catch(() => { });
const loggedInPath = router.currentRoute && router.currentRoute.path
// 登录页自己会去首页;已经在首页上则不再改地址
if (loggedInPath === homePath || loggedInPath === "/login") {
return
}
router.push({ path: homePath }).catch(() => { });
}) })
} else { } else {
// 本系统登录的用户:会话过期后回到登录页,不能悄悄换成别的账号 // 本系统登录的用户:会话过期后回到登录页,不能悄悄换成别的账号


+ 2
- 2
src/utils/stationTitle.js View File

@ -13,13 +13,13 @@ 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:32799": { 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.201": { titleImage: baicaopoTitleImage, name: "白草坡动环智能巡检系统" },
"192.168.147.13": { titleImage: baicaopoTitleImage, name: "白草坡动环智能巡检系统" }, "192.168.147.13": { titleImage: baicaopoTitleImage, name: "白草坡动环智能巡检系统" },
"192.168.129.2:20001": { 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: "小高山动环智能巡检系统" },


+ 7
- 6
src/views/cards/patrolReport/InspectionReporter.vue View File

@ -234,17 +234,18 @@ export default {
}, },
ftpImageViewUrl(row, suffix) { ftpImageViewUrl(row, suffix) {
// 添加安全检查 // 添加安全检查
if (!this.systemParams || !this.systemParams.ftpViewAddress) {
this.$modal.msgWarning("系统配置未加载,请稍后重试");
console.error("systemParams为空:", this.systemParams);
return;
}
// if (!this.systemParams || !this.systemParams.ftpViewAddress) {
// this.$modal.msgWarning("系统配置未加载,请稍后重试");
// console.error("systemParams为空:", this.systemParams);
// return;
// }
console.log("使用FTP地址:", this.systemParams.ftpViewAddress); console.log("使用FTP地址:", this.systemParams.ftpViewAddress);
if (row.filePath) { if (row.filePath) {
var fileName = row.filePath.split(".")[0]; var fileName = row.filePath.split(".")[0];
const url = this.systemParams.ftpViewAddress + fileName + "." + suffix;
// const url = this.systemParams.ftpViewAddress + fileName + "." + suffix;
const url = row.filePath;
console.log("导出URL:", url); console.log("导出URL:", url);
const a = document.createElement("a"); const a = document.createElement("a");
a.setAttribute("href", url); a.setAttribute("href", url);


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

@ -1392,7 +1392,7 @@ import Algorith from "@/views/cards/site_management/algorithm.vue";
import ImageAnnotation from "./components/ImageAnnotation.vue"; import ImageAnnotation from "./components/ImageAnnotation.vue";
// 设备树面板默认宽度(收窄后);存储 key 带 v2,避免老用户 localStorage 里的旧宽度盖住新默认 // 设备树面板默认宽度(收窄后);存储 key 带 v2,避免老用户 localStorage 里的旧宽度盖住新默认
const DEFAULT_LEFT_PANEL_WIDTH = 200;
const DEFAULT_LEFT_PANEL_WIDTH = 250;
const LEFT_PANEL_WIDTH_STORAGE_KEY = "leftPanelWidthV2"; const LEFT_PANEL_WIDTH_STORAGE_KEY = "leftPanelWidthV2";
export default { export default {
@ -3858,7 +3858,7 @@ export default {
.search { .search {
width: 100%; width: 100%;
position: relative; position: relative;
margin-top: 0;
margin-top: 10px;
margin-bottom: 10px; margin-bottom: 10px;
.el-input__inner { .el-input__inner {
@ -4010,7 +4010,7 @@ export default {
gap: 12px; gap: 12px;
flex-shrink: 0; flex-shrink: 0;
min-height: 34px; min-height: 34px;
padding-top: 3px;
padding-top: 10px;
box-sizing: border-box; box-sizing: border-box;
} }


+ 2
- 1
src/views/login.vue View File

@ -62,6 +62,7 @@
import { getCodeImg } from "@/api/login"; import { getCodeImg } from "@/api/login";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { getToken } from "@/utils/auth"; import { getToken } from "@/utils/auth";
import { homePath } from "@/utils/homePath";
import { encrypt, decrypt } from '@/utils/jsencrypt' import { encrypt, decrypt } from '@/utils/jsencrypt'
import md5 from 'md5.js' import md5 from 'md5.js'
@ -153,7 +154,7 @@ export default {
this.$store.dispatch("Login", postData).then(() => { this.$store.dispatch("Login", postData).then(() => {
// 登录成功后清除手动登录标记,下次进入系统恢复免登录 // 登录成功后清除手动登录标记,下次进入系统恢复免登录
sessionStorage.removeItem('manual-login'); sessionStorage.removeItem('manual-login');
this.$router.push({ path: this.redirect || "/" }).catch(() => { });
this.$router.push({ path: this.redirect || homePath }).catch(() => { });
}).catch(() => { }).catch(() => {
this.loading = false; this.loading = false;


+ 20
- 17
src/views/portal/index.vue View File

@ -163,14 +163,12 @@
</div> </div>
<div class="video-filter-row"> <div class="video-filter-row">
<el-select <el-select
v-model="videoStationIds"
v-model="videoStationId"
class="panel-select" class="panel-select"
size="mini" size="mini"
multiple
collapse-tags
clearable
placeholder="全部场站" placeholder="全部场站"
> >
<el-option label="全部场站" value="" />
<el-option <el-option
v-for="station in stationList" v-for="station in stationList"
:key="station.id" :key="station.id"
@ -402,7 +400,7 @@ export default {
cameraCount: { ...EMPTY_CAMERA }, cameraCount: { ...EMPTY_CAMERA },
portalChannels: [], portalChannels: [],
videoList: [], videoList: [],
videoStationIds: [],
videoStationId: "",
activeVideoIndex: 0, activeVideoIndex: 0,
treeVisible: false, treeVisible: false,
treeMounted: false, treeMounted: false,
@ -439,13 +437,14 @@ export default {
]; ];
}, },
filteredVideos() { filteredVideos() {
if (!this.videoStationIds.length) return this.portalChannels.slice(0, 4);
return this.videoStationIds.reduce((videos, stationId) => {
const station = this.stationList.find(item => String(item.id) === String(stationId));
if (!station) return videos;
const matched = this.portalChannels.filter(video => String(video.stationId) === String(stationId));
return videos.concat(matched);
}, []).slice(0, 4);
if (this.videoStationId === "" || this.videoStationId === null || this.videoStationId === undefined) {
return this.portalChannels.slice(0, 4);
}
const station = this.stationList.find(item => String(item.id) === String(this.videoStationId));
if (!station) return [];
return this.portalChannels
.filter(video => String(video.stationId) === String(this.videoStationId))
.slice(0, 4);
}, },
portalVideoSlots() { portalVideoSlots() {
return Array.from({ length: 4 }, (_, index) => this.videoList[index] || null); return Array.from({ length: 4 }, (_, index) => this.videoList[index] || null);
@ -462,9 +461,10 @@ export default {
trendQueryKey() { trendQueryKey() {
this.loadDefectTrend(); this.loadDefectTrend();
}, },
videoStationIds(ids) {
videoStationId(id) {
this.replacePortalVideos(this.filteredVideos); this.replacePortalVideos(this.filteredVideos);
if (ids && ids.length) this.openDeviceTree();
this.loadChannelStats();
if (id !== "" && id !== null && id !== undefined) this.openDeviceTree();
} }
}, },
mounted() { mounted() {
@ -477,7 +477,7 @@ export default {
}, },
methods: { methods: {
selectMonitorStation(station) { selectMonitorStation(station) {
this.videoStationIds = [station.id];
this.videoStationId = station.id;
this.openDeviceTree(); this.openDeviceTree();
}, },
openDeviceTree() { openDeviceTree() {
@ -485,7 +485,7 @@ export default {
this.treeVisible = true; this.treeVisible = true;
}, },
isMonitorStationSelected(stationId) { isMonitorStationSelected(stationId) {
return this.videoStationIds.some(id => String(id) === String(stationId));
return String(this.videoStationId) === String(stationId);
}, },
selectTrendForStation(station, algCategory) { selectTrendForStation(station, algCategory) {
this.trendStationId = station.code; this.trendStationId = station.code;
@ -516,7 +516,10 @@ export default {
}); });
}, },
loadChannelStats() { loadChannelStats() {
return getChannelStats({}).then(response => {
const params = {};
const station = this.stationList.find(item => String(item.id) === String(this.videoStationId));
if (station && station.code) params.stationCode = station.code;
return getChannelStats(params).then(response => {
const data = response && response.data ? response.data : {}; const data = response && response.data ? response.data : {};
const counts = Array.isArray(data.deviceSourceCounts) ? data.deviceSourceCounts : []; const counts = Array.isArray(data.deviceSourceCounts) ? data.deviceSourceCounts : [];
const cameraCount = { ...EMPTY_CAMERA, total: toCount(data.totalCount) }; const cameraCount = { ...EMPTY_CAMERA, total: toCount(data.totalCount) };


+ 76
- 24
src/views/video/LivePreview.vue View File

@ -1,8 +1,9 @@
<template> <template>
<div class="video-console live-preview">
<div class="video-console live-preview" :class="{ 'is-embed': embed }">
<div class="console-body"> <div class="console-body">
<div class="tree-panel" :style="{ width: treePanelWidth + 'px' }">
<div v-show="!embed || embedTreeVisible" class="tree-panel" :style="{ width: treePanelWidth + 'px' }">
<nvr-device-tree <nvr-device-tree
v-if="!embed || sessionReady"
:config="config" :config="config"
:channels="channels" :channels="channels"
:selected-code="selectedCode" :selected-code="selectedCode"
@ -13,6 +14,7 @@
@open="openChannel" @open="openChannel"
/> />
<div <div
v-if="!embed"
class="tree-resize-handle" class="tree-resize-handle"
:class="{ resizing: isTreeResizing }" :class="{ resizing: isTreeResizing }"
title="拖拽调整设备树宽度" title="拖拽调整设备树宽度"
@ -23,9 +25,22 @@
<span /> <span />
</div> </div>
</div> </div>
<button
v-if="embed"
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%)"
>
<!-- <img :src="embedTreeVisible ? treeCollapseIcon : treeToggleIcon" alt=""> -->
<img :src="embedTreeVisible ? treeToggleIcon : treeCollapseIcon" alt="">
</button>
<main class="monitor-workspace"> <main class="monitor-workspace">
<div class="monitor-head">
<div v-if="!embed" class="monitor-head">
<span>监控画面</span> <span>监控画面</span>
<small>双击设备树通道打开,单击画面切换控制对象</small> <small>双击设备树通道打开,单击画面切换控制对象</small>
</div> </div>
@ -50,7 +65,7 @@
<span><i />{{ slot.name }}</span> <span><i />{{ slot.name }}</span>
</div> </div>
<div class="camera-actions"> <div class="camera-actions">
<button type="button" title="历史回放" @click.stop="openPlayback(slot)"><i class="el-icon-time" /></button>
<button v-if="!embed" 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="fullscreen(index)"><i class="el-icon-full-screen" /></button>
<button type="button" title="关闭" @click.stop="closeSlot(index)"><i class="el-icon-close" /></button> <button type="button" title="关闭" @click.stop="closeSlot(index)"><i class="el-icon-close" /></button>
</div> </div>
@ -63,7 +78,7 @@
</div> </div>
</main> </main>
<aside v-show="controlPanelExpanded" class="control-panel">
<aside v-show="embed ? embedControlVisible : controlPanelExpanded" class="control-panel">
<section class="control-section"> <section class="control-section">
<div class="section-title"><span>云台控制</span><small>{{ selectedChannelName }}</small></div> <div class="section-title"><span>云台控制</span><small>{{ selectedChannelName }}</small></div>
<div class="ptz-grid"> <div class="ptz-grid">
@ -146,7 +161,7 @@
</div> </div>
</section> </section>
<section class="control-section">
<section v-if="!embed" class="control-section">
<div class="section-title"><span>操作</span></div> <div class="section-title"><span>操作</span></div>
<div class="operation-grid"> <div class="operation-grid">
<button type="button" class="wide" @click="capture"><i class="el-icon-camera" />抓图</button> <button type="button" class="wide" @click="capture"><i class="el-icon-camera" />抓图</button>
@ -161,6 +176,16 @@
</div> </div>
</section> </section>
</aside> </aside>
<button
v-if="embed"
type="button"
class="embed-toggle embed-toggle-right"
:class="{ open: embedControlVisible }"
:title="embedControlVisible ? '隐藏云台控制' : '显示云台控制'"
@click="embedControlVisible = !embedControlVisible"
>
<img :src="embedControlVisible ? treeToggleIcon : treeCollapseIcon" alt="">
</button>
</div> </div>
</div> </div>
</template> </template>
@ -168,6 +193,8 @@
<script> <script>
import NvrDeviceTree from './components/NvrDeviceTree.vue' import NvrDeviceTree from './components/NvrDeviceTree.vue'
import NvrStreamPlayer from './components/NvrStreamPlayer.vue' import NvrStreamPlayer from './components/NvrStreamPlayer.vue'
import treeToggleIcon from '@/assets/portal/tree-toggle.png'
import treeCollapseIcon from '@/assets/portal/tree-collapse.png'
import { import {
getNvrConfig, startLive, getLiveStatus, stopLive, controlPtz, getNvrConfig, startLive, getLiveStatus, stopLive, controlPtz,
getPresetList, createPreset, createSnapshot, downloadSnapshot, controlWiper getPresetList, createPreset, createSnapshot, downloadSnapshot, controlWiper
@ -181,9 +208,14 @@ const PTZ_CODES = {
ZOOM_IN: 23, ZOOM_OUT: 24, FOCUS_NEAR: 25, FOCUS_FAR: 26 ZOOM_IN: 23, ZOOM_OUT: 24, FOCUS_NEAR: 25, FOCUS_FAR: 26
} }
export default {
export default {
name: 'VideoLivePreview', name: 'VideoLivePreview',
components: { NvrDeviceTree, NvrStreamPlayer }, components: { NvrDeviceTree, NvrStreamPlayer },
props: {
embed: { type: Boolean, default: false },
// 嵌入页先出画面,token 就绪后再拉设备和视频
sessionReady: { type: Boolean, default: true }
},
data() { data() {
return { return {
config: {}, config: {},
@ -191,6 +223,10 @@ export default {
treeChannelsLoaded: false, treeChannelsLoaded: false,
treeChannelCodes: new Set(), treeChannelCodes: new Set(),
controlPanelExpanded: false, controlPanelExpanded: false,
embedTreeVisible: false,
embedControlVisible: false,
treeToggleIcon,
treeCollapseIcon,
treePanelWidth: 400, treePanelWidth: 400,
isTreeResizing: false, isTreeResizing: false,
treeResizeStartX: 0, treeResizeStartX: 0,
@ -258,8 +294,13 @@ export default {
} }
}, },
computed: { computed: {
// 汇聚门户进入的会话:设备树与拉流走首页同款聚合接口
isAggMode() { return this.$store.getters.fromPortal },
// 打包模式决定视频接口:build:agg 走汇聚接口,build:htjc6 走单站接口。
// 其它未配置的模式仍按是否从门户进入判断。
isAggMode() {
if (process.env.VUE_APP_AGG_VIDEO === 'true') return true
if (process.env.VUE_APP_AGG_VIDEO === 'false') return false
return this.$store.getters.fromPortal
},
capacity() { return this.layout }, capacity() { return this.layout },
visibleSlots() { return this.slots.slice(0, this.capacity) }, visibleSlots() { return this.slots.slice(0, this.capacity) },
activeSlot() { return this.slots[this.activeIndex] || null }, activeSlot() { return this.slots[this.activeIndex] || null },
@ -273,13 +314,20 @@ export default {
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}`) : '未选择画面' }
}, },
watch: {
sessionReady(ready) {
if (ready && this.embed) this.loadConfig()
}
},
created() { created() {
this.$root.$on('video-layout-change', this.changeLayout)
this.$root.$on('video-control-panel-change', this.changeControlPanel)
if (!this.embed) {
this.$root.$on('video-layout-change', this.changeLayout)
this.$root.$on('video-control-panel-change', this.changeControlPanel)
this.$root.$emit('video-layout-updated', this.layout)
this.$root.$emit('video-control-panel-updated', this.controlPanelExpanded)
}
document.addEventListener('fullscreenchange', this.handleFullscreenChange) document.addEventListener('fullscreenchange', this.handleFullscreenChange)
this.$root.$emit('video-layout-updated', this.layout)
this.$root.$emit('video-control-panel-updated', this.controlPanelExpanded)
this.loadConfig()
if (!this.embed || this.sessionReady) this.loadConfig()
}, },
async activated() { async activated() {
if (this.pageActive) return if (this.pageActive) return
@ -302,8 +350,10 @@ export default {
this.lifecycleId += 1 this.lifecycleId += 1
this.pageActive = false this.pageActive = false
document.removeEventListener('fullscreenchange', this.handleFullscreenChange) document.removeEventListener('fullscreenchange', this.handleFullscreenChange)
this.$root.$off('video-layout-change', this.changeLayout)
this.$root.$off('video-control-panel-change', this.changeControlPanel)
if (!this.embed) {
this.$root.$off('video-layout-change', this.changeLayout)
this.$root.$off('video-control-panel-change', this.changeControlPanel)
}
this.closeAll() this.closeAll()
}, },
methods: { methods: {
@ -485,15 +535,10 @@ export default {
if (this.slots[index] !== slot || !result) return if (this.slots[index] !== slot || !result) return
const payload = result.data || result const payload = result.data || result
const mediaUrl = payload.url || payload.playUrl || payload.mediaUrl || payload.address || '' const mediaUrl = payload.url || payload.playUrl || payload.mediaUrl || payload.address || ''
const hlsUrl = payload.hlsUrl || payload.hls || (/\.m3u8(?:\?|$)/i.test(mediaUrl) ? mediaUrl : '')
const flvUrl = payload.flvUrl || payload.flv || (/\.flv(?:\?|$)/i.test(mediaUrl) ? mediaUrl : '')
slot.streamSource.hlsUrl = this.replaceStreamHost(hlsUrl)
slot.streamSource.flvUrl = this.replaceStreamHost(flvUrl)
slot.streamSource.hlsUrl = payload.hlsUrl || payload.hls || (/\.m3u8(?:\?|$)/i.test(mediaUrl) ? mediaUrl : '')
slot.streamSource.flvUrl = payload.flvUrl || payload.flv || (/\.flv(?:\?|$)/i.test(mediaUrl) ? mediaUrl : '')
slot.loading = !(slot.streamSource.hlsUrl || slot.streamSource.flvUrl) slot.loading = !(slot.streamSource.hlsUrl || slot.streamSource.flvUrl)
}, },
replaceStreamHost(url) {
return url
},
pollLive(index, slot, attempt) { pollLive(index, slot, attempt) {
if (!this.pageActive || this.slots[index] !== slot) return if (!this.pageActive || this.slots[index] !== slot) return
if (attempt >= 20) { if (attempt >= 20) {
@ -898,5 +943,12 @@ export default {
::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%; } }
@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; } }
.is-embed .console-body { position: relative; }
.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: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; }
</style> </style>

+ 74
- 0
src/views/video/LivePreviewEmbed.vue View File

@ -0,0 +1,74 @@
<template>
<div class="live-preview-embed">
<live-preview embed :session-ready="sessionReady" />
<div v-if="bootError" class="embed-error">{{ bootError }}</div>
</div>
</template>
<script>
import { setToken } from '@/utils/auth'
import { getAuthToken } from '@/api/login'
import LivePreview from './LivePreview.vue'
const EMBED_USERNAME = 'admin'
const EMBED_PASSWORD = 'admin@123'
function readAccessToken(result) {
if (!result) return ''
if (typeof result === 'string') return result
if (typeof result.access_token === 'string') return result.access_token
if (typeof result.token === 'string') return result.token
const data = result.data
if (typeof data === 'string') return data
if (data && typeof data.access_token === 'string') return data.access_token
if (data && typeof data.token === 'string') return data.token
return ''
}
export default {
name: 'LivePreviewEmbed',
components: { LivePreview },
data() {
return {
sessionReady: false,
bootError: ''
}
},
created() {
this.ensureSession()
},
methods: {
ensureSession() {
getAuthToken(EMBED_USERNAME, EMBED_PASSWORD).then(result => {
const token = readAccessToken(result)
if (!token) {
this.bootError = '视频服务不可用'
return
}
setToken(token)
this.$store.commit('SET_TOKEN', token)
this.sessionReady = true
}).catch(() => {
this.bootError = '视频服务不可用'
})
}
}
}
</script>
<style scoped>
.live-preview-embed {
position: relative;
width: 100%;
height: 100%;
background: #0b1248;
}
.embed-error {
position: absolute;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
color: #8eb7df;
font-size: 14px;
}
</style>

Loading…
Cancel
Save