diff --git a/public/index.html b/public/index.html index df422da..e24be9a 100644 --- a/public/index.html +++ b/public/index.html @@ -6,6 +6,9 @@ if (location.pathname.indexOf('/video-preview-embed') !== -1) { document.documentElement.className += ' embed-preview'; } + if (location.search.indexOf('joinbright-token=') !== -1 || location.hash.indexOf('joinbright-token=') !== -1) { + document.documentElement.className += ' joinbright-entry'; + } @@ -40,6 +43,15 @@ display: none; } + html.joinbright-entry, + html.joinbright-entry body { + background: #061433; + } + + html.joinbright-entry .login { + visibility: hidden; + } + #loader-wrapper { position: fixed; top: 0; diff --git a/scripts/gen-menu-doc.js b/scripts/gen-menu-doc.js new file mode 100644 index 0000000..73d6377 --- /dev/null +++ b/scripts/gen-menu-doc.js @@ -0,0 +1,307 @@ +const { Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell, + Header, Footer, AlignmentType, HeadingLevel, BorderStyle, WidthType, + ShadingType, VerticalAlign, PageNumber } = require("C:/Users/ADMINI~1/AppData/Local/Temp/docx-gen/node_modules/docx"); +const fs = require("fs"); + +const NAVY = "0A2A70"; +const HEADER_BG = "123A7A"; +const ALT = "F4F7FB"; +const LINE = "C5D0E0"; +const MUTED = "5B6775"; + +const thin = { style: BorderStyle.SINGLE, size: 4, color: LINE }; +const borders = { top: thin, bottom: thin, left: thin, right: thin, insideHorizontal: thin, insideVertical: thin }; +const noBorder = { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }; +const noBorders = { top: noBorder, bottom: noBorder, left: noBorder, right: noBorder }; + +const font = { ascii: "Times New Roman", eastAsia: "SimSun", hAnsi: "Times New Roman" }; +const fontHei = { ascii: "Times New Roman", eastAsia: "SimHei", hAnsi: "Times New Roman" }; + +function run(text, opts = {}) { + return new TextRun({ + text, + font: opts.hei ? fontHei : font, + size: opts.size || 21, + bold: !!opts.bold, + color: opts.color || "000000", + italics: !!opts.italics, + }); +} + +function p(text, opts = {}) { + return new Paragraph({ + spacing: { before: opts.before ?? 60, after: opts.after ?? 60, line: 312 }, + alignment: opts.align || AlignmentType.JUSTIFIED, + indent: opts.indent ? { firstLine: 480 } : undefined, + keepNext: !!opts.keepNext, + children: [run(text, opts)], + }); +} + +function h1(text) { + return new Paragraph({ + heading: HeadingLevel.HEADING_1, + spacing: { before: 280, after: 140, line: 312 }, + border: { bottom: { style: BorderStyle.SINGLE, size: 12, color: NAVY, space: 4 } }, + children: [run(text, { hei: true, size: 32, bold: true, color: NAVY })], + }); +} + +function h2(text) { + return new Paragraph({ + heading: HeadingLevel.HEADING_2, + spacing: { before: 240, after: 100, line: 312 }, + children: [run(text, { hei: true, size: 28, bold: true, color: NAVY })], + }); +} + +function cell(text, opts = {}) { + const fill = opts.fill; + return new TableCell({ + borders, + width: { size: opts.w, type: WidthType.DXA }, + shading: fill ? { type: ShadingType.CLEAR, fill } : undefined, + margins: { top: 50, bottom: 50, left: 70, right: 70 }, + verticalAlign: VerticalAlign.CENTER, + children: [ + new Paragraph({ + spacing: { before: 20, after: 20, line: 276 }, + alignment: opts.center ? AlignmentType.CENTER : AlignmentType.LEFT, + children: [run(String(text), { size: opts.size || 18, bold: !!opts.bold, color: opts.color || "000000" })], + }), + ], + }); +} + +function table(headers, rows, widths) { + const total = widths.reduce((a, b) => a + b, 0); + const headerRow = new TableRow({ + tableHeader: true, + cantSplit: true, + children: headers.map((h, i) => cell(h, { w: widths[i], fill: HEADER_BG, bold: true, color: "FFFFFF", size: 18, center: true })), + }); + const body = rows.map((row, ri) => new TableRow({ + cantSplit: true, + children: row.map((val, i) => cell(val, { w: widths[i], fill: ri % 2 ? ALT : "FFFFFF", size: 18, center: i === 0 })), + })); + return new Table({ + width: { size: total, type: WidthType.DXA }, + columnWidths: widths, + borders, + rows: [headerRow, ...body], + }); +} + +const W = [700, 1400, 1900, 3400, 1800, 900]; // 10100 usable-ish; page content width ~ 11906-1701-1417 = 8788 +// Recalc content width: 11906 - 1418 - 1134 = 9354. Use slightly narrower margins. +const content = 9354; +const patrolW = [620, 1200, 1700, 2800, 1934, 1100]; +const sysW = [620, 1400, 1400, 2600, 2234, 1100]; +const stopW = [700, 2800, 5854]; + +const patrol = [ + ["1", "巡检任务", "task-summary", "cards/taskSummary/inspectionTask", "patrol:task:list", "dashboard"], + ["2", "实时监控", "video-preview", "video/LivePreview", "video:preview:list", "monitor"], + ["3", "巡检方案", "patrol-plan", "cards/patrolPlan/notstart", "patrol:plan:list", "task"], + ["4", "点位管理", "site-management", "cards/site_management/site_management", "patrol:point:list", "tree"], + ["5", "巡检报告", "patrol-report", "cards/patrolReport/InspectionReporter", "patrol:report:list", "documentation"], + ["6", "巡检计划", "patrol-schedule", "cards/tasksShow/index", "patrol:schedule:list", "date"], + ["7", "缺陷记录", "defect-record", "cards/defectRecord/index", "patrol:defect:list", "bug"], + ["8", "历史回放", "video-playback", "video/HistoryPlayback", "video:playback:list", "time"], +]; + +const system = [ + ["1", "用户管理", "user", "system/user/index", "system:user:list", "user"], + ["2", "角色管理", "role", "system/role/index", "system:role:list", "peoples"], + ["3", "菜单管理", "menu", "system/menu/index", "system:menu:list", "tree-table"], + ["4", "部门管理", "dept", "system/dept/index", "system:dept:list", "tree"], + ["5", "岗位管理", "post", "system/post/index", "system:post:list", "post"], + ["6", "字典管理", "dict", "system/dict/index", "system:dict:list", "dict"], + ["7", "参数设置", "config", "system/config/index", "system:config:list", "edit"], + ["8", "通知公告", "notice", "system/notice/index", "system:notice:list", "message"], +]; + +const stop = [ + ["1", "问题清单", "不是当前左侧菜单,停用"], + ["2", "巡视任务管理 / 巡检任务管理", "旧名称,与「巡检任务」重复,停用"], + ["3", "联动动作配置", "当前系统未使用,停用"], + ["4", "设备管理", "当前左侧不展示,停用"], + ["5", "点位配置", "另一套点位页面,与「点位管理」重复,停用"], + ["6", "区域管理", "当前左侧不展示,停用"], + ["7", "动环巡检(现有停用的旧目录)", "不要改旧记录,按本文新建一条目录"], + ["8", "系统监控", "若依自带,当前系统不用,停用"], + ["9", "系统工具", "若依自带,当前系统不用,停用"], + ["10", "测试官网", "若依自带,停用"], + ["11", "基础数据", "当前左侧不展示,停用"], + ["12", "巡视管理", "旧目录,停用"], +]; + +const hiddenPages = [ + ["1", "巡检报告详情", "从巡检报告点进去,不要单独建菜单"], + ["2", "分配角色", "从用户管理点进去,不要单独建菜单"], + ["3", "分配用户", "从角色管理点进去,不要单独建菜单"], + ["4", "字典数据", "从字典管理点进去,不要单独建菜单"], +]; + +const children = []; +children.push(new Paragraph({ + spacing: { before: 0, after: 40, line: 312 }, + children: [run("动环智能巡检系统", { hei: true, size: 22, color: MUTED })], +})); +children.push(new Paragraph({ + spacing: { before: 40, after: 80, line: 360 }, + border: { bottom: { style: BorderStyle.SINGLE, size: 16, color: NAVY, space: 6 } }, + children: [run("菜单整理说明(供测试录入)", { hei: true, size: 36, bold: true, color: NAVY })], +})); +children.push(p("录入位置:系统管理 → 菜单管理。按本文新建两组菜单,并把旧菜单停用。普通账号登录后,左侧只应看到「动环巡检」下的 8 项,顺序与下表一致。", { indent: true, size: 24 })); +children.push(p("管理员从顶部进入「系统管理」后,左侧应直接展开用户管理、角色管理等 8 项,不再套一层「系统管理」。", { indent: true, size: 24, before: 40 })); + +children.push(h1("一、填写规则")); +children.push(p("菜单类型按若依约定:目录填 M,菜单填 C,按钮填 F。本文只建目录和菜单,不建按钮。", { indent: true, size: 24 })); +children.push(p("路由地址不要带前导斜杠。组件路径相对 src/views,不要写 .vue,也不要写开头的斜杠。显示状态一律选「显示」,菜单状态一律选「正常」。是否外链选「否」,是否缓存可按页面默认。", { indent: true, size: 24 })); +children.push(p("父级只建目录,组件路径留空。子菜单的上级菜单选对应目录。排序按表中数字填写,数字越小越靠前。", { indent: true, size: 24 })); +children.push(p("权限标识先按表填写。若后端角色里还没有这些标识,给管理员角色勾上对应菜单即可;普通角色只勾「动环巡检」及其 8 个子菜单。", { indent: true, size: 24 })); + +children.push(h1("二、动环巡检")); +children.push(p("先新增一条目录,再把下面 8 条菜单挂到它下面。", { indent: true, size: 24, keepNext: true })); + +children.push(new Paragraph({ + keepNext: true, + spacing: { before: 80, after: 60, line: 312 }, + children: [run("表 1 父级目录:动环巡检", { bold: true, size: 21 })], +})); +children.push(table( + ["字段", "填写内容"], + [ + ["菜单名称", "动环巡检"], + ["上级菜单", "主类目"], + ["菜单类型", "目录(M)"], + ["显示排序", "1"], + ["路由地址", "inspection"], + ["组件路径", "留空"], + ["菜单图标", "dashboard"], + ["显示状态", "显示"], + ["菜单状态", "正常"], + ], + [2200, 7154] +)); + +children.push(new Paragraph({ + keepNext: true, + spacing: { before: 200, after: 60, line: 312 }, + children: [run("表 2 动环巡检下的菜单(上级菜单均选「动环巡检」,类型均为菜单 C)", { bold: true, size: 21 })], +})); +children.push(table( + ["排序", "菜单名称", "路由地址", "组件路径", "权限标识", "图标"], + patrol, + patrolW +)); + +children.push(h1("三、系统管理")); +children.push(p("只分配给管理员角色。若库里已有名为「系统管理」、路由为 system 的目录,直接在它下面按本表核对或补齐,不要再新建第二条同名目录。子菜单已存在且路由、组件一致的,只改名称、排序和图标,不要重复新增。", { indent: true, size: 24, keepNext: true })); + +children.push(new Paragraph({ + keepNext: true, + spacing: { before: 80, after: 60, line: 312 }, + children: [run("表 3 父级目录:系统管理", { bold: true, size: 21 })], +})); +children.push(table( + ["字段", "填写内容"], + [ + ["菜单名称", "系统管理"], + ["上级菜单", "主类目"], + ["菜单类型", "目录(M)"], + ["显示排序", "2"], + ["路由地址", "system"], + ["组件路径", "留空"], + ["菜单图标", "system"], + ["显示状态", "显示"], + ["菜单状态", "正常"], + ], + [2200, 7154] +)); + +children.push(new Paragraph({ + keepNext: true, + spacing: { before: 200, after: 60, line: 312 }, + children: [run("表 4 系统管理下的菜单(上级菜单均选「系统管理」,类型均为菜单 C)", { bold: true, size: 21 })], +})); +children.push(table( + ["排序", "菜单名称", "路由地址", "组件路径", "权限标识", "图标"], + system, + sysW +)); + +children.push(h1("四、需要停用的旧菜单")); +children.push(p("这些是若依或旧巡视系统残留,当前页面用不到。处理方式是把菜单状态改为「停用」,不要物理删除,避免角色、按钮权限丢关联。停用后重新登录,左侧不应再出现这些名称。", { indent: true, size: 24, keepNext: true })); +children.push(new Paragraph({ + keepNext: true, + spacing: { before: 80, after: 60, line: 312 }, + children: [run("表 5 停用清单", { bold: true, size: 21 })], +})); +children.push(table(["序号", "菜单名称", "处理说明"], stop, stopW)); + +children.push(h2("不要单独建菜单的页面")); +children.push(p("下面这些页面从其他菜单点进去,前端已有隐藏路由。菜单管理里不要再加同名菜单。", { indent: true, size: 24, keepNext: true })); +children.push(new Paragraph({ + keepNext: true, + spacing: { before: 80, after: 60, line: 312 }, + children: [run("表 6 隐藏页面", { bold: true, size: 21 })], +})); +children.push(table(["序号", "页面", "说明"], hiddenPages, stopW)); + +children.push(h1("五、录入后核对")); +children.push(p("用管理员账号退出后重新登录。左侧第一组应为:巡检任务、实时监控、巡检方案、点位管理、巡检报告、巡检计划、缺陷记录、历史回放。点每一项都能打开对应页面,地址分别是 /task-summary、/video-preview、/patrol-plan、/site-management、/patrol-report、/patrol-schedule、/defect-record、/video-playback。", { indent: true, size: 24 })); +children.push(p("从顶部进入系统管理后,左侧应依次为:用户管理、角色管理、菜单管理、部门管理、岗位管理、字典管理、参数设置、通知公告。", { indent: true, size: 24 })); +children.push(p("用普通账号登录,左侧只有动环巡检的 8 项,看不到系统管理,也看不到第四节里已停用的旧菜单。", { indent: true, size: 24 })); +children.push(p("若某一项能看到但点开是 404,先核对组件路径是否与表中完全一致,包括大小写和下划线。路由地址多写了前导斜杠时,也会和现有页面地址对不上。", { indent: true, size: 24 })); + +const doc = new Document({ + styles: { + default: { + document: { styles: [{ id: "Normal", run: { font: "SimSun", size: 24 } }] }, + }, + }, + sections: [{ + properties: { + page: { + size: { width: 11906, height: 16838 }, + margin: { top: 1134, bottom: 1134, left: 1134, right: 1134 }, + }, + }, + headers: { + default: new Header({ + children: [new Paragraph({ + border: { bottom: { style: BorderStyle.SINGLE, size: 8, color: NAVY, space: 6 } }, + spacing: { after: 80, line: 276 }, + children: [ + run("动环智能巡检系统", { size: 18, color: NAVY, bold: true }), + run(" 菜单整理说明", { size: 18, color: MUTED }), + ], + })], + }), + }, + footers: { + default: new Footer({ + children: [new Paragraph({ + alignment: AlignmentType.CENTER, + border: { top: { style: BorderStyle.SINGLE, size: 6, color: LINE, space: 6 } }, + spacing: { before: 60, line: 276 }, + children: [ + run("第 ", { size: 18, color: MUTED }), + new TextRun({ children: [PageNumber.CURRENT], font, size: 18, color: MUTED }), + run(" 页", { size: 18, color: MUTED }), + ], + })], + }), + }, + children, + }], +}); + +Packer.toBuffer(doc).then((buf) => { + const out = "E:/work/yanyuan/webs-admin/docs/菜单整理说明-测试录入.docx"; + fs.mkdirSync("E:/work/yanyuan/webs-admin/docs", { recursive: true }); + fs.writeFileSync(out, buf); + console.log("wrote", out, buf.length); +}); diff --git a/src/api/login.js b/src/api/login.js index 881bc93..1b74e01 100644 --- a/src/api/login.js +++ b/src/api/login.js @@ -12,6 +12,19 @@ export function getAuthToken(username, password) { }) } +// 中恒 iframe 跳转登录。只有业务码 200 才算成功,其余由调用方回到登录页。 +export function joinbrightLogin(token) { + return request({ + url: '/auth/joinbrightLogin', + headers: { + isToken: false + }, + method: 'get', + joinbrightAuth: true, + params: { 'joinbright-token': token } + }) +} + // 登录方法 export function login(username, password, code, uuid) { console.log('login', username, password, code, uuid) diff --git a/src/api/nvrVideo.js b/src/api/nvrVideo.js index 64b9bce..93cdf8c 100644 --- a/src/api/nvrVideo.js +++ b/src/api/nvrVideo.js @@ -1,12 +1,5 @@ import request from '@/utils/request' import { limitVideoRequest } from '@/utils/videoRequestLimit' -import axios from 'axios' - -const ivsRequest = axios.create({ - baseURL: process.env.VUE_APP_NVR_API || '/nvr-api', - timeout: 15000, - headers: { 'Content-Type': 'application/json;charset=utf-8' } -}) const unwrap = promise => promise.then(result => { if (result && Object.prototype.hasOwnProperty.call(result, 'data')) return result.data @@ -34,11 +27,12 @@ export function startLive(channel, streamType = 1) { url: '/patrol/video/realtime/start', method: 'post', timeout: 8000, + noAlert: true, data: { cameraCode: channel.cameraCode || channel.channelCode, streamType } - }))) + }), 'embed')) } export function getLiveStatus(cameraCode) { @@ -46,6 +40,7 @@ export function getLiveStatus(cameraCode) { url: '/patrol/video/realtime/status', method: 'get', timeout: 8000, + noAlert: true, params: { cameraCode } }))) } @@ -67,10 +62,6 @@ export function controlPtz(payload) { })) } -export function controlWiper(payload) { - return ivsRequest.post('/device/ptzcontrol', payload).then(response => response.data) -} - export function getPresetList(cameraCode) { const [device, domain] = encodeCameraCodeSegments(cameraCode) return unwrap(request({ diff --git a/src/api/portal/index.js b/src/api/portal/index.js index ee5dd42..eb4be94 100644 --- a/src/api/portal/index.js +++ b/src/api/portal/index.js @@ -70,7 +70,7 @@ export function getChannelTree(query, config = {}) { // 汇聚侧实时视频启动 // 返回 data: { stationCode, cameraCode, streamId, hls, flv, status } // 拉流会占住浏览器到同一地址的连接,必须可取消,否则切页后连接一直 pending。 -export function startRealtimeVideo(query) { +export function startRealtimeVideo(query, options = {}) { const { stationCode, ...data } = query || {}; const source = axios.CancelToken.source(); const promise = limitVideoRequest(registerAbort => { @@ -78,13 +78,13 @@ export function startRealtimeVideo(query) { return request({ url: "/agg/video/realtime/start", method: "post", + timeout: 8000, params: { stationCode }, data, - timeout: 8000, noAlert: true, cancelToken: source.token }); - }); + }, options.lane || "default"); return promise; } @@ -98,13 +98,13 @@ export function stopRealtimeVideo(query) { method: "post", params: { stationCode }, data, - timeout: 8000, noAlert: true }); } // 汇聚侧云台控制:按摄像头所属站点代理转发 // stationCode 取当前控制摄像头所在站(而非顶部站名筛选),以查询串方式传递 +// 雨刷也走这条接口:开 27、关 28,与方向/变倍/聚焦/光圈同一套控制码 export function aggPtzControl(payload) { const { stationCode, ...data } = payload || {}; return request({ diff --git a/src/assets/images/login-background.jpg b/src/assets/images/login-background.jpg index 8a89eb8..484df6b 100644 Binary files a/src/assets/images/login-background.jpg and b/src/assets/images/login-background.jpg differ diff --git a/src/assets/styles/ruoyi.scss b/src/assets/styles/ruoyi.scss index 7999230..c788b2c 100644 --- a/src/assets/styles/ruoyi.scss +++ b/src/assets/styles/ruoyi.scss @@ -171,6 +171,11 @@ } .el-switch__core { + border-color: #9aa3b5; + background-color: #9aa3b5; + } + + .el-switch.is-checked .el-switch__core { border-color: #2f7cff; background-color: #2f7cff; } @@ -967,6 +972,14 @@ table tbody tr > td { font-weight: 600; } +.el-select-dropdown .el-select-dropdown__item.is-disabled, +.el-select-dropdown .el-select-dropdown__item.is-disabled.hover, +.el-select-dropdown .el-select-dropdown__item.is-disabled:hover { + color: #7d8aa3 !important; + background-color: transparent !important; + cursor: not-allowed; +} + .el-select-dropdown.is-multiple .el-select-dropdown__item.selected::after { color: #00ffff !important; } @@ -1058,6 +1071,14 @@ table tbody tr > td { color: #dcecff !important; } +.vue-treeselect__option--disabled, +.vue-treeselect__option--disabled .vue-treeselect__label, +.vue-treeselect__option--disabled .vue-treeselect__label-container, +.vue-treeselect__option--disabled .vue-treeselect__count { + color: #7d8aa3 !important; + cursor: not-allowed; +} + .vue-treeselect__option--highlight, .vue-treeselect--single .vue-treeselect__option--selected, .vue-treeselect__option:has(.vue-treeselect__checkbox--checked) { @@ -1091,6 +1112,10 @@ table tbody tr > td { color: rgba(220, 236, 255, 0.5) !important; } +.vue-treeselect__single-value { + color: #dcecff !important; +} + .vue-treeselect__input { color: #dcecff !important; } diff --git a/src/components/AsyncImageFrame/index.vue b/src/components/AsyncImageFrame/index.vue index 78c4832..df32969 100644 --- a/src/components/AsyncImageFrame/index.vue +++ b/src/components/AsyncImageFrame/index.vue @@ -159,6 +159,7 @@ export default { showFrame: false, //返回不同坐标计算 maxTempPoints: [], // 存储每个区域的最高温点坐标 + csvLoadGeneration: 0, }; }, computed: { @@ -200,6 +201,7 @@ export default { this.showUploadDialog(); }, beforeDestroy() { + this.csvLoadGeneration += 1; window.removeEventListener("resize", this.updateImageSize); }, methods: { @@ -243,6 +245,7 @@ export default { stopDrawingIfNeeded() { this.showTooltip = false; + this.hoverData = null; }, // ==================== 图片尺寸更新 ==================== @@ -283,16 +286,22 @@ export default { const data = { filePath: this.imgName, }; + const generation = ++this.csvLoadGeneration; + this.temperatureData = []; + this.hoverTemperatureData = []; + this.hoverData = null; downloadCsv(data, this.stationCode) .then((response) => { + if (generation !== this.csvLoadGeneration) return; const blob = response instanceof Blob ? response : new Blob([response]); const reader = new FileReader(); reader.onload = (e) => { + if (generation !== this.csvLoadGeneration) return; try { const csvText = e.target.result; - this.parseCSVInChunks(csvText); + this.parseCSVInChunks(csvText, generation); } catch (error) { // this.$message.error('读取CSV出错: ' + error.message); } @@ -310,7 +319,7 @@ export default { }, // 分块解析CSV,避免栈溢出 - parseCSVInChunks(csvText) { + parseCSVInChunks(csvText, generation = this.csvLoadGeneration) { try { const lines = csvText.split('\n'); const tempData = []; @@ -320,6 +329,7 @@ export default { let index = 0; const processBatch = () => { + if (generation !== this.csvLoadGeneration) return; const endIndex = Math.min(index + BATCH_SIZE, lines.length); for (let i = index; i < endIndex; i++) { @@ -345,7 +355,7 @@ export default { if (index < lines.length) { setTimeout(processBatch, 0); - } else { + } else if (generation === this.csvLoadGeneration) { this.finalizeCSVData(tempData, maxCols); } }; @@ -594,8 +604,14 @@ export default { }, src: { async handler(val) { + const generation = ++this.csvLoadGeneration; + this.temperatureData = []; + this.hoverTemperatureData = []; + this.hoverData = null; if (val && val.length > 0) { - this.imgUrl = await this.ftpImageViewUrl(val); + const imgUrl = await this.ftpImageViewUrl(val); + if (generation !== this.csvLoadGeneration) return; + this.imgUrl = imgUrl; // this.imgUrl = "http://192.168.1.116:18891/shaoxing/videoMonitor/app/htjc6/htjcImage//test/39357.jpg"; this.imgName = val; diff --git a/src/directive/permission/hasPermi.js b/src/directive/permission/hasPermi.js index 7101e3e..c4ef074 100644 --- a/src/directive/permission/hasPermi.js +++ b/src/directive/permission/hasPermi.js @@ -10,6 +10,12 @@ export default { const { value } = binding const all_permission = "*:*:*"; const permissions = store.getters && store.getters.permissions + // 汇聚门户进入系统管理时,按钮按页面展示,不再按角色权限字符删掉。 + const portalSystemPage = store.getters && store.getters.fromPortal && window.location.pathname.indexOf("/system") !== -1 + + if (portalSystemPage) { + return + } if (value && value instanceof Array && value.length > 0) { const permissionFlag = value diff --git a/src/layout/components/Navbar.vue b/src/layout/components/Navbar.vue index 6e7f27b..84ac796 100644 --- a/src/layout/components/Navbar.vue +++ b/src/layout/components/Navbar.vue @@ -57,7 +57,7 @@ import { mapGetters } from "vuex"; import TopNav from "@/components/TopNav"; import Screenfull from "@/components/Screenfull"; -import { getEmbeddedUser } from "@/utils/embeddedAuth"; +import { clearJoinbrightToken, getEmbeddedUser } from "@/utils/embeddedAuth"; import { applyStationTitle, getStationTitleImage } from "@/utils/stationTitle"; import headerLogo from "@/assets/images/header/logo.png"; import xfdhBrand from "@/assets/images/header/xfdh-brand.png"; @@ -140,9 +140,8 @@ export default { }) .then(() => { try { - sessionStorage.removeItem('joinbright-token'); + clearJoinbrightToken(); sessionStorage.removeItem('main_userInfo'); - sessionStorage.removeItem('main_joinbright_token'); sessionStorage.removeItem('main_islogin'); } catch (e) {} if (window.parent && window.parent !== window) { diff --git a/src/layout/components/Sidebar/SidebarItem.vue b/src/layout/components/Sidebar/SidebarItem.vue index f46bcdc..247e44a 100644 --- a/src/layout/components/Sidebar/SidebarItem.vue +++ b/src/layout/components/Sidebar/SidebarItem.vue @@ -162,13 +162,6 @@ export default { str = "首页"; } } - if ( - str === "点位管理" && - this.basePath !== "/site-management" && - this.basePath !== "/inspection-workspace/points" - ) { - str = "点位配置"; - } return str; } }, diff --git a/src/layout/components/Sidebar/index.vue b/src/layout/components/Sidebar/index.vue index d4b32da..af31a50 100644 --- a/src/layout/components/Sidebar/index.vue +++ b/src/layout/components/Sidebar/index.vue @@ -50,122 +50,25 @@ export default { }, computed: { ...mapState(["settings"]), - ...mapGetters(["sidebarRouters", "sidebar", "roles", "fromPortal"]), - // admin 账号显示全部导航,其余账号只显示默认菜单 - isAdminAccount() { - if ((this.roles || []).indexOf("admin") !== -1) return true; - return this.$store.state.user.username === "admin"; + ...mapGetters(["sidebarRouters", "sidebar"]), + // 汇聚态点顶部「系统管理」时,左侧换成用户/角色等系统菜单,不再显示巡检菜单。 + // 子项直接铺开,不再套一层「系统管理」。 + inPortalSystem() { + return this.$route.path.indexOf("/system") === 0; }, displayedRoutes() { - // 汇聚入口正在看系统管理时,非管理员也要放出「系统管理」,否则侧边栏是空的 - const keepSystemMenu = this.fromPortal && this.$route.path.indexOf("/system") === 0; - const temporarilyHiddenRootMenus = this.isAdminAccount - ? new Set() - : new Set([ - "设备管理", - ...(keepSystemMenu ? [] : ["系统管理"]), - "区域管理", - "系统监控", - "基础数据", - ]); - // “点位配置”即后端配置在 /site-management 之外的“点位管理”菜单: - // 仅管理员可见;常规点位管理(/site-management)所有角色都可见 - const isImplementationPointsMenu = (route) => { - const title = String(route.meta && route.meta.title || "").split("|")[0]; - return title === "点位管理" && - route.path !== "/site-management" && - route.path !== "/inspection-workspace/points"; - }; - const filterHiddenNavigation = (routes = [], depth = 0) => routes.reduce((result, route) => { - const title = String(route.meta && route.meta.title || "").split("|")[0]; - // 后端菜单可能仍返回旧名“巡视任务管理”,两种命名都要隐藏 - const isTaskManagement = ["巡检任务管理", "巡视任务管理"].includes(title); - const isImplementationPoints = !this.isAdminAccount && isImplementationPointsMenu(route); - const isTemporarilyHiddenRootMenu = depth === 0 && temporarilyHiddenRootMenus.has(title); - // 汇聚门户进入的会话:历史回放暂时隐藏 - const isHistoryPlaybackHidden = this.fromPortal && - ["/video-playback", "/inspection-workspace/video-playback"].includes(route.path); - if (isTaskManagement || isImplementationPoints || isTemporarilyHiddenRootMenu || isHistoryPlaybackHidden) return result; - - result.push({ - ...route, - children: route.children - ? filterHiddenNavigation(route.children, depth + 1) - : route.children, - }); - return result; - }, []); - const visibleRoutes = filterHiddenNavigation(this.sidebarRouters); - - // 汇聚横向导航切到系统管理时,侧边栏只留系统管理(菜单、角色等),不再混着动环巡检 - if (this.fromPortal && this.$route.path.indexOf("/system") === 0) { - return visibleRoutes.filter(route => route.path === "/system"); - } - - if (!this.$route.meta.patrolWorkspace) { - 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 patrolRoutes = inspectionRoutes; - if (patrolRoutes.length) groups.push({ - path: "/inspection-menu", - meta: { title: "动环巡检", icon: "dashboard" }, - alwaysShow: true, - children: patrolRoutes, - }); - const routePath = this.$route.path; - if ( - this.fromPortal || - (!this.isAdminAccount && - (routePath === "/index" || routePath === "/" || routePath === "/xfdh/index" || patrolRoutes.some(route => routePath.indexOf(route.path + "/") === 0 || routePath === route.path))) - ) { - return patrolRoutes; - } - return [...patrolRoutes, ...otherRoutes]; - } - const workspacePaths = { - "/task-summary": "/inspection-workspace/task", - "/patrol-plan": "/inspection-workspace/plan", - "/site-management": "/inspection-workspace/points", - "/patrol-report": "/inspection-workspace/report", - "/patrol-schedule": "/inspection-workspace/schedule", - "/defect-record": "/inspection-workspace/defects", - "/video-preview": "/inspection-workspace/video-preview", - "/video-playback": "/inspection-workspace/video-playback", - }; - const workspaceMenus = visibleRoutes - .filter((route) => workspacePaths[route.path]) - .map((route) => { - const workspacePath = workspacePaths[route.path]; - const child = (route.children || []).find(item => !item.hidden); - return { - ...route, - path: workspacePath, - meta: { ...(route.meta || {}), icon: undefined }, - children: child ? [{ - ...child, - path: "", - query: undefined, - meta: { - ...(child.meta || {}), - icon: undefined, - activeMenu: workspacePath, - }, - }] : [], - }; - }); - // 工作区模式下管理员同样能看到点位配置 - if (this.isAdminAccount) { - return [...workspaceMenus, ...visibleRoutes.filter(isImplementationPointsMenu)]; - } - return workspaceMenus; + if (!this.inPortalSystem) return this.sidebarRouters; + const systemRoute = (this.sidebarRouters || []).find(route => { + return route.path === "/system" || (route.meta && route.meta.portalSystem); + }); + const children = systemRoute && systemRoute.children; + if (!children || !children.length) return this.sidebarRouters; + return children + .filter(child => child && !child.hidden) + .map(child => ({ + ...child, + path: "/system/" + String(child.path || "").replace(/^\//, ""), + })); }, activeMenu() { const route = this.$route; diff --git a/src/layout/components/TagsView/index.vue b/src/layout/components/TagsView/index.vue index 4ee22f8..259393f 100644 --- a/src/layout/components/TagsView/index.vue +++ b/src/layout/components/TagsView/index.vue @@ -93,6 +93,7 @@ export default { }, watch: { $route() { + this.syncModuleTags() this.addTags() this.moveToCurrentTag() }, @@ -106,6 +107,7 @@ export default { }, mounted() { this.initTags() + this.syncModuleTags() this.addTags() this.$root.$on('video-layout-updated', this.updateVideoLayout) this.$root.$on('video-control-panel-updated', this.updateVideoControlPanel) @@ -163,7 +165,8 @@ export default { filterAffixTags(routes, basePath = '/') { let tags = [] routes.forEach(route => { - if (route.meta && route.meta.affix) { + // 用户管理只在系统管理里固定,不能和巡检任务一起出现在动环巡检页签上 + if (route.meta && route.meta.affix && route.path !== 'user') { const tagPath = path.resolve(basePath, route.path) // 本地首页和后端菜单可能各有一条同名固定页签,只保留先收集到的那条 if (!tags.some(tag => tag.path === tagPath || (tag.meta && tag.meta.title === route.meta.title))) { @@ -199,6 +202,28 @@ export default { this.$store.dispatch('tagsView/addView', this.$route) } }, + isSystemTag(view) { + return !!(view && view.path && view.path.indexOf('/system') === 0) + }, + // 系统管理和动环巡检的页签分开。切到哪一边,就只留那一边的标签。 + syncModuleTags() { + const inSystem = this.isSystemTag(this.$route) + this.visitedViews + .filter(view => this.isSystemTag(view) !== inSystem) + .forEach(view => this.$store.dispatch('tagsView/delView', view)) + if (inSystem) { + this.$store.dispatch('tagsView/addVisitedView', { + fullPath: '/system/user', + path: '/system/user', + name: 'PortalSystemUser', + meta: { title: '用户管理', icon: 'user', affix: true } + }) + return + } + this.affixTags.forEach(tag => { + if (tag.name) this.$store.dispatch('tagsView/addVisitedView', tag) + }) + }, moveToCurrentTag() { const tags = this.$refs.tag || [] this.$nextTick(() => { diff --git a/src/layout/index.vue b/src/layout/index.vue index 83eb76d..95c93f8 100644 --- a/src/layout/index.vue +++ b/src/layout/index.vue @@ -124,7 +124,7 @@ export default { return [ { key: 'home', label: '首页', path: '/portal' }, { key: 'patrol', label: '动环巡检', path: '/index' }, - // { key: 'system', label: '系统管理', path: '/system/menu' } + { key: 'system', label: '系统管理', path: '/system/menu' } ] }, activeModule() { @@ -168,15 +168,9 @@ export default { } if (this.$route.path !== path) this.$router.push({ path }).catch(() => undefined) }, - // 系统管理落到当前账号能看到的第一个页面,没有菜单权限时退回用户管理 + // 系统管理固定进入已注册的用户管理,避免后台菜单未下发时拼出 404 地址 systemEntryPath() { - const routes = this.$store.getters.sidebarRouters || [] - const system = routes.find(route => route && route.path === '/system' && !route.hidden) - const child = system && (system.children || []).find(item => item && !item.hidden) - if (!child) return '/system/user' - const childPath = String(child.path || '') - if (!childPath) return '/system/user' - return childPath.startsWith('/') ? childPath : `/system/${childPath}` + return '/system/user' }, setSidebarWide(level) { this.sidebarWidthLevel = Number(level) || 0 diff --git a/src/permission.js b/src/permission.js index 058d770..1b4327a 100644 --- a/src/permission.js +++ b/src/permission.js @@ -4,19 +4,110 @@ import { Message } from "element-ui"; import NProgress from "nprogress"; import "nprogress/nprogress.css"; import { getToken } from "@/utils/auth"; -import { isEmbeddedSession } from "@/utils/embeddedAuth"; +import { + getJoinbrightToken, + hasExchangedJoinbrightToken, + hasJoinbrightTokenInLocation, + isEmbeddedSession, + joinbrightAlreadyFailed, + markJoinbrightFailed, + rememberJoinbrightToken +} from "@/utils/embeddedAuth"; import { isRelogin } from "@/utils/request"; import { homePath } from "@/utils/homePath"; -import md5 from 'md5.js' NProgress.configure({ showSpinner: false }); const whiteList = ["/login", "/auth-redirect", "/bind", "/register", "/video-preview-embed"]; +const JOINBRIGHT_LOGIN_ERROR_KEY = "joinbright-login-error"; + +let joinbrightLoginTask = null; +let joinbrightLoginToken = ""; + +function ensureJoinbrightLogin(token) { + if (joinbrightLoginTask && joinbrightLoginToken === token) return joinbrightLoginTask; + joinbrightLoginToken = token; + rememberJoinbrightToken(token); + const task = store.dispatch("JoinbrightLogin", token); + joinbrightLoginTask = task; + task.finally(() => { + if (joinbrightLoginTask === task) { + joinbrightLoginTask = null; + joinbrightLoginToken = ""; + } + }); + return task; +} + +function fullPathWithoutJoinbright(to) { + const params = new URLSearchParams(); + const query = to.query || {}; + Object.keys(query).forEach(key => { + if (key === "joinbright-token") return; + const value = query[key]; + if (Array.isArray(value)) { + value.forEach(item => params.append(key, item)); + } else if (value != null && value !== "") { + params.append(key, String(value)); + } + }); + const search = params.toString(); + return to.path + (search ? "?" + search : "") + (to.hash || ""); +} + +function goLogin(to, next, message) { + try { + sessionStorage.setItem(JOINBRIGHT_LOGIN_ERROR_KEY, "1"); + } catch (e) {} + if (message) Message.error(message); + if (to.path === "/login") { + next(); + return; + } + // 回到 iframe 实际打开的地址。/portal/zh 不能收成 /portal,否则手动登录后只会进首页。 + const redirect = to.path === "/portal/zh" + ? to.fullPath + : fullPathWithoutJoinbright(to); + next({ path: "/login", query: { redirect }, replace: true }); +} router.beforeEach(async (to, from, next) => { // NProgress.start(); + // 中恒 iframe 跳转:地址上有 joinbright-token 时先换登录态,不进入登录页。 + // 同一凭证只换一次;接口不是 200 或其它异常则回到登录页,不再自动登录。 + const joinbrightToken = getJoinbrightToken(to); + const shouldExchange = !!joinbrightToken + && !joinbrightAlreadyFailed(joinbrightToken) + && !hasExchangedJoinbrightToken(joinbrightToken); + if (shouldExchange && to.path !== "/video-preview-embed") { + try { + await ensureJoinbrightLogin(joinbrightToken); + } catch (err) { + markJoinbrightFailed(joinbrightToken); + await store.dispatch("FedLogOut"); + goLogin(to, next, (err && err.message) || "中恒登录失败,请重新登录"); + return; + } + if (to.path === "/login") { + next({ path: (to.query && to.query.redirect) || homePath, replace: true }); + return; + } + const query = { ...(to.query || {}) }; + const hadToken = !!query["joinbright-token"]; + delete query["joinbright-token"]; + // /portal/zh 是中恒入口,换票成功后进门户,地址上不再留凭证 + const path = to.path === "/portal/zh" ? "/portal" : to.path; + if (hadToken || path !== to.path) { + next({ path, query, hash: to.hash, replace: true }); + return; + } + } + + let joinbrightFailed = false; + try { joinbrightFailed = sessionStorage.getItem(JOINBRIGHT_LOGIN_ERROR_KEY) === "1"; } catch (e) {} const embeddedSession = isEmbeddedSession(); - if (getToken() || embeddedSession) { + // 中恒换票失败后必须停在登录页,不能再靠宿主会话免登录进系统。 + if ((getToken() || embeddedSession) && !(joinbrightFailed && !getToken())) { to.meta.title && store.dispatch("settings/setTitle", to.meta.title); /* has token*/ if (to.path === "/login") { @@ -27,9 +118,8 @@ router.beforeEach(async (to, from, next) => { // NProgress.done(); } else if (to.path === "/video-preview-embed") { next(); - } else if (embeddedSession) { - // 外部系统跳转进入:宿主框架已提供登录用户,无需再走登录页, - // 默认只挂载本地菜单(即非 admin 账号看到的默认内容)。 + } else if (embeddedSession && !getToken()) { + // 没有中恒凭证、也没有本系统 token 时,仍走原来的宿主免登录。 if (store.getters.sidebarRouters.length === 0) { await store.dispatch("GenerateDefaultRoutes"); } diff --git a/src/router/index.js b/src/router/index.js index 3e96046..b3246b5 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -37,6 +37,14 @@ export const constantRoutes = [ hidden: true, meta: { title: '动环智能巡检系统' } }, + { + // 中恒嵌入入口:/portal/zh?joinbright-token=xxx。 + // 不能在这里重定向,否则守卫看到的是去掉凭证后的 /portal,会先画出登录页。 + path: '/portal/zh', + component: () => import('@/views/portal/index'), + hidden: true, + meta: { title: '动环智能巡检系统' } + }, { // 供外部系统 iframe 嵌入:无后台外壳,页面内静默登录 path: '/video-preview-embed', @@ -304,6 +312,65 @@ export const constantRoutes = [ meta: { title: '历史回放', activeMenu: '/video-playback/index' } } ] + }, + { + // 汇聚门户「系统管理」入口。后台菜单未下发这组路由时,前端仍要能打开,否则会落到 404。 + // hidden 只影响普通侧边栏,汇聚态会单独把 children 摊到左侧。 + path: '/system', + component: Layout, + hidden: true, + redirect: '/system/user', + meta: { title: '系统管理', icon: 'system', portalSystem: true }, + children: [ + { + path: 'user', + component: () => import('@/views/system/user/index'), + name: 'PortalSystemUser', + meta: { title: '用户管理', icon: 'user', affix: true } + }, + { + path: 'role', + component: () => import('@/views/system/role/index'), + name: 'PortalSystemRole', + meta: { title: '角色管理', icon: 'peoples' } + }, + { + path: 'menu', + component: () => import('@/views/system/menu/index'), + name: 'PortalSystemMenu', + meta: { title: '菜单管理', icon: 'tree-table' } + }, + { + path: 'dept', + component: () => import('@/views/system/dept/index'), + name: 'PortalSystemDept', + meta: { title: '部门管理', icon: 'tree' } + }, + { + path: 'post', + component: () => import('@/views/system/post/index'), + name: 'PortalSystemPost', + meta: { title: '岗位管理', icon: 'post' } + }, + { + path: 'dict', + component: () => import('@/views/system/dict/index'), + name: 'PortalSystemDict', + meta: { title: '字典管理', icon: 'dict' } + }, + { + path: 'config', + component: () => import('@/views/system/config/index'), + name: 'PortalSystemConfig', + meta: { title: '参数设置', icon: 'edit' } + }, + { + path: 'notice', + component: () => import('@/views/system/notice/index'), + name: 'PortalSystemNotice', + meta: { title: '通知公告', icon: 'message' } + } + ] } ] diff --git a/src/store/modules/permission.js b/src/store/modules/permission.js index 9a78430..2fe2f8d 100644 --- a/src/store/modules/permission.js +++ b/src/store/modules/permission.js @@ -5,6 +5,26 @@ import Layout from '@/layout/index' import ParentView from '@/components/ParentView' import InnerLink from '@/layout/components/InnerLink' +// 左侧菜单来源。后台菜单还没测完,正式环境先用前端常量路由。 +// 测好后把这里改回 false,左侧菜单会重新走 /system/menu/getRouters。 +const USE_FRONTEND_MENU = true + +// 这些业务菜单写在前端常量路由里。走后台菜单时不再把它们拼进侧边栏。 +const FRONTEND_MENU_PATHS = new Set([ + '/task-summary', + '/video-preview', + '/patrol-plan', + '/site-management', + '/patrol-report', + '/patrol-schedule', + '/defect-record', + '/video-playback' +]) + +function constantSidebarRoutes() { + return constantRoutes.filter(route => !FRONTEND_MENU_PATHS.has(route.path)) +} + const permission = { state: { routes: [], @@ -43,6 +63,18 @@ const permission = { // 生成路由 GenerateRoutes({ commit }) { return new Promise(resolve => { + // 正式环境临时用前端菜单,不请求后台路由,避免未测完的菜单影响线上。 + if (USE_FRONTEND_MENU) { + const asyncRoutes = filterDynamicRoutes(dynamicRoutes) + router.addRoutes(asyncRoutes) + commit('SET_ROUTES', []) + commit('SET_SIDEBAR_ROUTERS', constantRoutes) + commit('SET_DEFAULT_ROUTES', []) + commit('SET_TOPBAR_ROUTES', []) + router.addRoutes([{ path: '*', redirect: '/404', hidden: true }]) + resolve([]) + return + } // 向后端请求路由数据 getRouters().then(res => { const sdata = JSON.parse(JSON.stringify(res.data)) @@ -53,7 +85,7 @@ const permission = { rewriteRoutes.push({ path: '*', redirect: '/404', hidden: true }) router.addRoutes(asyncRoutes); commit('SET_ROUTES', rewriteRoutes) - commit('SET_SIDEBAR_ROUTERS', constantRoutes.concat(sidebarRoutes)) + commit('SET_SIDEBAR_ROUTERS', constantSidebarRoutes().concat(sidebarRoutes)) commit('SET_DEFAULT_ROUTES', sidebarRoutes) commit('SET_TOPBAR_ROUTES', sidebarRoutes) resolve(rewriteRoutes) diff --git a/src/store/modules/user.js b/src/store/modules/user.js index 0db53db..4dca8e2 100644 --- a/src/store/modules/user.js +++ b/src/store/modules/user.js @@ -1,7 +1,19 @@ -import { login, logout, getInfo, refreshToken } from "@/api/login"; +import { login, logout, getInfo, refreshToken, joinbrightLogin } from "@/api/login"; import { getToken, setToken, setExpiresIn, removeToken } from "@/utils/auth"; +import { markJoinbrightExchanged, rememberJoinbrightToken } from "@/utils/embeddedAuth"; import defaultAvatar from "@/assets/images/aa-logo.png"; +function readAccessToken(res) { + if (!res) return ""; + if (typeof res.access_token === "string") return res.access_token; + if (typeof res.token === "string") return res.token; + const data = res.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 ""; +} + const user = { state: { token: getToken(), @@ -77,6 +89,43 @@ const user = { }); }, + // 中恒跳转:用 iframe 上的 joinbright-token 换本系统登录态,不走登录页 + JoinbrightLogin({ commit }, token) { + const joinbrightToken = (token || "").trim(); + return new Promise((resolve, reject) => { + if (!joinbrightToken) { + reject(new Error("缺少中恒登录凭证")); + return; + } + joinbrightLogin(joinbrightToken) + .then((res) => { + const accessToken = readAccessToken(res); + if (!accessToken) { + reject(new Error((res && res.msg) || "中恒登录失败")); + return; + } + const data = (res && res.data) || {}; + setToken(accessToken); + commit("SET_TOKEN", accessToken); + if (data.expires_in) { + setExpiresIn(data.expires_in); + commit("SET_EXPIRES_IN", data.expires_in); + } + rememberJoinbrightToken(joinbrightToken); + markJoinbrightExchanged(joinbrightToken); + try { + sessionStorage.removeItem("manual-login"); + sessionStorage.removeItem("joinbright-login-error"); + sessionStorage.removeItem("joinbright-401-retry"); + } catch (e) {} + resolve(); + }) + .catch((error) => { + reject(error); + }); + }); + }, + // 获取用户信息 GetInfo({ commit, state }) { return new Promise((resolve, reject) => { @@ -95,7 +144,7 @@ const user = { commit("SET_ROLES", ["ROLE_DEFAULT"]); } commit("SET_USERNAME", user.userName); - commit("SET_NAME", user.userName); + commit("SET_NAME", user.nickName || user.userName); commit("SET_AVATAR", avatar); commit("SET_PASSMAP", res.updatePassMap); resolve(res); diff --git a/src/utils/embeddedAuth.js b/src/utils/embeddedAuth.js index b224cf3..af56474 100644 --- a/src/utils/embeddedAuth.js +++ b/src/utils/embeddedAuth.js @@ -1,7 +1,146 @@ -// 外部系统(宿主框架)跳转进入时的会话识别。 -// 宿主框架会把已登录用户写入 sessionStorage 的 main_userInfo, -// 本系统据此免登录进入,并默认使用本地菜单。 +// 外部系统跳转进入时的会话识别。 +// 1. 中恒把动环平台嵌进 iframe,地址上带 joinbright-token,优先用它换登录态。 +// 2. 没有该凭证时,仍兼容宿主写入的 main_userInfo,以及原来的账号登录。 const EMBEDDED_USER_KEY = "main_userInfo"; +const JOINBRIGHT_TOKEN_KEY = "joinbright-token"; +const JOINBRIGHT_EXCHANGED_KEY = "joinbright-exchanged"; +const JOINBRIGHT_FAILED_KEY = "joinbright-failed"; +const JOINBRIGHT_QUERY_KEY = "joinbright-token"; + +function firstValue(value) { + if (Array.isArray(value)) return value[0] || ""; + return value || ""; +} + +function readQueryParam(search, name) { + if (!search) return ""; + const query = search.charAt(0) === "?" ? search.slice(1) : search; + const parts = query.split("&"); + for (let index = 0; index < parts.length; index++) { + const item = parts[index]; + if (!item) continue; + const eq = item.indexOf("="); + const key = decodeURIComponent(eq >= 0 ? item.slice(0, eq) : item); + if (key !== name) continue; + return decodeURIComponent((eq >= 0 ? item.slice(eq + 1) : "").replace(/\+/g, " ")); + } + return ""; +} + +function readTokenFromUrl(urlString) { + if (!urlString) return ""; + const hashIndex = urlString.indexOf("#"); + const beforeHash = hashIndex >= 0 ? urlString.slice(0, hashIndex) : urlString; + const hash = hashIndex >= 0 ? urlString.slice(hashIndex + 1) : ""; + const searchIndex = beforeHash.indexOf("?"); + const fromSearch = searchIndex >= 0 ? readQueryParam(beforeHash.slice(searchIndex + 1), JOINBRIGHT_QUERY_KEY) : ""; + if (fromSearch) return fromSearch; + const hashQueryIndex = hash.indexOf("?"); + if (hashQueryIndex >= 0) { + return readQueryParam(hash.slice(hashQueryIndex + 1), JOINBRIGHT_QUERY_KEY); + } + return ""; +} + +// 启动阶段还没进路由时,只认当前地址上的凭证。 +// 会话里缓存的旧凭证不能算,否则普通打开登录页也会被当成中恒跳转。 +export function hasJoinbrightTokenInLocation() { + return !!readTokenFromWindow(window); +} + +function readTokenFromWindow(win) { + try { + if (!win || !win.location) return ""; + return readTokenFromUrl(win.location.href); + } catch (e) { + return ""; + } +} + +function readStoredToken() { + try { + return sessionStorage.getItem(JOINBRIGHT_TOKEN_KEY) || sessionStorage.getItem("main_joinbright_token") || ""; + } catch (e) { + return ""; + } +} + +// 优先取当前页(iframe src)上的凭证,其次同源父页面,最后用本次会话缓存。 +export function getJoinbrightToken(route) { + const routeToken = firstValue(route && route.query && route.query[JOINBRIGHT_QUERY_KEY]); + if (routeToken) return String(routeToken); + const currentToken = readTokenFromWindow(window); + if (currentToken) return currentToken; + try { + if (window.parent && window.parent !== window) { + const parentToken = readTokenFromWindow(window.parent); + if (parentToken) return parentToken; + } + } catch (e) {} + try { + if (window.top && window.top !== window) { + const topToken = readTokenFromWindow(window.top); + if (topToken) return topToken; + } + } catch (e) {} + return readStoredToken(); +} + +export function rememberJoinbrightToken(token) { + if (!token) return; + try { + sessionStorage.setItem(JOINBRIGHT_TOKEN_KEY, token); + } catch (e) {} +} + +export function markJoinbrightExchanged(token) { + try { + if (token) sessionStorage.setItem(JOINBRIGHT_EXCHANGED_KEY, token); + sessionStorage.removeItem(JOINBRIGHT_FAILED_KEY); + } catch (e) {} +} + +export function hasExchangedJoinbrightToken(token) { + if (!token) return false; + try { + return sessionStorage.getItem(JOINBRIGHT_EXCHANGED_KEY) === token; + } catch (e) { + return false; + } +} + +export function markJoinbrightFailed(token) { + try { + if (token) sessionStorage.setItem(JOINBRIGHT_FAILED_KEY, token); + sessionStorage.removeItem(JOINBRIGHT_TOKEN_KEY); + sessionStorage.removeItem(JOINBRIGHT_EXCHANGED_KEY); + sessionStorage.removeItem("main_joinbright_token"); + } catch (e) {} +} + +export function joinbrightAlreadyFailed(token) { + if (!token) return false; + try { + return sessionStorage.getItem(JOINBRIGHT_FAILED_KEY) === token; + } catch (e) { + return false; + } +} + +export function clearJoinbrightExchange() { + try { + sessionStorage.removeItem(JOINBRIGHT_EXCHANGED_KEY); + } catch (e) {} +} + +export function clearJoinbrightToken() { + try { + sessionStorage.removeItem(JOINBRIGHT_TOKEN_KEY); + sessionStorage.removeItem(JOINBRIGHT_EXCHANGED_KEY); + sessionStorage.removeItem(JOINBRIGHT_FAILED_KEY); + sessionStorage.removeItem("main_joinbright_token"); + } catch (e) {} +} export function getEmbeddedUser() { const contexts = [window]; diff --git a/src/utils/request.js b/src/utils/request.js index f561bde..c4c0bec 100644 --- a/src/utils/request.js +++ b/src/utils/request.js @@ -9,7 +9,7 @@ import cache from '@/plugins/cache' import { saveAs } from 'file-saver' import { urlWhiteList } from '@/settings' import md5 from 'md5.js' -import { isEmbeddedSession } from '@/utils/embeddedAuth' +import { clearJoinbrightExchange, getJoinbrightToken, hasExchangedJoinbrightToken, isEmbeddedSession, joinbrightAlreadyFailed, markJoinbrightFailed } from '@/utils/embeddedAuth' import { homePath } from '@/utils/homePath' @@ -149,6 +149,16 @@ service.interceptors.response.use((res) => { return res.data } console.log('[GAB] code: ' + code) + // 中恒换票接口自己处理失败,不能在这里再静默登录或跳转,否则失败回登录页会被打断。 + if (res.config && res.config.joinbrightAuth) { + if (code !== 200) { + return Promise.reject(new Error(msg || '中恒登录失败')) + } + return res.data + } + if (code === 200) { + try { sessionStorage.removeItem('joinbright-401-retry') } catch (e) {} + } if (code === 401) { isRelogin.show = false; const currentPath = router.currentRoute && router.currentRoute.path @@ -162,6 +172,43 @@ service.interceptors.response.use((res) => { if (requestUrl === "/code" || requestUrl.indexOf("/code?") === 0) { return Promise.reject(new Error('验证码获取失败')) } + if (requestUrl.indexOf("/auth/joinbrightLogin") === 0 || requestUrl.indexOf("/auth/login") === 0) { + return Promise.reject(new Error(msg || '登录失败')) + } + const joinbrightToken = getJoinbrightToken() + // 中恒凭证已经失效:回登录页,不能再改用默认账号静默登录。 + if (joinbrightToken && joinbrightAlreadyFailed(joinbrightToken)) { + return Promise.reject(new Error('无效的会话,或者会话已过期,请重新登录。')) + } + // 中恒会话过期:同一凭证只再换一次票。再次失败就回登录页。 + if (joinbrightToken && hasExchangedJoinbrightToken(joinbrightToken)) { + let retried = false + try { retried = sessionStorage.getItem('joinbright-401-retry') === joinbrightToken } catch (e) {} + if (!retried) { + try { sessionStorage.setItem('joinbright-401-retry', joinbrightToken) } catch (e) {} + clearJoinbrightExchange() + store.dispatch("FedLogOut").then(() => { + const returnPath = router.currentRoute && router.currentRoute.fullPath + if (returnPath && router.currentRoute.path !== "/login") { + router.replace(returnPath).catch(() => { }) + } + }) + } else { + markJoinbrightFailed(joinbrightToken) + store.dispatch("FedLogOut").then(() => { + try { sessionStorage.setItem('joinbright-login-error', '1') } catch (e) {} + Message.error("登录状态已过期,请重新登录") + const returnPath = router.currentRoute && router.currentRoute.fullPath + router.push({ path: "/login", query: returnPath && returnPath !== "/login" ? { redirect: returnPath } : undefined }).catch(() => { }) + }) + } + return Promise.reject(new Error('无效的会话,或者会话已过期,请重新登录。')) + } + let joinbrightFailed = false + try { joinbrightFailed = sessionStorage.getItem('joinbright-login-error') === '1' } catch (e) {} + if (joinbrightFailed) { + return Promise.reject(new Error('无效的会话,或者会话已过期,请重新登录。')) + } if (isEmbeddedSession()) { // 外部系统跳转进入:宿主框架未提供本系统token,静默登录(默认普通账号)保证页面数据可用 const postData = new LoginEntity(); @@ -201,9 +248,11 @@ service.interceptors.response.use((res) => { Vue.prototype.$bus.$emit('second_validate') return Promise.reject('goon') } else if (code !== 200) { - Notification.error({ - title: msg - }) + if (!(res.config && res.config.noAlert)) { + Notification.error({ + title: msg + }) + } return Promise.reject('error') } else { return res.data @@ -215,6 +264,11 @@ service.interceptors.response.use((res) => { return Promise.reject(error) } console.log('[GAB] err: ' + error) + if (error.config && error.config.joinbrightAuth) { + const data = error.response && error.response.data + const message = (data && (data.msg || data.message)) || error.message || '中恒登录失败' + return Promise.reject(new Error(message)) + } let { message } = error; if (!message) { return Promise.reject(error) @@ -228,11 +282,14 @@ service.interceptors.response.use((res) => { else if (message.includes("Request failed with status code")) { message = "系统接口" + message.substr(message.length - 3) + "异常"; } - Message({ - message: message, - type: 'error', - duration: 5 * 1000 - }) + // 视频拉流失败由画面上的红点表示,不再弹全局错误框 + if (!(error.config && error.config.noAlert)) { + Message({ + message: message, + type: 'error', + duration: 5 * 1000 + }) + } return Promise.reject(error) } ) diff --git a/src/utils/videoRequestLimit.js b/src/utils/videoRequestLimit.js index 0a2c6ae..b7b8334 100644 --- a/src/utils/videoRequestLimit.js +++ b/src/utils/videoRequestLimit.js @@ -1,21 +1,23 @@ // 浏览器对同一地址的并发连接有上限(HTTP/1.1 通常是 6)。 // 首页四路拉流一旦卡住就会占满连接,巡检列表等业务请求只能一直 pending,页面跟着卡死。 -// 拉流单独排队,同时最多两路;离开页面时把还没发出的请求丢掉,并取消已经发出的请求。 -const MAX_CONCURRENT = 2 -let running = 0 -const queue = [] +// 拉流单独排队:实时监控、嵌入预览都同时最多三路,每路请求 8 秒超时。 +// 离开页面时把还没发出的请求丢掉,并取消已经发出的请求。 +const lanes = { + default: { max: 3, running: 0, queue: [] }, + embed: { max: 3, running: 0, queue: [] } +} -function pump() { - while (running < MAX_CONCURRENT && queue.length) { - const job = queue.shift() +function pump(lane) { + while (lane.running < lane.max && lane.queue.length) { + const job = lane.queue.shift() if (job.cancelled) continue - running += 1 + lane.running += 1 let settled = false const finish = () => { if (settled) return settled = true - running -= 1 - pump() + lane.running -= 1 + pump(lane) } Promise.resolve() .then(() => job.task(signal => { @@ -40,19 +42,20 @@ function cancelledError() { return error } -export function limitVideoRequest(task) { +export function limitVideoRequest(task, laneName = "default") { + const lane = lanes[laneName] || lanes.default let job const promise = new Promise((resolve, reject) => { job = { task, resolve, reject, cancelled: false, abort: null } - queue.push(job) - pump() + lane.queue.push(job) + pump(lane) }) // 还在排队就直接丢掉;已经发出去的交给调用方注册的 abort 取消。 promise.cancel = () => { if (!job || job.cancelled) return job.cancelled = true - const index = queue.indexOf(job) - if (index !== -1) queue.splice(index, 1) + const index = lane.queue.indexOf(job) + if (index !== -1) lane.queue.splice(index, 1) if (typeof job.abort === "function") job.abort() job.reject(cancelledError()) } diff --git a/src/views/cards/components/ImagePreviewZoomTemperature/index.vue b/src/views/cards/components/ImagePreviewZoomTemperature/index.vue index d40c552..05de136 100644 --- a/src/views/cards/components/ImagePreviewZoomTemperature/index.vue +++ b/src/views/cards/components/ImagePreviewZoomTemperature/index.vue @@ -168,6 +168,7 @@ export default { lastMouseX: null, lastMouseY: null, + csvLoadGeneration: 0, // 图片实际内容显示区域(在 zoom-container 坐标系中) imageContentRect: { @@ -275,6 +276,7 @@ export default { this.startAnimationLoop(); }, beforeDestroy() { + this.csvLoadGeneration += 1; this.unbindGlobalEvents(); this.stopAnimationLoop(); window.removeEventListener('resize', this.updateImageContentRect); @@ -371,13 +373,7 @@ export default { }, startAnimationLoop() { - const updateTemperature = () => { - if (this.dataShowTemperatureTool && this.lastMouseX && this.lastMouseY) { - this.calculateTemperatureAtPosition(this.lastMouseX, this.lastMouseY); - } - this.animationFrameId = requestAnimationFrame(updateTemperature); - }; - this.animationFrameId = requestAnimationFrame(updateTemperature); + // 温度在鼠标移动时计算,不再用空的动画帧一直占着主线程 }, stopAnimationLoop() { @@ -707,6 +703,9 @@ export default { }, async loadTemperatureDataForCurrentImage() { + const generation = ++this.csvLoadGeneration; + this.temperatureData = []; + this.hoverData = null; if (!this.dataShowTemperatureTool) { // console.log("测温工具未启用"); return; @@ -726,14 +725,16 @@ export default { try { const data = { filePath: this.selectImgUrl}; const response = await downloadCsv(data, this.stationCode); + if (generation !== this.csvLoadGeneration) return; const blob = response instanceof Blob ? response : new Blob([response]); const reader = new FileReader(); reader.onload = (e) => { + if (generation !== this.csvLoadGeneration) return; try { const csvText = e.target.result; - this.parseCSVInChunks(csvText); + this.parseCSVInChunks(csvText, generation); } catch (error) { // console.error("处理CSV数据出错:", error); } @@ -745,7 +746,7 @@ export default { } }, - parseCSVInChunks(csvText) { + parseCSVInChunks(csvText, generation = this.csvLoadGeneration) { try { const lines = csvText.split('\n'); const tempData = []; @@ -755,6 +756,7 @@ export default { let index = 0; const processBatch = () => { + if (generation !== this.csvLoadGeneration) return; const endIndex = Math.min(index + BATCH_SIZE, lines.length); for (let i = index; i < endIndex; i++) { @@ -780,7 +782,7 @@ export default { if (index < lines.length) { setTimeout(processBatch, 0); - } else { + } else if (generation === this.csvLoadGeneration) { this.finalizeCSVData(tempData, maxCols); } }; diff --git a/src/views/cards/defectRecord/index.vue b/src/views/cards/defectRecord/index.vue index 6311547..aabc092 100644 --- a/src/views/cards/defectRecord/index.vue +++ b/src/views/cards/defectRecord/index.vue @@ -10,12 +10,26 @@ 总缺陷数 {{ total }} + + + - + -- + + + 返回 - + - - + + - + + + + + + + + +
3) { + return [0, 0]; + } + return [1, 1]; }, lookImageDetail(imgUrl) { @@ -571,10 +578,29 @@ export default { font-weight: bold; } +.report-header-table { + ::v-deep .el-table__body-wrapper { + max-height: none; + overflow: hidden; + } + + ::v-deep td.el-table__cell .cell { + white-space: normal; + word-break: break-all; + line-height: 20px; + } +} + .point-info-title { font-size: 20px; + line-height: 28px; text-align: center; - padding: 10px; + margin: 0; + padding: 12px 0; +} + +.point-info-title + .task-group { + margin-top: 0; } .task-group { diff --git a/src/views/cards/taskSummary/newInspectionTaskDetail.vue b/src/views/cards/taskSummary/newInspectionTaskDetail.vue index 0a47e0e..9ee669e 100644 --- a/src/views/cards/taskSummary/newInspectionTaskDetail.vue +++ b/src/views/cards/taskSummary/newInspectionTaskDetail.vue @@ -859,6 +859,8 @@ export default { }, onPointTableRowClicked(row) { + this.showPreviewImages = false; + this.showImgesUrls = []; this.selectPointInfo = row; }, @@ -871,9 +873,11 @@ export default { onImagePreviewClicked(imgUrl, isInfrared = false) { if (!imgUrl) return; this.previewImagePos = 0; - this.showImgesUrls = [imgUrl]; this.isInfraredPreview = isInfrared; - this.showPreviewImages = true; + this.showImgesUrls = [imgUrl]; + this.$nextTick(() => { + this.showPreviewImages = true; + }); }, }, }; diff --git a/src/views/login.vue b/src/views/login.vue index e6d68e2..17f04df 100644 --- a/src/views/login.vue +++ b/src/views/login.vue @@ -1,5 +1,5 @@