From 2f1db1ec94b91132f4071be360da2366cfd257c5 Mon Sep 17 00:00:00 2001 From: douyage Date: Fri, 9 Oct 2026 14:21:02 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=B1=87=E8=81=9A=E7=9A=84?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=EF=BC=8C=E6=B1=87=E8=81=9A=E4=B8=8Bcsv?= =?UTF-8?q?=E7=9A=84=E6=96=87=E4=BB=B6=EF=BC=8C=E4=BF=AE=E6=94=B9=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E4=BC=9A=E7=BB=99=E6=B5=8F=E8=A7=88=E5=99=A8=E5=8D=A1?= =?UTF-8?q?=E4=BD=8F=E9=97=AE=E9=A2=98=EF=BC=8C=E4=BF=AE=E6=94=B9=E6=B1=87?= =?UTF-8?q?=E8=81=9A=E4=B8=8B=E7=82=B9=E4=BD=8D=E7=AE=A1=E7=90=86=E9=87=8C?= =?UTF-8?q?=E9=9D=A2=E6=8E=A5=E5=8F=A3=E6=8A=A5=E9=94=99=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/nvrVideo.js | 16 ++- src/api/portal/index.js | 52 +++++--- src/api/videosMonitor/index.js | 3 +- src/layout/index.vue | 21 +++- src/permission.js | 6 +- src/store/getters.js | 2 + src/utils/image-url.js | 8 ++ src/utils/request.js | 13 +- src/utils/videoRequestLimit.js | 60 ++++++++++ .../ImagePreviewZoomTemperature/index.vue | 11 +- .../cards/site_management/site_management.vue | 23 ++-- .../cards/taskSummary/inspectionTask.vue | 23 +++- .../portal/components/PortalDeviceTree.vue | 43 ++++++- src/views/portal/index.vue | 113 ++++++++++++++---- src/views/video/LivePreview.vue | 17 ++- 15 files changed, 333 insertions(+), 78 deletions(-) create mode 100644 src/utils/videoRequestLimit.js 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 @@ -->