diff --git a/src/api/nvrVideo.js b/src/api/nvrVideo.js
index 1072b13..64b9bce 100644
--- a/src/api/nvrVideo.js
+++ b/src/api/nvrVideo.js
@@ -1,4 +1,5 @@
import request from '@/utils/request'
+import { limitVideoRequest } from '@/utils/videoRequestLimit'
import axios from 'axios'
const ivsRequest = axios.create({
@@ -29,30 +30,33 @@ export function getNvrConfig() {
}
export function startLive(channel, streamType = 1) {
- return unwrap(request({
+ return unwrap(limitVideoRequest(() => request({
url: '/patrol/video/realtime/start',
method: 'post',
+ timeout: 8000,
data: {
cameraCode: channel.cameraCode || channel.channelCode,
streamType
}
- }))
+ })))
}
export function getLiveStatus(cameraCode) {
- return unwrap(request({
+ return unwrap(limitVideoRequest(() => request({
url: '/patrol/video/realtime/status',
method: 'get',
+ timeout: 8000,
params: { cameraCode }
- }))
+ })))
}
export function stopLive(cameraCode) {
- return unwrap(request({
+ return unwrap(limitVideoRequest(() => request({
url: '/patrol/video/realtime/stop',
method: 'post',
+ timeout: 8000,
data: { cameraCode }
- }))
+ })))
}
export function controlPtz(payload) {
diff --git a/src/api/portal/index.js b/src/api/portal/index.js
index 60c12f1..ee5dd42 100644
--- a/src/api/portal/index.js
+++ b/src/api/portal/index.js
@@ -2,78 +2,95 @@
// 这些接口由中心端后端提供,返回"所有站"的汇总数据;
// 与单站业务接口不同,它们不依赖当前登录用户所属站。
// 后端未就绪时页面以空态展示,不影响其它功能。
+import axios from "axios";
import request from "@/utils/request";
+import { limitVideoRequest } from "@/utils/videoRequestLimit";
// 场站列表
// 返回 data: [{ id, stationCode, stationName, baseUrl, portalUrl, enabled }]
-export function getStationList(query) {
+export function getStationList(query, config = {}) {
return request({
url: "/agg/station/all",
method: "get",
params: query,
- noAlert: true
+ noAlert: true,
+ cancelToken: config.cancelToken
});
}
// 本轮巡检任务统计(表计/外观/红外)
// 返回 data: [{ algCategory, inspectedTaskCount, completedTaskCount, defectCount, ... }]
-export function getInspectionOverview(query) {
+export function getInspectionOverview(query, config = {}) {
return request({
url: "/agg/statistic/inspection",
method: "get",
params: query,
- noAlert: true
+ noAlert: true,
+ cancelToken: config.cancelToken
});
}
// 缺陷发现趋势(最近 12 个月或 4 个季度)
// 返回 data: [{ date, minorCount, generalCount, seriousCount, urgentCount, ... }]
-export function getDefectTrend(query) {
+export function getDefectTrend(query, config = {}) {
return request({
url: "/agg/statistic/defect",
method: "get",
params: query,
- noAlert: true
+ noAlert: true,
+ cancelToken: config.cancelToken
});
}
// 通道设备来源统计
// 返回 data: { totalCount, deviceSourceCounts: [{ deviceSource, count }] }
-export function getChannelStats(query) {
+export function getChannelStats(query, config = {}) {
return request({
url: "/agg/statistic/channel",
method: "get",
params: query,
- noAlert: true
+ noAlert: true,
+ cancelToken: config.cancelToken
});
}
// 通道树
// 返回 data: 区域、巡检设备、通道三级树
-export function getChannelTree(query) {
+export function getChannelTree(query, config = {}) {
return request({
url: "/agg/tree/channel",
method: "get",
params: query,
- noAlert: true
+ noAlert: true,
+ skipWorkspaceStation: !!config.skipWorkspaceStation,
+ cancelToken: config.cancelToken
});
}
// 汇聚侧实时视频启动
// 返回 data: { stationCode, cameraCode, streamId, hls, flv, status }
+// 拉流会占住浏览器到同一地址的连接,必须可取消,否则切页后连接一直 pending。
export function startRealtimeVideo(query) {
const { stationCode, ...data } = query || {};
- return request({
- url: "/agg/video/realtime/start",
- method: "post",
- params: { stationCode },
- data,
- noAlert: true
+ const source = axios.CancelToken.source();
+ const promise = limitVideoRequest(registerAbort => {
+ registerAbort(() => source.cancel("video request cancelled"));
+ return request({
+ url: "/agg/video/realtime/start",
+ method: "post",
+ params: { stationCode },
+ data,
+ timeout: 8000,
+ noAlert: true,
+ cancelToken: source.token
+ });
});
+ return promise;
}
// 汇聚侧实时视频停止
-// 入参按后端要求传 stationCode + cameraCode
+// 入参按后端要求传 stationCode + cameraCode。
+// 停流不进拉流队列:离开首页时拉流可能还占着名额,停流再排队就会和业务请求一起卡住。
export function stopRealtimeVideo(query) {
const { stationCode, ...data } = query || {};
return request({
@@ -81,6 +98,7 @@ export function stopRealtimeVideo(query) {
method: "post",
params: { stationCode },
data,
+ timeout: 8000,
noAlert: true
});
}
diff --git a/src/api/videosMonitor/index.js b/src/api/videosMonitor/index.js
index e0b1cd0..37caf67 100644
--- a/src/api/videosMonitor/index.js
+++ b/src/api/videosMonitor/index.js
@@ -28,11 +28,12 @@ export function getAreaEqubookTreeSelect(params) {
* @param {*} data
* @returns
*/
-export function list(params) {
+export function list(params, config = {}) {
return request({
url: "/patrol/taskstatus/list",
method: "get",
params,
+ cancelToken: config.cancelToken,
});
}
/**
diff --git a/src/layout/index.vue b/src/layout/index.vue
index 50cbb7e..d2758ae 100644
--- a/src/layout/index.vue
+++ b/src/layout/index.vue
@@ -57,6 +57,7 @@ import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components'
import ResizeMixin from './mixin/ResizeHandler'
import { mapState, mapGetters } from 'vuex'
import variables from '@/assets/styles/variables.scss'
+import axios from 'axios'
import { getStationList } from '@/api/portal'
export default {
@@ -74,7 +75,8 @@ export default {
// 侧边栏宽度档位:0=160px,1=200px,2=240px,3=280px,随子菜单展开层级加深
sidebarWidthLevel: 0,
// 汇聚态站名筛选选项
- stationOptions: []
+ stationOptions: [],
+ stationRequestSource: null
}
},
mixins: [ResizeMixin],
@@ -137,12 +139,25 @@ export default {
mounted() {
if (this.fromPortal) this.loadStationOptions()
},
+ beforeDestroy() {
+ this.cancelStationOptions()
+ },
methods: {
+ cancelStationOptions() {
+ if (!this.stationRequestSource) return
+ this.stationRequestSource.cancel('layout station list cancelled')
+ this.stationRequestSource = null
+ },
// 站名下拉选项:与汇聚首页同款接口
loadStationOptions() {
- getStationList({}).then(response => {
+ this.cancelStationOptions()
+ this.stationRequestSource = axios.CancelToken.source()
+ getStationList({}, { cancelToken: this.stationRequestSource.token }).then(response => {
+ this.stationRequestSource = null
this.stationOptions = Array.isArray(response && response.data) ? response.data : []
- }).catch(() => {
+ }).catch((error) => {
+ if (axios.isCancel(error)) return
+ this.stationRequestSource = null
this.stationOptions = []
})
},
diff --git a/src/permission.js b/src/permission.js
index 7a30355..058d770 100644
--- a/src/permission.js
+++ b/src/permission.js
@@ -21,7 +21,9 @@ router.beforeEach(async (to, from, next) => {
/* has token*/
if (to.path === "/login") {
console.info("[GAB] to next");
- next({ path: homePath, replace: true });
+ // 已经登录时回到进来前的地址;没有记录才去打包配置的首页
+ const redirect = to.query && to.query.redirect
+ next({ path: redirect || homePath, replace: true });
// NProgress.done();
} else if (to.path === "/video-preview-embed") {
next();
@@ -70,7 +72,7 @@ router.beforeEach(async (to, from, next) => {
// 在免登录白名单,直接进入
next();
} else {
- next(`/login`); // 否则全部重定向到登录页
+ next({ path: "/login", query: { redirect: to.fullPath } }); // 记住进来的地址,登录后回到这一页
// NProgress.done();
}
}
diff --git a/src/store/getters.js b/src/store/getters.js
index 1e045ea..33d246d 100644
--- a/src/store/getters.js
+++ b/src/store/getters.js
@@ -20,6 +20,8 @@ const getters = {
siteId: state => state.siteContext.siteId,
siteName: state => state.siteContext.siteName,
inSite: state => state.siteContext.siteId !== undefined && state.siteContext.siteId !== null,
+ // 从汇聚门户点进动环巡检后保留的会话标记:横向导航、聚合接口都靠它。
+ // 直接打开 /index 不会带这个标记,所以仍是站端。
fromPortal: state => state.siteContext.fromPortal,
workspaceStationCode: state => state.siteContext.workspaceStationCode,
}
diff --git a/src/utils/image-url.js b/src/utils/image-url.js
index ab50a0b..6e319e4 100644
--- a/src/utils/image-url.js
+++ b/src/utils/image-url.js
@@ -1,3 +1,5 @@
+import store from "@/store";
+
const imagePathPrefix = process.env.VUE_APP_IMAGE_PATH_PREFIX || "";
function joinPath(left, right) {
@@ -33,6 +35,12 @@ export function buildImageUrl(ftpViewAddress, imagePath) {
return path;
}
+ // 汇聚接口直接返回各站完整图片地址,由 nginx 代理,前端不再拼前缀。
+ // 站端仍走下面的前缀拼接。
+ if (store.getters.fromPortal && !store.getters.inSite) {
+ return path;
+ }
+
if (/^https?:\/\//i.test(path)) {
try {
const absoluteUrl = new URL(path);
diff --git a/src/utils/request.js b/src/utils/request.js
index 12366f9..3fb0dab 100644
--- a/src/utils/request.js
+++ b/src/utils/request.js
@@ -68,7 +68,8 @@ service.interceptors.request.use(config => {
if (mapped) config.url = "/" + mapped
// 工作台顶部站名下拉:选中站后给列表/树查询带上 stationCode("全部场站"不传)
const wsCode = store.getters.workspaceStationCode
- if (wsCode && config.method === "get" && AGG_STATION_CODE_PATHS.some(p => config.url.indexOf(p) === 0)) {
+ // 首页设备树自己带场站,不能被动环巡检右上角的选择覆盖
+ if (wsCode && !config.skipWorkspaceStation && config.method === "get" && AGG_STATION_CODE_PATHS.some(p => config.url.indexOf(p) === 0)) {
config.params = Object.assign({}, config.params, { stationCode: wsCode })
}
}
@@ -179,7 +180,8 @@ service.interceptors.response.use((res) => {
// 本系统登录的用户:会话过期后回到登录页,不能悄悄换成别的账号
store.dispatch("FedLogOut").then(() => {
Message.error("登录状态已过期,请重新登录");
- router.push({ path: "/login" }).catch(() => { });
+ const returnPath = router.currentRoute && router.currentRoute.fullPath
+ router.push({ path: "/login", query: returnPath && returnPath !== "/login" ? { redirect: returnPath } : undefined }).catch(() => { });
});
}
return Promise.reject(new Error('无效的会话,或者会话已过期,请重新登录。'))
@@ -208,8 +210,15 @@ service.interceptors.response.use((res) => {
}
},
error => {
+ // 页面切走时主动取消的请求不是失败,不能弹错误,也不能当成异常去重试。
+ if (axios.isCancel(error)) {
+ return Promise.reject(error)
+ }
console.log('[GAB] err: ' + error)
let { message } = error;
+ if (!message) {
+ return Promise.reject(error)
+ }
if (message == "Network Error") {
message = "后端接口连接异常";
}
diff --git a/src/utils/videoRequestLimit.js b/src/utils/videoRequestLimit.js
new file mode 100644
index 0000000..0a2c6ae
--- /dev/null
+++ b/src/utils/videoRequestLimit.js
@@ -0,0 +1,60 @@
+// 浏览器对同一地址的并发连接有上限(HTTP/1.1 通常是 6)。
+// 首页四路拉流一旦卡住就会占满连接,巡检列表等业务请求只能一直 pending,页面跟着卡死。
+// 拉流单独排队,同时最多两路;离开页面时把还没发出的请求丢掉,并取消已经发出的请求。
+const MAX_CONCURRENT = 2
+let running = 0
+const queue = []
+
+function pump() {
+ while (running < MAX_CONCURRENT && queue.length) {
+ const job = queue.shift()
+ if (job.cancelled) continue
+ running += 1
+ let settled = false
+ const finish = () => {
+ if (settled) return
+ settled = true
+ running -= 1
+ pump()
+ }
+ Promise.resolve()
+ .then(() => job.task(signal => {
+ job.abort = signal
+ }))
+ .then(
+ value => {
+ finish()
+ if (!job.cancelled) job.resolve(value)
+ },
+ error => {
+ finish()
+ if (!job.cancelled) job.reject(error)
+ }
+ )
+ }
+}
+
+function cancelledError() {
+ const error = new Error("video request cancelled")
+ error.code = "ERR_CANCELED"
+ return error
+}
+
+export function limitVideoRequest(task) {
+ let job
+ const promise = new Promise((resolve, reject) => {
+ job = { task, resolve, reject, cancelled: false, abort: null }
+ queue.push(job)
+ pump()
+ })
+ // 还在排队就直接丢掉;已经发出去的交给调用方注册的 abort 取消。
+ promise.cancel = () => {
+ if (!job || job.cancelled) return
+ job.cancelled = true
+ const index = queue.indexOf(job)
+ if (index !== -1) queue.splice(index, 1)
+ if (typeof job.abort === "function") job.abort()
+ job.reject(cancelledError())
+ }
+ return promise
+}
diff --git a/src/views/cards/components/ImagePreviewZoomTemperature/index.vue b/src/views/cards/components/ImagePreviewZoomTemperature/index.vue
index ca59b50..d40c552 100644
--- a/src/views/cards/components/ImagePreviewZoomTemperature/index.vue
+++ b/src/views/cards/components/ImagePreviewZoomTemperature/index.vue
@@ -31,7 +31,7 @@
-->