Browse Source

按照站内的要求修改样式,视频的播放等

inspection_yanyuan
douyage 1 week ago
parent
commit
af258c3fd0
36 changed files with 1883 additions and 551 deletions
  1. +12
    -0
      public/index.html
  2. +307
    -0
      scripts/gen-menu-doc.js
  3. +13
    -0
      src/api/login.js
  4. +3
    -12
      src/api/nvrVideo.js
  5. +4
    -4
      src/api/portal/index.js
  6. BIN
      src/assets/images/login-background.jpg
  7. +25
    -0
      src/assets/styles/ruoyi.scss
  8. +20
    -4
      src/components/AsyncImageFrame/index.vue
  9. +6
    -0
      src/directive/permission/hasPermi.js
  10. +2
    -3
      src/layout/components/Navbar.vue
  11. +0
    -7
      src/layout/components/Sidebar/SidebarItem.vue
  12. +17
    -114
      src/layout/components/Sidebar/index.vue
  13. +26
    -1
      src/layout/components/TagsView/index.vue
  14. +3
    -9
      src/layout/index.vue
  15. +96
    -6
      src/permission.js
  16. +67
    -0
      src/router/index.js
  17. +33
    -1
      src/store/modules/permission.js
  18. +51
    -2
      src/store/modules/user.js
  19. +142
    -3
      src/utils/embeddedAuth.js
  20. +66
    -9
      src/utils/request.js
  21. +18
    -15
      src/utils/videoRequestLimit.js
  22. +12
    -10
      src/views/cards/components/ImagePreviewZoomTemperature/index.vue
  23. +44
    -4
      src/views/cards/defectRecord/index.vue
  24. +61
    -35
      src/views/cards/patrolReport/patrolReportDetail.vue
  25. +6
    -2
      src/views/cards/taskSummary/newInspectionTaskDetail.vue
  26. +115
    -22
      src/views/login.vue
  27. +4
    -2
      src/views/portal/components/DefectTrendChart.vue
  28. +21
    -11
      src/views/portal/components/PortalDeviceTree.vue
  29. +170
    -100
      src/views/portal/index.vue
  30. +10
    -2
      src/views/system/role/index.vue
  31. +209
    -47
      src/views/system/user/index.vue
  32. +87
    -46
      src/views/video/LivePreview.vue
  33. +6
    -1
      src/views/video/LivePreviewEmbed.vue
  34. +88
    -20
      src/views/video/components/NvrDeviceTree.vue
  35. +137
    -57
      src/views/video/components/NvrStreamPlayer.vue
  36. +2
    -2
      vue.config.js

+ 12
- 0
public/index.html View File

@ -6,6 +6,9 @@
if (location.pathname.indexOf('/video-preview-embed') !== -1) { if (location.pathname.indexOf('/video-preview-embed') !== -1) {
document.documentElement.className += ' embed-preview'; document.documentElement.className += ' embed-preview';
} }
if (location.search.indexOf('joinbright-token=') !== -1 || location.hash.indexOf('joinbright-token=') !== -1) {
document.documentElement.className += ' joinbright-entry';
}
</script> </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">
@ -40,6 +43,15 @@
display: none; display: none;
} }
html.joinbright-entry,
html.joinbright-entry body {
background: #061433;
}
html.joinbright-entry .login {
visibility: hidden;
}
#loader-wrapper { #loader-wrapper {
position: fixed; position: fixed;
top: 0; top: 0;


+ 307
- 0
scripts/gen-menu-doc.js View File

@ -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);
});

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

@ -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) { export function login(username, password, code, uuid) {
console.log('login', username, password, code, uuid) console.log('login', username, password, code, uuid)


+ 3
- 12
src/api/nvrVideo.js View File

@ -1,12 +1,5 @@
import request from '@/utils/request' import request from '@/utils/request'
import { limitVideoRequest } from '@/utils/videoRequestLimit' 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 => { const unwrap = promise => promise.then(result => {
if (result && Object.prototype.hasOwnProperty.call(result, 'data')) return result.data 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', url: '/patrol/video/realtime/start',
method: 'post', method: 'post',
timeout: 8000, timeout: 8000,
noAlert: true,
data: { data: {
cameraCode: channel.cameraCode || channel.channelCode, cameraCode: channel.cameraCode || channel.channelCode,
streamType streamType
} }
})))
}), 'embed'))
} }
export function getLiveStatus(cameraCode) { export function getLiveStatus(cameraCode) {
@ -46,6 +40,7 @@ export function getLiveStatus(cameraCode) {
url: '/patrol/video/realtime/status', url: '/patrol/video/realtime/status',
method: 'get', method: 'get',
timeout: 8000, timeout: 8000,
noAlert: true,
params: { cameraCode } 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) { export function getPresetList(cameraCode) {
const [device, domain] = encodeCameraCodeSegments(cameraCode) const [device, domain] = encodeCameraCodeSegments(cameraCode)
return unwrap(request({ return unwrap(request({


+ 4
- 4
src/api/portal/index.js View File

@ -70,7 +70,7 @@ export function getChannelTree(query, config = {}) {
// 汇聚侧实时视频启动 // 汇聚侧实时视频启动
// 返回 data: { stationCode, cameraCode, streamId, hls, flv, status } // 返回 data: { stationCode, cameraCode, streamId, hls, flv, status }
// 拉流会占住浏览器到同一地址的连接,必须可取消,否则切页后连接一直 pending。 // 拉流会占住浏览器到同一地址的连接,必须可取消,否则切页后连接一直 pending。
export function startRealtimeVideo(query) {
export function startRealtimeVideo(query, options = {}) {
const { stationCode, ...data } = query || {}; const { stationCode, ...data } = query || {};
const source = axios.CancelToken.source(); const source = axios.CancelToken.source();
const promise = limitVideoRequest(registerAbort => { const promise = limitVideoRequest(registerAbort => {
@ -78,13 +78,13 @@ export function startRealtimeVideo(query) {
return request({ return request({
url: "/agg/video/realtime/start", url: "/agg/video/realtime/start",
method: "post", method: "post",
timeout: 8000,
params: { stationCode }, params: { stationCode },
data, data,
timeout: 8000,
noAlert: true, noAlert: true,
cancelToken: source.token cancelToken: source.token
}); });
});
}, options.lane || "default");
return promise; return promise;
} }
@ -98,13 +98,13 @@ export function stopRealtimeVideo(query) {
method: "post", method: "post",
params: { stationCode }, params: { stationCode },
data, data,
timeout: 8000,
noAlert: true noAlert: true
}); });
} }
// 汇聚侧云台控制:按摄像头所属站点代理转发 // 汇聚侧云台控制:按摄像头所属站点代理转发
// stationCode 取当前控制摄像头所在站(而非顶部站名筛选),以查询串方式传递 // stationCode 取当前控制摄像头所在站(而非顶部站名筛选),以查询串方式传递
// 雨刷也走这条接口:开 27、关 28,与方向/变倍/聚焦/光圈同一套控制码
export function aggPtzControl(payload) { export function aggPtzControl(payload) {
const { stationCode, ...data } = payload || {}; const { stationCode, ...data } = payload || {};
return request({ return request({


BIN
src/assets/images/login-background.jpg View File

Before After
Width: 1000  |  Height: 563  |  Size: 509 KiB Width: 1867  |  Height: 973  |  Size: 235 KiB

+ 25
- 0
src/assets/styles/ruoyi.scss View File

@ -171,6 +171,11 @@
} }
.el-switch__core { .el-switch__core {
border-color: #9aa3b5;
background-color: #9aa3b5;
}
.el-switch.is-checked .el-switch__core {
border-color: #2f7cff; border-color: #2f7cff;
background-color: #2f7cff; background-color: #2f7cff;
} }
@ -967,6 +972,14 @@ table tbody tr > td {
font-weight: 600; 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 { .el-select-dropdown.is-multiple .el-select-dropdown__item.selected::after {
color: #00ffff !important; color: #00ffff !important;
} }
@ -1058,6 +1071,14 @@ table tbody tr > td {
color: #dcecff !important; 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__option--highlight,
.vue-treeselect--single .vue-treeselect__option--selected, .vue-treeselect--single .vue-treeselect__option--selected,
.vue-treeselect__option:has(.vue-treeselect__checkbox--checked) { .vue-treeselect__option:has(.vue-treeselect__checkbox--checked) {
@ -1091,6 +1112,10 @@ table tbody tr > td {
color: rgba(220, 236, 255, 0.5) !important; color: rgba(220, 236, 255, 0.5) !important;
} }
.vue-treeselect__single-value {
color: #dcecff !important;
}
.vue-treeselect__input { .vue-treeselect__input {
color: #dcecff !important; color: #dcecff !important;
} }


+ 20
- 4
src/components/AsyncImageFrame/index.vue View File

@ -159,6 +159,7 @@ export default {
showFrame: false, //返回不同坐标计算 showFrame: false, //返回不同坐标计算
maxTempPoints: [], // 存储每个区域的最高温点坐标 maxTempPoints: [], // 存储每个区域的最高温点坐标
csvLoadGeneration: 0,
}; };
}, },
computed: { computed: {
@ -200,6 +201,7 @@ export default {
this.showUploadDialog(); this.showUploadDialog();
}, },
beforeDestroy() { beforeDestroy() {
this.csvLoadGeneration += 1;
window.removeEventListener("resize", this.updateImageSize); window.removeEventListener("resize", this.updateImageSize);
}, },
methods: { methods: {
@ -243,6 +245,7 @@ export default {
stopDrawingIfNeeded() { stopDrawingIfNeeded() {
this.showTooltip = false; this.showTooltip = false;
this.hoverData = null;
}, },
// ==================== 图片尺寸更新 ==================== // ==================== 图片尺寸更新 ====================
@ -283,16 +286,22 @@ export default {
const data = { const data = {
filePath: this.imgName, filePath: this.imgName,
}; };
const generation = ++this.csvLoadGeneration;
this.temperatureData = [];
this.hoverTemperatureData = [];
this.hoverData = null;
downloadCsv(data, this.stationCode) downloadCsv(data, this.stationCode)
.then((response) => { .then((response) => {
if (generation !== this.csvLoadGeneration) return;
const blob = response instanceof Blob ? response : new Blob([response]); const blob = response instanceof Blob ? response : new Blob([response]);
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
if (generation !== this.csvLoadGeneration) return;
try { try {
const csvText = e.target.result; const csvText = e.target.result;
this.parseCSVInChunks(csvText);
this.parseCSVInChunks(csvText, generation);
} catch (error) { } catch (error) {
// this.$message.error('读取CSV出错: ' + error.message); // this.$message.error('读取CSV出错: ' + error.message);
} }
@ -310,7 +319,7 @@ export default {
}, },
// 分块解析CSV,避免栈溢出 // 分块解析CSV,避免栈溢出
parseCSVInChunks(csvText) {
parseCSVInChunks(csvText, generation = this.csvLoadGeneration) {
try { try {
const lines = csvText.split('\n'); const lines = csvText.split('\n');
const tempData = []; const tempData = [];
@ -320,6 +329,7 @@ export default {
let index = 0; let index = 0;
const processBatch = () => { const processBatch = () => {
if (generation !== this.csvLoadGeneration) return;
const endIndex = Math.min(index + BATCH_SIZE, lines.length); const endIndex = Math.min(index + BATCH_SIZE, lines.length);
for (let i = index; i < endIndex; i++) { for (let i = index; i < endIndex; i++) {
@ -345,7 +355,7 @@ export default {
if (index < lines.length) { if (index < lines.length) {
setTimeout(processBatch, 0); setTimeout(processBatch, 0);
} else {
} else if (generation === this.csvLoadGeneration) {
this.finalizeCSVData(tempData, maxCols); this.finalizeCSVData(tempData, maxCols);
} }
}; };
@ -594,8 +604,14 @@ export default {
}, },
src: { src: {
async handler(val) { async handler(val) {
const generation = ++this.csvLoadGeneration;
this.temperatureData = [];
this.hoverTemperatureData = [];
this.hoverData = null;
if (val && val.length > 0) { 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.imgUrl = "http://192.168.1.116:18891/shaoxing/videoMonitor/app/htjc6/htjcImage//test/39357.jpg";
this.imgName = val; this.imgName = val;


+ 6
- 0
src/directive/permission/hasPermi.js View File

@ -10,6 +10,12 @@ export default {
const { value } = binding const { value } = binding
const all_permission = "*:*:*"; const all_permission = "*:*:*";
const permissions = store.getters && store.getters.permissions 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) { if (value && value instanceof Array && value.length > 0) {
const permissionFlag = value const permissionFlag = value


+ 2
- 3
src/layout/components/Navbar.vue View File

@ -57,7 +57,7 @@
import { mapGetters } from "vuex"; import { mapGetters } from "vuex";
import TopNav from "@/components/TopNav"; import TopNav from "@/components/TopNav";
import Screenfull from "@/components/Screenfull"; import Screenfull from "@/components/Screenfull";
import { getEmbeddedUser } from "@/utils/embeddedAuth";
import { clearJoinbrightToken, getEmbeddedUser } from "@/utils/embeddedAuth";
import { applyStationTitle, getStationTitleImage } from "@/utils/stationTitle"; import { applyStationTitle, getStationTitleImage } from "@/utils/stationTitle";
import headerLogo from "@/assets/images/header/logo.png"; import headerLogo from "@/assets/images/header/logo.png";
import xfdhBrand from "@/assets/images/header/xfdh-brand.png"; import xfdhBrand from "@/assets/images/header/xfdh-brand.png";
@ -140,9 +140,8 @@ export default {
}) })
.then(() => { .then(() => {
try { try {
sessionStorage.removeItem('joinbright-token');
clearJoinbrightToken();
sessionStorage.removeItem('main_userInfo'); sessionStorage.removeItem('main_userInfo');
sessionStorage.removeItem('main_joinbright_token');
sessionStorage.removeItem('main_islogin'); sessionStorage.removeItem('main_islogin');
} catch (e) {} } catch (e) {}
if (window.parent && window.parent !== window) { if (window.parent && window.parent !== window) {


+ 0
- 7
src/layout/components/Sidebar/SidebarItem.vue View File

@ -162,13 +162,6 @@ export default {
str = "首页"; str = "首页";
} }
} }
if (
str === "点位管理" &&
this.basePath !== "/site-management" &&
this.basePath !== "/inspection-workspace/points"
) {
str = "点位配置";
}
return str; return str;
} }
}, },


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

@ -50,122 +50,25 @@ export default {
}, },
computed: { computed: {
...mapState(["settings"]), ...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() { 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() { activeMenu() {
const route = this.$route; const route = this.$route;


+ 26
- 1
src/layout/components/TagsView/index.vue View File

@ -93,6 +93,7 @@ export default {
}, },
watch: { watch: {
$route() { $route() {
this.syncModuleTags()
this.addTags() this.addTags()
this.moveToCurrentTag() this.moveToCurrentTag()
}, },
@ -106,6 +107,7 @@ export default {
}, },
mounted() { mounted() {
this.initTags() this.initTags()
this.syncModuleTags()
this.addTags() this.addTags()
this.$root.$on('video-layout-updated', this.updateVideoLayout) this.$root.$on('video-layout-updated', this.updateVideoLayout)
this.$root.$on('video-control-panel-updated', this.updateVideoControlPanel) this.$root.$on('video-control-panel-updated', this.updateVideoControlPanel)
@ -163,7 +165,8 @@ export default {
filterAffixTags(routes, basePath = '/') { filterAffixTags(routes, basePath = '/') {
let tags = [] let tags = []
routes.forEach(route => { 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) const tagPath = path.resolve(basePath, route.path)
// 本地首页和后端菜单可能各有一条同名固定页签,只保留先收集到的那条 // 本地首页和后端菜单可能各有一条同名固定页签,只保留先收集到的那条
if (!tags.some(tag => tag.path === tagPath || (tag.meta && tag.meta.title === route.meta.title))) { 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) 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() { moveToCurrentTag() {
const tags = this.$refs.tag || [] const tags = this.$refs.tag || []
this.$nextTick(() => { this.$nextTick(() => {


+ 3
- 9
src/layout/index.vue View File

@ -124,7 +124,7 @@ export default {
return [ return [
{ 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/menu' }
{ key: 'system', label: '系统管理', path: '/system/menu' }
] ]
}, },
activeModule() { activeModule() {
@ -168,15 +168,9 @@ export default {
} }
if (this.$route.path !== path) this.$router.push({ path }).catch(() => undefined) if (this.$route.path !== path) this.$router.push({ path }).catch(() => undefined)
}, },
// 系统管理落到当前账号能看到的第一个页面,没有菜单权限时退回用户管理
// 系统管理固定进入已注册的用户管理,避免后台菜单未下发时拼出 404 地址
systemEntryPath() { 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) { setSidebarWide(level) {
this.sidebarWidthLevel = Number(level) || 0 this.sidebarWidthLevel = Number(level) || 0


+ 96
- 6
src/permission.js View File

@ -4,19 +4,110 @@ import { Message } from "element-ui";
import NProgress from "nprogress"; import NProgress from "nprogress";
import "nprogress/nprogress.css"; import "nprogress/nprogress.css";
import { getToken } from "@/utils/auth"; 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 { isRelogin } from "@/utils/request";
import { homePath } from "@/utils/homePath"; import { homePath } from "@/utils/homePath";
import md5 from 'md5.js'
NProgress.configure({ showSpinner: false }); NProgress.configure({ showSpinner: false });
const whiteList = ["/login", "/auth-redirect", "/bind", "/register", "/video-preview-embed"]; 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) => { router.beforeEach(async (to, from, next) => {
// NProgress.start(); // 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(); const embeddedSession = isEmbeddedSession();
if (getToken() || embeddedSession) {
// 中恒换票失败后必须停在登录页,不能再靠宿主会话免登录进系统。
if ((getToken() || embeddedSession) && !(joinbrightFailed && !getToken())) {
to.meta.title && store.dispatch("settings/setTitle", to.meta.title); to.meta.title && store.dispatch("settings/setTitle", to.meta.title);
/* has token*/ /* has token*/
if (to.path === "/login") { if (to.path === "/login") {
@ -27,9 +118,8 @@ router.beforeEach(async (to, from, next) => {
// NProgress.done(); // NProgress.done();
} else if (to.path === "/video-preview-embed") { } else if (to.path === "/video-preview-embed") {
next(); next();
} else if (embeddedSession) {
// 外部系统跳转进入:宿主框架已提供登录用户,无需再走登录页,
// 默认只挂载本地菜单(即非 admin 账号看到的默认内容)。
} else if (embeddedSession && !getToken()) {
// 没有中恒凭证、也没有本系统 token 时,仍走原来的宿主免登录。
if (store.getters.sidebarRouters.length === 0) { if (store.getters.sidebarRouters.length === 0) {
await store.dispatch("GenerateDefaultRoutes"); await store.dispatch("GenerateDefaultRoutes");
} }


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

@ -37,6 +37,14 @@ export const constantRoutes = [
hidden: true, hidden: true,
meta: { title: '动环智能巡检系统' } meta: { title: '动环智能巡检系统' }
}, },
{
// 中恒嵌入入口:/portal/zh?joinbright-token=xxx。
// 不能在这里重定向,否则守卫看到的是去掉凭证后的 /portal,会先画出登录页。
path: '/portal/zh',
component: () => import('@/views/portal/index'),
hidden: true,
meta: { title: '动环智能巡检系统' }
},
{ {
// 供外部系统 iframe 嵌入:无后台外壳,页面内静默登录 // 供外部系统 iframe 嵌入:无后台外壳,页面内静默登录
path: '/video-preview-embed', path: '/video-preview-embed',
@ -304,6 +312,65 @@ export const constantRoutes = [
meta: { title: '历史回放', activeMenu: '/video-playback/index' } 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' }
}
]
} }
] ]


+ 33
- 1
src/store/modules/permission.js View File

@ -5,6 +5,26 @@ import Layout from '@/layout/index'
import ParentView from '@/components/ParentView' import ParentView from '@/components/ParentView'
import InnerLink from '@/layout/components/InnerLink' 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 = { const permission = {
state: { state: {
routes: [], routes: [],
@ -43,6 +63,18 @@ const permission = {
// 生成路由 // 生成路由
GenerateRoutes({ commit }) { GenerateRoutes({ commit }) {
return new Promise(resolve => { 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 => { getRouters().then(res => {
const sdata = JSON.parse(JSON.stringify(res.data)) const sdata = JSON.parse(JSON.stringify(res.data))
@ -53,7 +85,7 @@ const permission = {
rewriteRoutes.push({ path: '*', redirect: '/404', hidden: true }) rewriteRoutes.push({ path: '*', redirect: '/404', hidden: true })
router.addRoutes(asyncRoutes); router.addRoutes(asyncRoutes);
commit('SET_ROUTES', rewriteRoutes) 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_DEFAULT_ROUTES', sidebarRoutes)
commit('SET_TOPBAR_ROUTES', sidebarRoutes) commit('SET_TOPBAR_ROUTES', sidebarRoutes)
resolve(rewriteRoutes) resolve(rewriteRoutes)


+ 51
- 2
src/store/modules/user.js View File

@ -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 { getToken, setToken, setExpiresIn, removeToken } from "@/utils/auth";
import { markJoinbrightExchanged, rememberJoinbrightToken } from "@/utils/embeddedAuth";
import defaultAvatar from "@/assets/images/aa-logo.png"; 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 = { const user = {
state: { state: {
token: getToken(), 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 }) { GetInfo({ commit, state }) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -95,7 +144,7 @@ const user = {
commit("SET_ROLES", ["ROLE_DEFAULT"]); commit("SET_ROLES", ["ROLE_DEFAULT"]);
} }
commit("SET_USERNAME", user.userName); commit("SET_USERNAME", user.userName);
commit("SET_NAME", user.userName);
commit("SET_NAME", user.nickName || user.userName);
commit("SET_AVATAR", avatar); commit("SET_AVATAR", avatar);
commit("SET_PASSMAP", res.updatePassMap); commit("SET_PASSMAP", res.updatePassMap);
resolve(res); resolve(res);


+ 142
- 3
src/utils/embeddedAuth.js View File

@ -1,7 +1,146 @@
// 外部系统(宿主框架)跳转进入时的会话识别。
// 宿主框架会把已登录用户写入 sessionStorage 的 main_userInfo,
// 本系统据此免登录进入,并默认使用本地菜单。
// 外部系统跳转进入时的会话识别。
// 1. 中恒把动环平台嵌进 iframe,地址上带 joinbright-token,优先用它换登录态。
// 2. 没有该凭证时,仍兼容宿主写入的 main_userInfo,以及原来的账号登录。
const EMBEDDED_USER_KEY = "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() { export function getEmbeddedUser() {
const contexts = [window]; const contexts = [window];


+ 66
- 9
src/utils/request.js View File

@ -9,7 +9,7 @@ import cache from '@/plugins/cache'
import { saveAs } from 'file-saver' 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 { clearJoinbrightExchange, getJoinbrightToken, hasExchangedJoinbrightToken, isEmbeddedSession, joinbrightAlreadyFailed, markJoinbrightFailed } from '@/utils/embeddedAuth'
import { homePath } from '@/utils/homePath' import { homePath } from '@/utils/homePath'
@ -149,6 +149,16 @@ service.interceptors.response.use((res) => {
return res.data return res.data
} }
console.log('[GAB] code: ' + code) 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) { if (code === 401) {
isRelogin.show = false; isRelogin.show = false;
const currentPath = router.currentRoute && router.currentRoute.path const currentPath = router.currentRoute && router.currentRoute.path
@ -162,6 +172,43 @@ service.interceptors.response.use((res) => {
if (requestUrl === "/code" || requestUrl.indexOf("/code?") === 0) { if (requestUrl === "/code" || requestUrl.indexOf("/code?") === 0) {
return Promise.reject(new Error('验证码获取失败')) 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()) { if (isEmbeddedSession()) {
// 外部系统跳转进入:宿主框架未提供本系统token,静默登录(默认普通账号)保证页面数据可用 // 外部系统跳转进入:宿主框架未提供本系统token,静默登录(默认普通账号)保证页面数据可用
const postData = new LoginEntity(); const postData = new LoginEntity();
@ -201,9 +248,11 @@ service.interceptors.response.use((res) => {
Vue.prototype.$bus.$emit('second_validate') Vue.prototype.$bus.$emit('second_validate')
return Promise.reject('goon') return Promise.reject('goon')
} else if (code !== 200) { } else if (code !== 200) {
Notification.error({
title: msg
})
if (!(res.config && res.config.noAlert)) {
Notification.error({
title: msg
})
}
return Promise.reject('error') return Promise.reject('error')
} else { } else {
return res.data return res.data
@ -215,6 +264,11 @@ service.interceptors.response.use((res) => {
return Promise.reject(error) return Promise.reject(error)
} }
console.log('[GAB] err: ' + 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; let { message } = error;
if (!message) { if (!message) {
return Promise.reject(error) return Promise.reject(error)
@ -228,11 +282,14 @@ service.interceptors.response.use((res) => {
else if (message.includes("Request failed with status code")) { else if (message.includes("Request failed with status code")) {
message = "系统接口" + message.substr(message.length - 3) + "异常"; 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) return Promise.reject(error)
} }
) )


+ 18
- 15
src/utils/videoRequestLimit.js View File

@ -1,21 +1,23 @@
// 浏览器对同一地址的并发连接有上限(HTTP/1.1 通常是 6)。 // 浏览器对同一地址的并发连接有上限(HTTP/1.1 通常是 6)。
// 首页四路拉流一旦卡住就会占满连接,巡检列表等业务请求只能一直 pending,页面跟着卡死。 // 首页四路拉流一旦卡住就会占满连接,巡检列表等业务请求只能一直 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 if (job.cancelled) continue
running += 1
lane.running += 1
let settled = false let settled = false
const finish = () => { const finish = () => {
if (settled) return if (settled) return
settled = true settled = true
running -= 1
pump()
lane.running -= 1
pump(lane)
} }
Promise.resolve() Promise.resolve()
.then(() => job.task(signal => { .then(() => job.task(signal => {
@ -40,19 +42,20 @@ function cancelledError() {
return error return error
} }
export function limitVideoRequest(task) {
export function limitVideoRequest(task, laneName = "default") {
const lane = lanes[laneName] || lanes.default
let job let job
const promise = new Promise((resolve, reject) => { const promise = new Promise((resolve, reject) => {
job = { task, resolve, reject, cancelled: false, abort: null } job = { task, resolve, reject, cancelled: false, abort: null }
queue.push(job)
pump()
lane.queue.push(job)
pump(lane)
}) })
// 还在排队就直接丢掉;已经发出去的交给调用方注册的 abort 取消。 // 还在排队就直接丢掉;已经发出去的交给调用方注册的 abort 取消。
promise.cancel = () => { promise.cancel = () => {
if (!job || job.cancelled) return if (!job || job.cancelled) return
job.cancelled = true 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() if (typeof job.abort === "function") job.abort()
job.reject(cancelledError()) job.reject(cancelledError())
} }


+ 12
- 10
src/views/cards/components/ImagePreviewZoomTemperature/index.vue View File

@ -168,6 +168,7 @@ export default {
lastMouseX: null, lastMouseX: null,
lastMouseY: null, lastMouseY: null,
csvLoadGeneration: 0,
// 图片实际内容显示区域(在 zoom-container 坐标系中) // 图片实际内容显示区域(在 zoom-container 坐标系中)
imageContentRect: { imageContentRect: {
@ -275,6 +276,7 @@ export default {
this.startAnimationLoop(); this.startAnimationLoop();
}, },
beforeDestroy() { beforeDestroy() {
this.csvLoadGeneration += 1;
this.unbindGlobalEvents(); this.unbindGlobalEvents();
this.stopAnimationLoop(); this.stopAnimationLoop();
window.removeEventListener('resize', this.updateImageContentRect); window.removeEventListener('resize', this.updateImageContentRect);
@ -371,13 +373,7 @@ export default {
}, },
startAnimationLoop() { 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() { stopAnimationLoop() {
@ -707,6 +703,9 @@ export default {
}, },
async loadTemperatureDataForCurrentImage() { async loadTemperatureDataForCurrentImage() {
const generation = ++this.csvLoadGeneration;
this.temperatureData = [];
this.hoverData = null;
if (!this.dataShowTemperatureTool) { if (!this.dataShowTemperatureTool) {
// console.log("测温工具未启用"); // console.log("测温工具未启用");
return; return;
@ -726,14 +725,16 @@ export default {
try { try {
const data = { filePath: this.selectImgUrl}; const data = { filePath: this.selectImgUrl};
const response = await downloadCsv(data, this.stationCode); const response = await downloadCsv(data, this.stationCode);
if (generation !== this.csvLoadGeneration) return;
const blob = response instanceof Blob ? response : new Blob([response]); const blob = response instanceof Blob ? response : new Blob([response]);
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
if (generation !== this.csvLoadGeneration) return;
try { try {
const csvText = e.target.result; const csvText = e.target.result;
this.parseCSVInChunks(csvText);
this.parseCSVInChunks(csvText, generation);
} catch (error) { } catch (error) {
// console.error("处理CSV数据出错:", error); // console.error("处理CSV数据出错:", error);
} }
@ -745,7 +746,7 @@ export default {
} }
}, },
parseCSVInChunks(csvText) {
parseCSVInChunks(csvText, generation = this.csvLoadGeneration) {
try { try {
const lines = csvText.split('\n'); const lines = csvText.split('\n');
const tempData = []; const tempData = [];
@ -755,6 +756,7 @@ export default {
let index = 0; let index = 0;
const processBatch = () => { const processBatch = () => {
if (generation !== this.csvLoadGeneration) return;
const endIndex = Math.min(index + BATCH_SIZE, lines.length); const endIndex = Math.min(index + BATCH_SIZE, lines.length);
for (let i = index; i < endIndex; i++) { for (let i = index; i < endIndex; i++) {
@ -780,7 +782,7 @@ export default {
if (index < lines.length) { if (index < lines.length) {
setTimeout(processBatch, 0); setTimeout(processBatch, 0);
} else {
} else if (generation === this.csvLoadGeneration) {
this.finalizeCSVData(tempData, maxCols); this.finalizeCSVData(tempData, maxCols);
} }
}; };


+ 44
- 4
src/views/cards/defectRecord/index.vue View File

@ -10,12 +10,26 @@
<span class="total-label">总缺陷数</span> <span class="total-label">总缺陷数</span>
<span class="total-value">{{ total }}</span> <span class="total-value">{{ total }}</span>
</div> </div>
<el-select
v-model="queryParams.defectLevel"
class="query-item query-level"
clearable
placeholder="缺陷等级"
@change="handleQuery"
>
<el-option
v-for="dict in dict.type.defect_level"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
<el-select <el-select
v-model="defectType" v-model="defectType"
class="query-item query-type" class="query-item query-type"
filterable filterable
clearable clearable
placeholder="算法类型"
placeholder="缺陷类型"
@change="handleQuery" @change="handleQuery"
> >
<el-option <el-option
@ -82,7 +96,7 @@
min-width="140" min-width="140"
show-overflow-tooltip show-overflow-tooltip
/> />
<el-table-column label="算法类型" align="center" prop="algName" />
<el-table-column label="缺陷类型" align="center" prop="algName" />
<el-table-column label="区域" align="center" prop="areaName" /> <el-table-column label="区域" align="center" prop="areaName" />
<!-- 站名列:汇聚门户聚合数据下展示 --> <!-- 站名列:汇聚门户聚合数据下展示 -->
<el-table-column <el-table-column
@ -108,6 +122,11 @@
<span v-else>--</span> <span v-else>--</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="缺陷等级" align="center" prop="defectLevel" width="100">
<template slot-scope="scope">
<dict-tag :options="dict.type.defect_level" :value="scope.row.defectLevel" />
</template>
</el-table-column>
<el-table-column label="缺陷结果" align="center" prop="desc" /> <el-table-column label="缺陷结果" align="center" prop="desc" />
</el-table> </el-table>
<CardPagination <CardPagination
@ -124,7 +143,7 @@
title="图片预览" title="图片预览"
:visible.sync="showPreviewImages" :visible.sync="showPreviewImages"
:close-on-click-modal="false" :close-on-click-modal="false"
:close-on-press-escape="false"
:close-on-press-escape="true"
:destroy-on-close="true" :destroy-on-close="true"
custom-class="card-dialog card-page defect-img-preview-dialog" custom-class="card-dialog card-page defect-img-preview-dialog"
width="70%" width="70%"
@ -149,9 +168,10 @@ import AsyncImage from "@/components/AsyncImage/index.vue";
export default { export default {
name: "DefectRecord", name: "DefectRecord",
components: { CardBox, CardPagination, ImagePreview, AsyncImage }, components: { CardBox, CardPagination, ImagePreview, AsyncImage },
dicts: ["defect_level"],
data() { data() {
return { return {
queryParams: { pageNum: 1, pageSize: 20, pointName: "" },
queryParams: { pageNum: 1, pageSize: 20, pointName: "", defectLevel: "" },
defectType: "", // 算法类型筛选(单选) defectType: "", // 算法类型筛选(单选)
defectTypeOptions: [], // 算法类型下拉选项(后端 descOptions 接口提供) defectTypeOptions: [], // 算法类型下拉选项(后端 descOptions 接口提供)
dataList: [], dataList: [],
@ -181,10 +201,14 @@ export default {
}, },
computed: {}, computed: {},
created() { created() {
this.applyRouteDefectLevel(false);
this.loadDescOptions(); this.loadDescOptions();
this.loadData(); this.loadData();
window.addEventListener("resize", this.setTableHeight); window.addEventListener("resize", this.setTableHeight);
}, },
activated() {
this.applyRouteDefectLevel(true);
},
mounted() { mounted() {
this.setTableHeight(); this.setTableHeight();
}, },
@ -196,6 +220,14 @@ export default {
window.removeEventListener("resize", this.setTableHeight); window.removeEventListener("resize", this.setTableHeight);
}, },
methods: { methods: {
applyRouteDefectLevel(reload) {
const raw = this.$route.query && this.$route.query.defectLevel;
if (raw === undefined || raw === null || raw === "") return;
const next = String(Array.isArray(raw) ? raw[0] : raw);
if (this.queryParams.defectLevel === next) return;
this.queryParams.defectLevel = next;
if (reload) this.handleQuery();
},
setTableHeight() { setTableHeight() {
if (!this.$refs.tableCardRef) return; if (!this.$refs.tableCardRef) return;
const pagination = this.$refs.paginationRef; const pagination = this.$refs.paginationRef;
@ -221,6 +253,7 @@ export default {
resetQuery() { resetQuery() {
this.defectType = ""; this.defectType = "";
this.queryParams.pointName = ""; this.queryParams.pointName = "";
this.queryParams.defectLevel = "";
this.dateRange = []; this.dateRange = [];
this.handleQuery(); this.handleQuery();
}, },
@ -236,6 +269,9 @@ export default {
pageNum: this.queryParams.pageNum, pageNum: this.queryParams.pageNum,
pageSize: this.queryParams.pageSize, pageSize: this.queryParams.pageSize,
pointName: this.queryParams.pointName || undefined, pointName: this.queryParams.pointName || undefined,
defectLevel: this.queryParams.defectLevel === "" || this.queryParams.defectLevel === undefined
? undefined
: this.queryParams.defectLevel,
algName: this.defectType || undefined, algName: this.defectType || undefined,
beginTime: beginTime:
this.dateRange && this.dateRange.length === 2 this.dateRange && this.dateRange.length === 2
@ -419,6 +455,10 @@ export default {
} }
} }
.query-level {
width: 140px;
}
.query-type { .query-type {
width: 220px; width: 220px;
} }


+ 61
- 35
src/views/cards/patrolReport/patrolReportDetail.vue View File

@ -13,29 +13,40 @@
>返回</el-button> >返回</el-button>
</div> </div>
<el-table <el-table
class="report-table"
class="report-table report-header-table"
border border
:data="pointInfo.headerInfoList" :data="pointInfo.headerInfoList"
:show-header="false" :show-header="false"
:span-method="headerSpan" :span-method="headerSpan"
> >
<el-table-column prop="title1" label="" width="140">
<el-table-column prop="title1" label="" width="110" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
<span class="header-title">{{ scope.row.title1 }}</span> <span class="header-title">{{ scope.row.title1 }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="info1" label=""> </el-table-column>
<el-table-column prop="title2" label="" width="140">
<el-table-column prop="info1" label="" align="center"> </el-table-column>
<el-table-column prop="title2" label="" width="110" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
<span class="header-title">{{ scope.row.title2 }}</span> <span class="header-title">{{ scope.row.title2 }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="info2" label=""> </el-table-column>
<el-table-column prop="info2" label="" align="center"> </el-table-column>
<el-table-column prop="title3" label="" width="110" align="center">
<template slot-scope="scope">
<span class="header-title">{{ scope.row.title3 }}</span>
</template>
</el-table-column>
<el-table-column prop="info3" label="" align="center"> </el-table-column>
<el-table-column prop="title4" label="" width="110" align="center">
<template slot-scope="scope">
<span class="header-title">{{ scope.row.title4 }}</span>
</template>
</el-table-column>
<el-table-column prop="info4" label="" align="center"> </el-table-column>
</el-table> </el-table>
<!-- 缺陷点位汇总 - 按任务分组 --> <!-- 缺陷点位汇总 - 按任务分组 -->
<div <div
style="margin-top: 10px"
v-if=" v-if="
pointInfo.errorPointsByTask && pointInfo.errorPointsByTask &&
Object.keys(pointInfo.errorPointsByTask).length > 0 Object.keys(pointInfo.errorPointsByTask).length > 0
@ -111,7 +122,6 @@
<!-- 正常点位汇总 - 按任务分组 --> <!-- 正常点位汇总 - 按任务分组 -->
<div <div
style="margin-top: 10px"
v-if=" v-if="
pointInfo.normalPointsByTask && pointInfo.normalPointsByTask &&
Object.keys(pointInfo.normalPointsByTask).length > 0 Object.keys(pointInfo.normalPointsByTask).length > 0
@ -316,32 +326,23 @@ export default {
{ {
title1: "升压站", title1: "升压站",
info1: this.reportInfo.stationName, info1: this.reportInfo.stationName,
title2: "",
info2: "",
},
{
title1: "审核人",
info1: this.reportInfo.checkPerson,
title2: "审核时间",
info2: this.reportInfo.checkTime,
},
{
title1: "巡检开始时间",
info1: this.reportInfo.inspectionStartTime,
title2: "巡检结束时间",
info2: this.reportInfo.inspectionEndTime,
},
{
title1: "巡检任务",
info1: this.reportInfo.inspectionTaskName,
title2: "巡检统计",
info2: this.reportInfo.patrolStatistics,
title2: "巡检任务",
info2: this.reportInfo.inspectionTaskName,
title3: "巡检开始时间",
info3: this.reportInfo.inspectionStartTime,
title4: "巡检结束时间",
info4: this.reportInfo.inspectionEndTime,
}, },
{ {
title1: "巡检结论", title1: "巡检结论",
info1: this.reportInfo.description, info1: this.reportInfo.description,
title2: "",
info2: "",
title2: "巡检统计",
info2: this.reportInfo.patrolStatistics,
title3: "",
info3: "",
title4: "",
info4: "",
spanLast: true,
}, },
]; ];
@ -493,12 +494,18 @@ export default {
this.$emit("update:visible", false); this.$emit("update:visible", false);
}, },
headerSpan({ row, column, rowIndex, columnIndex }) {
if (rowIndex === 0) {
if (columnIndex === 1) {
return [1, 3];
}
headerSpan({ row, columnIndex }) {
if (!row.spanLast) {
return [1, 1];
}
// 第二行只显示结论和统计,统计内容占满剩余宽度
if (columnIndex === 3) {
return [1, 5];
} }
if (columnIndex > 3) {
return [0, 0];
}
return [1, 1];
}, },
lookImageDetail(imgUrl) { lookImageDetail(imgUrl) {
@ -571,10 +578,29 @@ export default {
font-weight: bold; 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 { .point-info-title {
font-size: 20px; font-size: 20px;
line-height: 28px;
text-align: center; text-align: center;
padding: 10px;
margin: 0;
padding: 12px 0;
}
.point-info-title + .task-group {
margin-top: 0;
} }
.task-group { .task-group {


+ 6
- 2
src/views/cards/taskSummary/newInspectionTaskDetail.vue View File

@ -859,6 +859,8 @@ export default {
}, },
onPointTableRowClicked(row) { onPointTableRowClicked(row) {
this.showPreviewImages = false;
this.showImgesUrls = [];
this.selectPointInfo = row; this.selectPointInfo = row;
}, },
@ -871,9 +873,11 @@ export default {
onImagePreviewClicked(imgUrl, isInfrared = false) { onImagePreviewClicked(imgUrl, isInfrared = false) {
if (!imgUrl) return; if (!imgUrl) return;
this.previewImagePos = 0; this.previewImagePos = 0;
this.showImgesUrls = [imgUrl];
this.isInfraredPreview = isInfrared; this.isInfraredPreview = isInfrared;
this.showPreviewImages = true;
this.showImgesUrls = [imgUrl];
this.$nextTick(() => {
this.showPreviewImages = true;
});
}, },
}, },
}; };


+ 115
- 22
src/views/login.vue View File

@ -1,5 +1,5 @@
<template> <template>
<div class="login">
<div v-if="!joining" class="login">
<el-form ref="loginForm" :model="loginForm" :rules="loginRules" class="login-form"> <el-form ref="loginForm" :model="loginForm" :rules="loginRules" class="login-form">
<h3 class="title">动环智能巡检系统</h3> <h3 class="title">动环智能巡检系统</h3>
<el-form-item prop="username"> <el-form-item prop="username">
@ -37,17 +37,20 @@
<img :src="codeUrl" @click="getCode" class="login-code-img"/> <img :src="codeUrl" @click="getCode" class="login-code-img"/>
</div> </div>
</el-form-item> --> </el-form-item> -->
<el-checkbox v-model="loginForm.rememberMe" style="margin:0px 0px 25px 0px;">记住密码</el-checkbox>
<div class="login-options">
<el-checkbox v-model="loginForm.rememberMe">记住密码</el-checkbox>
<span class="login-forget" @click="handleForget">忘记密码?</span>
</div>
<el-form-item style="width:100%;"> <el-form-item style="width:100%;">
<el-button <el-button
:loading="loading" :loading="loading"
size="medium" size="medium"
type="primary" type="primary"
style="width:100%;"
class="login-submit"
@click.native.prevent="handleLogin" @click.native.prevent="handleLogin"
> >
<span v-if="!loading">登 录</span>
<span v-else>登 录 中...</span>
<span v-if="!loading">登录</span>
<span v-else>登录中...</span>
</el-button> </el-button>
<div style="float: right;" v-if="register"> <div style="float: right;" v-if="register">
<router-link class="link-type" :to="'/register'">立即注册</router-link> <router-link class="link-type" :to="'/register'">立即注册</router-link>
@ -63,6 +66,7 @@ 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 { homePath } from "@/utils/homePath";
import { hasJoinbrightTokenInLocation } from "@/utils/embeddedAuth";
import { encrypt, decrypt } from '@/utils/jsencrypt' import { encrypt, decrypt } from '@/utils/jsencrypt'
import md5 from 'md5.js' import md5 from 'md5.js'
@ -88,6 +92,8 @@ export default {
// code: [{ required: true, trigger: "change", message: "请输入验证码" }] // code: [{ required: true, trigger: "change", message: "请输入验证码" }]
}, },
loading: false, loading: false,
// 地址上还带着中恒凭证时,守卫会先换票再离开登录页,这段时间不画登录表单
joining: hasJoinbrightTokenInLocation(),
// 验证码开关 // 验证码开关
captchaEnabled: true, captchaEnabled: true,
// 注册开关 // 注册开关
@ -107,8 +113,8 @@ export default {
this.getCode(); this.getCode();
this.getCookie(); this.getCookie();
// 免登录进入:首次打开系统时用默认普通账号(htjc)自动登录; // 免登录进入:首次打开系统时用默认普通账号(htjc)自动登录;
// 显式退出后(登录页带有 manual-login 标记)不再自动登录,由用户手动输入账号密码切换
if (!getToken() && sessionStorage.getItem('manual-login') !== '1') {
// 显式退出,或中恒凭证登录失败后,停在登录页,由用户手动输入账号密码
if (!this.joining && !getToken() && sessionStorage.getItem('manual-login') !== '1' && sessionStorage.getItem('joinbright-login-error') !== '1') {
this.loginForm.username = 'htjc'; this.loginForm.username = 'htjc';
this.loginForm.password = 'htjc@123'; this.loginForm.password = 'htjc@123';
this.login(); this.login();
@ -154,7 +160,9 @@ 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 || homePath }).catch(() => { });
sessionStorage.removeItem('joinbright-login-error');
const redirect = this.redirect || (this.$route.query && this.$route.query.redirect);
this.$router.push({ path: redirect || homePath }).catch(() => { });
}).catch(() => { }).catch(() => {
this.loading = false; this.loading = false;
@ -172,6 +180,11 @@ export default {
this.login() this.login()
} }
}); });
},
handleForget() {
this.$alert("请联系管理员重置密码", "忘记密码", {
confirmButtonText: "确定"
});
} }
} }
}; };
@ -179,39 +192,119 @@ export default {
<style rel="stylesheet/scss" lang="scss"> <style rel="stylesheet/scss" lang="scss">
.login { .login {
position: relative;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: 100%; height: 100%;
background-image: url("../assets/images/login-background.jpg"); background-image: url("../assets/images/login-background.jpg");
background-size: cover; background-size: cover;
background-position: center;
} }
.title { .title {
margin: 0px auto 26px auto;
margin: 8px auto 42px auto;
text-align: center; text-align: center;
color: #707070;
color: #fff;
font-family: var(--app-heading-font-family); font-family: var(--app-heading-font-family);
font-size: 32px;
font-weight: 900;
font-size: 36px;
font-weight: 700;
letter-spacing: 2px;
} }
.login-form { .login-form {
border-radius: 6px;
background: #ffffff;
width: 400px;
padding: 25px 25px 5px 25px;
border-radius: 2px;
border: 1px solid rgba(120, 176, 255, .55);
background: rgba(10, 42, 112, .72);
box-shadow: 0 12px 40px rgba(0, 8, 28, .35);
width: 560px;
padding: 56px 64px 40px;
.el-input { .el-input {
height: 38px;
height: 46px;
input { input {
height: 38px;
height: 46px;
line-height: 46px;
color: #d7e6ff;
background: rgba(18, 58, 122, .55);
border: 1px solid rgba(92, 150, 230, .85);
border-radius: 2px;
}
input::placeholder {
color: rgba(176, 204, 240, .75);
} }
// 浏览器记住账号后会用自动填充把背景刷成白色,这里锁回深蓝
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
-webkit-text-fill-color: #d7e6ff !important;
caret-color: #d7e6ff;
box-shadow: 0 0 0 1000px rgba(18, 58, 122, .92) inset !important;
transition: background-color 99999s ease-out 0s;
}
}
.el-input__prefix {
left: 10px;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
top:-7px
}
.input-icon.svg-icon {
position: absolute;
top: 50%;
left: 0;
width: 16px;
height: 16px;
margin: 0;
line-height: 1;
vertical-align: top;
color: #8eb6ef;
transform: translateY(-50%);
} }
.input-icon {
height: 39px;
width: 14px;
margin-left: 2px;
.el-form-item {
margin-bottom: 22px;
} }
} }
.login-options {
display: flex;
align-items: center;
justify-content: space-between;
margin: 4px 0 28px;
color: #d5e4ff;
font-size: 14px;
}
.login-form .el-checkbox {
color: #d5e4ff;
}
.login-form .el-checkbox__inner {
background: transparent;
border-color: rgba(150, 190, 240, .8);
}
.login-form .el-checkbox__input.is-checked + .el-checkbox__label,
.login-form .el-checkbox__label {
color: #d5e4ff;
}
.login-forget {
color: #d5e4ff;
cursor: pointer;
}
.login-forget:hover {
color: #fff;
}
.login-submit {
width: 100%;
height: 46px;
border: 0;
border-radius: 2px;
background: #1f6fe0;
font-size: 16px;
letter-spacing: 4px;
}
.login-submit:hover,
.login-submit:focus {
background: #3482ee;
}
.login-tip { .login-tip {
font-size: 13px; font-size: 13px;
text-align: center; text-align: center;


+ 4
- 2
src/views/portal/components/DefectTrendChart.vue View File

@ -45,8 +45,10 @@ export default {
}, },
yAxis: { yAxis: {
type: "value", type: "value",
splitLine: { lineStyle: { color: "rgba(84,130,255,.14)", type: "dashed" } },
axisLabel: { color: "#9fb4e8", fontSize: 11 }
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { lineStyle: { color: "rgba(84,130,255,.14)", type: "dashed" } }
}, },
series: this.series.map(item => ({ series: this.series.map(item => ({
name: item.name, name: item.name,


+ 21
- 11
src/views/portal/components/PortalDeviceTree.vue View File

@ -5,20 +5,23 @@
<strong>{{ visibleChannelCount }}</strong> <strong>{{ visibleChannelCount }}</strong>
</div> </div>
<div class="tree-filters"> <div class="tree-filters">
<el-select v-model="onlineStatus" size="small" placeholder="状态" clearable>
<el-option label="全部" value="" />
<el-option label="在线" value="1" />
<el-option label="离线" value="0" />
</el-select>
<div class="tree-filter-bar">
<el-select v-model="onlineStatus" size="small" placeholder="状态" clearable>
<el-option label="全部" value="" />
<el-option label="在线" value="1" />
<el-option label="离线" value="0" />
</el-select>
<el-button size="small" icon="el-icon-refresh" title="刷新列表" @click="loadTree" />
</div>
<el-input <el-input
v-model.trim="keywords" v-model.trim="keywords"
class="tree-search"
size="small" size="small"
clearable clearable
prefix-icon="el-icon-search" prefix-icon="el-icon-search"
placeholder="搜索" placeholder="搜索"
@input="refreshTree" @input="refreshTree"
/> />
<el-button size="small" icon="el-icon-refresh" title="刷新列表" @click="loadTree" />
</div> </div>
<div v-loading="internalLoading" class="tree-content"> <div v-loading="internalLoading" class="tree-content">
<el-tree <el-tree
@ -239,17 +242,24 @@ export default {
.tree-filters { .tree-filters {
display: flex; display: flex;
flex-direction: column;
gap: 6px; gap: 6px;
padding: 10px 8px; padding: 10px 8px;
} }
.tree-filters .el-select {
width: 78px;
.tree-filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.tree-filter-bar .el-select {
width: 88px;
} }
.tree-filters .el-input {
flex: 1;
min-width: 0;
.tree-search {
width: 100%;
} }
.tree-filters .el-button { .tree-filters .el-button {


+ 170
- 100
src/views/portal/index.vue View File

@ -49,6 +49,7 @@
]" ]"
@mouseenter="item.defectCarousel && pauseDefectCarousel()" @mouseenter="item.defectCarousel && pauseDefectCarousel()"
@mouseleave="item.defectCarousel && startDefectCarousel()" @mouseleave="item.defectCarousel && startDefectCarousel()"
@click="item.defectCarousel && openDefectRecords(activeDefectSlide)"
> >
<template v-if="item.defectCarousel"> <template v-if="item.defectCarousel">
<div class="overview-defect-viewport"> <div class="overview-defect-viewport">
@ -186,7 +187,7 @@
</el-select> </el-select>
</div> </div>
<div class="video-monitor-body" :class="{ 'tree-visible': treeVisible }">
<div class="video-monitor-body">
<div class="video-monitor-main"> <div class="video-monitor-main">
<div class="camera-stats"> <div class="camera-stats">
<div <div
@ -228,10 +229,10 @@
<path d="M32 20l12-6v20l-12-6z" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linejoin="round" /> <path d="M32 20l12-6v20l-12-6z" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linejoin="round" />
<circle cx="18" cy="23" r="5" fill="none" stroke="currentColor" stroke-width="2.4" /> <circle cx="18" cy="23" r="5" fill="none" stroke="currentColor" stroke-width="2.4" />
</svg> </svg>
<span class="video-loading">{{ video.playbackError ? '视频加载失败' : (video.loading ? '正在连接' : '等待视频') }}</span>
<span class="video-loading">{{ video.playbackError ? '等待视频' : (video.loading ? '正在连接' : '等待视频') }}</span>
</div> </div>
<div class="video-mask" /> <div class="video-mask" />
<span class="video-dot" :class="video.status === 'abnormal' ? 'abnormal' : 'normal'" />
<span class="video-dot" :class="video.playbackError || video.status === 'abnormal' ? 'abnormal' : 'normal'" />
<div class="video-labels"> <div class="video-labels">
<div class="video-station">{{ video.stationName }}</div> <div class="video-station">{{ video.stationName }}</div>
<div class="video-camera">{{ video.cameraName }}</div> <div class="video-camera">{{ video.cameraName }}</div>
@ -362,10 +363,10 @@ function createEmptyTrend() {
} }
const DEFECT_SLIDES = [ const DEFECT_SLIDES = [
{ key: "slight", label: "轻微缺陷", fields: ["slightDefects", "minorDefects"] },
{ key: "general", label: "一般缺陷", fields: ["generalDefects", "normalDefects"] },
{ key: "serious", label: "严重缺陷", fields: ["seriousDefects"] },
{ key: "urgent", label: "重大缺陷", fields: ["urgentDefects"] }
{ key: "slight", label: "轻微缺陷", defectLevel: "4", fields: ["slightDefects", "minorDefects"] },
{ key: "general", label: "一般缺陷", defectLevel: "3", fields: ["generalDefects", "normalDefects"] },
{ key: "serious", label: "严重缺陷", defectLevel: "2", fields: ["seriousDefects"] },
{ key: "urgent", label: "重大缺陷", defectLevel: "1", fields: ["urgentDefects"] }
]; ];
// 场站卡片下方的缺陷趋势快捷筛选 // 场站卡片下方的缺陷趋势快捷筛选
@ -404,7 +405,7 @@ export default {
navItems: [ navItems: [
{ key: "home", label: "首页" }, { key: "home", label: "首页" },
{ key: "patrol", label: "动环巡检" }, { key: "patrol", label: "动环巡检" },
// { key: "system", label: "系统管理" }
{ key: "system", label: "系统管理" }
], ],
stationButtons: STATION_BUTTONS, stationButtons: STATION_BUTTONS,
defectSlides: DEFECT_SLIDES, defectSlides: DEFECT_SLIDES,
@ -429,8 +430,10 @@ export default {
treeVisible: false, treeVisible: false,
treeMounted: false, treeMounted: false,
portalPlayers: {}, portalPlayers: {},
portalPlayTimers: {},
portalStartTimers: {}, portalStartTimers: {},
portalStartRequests: {}, portalStartRequests: {},
portalAttemptedCodes: {},
portalRequestSource: null, portalRequestSource: null,
portalAlive: true, portalAlive: true,
portalReleased: false, portalReleased: false,
@ -624,6 +627,7 @@ export default {
return this.stopPortalVideos().then(() => { return this.stopPortalVideos().then(() => {
// 切页后停流才返回时,不能再把播放器挂回去。 // 切页后停流才返回时,不能再把播放器挂回去。
if (!this.portalAlive) return; if (!this.portalAlive) return;
this.portalAttemptedCodes = {};
this.videoList = this.pickRandomVideos(videos, 4); this.videoList = this.pickRandomVideos(videos, 4);
this.activeVideoIndex = 0; this.activeVideoIndex = 0;
this.$nextTick(() => this.startDefaultPortalVideos()); this.$nextTick(() => this.startDefaultPortalVideos());
@ -659,8 +663,7 @@ export default {
this.$set(video, "playUrl", stream.hlsUrl); this.$set(video, "playUrl", stream.hlsUrl);
this.$set(video, "flvUrl", stream.flvUrl); this.$set(video, "flvUrl", stream.flvUrl);
if (!stream.hlsUrl && !stream.flvUrl) { if (!stream.hlsUrl && !stream.flvUrl) {
this.$set(video, "loading", false);
this.$set(video, "playbackError", true);
this.advancePortalVideo(video, index);
return; return;
} }
this.$nextTick(() => this.attachPortalPlayer(video, index)); this.$nextTick(() => this.attachPortalPlayer(video, index));
@ -670,8 +673,7 @@ export default {
} }
// 切页取消的请求不要再改状态,组件可能已经销毁。 // 切页取消的请求不要再改状态,组件可能已经销毁。
if (!this.portalAlive || (error && error.code === "ERR_CANCELED")) return; if (!this.portalAlive || (error && error.code === "ERR_CANCELED")) return;
this.$set(video, "loading", false);
this.$set(video, "playbackError", true);
if (this.videoList[index] === video) this.advancePortalVideo(video, index);
} }
}, },
attachPortalPlayer(video, index) { attachPortalPlayer(video, index) {
@ -705,8 +707,10 @@ export default {
}); });
const player = { hls, element, stopped: false }; const player = { hls, element, stopped: false };
this.$set(this.portalPlayers, index, player); this.$set(this.portalPlayers, index, player);
this.armPortalPlayTimeout(video, index, player);
const markPlaying = () => { const markPlaying = () => {
if (this.portalPlayers[index] !== player || player.stopped) return; if (this.portalPlayers[index] !== player || player.stopped) return;
this.clearPortalPlayTimer(index);
this.$set(video, "loading", false); this.$set(video, "loading", false);
this.$set(video, "playbackError", false); this.$set(video, "playbackError", false);
}; };
@ -723,7 +727,9 @@ export default {
}); });
hls.on(Hls.Events.ERROR, (_, detail) => { hls.on(Hls.Events.ERROR, (_, detail) => {
if (this.portalPlayers[index] !== player || player.stopped || !detail || !detail.fatal) return; if (this.portalPlayers[index] !== player || player.stopped || !detail || !detail.fatal) return;
if (flvUrl) {
if (flvUrl && !player.flvFallbackUsed) {
player.flvFallbackUsed = true;
this.clearPortalPlayTimer(index);
this.attachPortalFlv(video, index, element, flvUrl); this.attachPortalFlv(video, index, element, flvUrl);
return; return;
} }
@ -733,14 +739,18 @@ export default {
} else if (hlsUrl && element.canPlayType("application/vnd.apple.mpegurl")) { } else if (hlsUrl && element.canPlayType("application/vnd.apple.mpegurl")) {
const player = { native: true, element, stopped: false }; const player = { native: true, element, stopped: false };
this.$set(this.portalPlayers, index, player); this.$set(this.portalPlayers, index, player);
this.armPortalPlayTimeout(video, index, player);
element.onplaying = () => { element.onplaying = () => {
if (this.portalPlayers[index] !== player || player.stopped) return; if (this.portalPlayers[index] !== player || player.stopped) return;
this.clearPortalPlayTimer(index);
this.$set(video, "loading", false); this.$set(video, "loading", false);
this.$set(video, "playbackError", false); this.$set(video, "playbackError", false);
}; };
element.onerror = () => { element.onerror = () => {
if (this.portalPlayers[index] !== player || player.stopped) return; if (this.portalPlayers[index] !== player || player.stopped) return;
if (flvUrl) {
if (flvUrl && !player.flvFallbackUsed) {
player.flvFallbackUsed = true;
this.clearPortalPlayTimer(index);
this.attachPortalFlv(video, index, element, flvUrl); this.attachPortalFlv(video, index, element, flvUrl);
return; return;
} }
@ -751,14 +761,12 @@ export default {
} else if (flvUrl) { } else if (flvUrl) {
this.attachPortalFlv(video, index, element, flvUrl); this.attachPortalFlv(video, index, element, flvUrl);
} else { } else {
this.$set(video, "loading", false);
this.$set(video, "playbackError", true);
this.advancePortalVideo(video, index);
} }
}, },
attachPortalFlv(video, index, element, url) { attachPortalFlv(video, index, element, url) {
if (!url || !flvjs.isSupported()) { if (!url || !flvjs.isSupported()) {
this.$set(video, "loading", false);
this.$set(video, "playbackError", true);
this.advancePortalVideo(video, index);
return; return;
} }
this.destroyPortalPlayer(index); this.destroyPortalPlayer(index);
@ -768,8 +776,10 @@ export default {
); );
const player = { flv, element, stopped: false }; const player = { flv, element, stopped: false };
this.$set(this.portalPlayers, index, player); this.$set(this.portalPlayers, index, player);
this.armPortalPlayTimeout(video, index, player);
const markPlaying = () => { const markPlaying = () => {
if (this.portalPlayers[index] !== player || player.stopped) return; if (this.portalPlayers[index] !== player || player.stopped) return;
this.clearPortalPlayTimer(index);
this.$set(video, "loading", false); this.$set(video, "loading", false);
this.$set(video, "playbackError", false); this.$set(video, "playbackError", false);
}; };
@ -784,54 +794,94 @@ export default {
this.failPortalPlayer(player, video, index); this.failPortalPlayer(player, video, index);
}); });
}, },
failPortalPlayer(player, video, index) {
if (!player || player.stopped) return;
releasePortalMedia(player) {
if (!player) return;
player.stopped = true; player.stopped = true;
if (player.hls) { if (player.hls) {
try { player.hls.stopLoad(); } catch (_) { /* already stopped */ }
try { player.hls.destroy(); } catch (_) { /* already destroyed */ }
const hls = player.hls;
player.hls = null; player.hls = null;
try { hls.stopLoad(); } catch (_) { /* already stopped */ }
try { hls.detachMedia(); } catch (_) { /* already detached */ }
try { hls.destroy(); } catch (_) { /* already destroyed */ }
} }
if (player.flv) { if (player.flv) {
try { player.flv.destroy(); } catch (_) { /* already destroyed */ }
const flv = player.flv;
player.flv = null; player.flv = null;
try { flv.pause(); } catch (_) { /* already paused */ }
try { flv.unload(); } catch (_) { /* already unloaded */ }
try { flv.detachMediaElement(); } catch (_) { /* already detached */ }
try { flv.destroy(); } catch (_) { /* already destroyed */ }
} }
if (player.element) { if (player.element) {
player.element.onplaying = null; player.element.onplaying = null;
player.element.onloadeddata = null; player.element.onloadeddata = null;
player.element.oncanplay = null; player.element.oncanplay = null;
player.element.onerror = null; player.element.onerror = null;
player.element.pause();
try { player.element.pause(); } catch (_) { /* element already gone */ }
player.element.removeAttribute("src"); player.element.removeAttribute("src");
player.element.load();
try { player.element.load(); } catch (_) { /* element already gone */ }
} }
if (this.videoList[index] === video) {
this.$set(video, "loading", false);
this.$set(video, "playbackError", true);
},
failPortalPlayer(player, video, index) {
if (!player || player.stopped) return;
this.releasePortalMedia(player);
if (this.videoList[index] === video) this.advancePortalVideo(video, index);
},
clearPortalPlayTimer(index) {
if (!this.portalPlayTimers[index]) return;
window.clearTimeout(this.portalPlayTimers[index]);
this.$delete(this.portalPlayTimers, index);
},
armPortalPlayTimeout(video, index, player) {
this.clearPortalPlayTimer(index);
this.$set(this.portalPlayTimers, index, window.setTimeout(() => {
this.$delete(this.portalPlayTimers, index);
if (!this.portalAlive || this.videoList[index] !== video || this.portalPlayers[index] !== player || player.stopped) return;
this.failPortalPlayer(player, video, index);
}, 10000));
},
clearPortalAttempted(index) {
this.$delete(this.portalAttemptedCodes, index);
},
nextPortalCandidate(failedVideo, index) {
const pool = this.filteredVideos.filter(item => item && item.cameraCode && item.status !== "abnormal");
if (!pool.length) return null;
const attempted = this.portalAttemptedCodes[index] || [];
const occupied = new Set(
this.videoList
.filter((item, slotIndex) => item && item.cameraCode && slotIndex !== index)
.map(item => item.cameraCode)
);
const currentIndex = Math.max(0, pool.findIndex(item => item.cameraCode === failedVideo.cameraCode));
for (let offset = 1; offset <= pool.length; offset += 1) {
const candidate = pool[(currentIndex + offset) % pool.length];
if (!candidate || attempted.includes(candidate.cameraCode) || occupied.has(candidate.cameraCode)) continue;
return candidate;
} }
return null;
},
async advancePortalVideo(failedVideo, index) {
if (!this.portalAlive || this.videoList[index] !== failedVideo) return;
const attempted = (this.portalAttemptedCodes[index] || []).slice();
if (failedVideo.cameraCode && !attempted.includes(failedVideo.cameraCode)) attempted.push(failedVideo.cameraCode);
this.$set(this.portalAttemptedCodes, index, attempted);
const nextVideo = this.nextPortalCandidate(failedVideo, index);
await this.stopPortalVideo(failedVideo, index);
if (!this.portalAlive || this.videoList[index] !== failedVideo) return;
if (!nextVideo) {
this.$set(failedVideo, "loading", false);
this.$set(failedVideo, "playbackError", true);
return;
}
const replacement = { ...nextVideo, loading: true, playbackError: false, playUrl: "", flvUrl: "", streamId: "" };
this.$set(this.videoList, index, replacement);
this.$nextTick(() => this.startPortalVideo(replacement, index));
}, },
destroyPortalPlayer(index) { destroyPortalPlayer(index) {
this.clearPortalPlayTimer(index);
const player = this.portalPlayers[index]; const player = this.portalPlayers[index];
if (!player) return; if (!player) return;
player.stopped = true;
if (player.hls) {
try { player.hls.stopLoad(); } catch (_) { /* already stopped */ }
try { player.hls.destroy(); } catch (_) { /* already destroyed */ }
player.hls = null;
}
if (player.flv) {
try { player.flv.destroy(); } catch (_) { /* already destroyed */ }
player.flv = null;
}
if (player.element) {
player.element.onplaying = null;
player.element.onloadeddata = null;
player.element.oncanplay = null;
player.element.onerror = null;
player.element.pause();
player.element.removeAttribute("src");
player.element.load();
}
this.releasePortalMedia(player);
this.$delete(this.portalPlayers, index); this.$delete(this.portalPlayers, index);
}, },
clearPortalStartTimer(index) { clearPortalStartTimer(index) {
@ -863,6 +913,7 @@ export default {
window.clearTimeout(this.portalStartTimers[videoId]); window.clearTimeout(this.portalStartTimers[videoId]);
this.$delete(this.portalStartTimers, videoId); this.$delete(this.portalStartTimers, videoId);
}); });
Object.keys(this.portalPlayTimers).forEach(videoId => this.clearPortalPlayTimer(videoId));
return Promise.all(videos.map((video, index) => this.stopPortalVideo(video, index))); return Promise.all(videos.map((video, index) => this.stopPortalVideo(video, index)));
}, },
selectPortalVideoSlot(index) { selectPortalVideoSlot(index) {
@ -882,6 +933,7 @@ export default {
if ((!current.playUrl || current.playbackError) && !current.loading) this.startPortalVideo(current, index); if ((!current.playUrl || current.playbackError) && !current.loading) this.startPortalVideo(current, index);
return; return;
} }
this.clearPortalAttempted(index);
await this.stopPortalVideo(current, index); await this.stopPortalVideo(current, index);
this.$set(this.videoList, index, video); this.$set(this.videoList, index, video);
this.$nextTick(() => this.startPortalVideo(video, index)); this.$nextTick(() => this.startPortalVideo(video, index));
@ -969,6 +1021,16 @@ export default {
const field = slide.fields.find(key => card[key] !== undefined && card[key] !== null); const field = slide.fields.find(key => card[key] !== undefined && card[key] !== null);
return field ? card[field] : 0; return field ? card[field] : 0;
}, },
openDefectRecords(slide) {
if (!slide || slide.defectLevel === undefined || slide.defectLevel === "") return;
this.$store.dispatch("siteContext/markFromPortal");
this.$store.dispatch("siteContext/exitSite");
this.releasePortalPage();
this.$router.push({
path: "/defect-record/index",
query: { defectLevel: slide.defectLevel }
}).catch(() => undefined);
},
setDefectSlide(index) { setDefectSlide(index) {
if (index === this.defectSlideIndex) return; if (index === this.defectSlideIndex) return;
this.defectSlideDirection = index > this.defectSlideIndex ? "next" : "prev"; this.defectSlideDirection = index > this.defectSlideIndex ? "next" : "prev";
@ -1000,13 +1062,8 @@ export default {
this.stopPortalVideos(); this.stopPortalVideos();
}, },
systemEntryPath() { 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}`;
// 固定进已注册的用户管理。后台菜单没下发 /system 时,按菜单树拼出来的地址会 404。
return "/system/user";
}, },
onNavClick(item) { onNavClick(item) {
if (item.key === "home") { if (item.key === "home") {
@ -1736,6 +1793,7 @@ $panel-border: rgba(84, 130, 255, 0.22);
} }
.video-monitor-body { .video-monitor-body {
position: relative;
display: flex; display: flex;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@ -1750,22 +1808,27 @@ $panel-border: rgba(84, 130, 255, 0.22);
} }
.portal-device-tree { .portal-device-tree {
align-self: flex-start;
flex: 0 0 46%;
width: 46%;
height: calc(100% + 1px);
min-width: 280px;
max-width: 360px;
position: absolute;
top: 0;
right: 0;
bottom: 0;
z-index: 4;
width: 242px;
min-width: 0;
min-height: 0; min-height: 0;
padding: 0; padding: 0;
margin: -1px 0 14px;
margin: 0;
overflow: hidden; overflow: hidden;
background: transparent !important;
background: rgba(7, 24, 78, 0.42) !important;
border: 1px solid rgba(63, 127, 216, 0.72);
box-shadow: inset 0 0 18px rgba(36, 110, 200, 0.14), 0 0 8px rgba(36, 120, 210, 0.22);
::v-deep .nvr-tree {
::v-deep .portal-tree {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
background: transparent !important; background: transparent !important;
border: 0;
box-shadow: none;
} }
::v-deep .tree-title { ::v-deep .tree-title {
@ -1773,15 +1836,53 @@ $panel-border: rgba(84, 130, 255, 0.22);
} }
::v-deep .tree-filters { ::v-deep .tree-filters {
padding-right: 0;
padding-left: 0;
padding-right: 8px;
padding-left: 8px;
background: transparent !important; background: transparent !important;
} }
::v-deep .tree-filters .el-input__inner {
color: #f4fbff !important;
-webkit-text-fill-color: #f4fbff;
background-color: transparent !important;
border-color: #4aa6ff !important;
box-shadow: none;
}
::v-deep .tree-filters .el-input__inner::placeholder {
color: rgba(214, 242, 255, 0.78);
-webkit-text-fill-color: rgba(214, 242, 255, 0.78);
}
::v-deep .tree-filters .el-button {
color: #e7f8ff;
border-color: #4aa6ff;
background: transparent;
}
::v-deep .tree-content, ::v-deep .tree-content,
::v-deep .el-tree { ::v-deep .el-tree {
background: transparent !important; background: transparent !important;
} }
::v-deep .tree-content {
scrollbar-color: rgba(96, 186, 255, 0.6) transparent;
}
::v-deep .tree-content::-webkit-scrollbar-track {
background: transparent;
}
::v-deep .tree-content::-webkit-scrollbar-thumb {
background: rgba(96, 186, 255, 0.55);
border-color: transparent;
}
::v-deep .el-tree-node__content:hover,
::v-deep .el-tree-node:focus > .el-tree-node__content,
::v-deep .el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content {
background-color: rgba(11, 37, 103, 0.45) !important;
}
} }
.tree-toggle { .tree-toggle {
@ -1811,10 +1912,8 @@ $panel-border: rgba(84, 130, 255, 0.22);
filter: brightness(1.2) drop-shadow(0 0 6px rgba(32, 218, 255, 0.65)); filter: brightness(1.2) drop-shadow(0 0 6px rgba(32, 218, 255, 0.65));
} }
/* 树展开时:按钮贴到树面板左缘,显示收起箭头
树宽被 min-width/max-width 钳制,按钮用同样的 clamp 规则才能在任何屏幕下对齐树缘 */
&.open { &.open {
right: clamp(280px, 46%, 360px);
right: 242px;
} }
} }
@ -1885,23 +1984,7 @@ $panel-border: rgba(84, 130, 255, 0.22);
padding: 0 14px 12px; padding: 0 14px 12px;
} }
.video-monitor-body.tree-visible {
.camera-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 12px;
padding-right: 12px;
padding-left: 12px;
}
.video-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 8px;
overflow: hidden;
}
}
.video-monitor-body:not(.tree-visible) .video-grid {
.video-monitor-body .video-grid {
padding-right: 28px; padding-right: 28px;
} }
@ -2037,19 +2120,6 @@ $panel-border: rgba(84, 130, 255, 0.22);
} }
} }
.portal-device-tree {
min-width: 260px;
}
/* 窄屏下树宽下限变为 260px,按钮同步对齐 */
.tree-toggle.open {
right: clamp(260px, 46%, 360px);
}
.video-monitor-body.tree-visible .camera-stat {
padding-right: 6px;
padding-left: 6px;
}
} }
.video-grid { .video-grid {
@ -2115,8 +2185,8 @@ $panel-border: rgba(84, 130, 255, 0.22);
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
/* contain:完整显示画面不裁切;树打开格子变窄时两侧留底色而不是切割视频 */
object-fit: contain;
/* cover:铺满格子,和实时监控嵌入页一样不留黑边 */
object-fit: cover;
background: #050b24; background: #050b24;
&.hidden { &.hidden {


+ 10
- 2
src/views/system/role/index.vue View File

@ -185,7 +185,7 @@
<el-form-item label="菜单权限"> <el-form-item label="菜单权限">
<el-checkbox v-model="menuExpand" @change="handleCheckedTreeExpand($event, 'menu')">展开/折叠</el-checkbox> <el-checkbox v-model="menuExpand" @change="handleCheckedTreeExpand($event, 'menu')">展开/折叠</el-checkbox>
<el-checkbox v-model="menuNodeAll" @change="handleCheckedTreeNodeAll($event, 'menu')">全选/全不选</el-checkbox> <el-checkbox v-model="menuNodeAll" @change="handleCheckedTreeNodeAll($event, 'menu')">全选/全不选</el-checkbox>
<el-checkbox v-model="form.menuCheckStrictly" @change="handleCheckedTreeConnect($event, 'menu')">父子联动</el-checkbox>
<el-checkbox v-model="form.menuCheckStrictly" :true-label="true" :false-label="false" @change="handleCheckedTreeConnect($event, 'menu')">父子联动</el-checkbox>
<el-tree <el-tree
class="tree-border" class="tree-border"
:data="menuOptions" :data="menuOptions"
@ -229,7 +229,7 @@
<el-form-item label="数据权限" v-show="form.dataScope == 2"> <el-form-item label="数据权限" v-show="form.dataScope == 2">
<el-checkbox v-model="deptExpand" @change="handleCheckedTreeExpand($event, 'dept')">展开/折叠</el-checkbox> <el-checkbox v-model="deptExpand" @change="handleCheckedTreeExpand($event, 'dept')">展开/折叠</el-checkbox>
<el-checkbox v-model="deptNodeAll" @change="handleCheckedTreeNodeAll($event, 'dept')">全选/全不选</el-checkbox> <el-checkbox v-model="deptNodeAll" @change="handleCheckedTreeNodeAll($event, 'dept')">全选/全不选</el-checkbox>
<el-checkbox v-model="form.deptCheckStrictly" @change="handleCheckedTreeConnect($event, 'dept')">父子联动</el-checkbox>
<el-checkbox v-model="form.deptCheckStrictly" :true-label="true" :false-label="false" @change="handleCheckedTreeConnect($event, 'dept')">父子联动</el-checkbox>
<el-tree <el-tree
class="tree-border" class="tree-border"
:data="deptOptions" :data="deptOptions"
@ -467,6 +467,12 @@ export default {
break; break;
} }
}, },
// 后端把父子联动存成 1/0 或 "true"/"false",勾选框只认布尔值
normalizeCheckStrictly(form) {
const checked = (value) => value === true || value === 1 || value === "1" || value === "true";
this.$set(form, "menuCheckStrictly", checked(form.menuCheckStrictly));
this.$set(form, "deptCheckStrictly", checked(form.deptCheckStrictly));
},
// 树权限(展开/折叠) // 树权限(展开/折叠)
handleCheckedTreeExpand(value, type) { handleCheckedTreeExpand(value, type) {
if (type == 'menu') { if (type == 'menu') {
@ -511,6 +517,7 @@ export default {
const roleMenu = this.getRoleMenuTreeselect(roleId); const roleMenu = this.getRoleMenuTreeselect(roleId);
getRole(roleId).then(response => { getRole(roleId).then(response => {
this.form = response.data; this.form = response.data;
this.normalizeCheckStrictly(this.form);
this.open = true; this.open = true;
this.$nextTick(() => { this.$nextTick(() => {
roleMenu.then(res => { roleMenu.then(res => {
@ -537,6 +544,7 @@ export default {
const deptTreeSelect = this.getDeptTree(row.roleId); const deptTreeSelect = this.getDeptTree(row.roleId);
getRole(row.roleId).then(response => { getRole(row.roleId).then(response => {
this.form = response.data; this.form = response.data;
this.normalizeCheckStrictly(this.form);
this.openDataScope = true; this.openDataScope = true;
this.$nextTick(() => { this.$nextTick(() => {
deptTreeSelect.then(res => { deptTreeSelect.then(res => {


+ 209
- 47
src/views/system/user/index.vue View File

@ -4,18 +4,18 @@
<splitpanes :horizontal="this.$store.getters.device === 'mobile'" class="default-theme"> <splitpanes :horizontal="this.$store.getters.device === 'mobile'" class="default-theme">
<!--部门数据--> <!--部门数据-->
<pane size="16"> <pane size="16">
<el-col>
<div class="dept-pane">
<div class="head-container"> <div class="head-container">
<el-input v-model="deptName" placeholder="请输入部门名称" clearable size="small" prefix-icon="el-icon-search" style="margin-bottom: 20px" /> <el-input v-model="deptName" placeholder="请输入部门名称" clearable size="small" prefix-icon="el-icon-search" style="margin-bottom: 20px" />
</div> </div>
<div class="head-container">
<div class="head-container dept-tree">
<el-tree :data="deptOptions" :props="defaultProps" :expand-on-click-node="false" :filter-node-method="filterNode" ref="tree" node-key="id" default-expand-all highlight-current @node-click="handleNodeClick" /> <el-tree :data="deptOptions" :props="defaultProps" :expand-on-click-node="false" :filter-node-method="filterNode" ref="tree" node-key="id" default-expand-all highlight-current @node-click="handleNodeClick" />
</div> </div>
</el-col>
</div>
</pane> </pane>
<!--用户数据--> <!--用户数据-->
<pane size="84"> <pane size="84">
<el-col>
<div class="user-pane">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px"> <el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
<el-form-item label="用户名称" prop="userName"> <el-form-item label="用户名称" prop="userName">
<el-input v-model="queryParams.userName" placeholder="请输入用户名称" clearable style="width: 240px" @keyup.enter.native="handleQuery" /> <el-input v-model="queryParams.userName" placeholder="请输入用户名称" clearable style="width: 240px" @keyup.enter.native="handleQuery" />
@ -56,7 +56,8 @@
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" :columns="columns"></right-toolbar> <right-toolbar :showSearch.sync="showSearch" @queryTable="getList" :columns="columns"></right-toolbar>
</el-row> </el-row>
<el-table v-loading="loading" :data="userList" @selection-change="handleSelectionChange">
<div class="user-table-wrap" ref="tableWrap">
<el-table ref="userTable" v-loading="loading" :data="userList" :height="tableHeight" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="50" align="center" /> <el-table-column type="selection" width="50" align="center" />
<el-table-column label="用户编号" align="center" key="userId" prop="userId" v-if="columns[0].visible" /> <el-table-column label="用户编号" align="center" key="userId" prop="userId" v-if="columns[0].visible" />
<el-table-column label="用户名称" align="center" key="userName" prop="userName" v-if="columns[1].visible" :show-overflow-tooltip="true" /> <el-table-column label="用户名称" align="center" key="userName" prop="userName" v-if="columns[1].visible" :show-overflow-tooltip="true" />
@ -65,7 +66,7 @@
<el-table-column label="手机号码" align="center" key="phonenumber" prop="phonenumber" v-if="columns[4].visible" width="120" /> <el-table-column label="手机号码" align="center" key="phonenumber" prop="phonenumber" v-if="columns[4].visible" width="120" />
<el-table-column label="状态" align="center" key="status" v-if="columns[5].visible"> <el-table-column label="状态" align="center" key="status" v-if="columns[5].visible">
<template slot-scope="scope"> <template slot-scope="scope">
<el-switch v-model="scope.row.status" active-value="0" inactive-value="1" @change="handleStatusChange(scope.row)"></el-switch>
<el-switch class="status-switch" v-model="scope.row.status" active-value="0" inactive-value="1" @change="handleStatusChange(scope.row)"></el-switch>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="创建时间" align="center" prop="createTime" v-if="columns[6].visible" width="160"> <el-table-column label="创建时间" align="center" prop="createTime" v-if="columns[6].visible" width="160">
@ -87,16 +88,17 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div>
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" /> <pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
</el-col>
</div>
</pane> </pane>
</splitpanes> </splitpanes>
</el-row> </el-row>
<!-- 添加或修改用户配置对话框 --> <!-- 添加或修改用户配置对话框 -->
<el-dialog :title="title" :visible.sync="open" width="600px" append-to-body> <el-dialog :title="title" :visible.sync="open" width="600px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-form ref="form" :model="form" :rules="rules" label-width="80px" autocomplete="off" @submit.native.prevent>
<el-row> <el-row>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="用户昵称" prop="nickName"> <el-form-item label="用户昵称" prop="nickName">
@ -124,12 +126,12 @@
<el-row> <el-row>
<el-col :span="12"> <el-col :span="12">
<el-form-item v-if="form.userId == undefined" label="用户名称" prop="userName"> <el-form-item v-if="form.userId == undefined" label="用户名称" prop="userName">
<el-input v-model="form.userName" placeholder="请输入用户名称" maxlength="30" />
<el-input v-model="form.userName" placeholder="请输入用户名称" maxlength="30" autocomplete="off" :readonly="autofillLocked" @focus="autofillLocked = false" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item v-if="form.userId == undefined" label="用户密码" prop="password"> <el-form-item v-if="form.userId == undefined" label="用户密码" prop="password">
<el-input v-model="form.password" placeholder="请输入用户密码" type="password" maxlength="20" show-password />
<el-input v-model="form.password" placeholder="请输入用户密码" type="password" maxlength="20" show-password autocomplete="new-password" :readonly="autofillLocked" @focus="autofillLocked = false" />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
@ -167,8 +169,8 @@
</el-row> </el-row>
<el-row> <el-row>
<el-col :span="24"> <el-col :span="24">
<el-form-item label="登录IP" prop="aollowIp">
<el-input v-model="form.aollowIp" placeholder="请输入允许登录IP" maxlength="30" />
<el-form-item label="登录IP" prop="allowIp">
<el-input v-model="form.allowIp" placeholder="请输入允许登录IP" maxlength="30" />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
@ -187,7 +189,7 @@
</el-dialog> </el-dialog>
<!-- 用户导入对话框 --> <!-- 用户导入对话框 -->
<el-dialog :title="upload.title" :visible.sync="upload.open" width="400px" append-to-body>
<el-dialog :title="upload.title" :visible.sync="upload.open" width="400px" append-to-body @close="resetImport">
<el-upload ref="upload" :limit="1" accept=".xlsx, .xls" :headers="upload.headers" :action="upload.url + '?updateSupport=' + upload.updateSupport" :disabled="upload.isUploading" :on-progress="handleFileUploadProgress" :on-success="handleFileSuccess" :auto-upload="false" drag> <el-upload ref="upload" :limit="1" accept=".xlsx, .xls" :headers="upload.headers" :action="upload.url + '?updateSupport=' + upload.updateSupport" :disabled="upload.isUploading" :on-progress="handleFileUploadProgress" :on-success="handleFileSuccess" :auto-upload="false" drag>
<i class="el-icon-upload"></i> <i class="el-icon-upload"></i>
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div> <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
@ -201,7 +203,7 @@
</el-upload> </el-upload>
<div slot="footer" class="dialog-footer"> <div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitFileForm">确 定</el-button> <el-button type="primary" @click="submitFileForm">确 定</el-button>
<el-button @click="upload.open = false">取 消</el-button>
<el-button @click="cancelImport">取 消</el-button>
</div> </div>
</el-dialog> </el-dialog>
</div> </div>
@ -209,11 +211,13 @@
<script> <script>
import { listUser, getUser, delUser, addUser, updateUser, resetUserPwd, changeUserStatus, deptTreeSelect } from "@/api/system/user"; import { listUser, getUser, delUser, addUser, updateUser, resetUserPwd, changeUserStatus, deptTreeSelect } from "@/api/system/user";
import { listDept } from "@/api/system/dept";
import { getToken } from "@/utils/auth"; import { getToken } from "@/utils/auth";
import Treeselect from "@riophae/vue-treeselect"; import Treeselect from "@riophae/vue-treeselect";
import "@riophae/vue-treeselect/dist/vue-treeselect.css"; import "@riophae/vue-treeselect/dist/vue-treeselect.css";
import { Splitpanes, Pane } from "splitpanes"; import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css"; import "splitpanes/dist/splitpanes.css";
import md5 from "md5.js";
export default { export default {
name: "User", name: "User",
@ -243,10 +247,10 @@ export default {
enabledDeptOptions: undefined, enabledDeptOptions: undefined,
// 是否显示弹出层 // 是否显示弹出层
open: false, open: false,
// 打开新增弹窗时先只读,挡住浏览器把登录账号填进用户名和密码
autofillLocked: true,
// 部门名称 // 部门名称
deptName: undefined, deptName: undefined,
// 默认密码
initPassword: undefined,
// 日期范围 // 日期范围
dateRange: [], dateRange: [],
// 岗位选项 // 岗位选项
@ -320,56 +324,82 @@ export default {
message: "请输入正确的手机号码", message: "请输入正确的手机号码",
trigger: "blur" trigger: "blur"
} }
],
aollowIp: [
{ required: true, message: "请输入允许登录IP", trigger: "blur" }
] ]
}
},
tableHeight: 400
}; };
}, },
watch: { watch: {
// 根据名称筛选部门树 // 根据名称筛选部门树
deptName(val) { deptName(val) {
this.$refs.tree.filter(val); this.$refs.tree.filter(val);
},
showSearch() {
this.$nextTick(this.setTableHeight);
} }
}, },
created() { created() {
this.getList(); this.getList();
this.getDeptTree(); this.getDeptTree();
this.getConfigKey("sys.user.initPassword").then(response => {
this.initPassword = response.msg;
});
},
mounted() {
this.setTableHeight();
window.addEventListener("resize", this.setTableHeight);
},
beforeDestroy() {
window.removeEventListener("resize", this.setTableHeight);
}, },
methods: { methods: {
setTableHeight() {
this.$nextTick(() => {
const table = this.$refs.userTable && this.$refs.userTable.$el;
const pane = table && table.closest(".user-pane");
if (!table || !pane) return;
const header = table.querySelector(".el-table__header-wrapper");
const body = table.querySelector(".el-table__body");
const pagination = pane.querySelector(".pagination-container");
const available = pane.getBoundingClientRect().bottom - table.getBoundingClientRect().top - (pagination ? pagination.offsetHeight + 8 : 0);
const content = (header ? header.offsetHeight : 0) + (body ? body.offsetHeight : 0) + 2;
// 10 条不够一屏时按内容高度收起,不留空白;20 条超出时才限制高度并在表内滚动。
const next = Math.max(120, Math.min(content || available, available));
if (next > 0) this.tableHeight = next;
this.$nextTick(() => {
if (this.$refs.userTable) this.$refs.userTable.doLayout();
});
});
},
/** 查询用户列表 */ /** 查询用户列表 */
getList() { getList() {
this.loading = true; this.loading = true;
listUser(this.addDateRange(this.queryParams, this.dateRange)).then(response => { listUser(this.addDateRange(this.queryParams, this.dateRange)).then(response => {
this.userList = response.rows; this.userList = response.rows;
this.total = response.total; this.total = response.total;
this.setTableHeight();
this.loading = false; this.loading = false;
} }
); );
}, },
/** 查询部门下拉树结构 */ /** 查询部门下拉树结构 */
getDeptTree() {
deptTreeSelect().then(response => {
this.deptOptions = response.data;
this.enabledDeptOptions = this.filterDisabledDept(JSON.parse(JSON.stringify(response.data)));
});
},
// 过滤禁用的部门
filterDisabledDept(deptList) {
return deptList.filter(dept => {
if (dept.disabled) {
return false;
}
if (dept.children && dept.children.length) {
dept.children = this.filterDisabledDept(dept.children);
}
return true;
});
},
getDeptTree() {
Promise.all([deptTreeSelect(), listDept()]).then(([treeRes, deptRes]) => {
const disabledIds = new Set(
(deptRes.data || []).filter(dept => String(dept.status) === "1").map(dept => dept.deptId)
);
this.deptOptions = treeRes.data;
this.enabledDeptOptions = this.markDisabledDept(JSON.parse(JSON.stringify(treeRes.data)), disabledIds);
});
},
// 停用部门(status=1)置灰不可选,与角色下拉保持一致;父级停用时下级一并置灰
markDisabledDept(deptList, disabledIds, parentDisabled = false) {
return (deptList || []).map(dept => {
const selfDisabled = parentDisabled || dept.disabled || disabledIds.has(dept.id);
return {
...dept,
isDisabled: selfDisabled,
children: dept.children ? this.markDisabledDept(dept.children, disabledIds, selfDisabled) : undefined
};
});
},
// 筛选节点 // 筛选节点
filterNode(value, data) { filterNode(value, data) {
if (!value) return true; if (!value) return true;
@ -408,7 +438,7 @@ export default {
email: undefined, email: undefined,
sex: undefined, sex: undefined,
status: "0", status: "0",
aollowIp: undefined,
allowIp: undefined,
remark: undefined, remark: undefined,
postIds: [], postIds: [],
roleIds: [] roleIds: []
@ -450,12 +480,16 @@ export default {
/** 新增按钮操作 */ /** 新增按钮操作 */
handleAdd() { handleAdd() {
this.reset(); this.reset();
this.autofillLocked = true;
getUser().then(response => { getUser().then(response => {
this.postOptions = response.posts; this.postOptions = response.posts;
this.roleOptions = response.roles; this.roleOptions = response.roles;
this.open = true; this.open = true;
this.title = "添加用户"; this.title = "添加用户";
this.form.password = this.initPassword;
this.$nextTick(() => {
this.form.userName = undefined;
this.form.password = undefined;
});
}); });
}, },
/** 修改按钮操作 */ /** 修改按钮操作 */
@ -464,6 +498,8 @@ export default {
const userId = row.userId || this.ids; const userId = row.userId || this.ids;
getUser(userId).then(response => { getUser(userId).then(response => {
this.form = response.data; this.form = response.data;
// 详情只返回 aollowIp。后补 allowIp 必须用 $set,否则 Vue2 不追踪,输入会被弹回原值
this.$set(this.form, "allowIp", this.form.allowIp || this.form.aollowIp || "");
this.postOptions = response.posts; this.postOptions = response.posts;
this.roleOptions = response.roles; this.roleOptions = response.roles;
this.$set(this.form, "postIds", response.postIds); this.$set(this.form, "postIds", response.postIds);
@ -487,7 +523,8 @@ export default {
} }
}, },
}).then(({ value }) => { }).then(({ value }) => {
resetUserPwd(row.userId, value).then(response => {
const password = new md5().update(value).digest("hex");
resetUserPwd(row.userId, password).then(response => {
this.$modal.msgSuccess("修改成功,新密码是:" + value); this.$modal.msgSuccess("修改成功,新密码是:" + value);
}); });
}).catch(() => {}); }).catch(() => {});
@ -501,14 +538,18 @@ export default {
submitForm: function() { submitForm: function() {
this.$refs["form"].validate(valid => { this.$refs["form"].validate(valid => {
if (valid) { if (valid) {
// 页面字段是 allowIp,后端新增/修改认的是拼写 aollowIp
const payload = { ...this.form, aollowIp: this.form.allowIp };
delete payload.allowIp;
if (this.form.userId != undefined) { if (this.form.userId != undefined) {
updateUser(this.form).then(response => {
updateUser(payload).then(response => {
this.$modal.msgSuccess("修改成功"); this.$modal.msgSuccess("修改成功");
this.open = false; this.open = false;
this.getList(); this.getList();
}); });
} else { } else {
addUser(this.form).then(response => {
payload.password = new md5().update(this.form.password).digest("hex");
addUser(payload).then(response => {
this.$modal.msgSuccess("新增成功"); this.$modal.msgSuccess("新增成功");
this.open = false; this.open = false;
this.getList(); this.getList();
@ -536,7 +577,21 @@ export default {
/** 导入按钮操作 */ /** 导入按钮操作 */
handleImport() { handleImport() {
this.upload.title = "用户导入"; this.upload.title = "用户导入";
this.upload.isUploading = false;
this.upload.open = true; this.upload.open = true;
this.$nextTick(() => {
this.resetImport();
});
},
/** 关闭导入弹窗时清空已选文件,避免再次打开残留 */
cancelImport() {
this.upload.open = false;
},
resetImport() {
this.upload.isUploading = false;
if (this.$refs.upload) {
this.$refs.upload.clearFiles();
}
}, },
/** 下载模板操作 */ /** 下载模板操作 */
importTemplate() { importTemplate() {
@ -561,4 +616,111 @@ export default {
} }
} }
}; };
</script>
</script>
<style lang="scss" scoped>
.app-container > .el-row {
flex: 1 1 auto;
height: 100%;
min-height: 0;
overflow: hidden;
}
::v-deep .default-theme.splitpanes {
height: 100%;
}
::v-deep .splitpanes__pane {
overflow: hidden;
}
.dept-pane,
.user-pane {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.dept-tree {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
}
.user-pane > .el-form,
.user-pane > .el-row,
.user-pane ::v-deep .pagination-container {
flex: 0 0 auto;
}
.user-pane ::v-deep .pagination-container {
position: relative;
flex: 0 0 52px;
height: 52px;
margin-top: 8px;
margin-bottom: 0;
padding: 0 4px !important;
overflow: visible;
box-sizing: border-box;
}
.user-pane ::v-deep .pagination-container .el-pagination {
position: absolute;
right: 0;
top: 50%;
bottom: auto;
transform: translateY(-50%);
}
.user-table-wrap {
flex: 0 1 auto;
min-height: 0;
overflow: hidden;
}
::v-deep .status-switch .el-switch__core {
border-color: #9aa3b5;
background-color: #9aa3b5;
}
::v-deep .status-switch.is-checked .el-switch__core {
border-color: #2f7cff;
background-color: #2f7cff;
}
::v-deep .el-dialog .el-select {
width: 100%;
}
::v-deep .el-dialog .el-select .el-input__inner {
height: 32px;
}
/* 多选没值时不要改标签定位,否则输入框会被压成一条线。有已选标签再换行撑开。 */
::v-deep .el-dialog .el-select .el-select__tags:has(.el-tag) {
position: relative;
top: 0;
max-width: 100% !important;
transform: none;
flex-wrap: wrap;
padding: 2px 24px 2px 4px;
}
::v-deep .el-dialog .el-select:has(.el-tag) .el-input {
position: absolute;
top: 0;
left: 0;
height: 100%;
}
::v-deep .el-dialog .el-select:has(.el-tag) .el-input__inner {
height: 100% !important;
}
::v-deep .el-dialog .el-select .el-tag {
max-width: 100%;
margin: 2px 0 2px 4px;
}
</style>

+ 87
- 46
src/views/video/LivePreview.vue View File

@ -11,10 +11,11 @@
:config="config" :config="config"
:channels="channels" :channels="channels"
:selected-code="selectedCode" :selected-code="selectedCode"
:loading="loading"
:loading="!embed && loading"
:use-agg-api="isAggMode" :use-agg-api="isAggMode"
:station-code="embedStationCode" :station-code="embedStationCode"
:default-expanded="embedTreeExpanded" :default-expanded="embedTreeExpanded"
@station-change="handleEmbedStationChange"
@channels-loaded="handleTreeChannels" @channels-loaded="handleTreeChannels"
@select="selectChannel" @select="selectChannel"
@open="openChannel" @open="openChannel"
@ -70,11 +71,14 @@
<nvr-stream-player <nvr-stream-player
:source="slot.streamSource" :source="slot.streamSource"
:loading="slot.loading" :loading="slot.loading"
:failed="slot.streamFailed"
:muted="slot.muted !== false" :muted="slot.muted !== false"
fit="cover"
:release-connection="true"
@error="handleStreamError($event, index)" @error="handleStreamError($event, index)"
/> />
<div class="camera-label"> <div class="camera-label">
<span><i />{{ slot.name }}</span>
<span><i :class="{ failed: slot.streamFailed }" />{{ slot.name }}</span>
</div> </div>
<div class="camera-actions"> <div class="camera-actions">
<button v-if="!embed" 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>
@ -252,7 +256,7 @@ import treeToggleIcon from '@/assets/portal/tree-toggle.png'
import treeCollapseIcon from '@/assets/portal/tree-collapse.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
} from '@/api/nvrVideo' } from '@/api/nvrVideo'
import { startRealtimeVideo, stopRealtimeVideo, aggPtzControl, aggPresetList, aggPresetCreate } from '@/api/portal' import { startRealtimeVideo, stopRealtimeVideo, aggPtzControl, aggPresetList, aggPresetCreate } from '@/api/portal'
@ -273,7 +277,7 @@ const PTZ_CODES = {
// 地址参数:只看某一个场站,空字符串为全部场站 // 地址参数:只看某一个场站,空字符串为全部场站
embedStationCode: { type: String, default: '' }, embedStationCode: { type: String, default: '' },
// 地址参数:导航树默认展开 // 地址参数:导航树默认展开
embedTreeExpanded: { type: Boolean, default: false },
embedTreeExpanded: { type: Boolean, default: true },
// 地址参数:云台控制面板默认展开 // 地址参数:云台控制面板默认展开
embedControlExpanded: { type: Boolean, default: false }, embedControlExpanded: { type: Boolean, default: false },
// 地址参数:默认宫格,1 / 4 / 9 / 16 // 地址参数:默认宫格,1 / 4 / 9 / 16
@ -286,7 +290,7 @@ const PTZ_CODES = {
treeChannelsLoaded: false, treeChannelsLoaded: false,
treeChannelCodes: new Set(), treeChannelCodes: new Set(),
controlPanelExpanded: false, controlPanelExpanded: false,
embedTreeVisible: false,
embedTreeVisible: true,
embedControlVisible: false, embedControlVisible: false,
embedPanelDrag: { embedPanelDrag: {
tree: { x: 0, y: 0, moved: false }, tree: { x: 0, y: 0, moved: false },
@ -437,11 +441,14 @@ const PTZ_CODES = {
deactivated() { deactivated() {
this.pauseStreams() this.pauseStreams()
}, },
beforeDestroy() {
beforeDestroy() {
this.stopTreeResize() this.stopTreeResize()
this.stopEmbedPanelDrag() this.stopEmbedPanelDrag()
this.lifecycleId += 1 this.lifecycleId += 1
this.pageActive = false this.pageActive = false
if (this.ptzActive) this.stopPtz()
clearInterval(this.ptzRepeatTimer)
this.ptzRepeatTimer = null
document.removeEventListener('fullscreenchange', this.handleFullscreenChange) document.removeEventListener('fullscreenchange', this.handleFullscreenChange)
document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange) document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange)
if (!this.embed) { if (!this.embed) {
@ -583,6 +590,15 @@ const PTZ_CODES = {
if (!name) return true if (!name) return true
return /^(camera|cam|channel)[-_ ]?\d+$/i.test(String(name).trim()) return /^(camera|cam|channel)[-_ ]?\d+$/i.test(String(name).trim())
}, },
handleEmbedStationChange(stationCode) {
if (!this.embed) return
const next = stationCode || ''
if (String((this.$route.query && this.$route.query.stationCode) || '') === next) return
const query = { ...this.$route.query }
if (next) query.stationCode = next
else delete query.stationCode
this.$router.replace({ path: this.$route.path, query }).catch(() => {})
},
handleTreeChannels(channels) { handleTreeChannels(channels) {
this.treeChannelsLoaded = true this.treeChannelsLoaded = true
const treeCodes = new Set((channels || []).filter(item => item && item.cameraCode).map(item => item.cameraCode)) const treeCodes = new Set((channels || []).filter(item => item && item.cameraCode).map(item => item.cameraCode))
@ -654,9 +670,15 @@ const PTZ_CODES = {
this.playbackHistory = [] this.playbackHistory = []
this.activeIndex = 0 this.activeIndex = 0
this.controlTarget = null this.controlTarget = null
for (let index = 0; index < channels.length; index += 1) {
// 实时监控和嵌入预览都一次发 3 路,避免第 6 路占满浏览器连接后后面一直 pending。
const batchSize = 3
for (let index = 0; index < channels.length; index += batchSize) {
if (!this.pageActive) return if (!this.pageActive) return
await this.startChannel(channels[index], index)
const batch = []
for (let offset = 0; offset < batchSize && index + offset < channels.length; offset += 1) {
batch.push(this.startChannel(channels[index + offset], index + offset))
}
await Promise.all(batch)
} }
if (highlightCode) { if (highlightCode) {
const highlightIndex = channels.findIndex(item => item && item.cameraCode === highlightCode) const highlightIndex = channels.findIndex(item => item && item.cameraCode === highlightCode)
@ -712,8 +734,10 @@ const PTZ_CODES = {
startPromise: null, startPromise: null,
muted: true, muted: true,
errorNotified: false, errorNotified: false,
streamFailed: false,
attemptedCameraCodes: [...attemptedCameraCodes, channel.cameraCode], attemptedCameraCodes: [...attemptedCameraCodes, channel.cameraCode],
timer: null, timer: null,
pollGeneration: 0,
streamType streamType
} }
this.$set(this.slots, index, slot) this.$set(this.slots, index, slot)
@ -727,15 +751,17 @@ const PTZ_CODES = {
try { try {
// 汇聚模式:拉流走首页同款聚合接口(响应含 hls/flv,applyLiveResult 直接兼容) // 汇聚模式:拉流走首页同款聚合接口(响应含 hls/flv,applyLiveResult 直接兼容)
slot.startPromise = this.isAggMode slot.startPromise = this.isAggMode
? startRealtimeVideo({ stationCode: channel.stationCode, cameraCode: channel.cameraCode })
? startRealtimeVideo(
{ stationCode: channel.stationCode, cameraCode: channel.cameraCode },
{ lane: "embed" }
)
: startLive(channel, streamType) : startLive(channel, streamType)
const result = await slot.startPromise const result = await slot.startPromise
if (!this.pageActive || this.slots[index] !== slot) return if (!this.pageActive || this.slots[index] !== slot) return
this.applyLiveResult(index, slot, result) this.applyLiveResult(index, slot, result)
if (!slot.streamSource.hlsUrl && !slot.streamSource.flvUrl) { if (!slot.streamSource.hlsUrl && !slot.streamSource.flvUrl) {
if (this.isAggMode) { if (this.isAggMode) {
slot.loading = false
this.$message.error('视频流启动失败,请稍后重试')
this.handleStreamError(new Error('未获取到视频流'), index)
} else { } else {
this.pollLive(index, slot, 0) this.pollLive(index, slot, 0)
} }
@ -746,8 +772,7 @@ const PTZ_CODES = {
// 切页取消的拉流不是播放失败,不能再弹错误,也不能继续占着画面。 // 切页取消的拉流不是播放失败,不能再弹错误,也不能继续占着画面。
if (!this.pageActive || (error && error.code === 'ERR_CANCELED')) return if (!this.pageActive || (error && error.code === 'ERR_CANCELED')) return
if (this.slots[index] === slot) { if (this.slots[index] === slot) {
this.$set(this.slots, index, null)
this.$message.error(error.message)
this.handleStreamError(error, index)
} }
} }
}, },
@ -758,23 +783,36 @@ const PTZ_CODES = {
slot.streamSource.hlsUrl = payload.hlsUrl || payload.hls || (/\.m3u8(?:\?|$)/i.test(mediaUrl) ? mediaUrl : '') 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.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)
if (!slot.loading) slot.streamFailed = false
},
markStreamFailed(slot) {
if (!slot) return
slot.loading = false
slot.streamFailed = true
slot.streamSource = { hlsUrl: '', flvUrl: '' }
}, },
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) {
slot.loading = false
this.$message.error('视频流启动超时,请检查摄像机或流媒体服务')
this.handleStreamError(new Error('视频流播放超时'), index)
return return
} }
const generation = slot.pollGeneration
clearTimeout(slot.timer)
slot.timer = setTimeout(async() => { slot.timer = setTimeout(async() => {
if (!this.pageActive || this.slots[index] !== slot) return
if (!this.pageActive || this.slots[index] !== slot || slot.pollGeneration !== generation) return
try { try {
const result = await getLiveStatus(slot.cameraCode) const result = await getLiveStatus(slot.cameraCode)
if (!this.pageActive || this.slots[index] !== slot || slot.pollGeneration !== generation) return
this.applyLiveResult(index, slot, result) this.applyLiveResult(index, slot, result)
if (!result || result.status !== 'PLAYING' || !(slot.streamSource.hlsUrl || slot.streamSource.flvUrl)) { if (!result || result.status !== 'PLAYING' || !(slot.streamSource.hlsUrl || slot.streamSource.flvUrl)) {
this.pollLive(index, slot, attempt + 1) this.pollLive(index, slot, attempt + 1)
} }
} catch (_) { this.pollLive(index, slot, attempt + 1) }
} catch (_) {
if (this.pageActive && this.slots[index] === slot && slot.pollGeneration === generation) {
this.pollLive(index, slot, attempt + 1)
}
}
}, 800) }, 800)
}, },
async closeSlot(index, notify = true) { async closeSlot(index, notify = true) {
@ -782,7 +820,9 @@ const PTZ_CODES = {
if (!slot) return if (!slot) return
const cameraCode = slot.cameraCode const cameraCode = slot.cameraCode
if (index === this.activeIndex && this.ptzActive) this.stopPtz() if (index === this.activeIndex && this.ptzActive) this.stopPtz()
slot.pollGeneration += 1
clearTimeout(slot.timer) clearTimeout(slot.timer)
slot.timer = null
this.$set(this.slots, index, null) this.$set(this.slots, index, null)
this.forgetPlayback(cameraCode) this.forgetPlayback(cameraCode)
if (this.controlTarget === slot) this.controlTarget = null if (this.controlTarget === slot) this.controlTarget = null
@ -906,12 +946,16 @@ const PTZ_CODES = {
let nextChannel = null let nextChannel = null
for (let offset = 1; offset <= this.channels.length; offset += 1) { for (let offset = 1; offset <= this.channels.length; offset += 1) {
const candidate = this.channels[(currentIndex + offset + this.channels.length) % this.channels.length] const candidate = this.channels[(currentIndex + offset + this.channels.length) % this.channels.length]
if (candidate && !attemptedCodes.has(candidate.cameraCode) && !occupiedCodes.has(candidate.cameraCode)) {
if (
candidate &&
this.isChannelOnline(candidate) &&
!attemptedCodes.has(candidate.cameraCode) &&
!occupiedCodes.has(candidate.cameraCode)
) {
nextChannel = candidate nextChannel = candidate
break break
} }
} }
this.$set(this.slots, index, null)
try { try {
if (this.isAggMode && failedSlot.stationCode) { if (this.isAggMode && failedSlot.stationCode) {
await stopRealtimeVideo({ stationCode: failedSlot.stationCode, cameraCode: failedSlot.cameraCode }) await stopRealtimeVideo({ stationCode: failedSlot.stationCode, cameraCode: failedSlot.cameraCode })
@ -919,11 +963,12 @@ const PTZ_CODES = {
await stopLive(failedSlot.cameraCode) await stopLive(failedSlot.cameraCode)
} }
} catch (_) { /* continue switching cameras */ } } catch (_) { /* continue switching cameras */ }
if (!this.pageActive) return
if (!this.pageActive || this.slots[index] !== failedSlot) return
if (nextChannel) { if (nextChannel) {
this.$set(this.slots, index, null)
await this.startChannel(nextChannel, index, Array.from(attemptedCodes)) await this.startChannel(nextChannel, index, Array.from(attemptedCodes))
} else { } else {
this.$message.error((error && error.message) || '视频播放失败,暂无其他可用摄像头')
this.markStreamFailed(failedSlot)
} }
}, },
fullscreenElement() { fullscreenElement() {
@ -983,19 +1028,32 @@ const PTZ_CODES = {
? this.slots.find(slot => slot && slot.cameraCode === cameraCode) || this.controlTarget ? this.slots.find(slot => slot && slot.cameraCode === cameraCode) || this.controlTarget
: this.controlTarget : this.controlTarget
const targetCode = (target && target.cameraCode) || cameraCode const targetCode = (target && target.cameraCode) || cameraCode
if (!targetCode) { this.$message.warning('请先选择有视频的画面'); return }
if (!targetCode) {
const error = new Error('请先选择有视频的画面')
error.resultUncertain = false
throw error
}
let result let result
if (this.isAggMode) { if (this.isAggMode) {
// 汇聚模式:stationCode 取当前控制摄像头所属的站(而非右上角站名筛选) // 汇聚模式:stationCode 取当前控制摄像头所属的站(而非右上角站名筛选)
const stationCode = target && target.stationCode const stationCode = target && target.stationCode
if (!stationCode) { this.$message.warning('未获取到该摄像头所属站点信息'); return }
if (!stationCode) {
const error = new Error('未获取到该摄像头所属站点信息')
error.resultUncertain = false
throw error
}
result = await aggPtzControl({ stationCode, cameraCode: targetCode, controlCode, controlPara1, controlPara2 }) result = await aggPtzControl({ stationCode, cameraCode: targetCode, controlCode, controlPara1, controlPara2 })
} else { } else {
result = await controlPtz({ cameraCode: targetCode, controlCode, controlPara1, controlPara2 }) result = await controlPtz({ cameraCode: targetCode, controlCode, controlPara1, controlPara2 })
} }
// 兼容两种响应:聚合代理可能包一层 data;resultCode 非 0 视为失败 // 兼容两种响应:聚合代理可能包一层 data;resultCode 非 0 视为失败
const body = result && Object.prototype.hasOwnProperty.call(result, 'data') && result.data ? result.data : result const body = result && Object.prototype.hasOwnProperty.call(result, 'data') && result.data ? result.data : result
if (body && body.resultCode !== undefined && Number(body.resultCode) !== 0) throw new Error('云台控制失败')
if (body && body.resultCode !== undefined && Number(body.resultCode) !== 0) {
const error = new Error('云台控制失败')
error.resultUncertain = false
throw error
}
return body
}, },
startPtz(action) { startPtz(action) {
if (action === 'STOP') { if (action === 'STOP') {
@ -1120,29 +1178,12 @@ const PTZ_CODES = {
if (!this.controlTarget) { this.$message.warning('请先选择有视频的画面'); return } if (!this.controlTarget) { this.$message.warning('请先选择有视频的画面'); return }
this.$set(this.controlTarget, 'muted', !(this.controlTarget.muted !== false)) this.$set(this.controlTarget, 'muted', !(this.controlTarget.muted !== false))
}, },
getIvsIdentity() {
const firstValue = value => Array.isArray(value) ? value[0] : value
const query = this.$route.query || {}
const username = firstValue(query.username) || sessionStorage.getItem('username') || this.$store.getters.name
const joinbrightToken = firstValue(query['joinbright-token']) || firstValue(query.token) || sessionStorage.getItem('joinbright-token')
return { username: username || undefined, joinbrightToken: joinbrightToken || undefined }
},
async sendWiperCommand(cameraCode, controlCode) { async sendWiperCommand(cameraCode, controlCode) {
try { try {
const result = await controlWiper({
cameraCode,
controlCode,
controlPara1: '1',
controlPara2: '',
...this.getIvsIdentity()
})
if (!result || typeof result !== 'object') {
const error = new Error('雨刷器接口响应无效')
error.resultUncertain = true
throw error
}
if (Number(result.resultCode) !== 0) {
const error = new Error(result.message || '雨刷器控制失败')
// 雨刷与云台同一套控制接口:汇聚页走 /agg/proxy/patrol/preset/ptzControl,单站走 /patrol/preset/ptzControl
const result = await this.sendPtz(controlCode, '1', '', cameraCode)
if (result === undefined) {
const error = new Error('雨刷器控制失败')
error.resultUncertain = false error.resultUncertain = false
throw error throw error
} }
@ -1221,7 +1262,7 @@ const PTZ_CODES = {
.monitor-head { height: 32px; display: flex; align-items: center; justify-content: space-between; color: #cfe5ff; }.monitor-head small { color: #648fbc; } .monitor-head { height: 32px; display: flex; align-items: center; justify-content: space-between; color: #cfe5ff; }.monitor-head small { color: #648fbc; }
.camera-grid { flex: 1; min-height: 0; display: grid; gap: 3px; background: rgba(14, 75, 146, .5); border: 1px solid #176bc2; } .camera-grid { flex: 1; min-height: 0; display: grid; gap: 3px; background: rgba(14, 75, 146, .5); border: 1px solid #176bc2; }
.camera-card { position: relative; min-width: 0; min-height: 0; overflow: hidden; border: 1px solid transparent; background: #050b24; cursor: pointer; }.camera-card.active { border-color: #54ffff; box-shadow: inset 0 0 0 1px rgba(84,255,255,.35); } .camera-card { position: relative; min-width: 0; min-height: 0; overflow: hidden; border: 1px solid transparent; background: #050b24; cursor: pointer; }.camera-card.active { border-color: #54ffff; box-shadow: inset 0 0 0 1px rgba(84,255,255,.35); }
.camera-label { position: absolute; left: 0; right: 0; top: 0; height: 28px; padding: 0 8px; display: flex; align-items: center; justify-content: space-between; color: #d8eaff; background: linear-gradient(180deg, rgba(0,10,36,.92), transparent); font-size: 11px; pointer-events: none; user-select: none; }.camera-label i { display: inline-block; width: 6px; height: 6px; margin-right: 5px; border-radius: 50%; background: #24e39f; box-shadow: 0 0 6px #24e39f; }
.camera-label { position: absolute; left: 0; right: 0; top: 0; height: 28px; padding: 0 8px; display: flex; align-items: center; justify-content: space-between; color: #d8eaff; background: linear-gradient(180deg, rgba(0,10,36,.92), transparent); font-size: 11px; pointer-events: none; user-select: none; }.camera-label i { display: inline-block; width: 6px; height: 6px; margin-right: 5px; border-radius: 50%; background: #24e39f; box-shadow: 0 0 6px #24e39f; }.camera-label i.failed { background: #ff6252; box-shadow: 0 0 6px #ff6252; }
.camera-actions { position: absolute; right: 5px; bottom: 5px; display: none; gap: 3px; }.camera-card:hover .camera-actions, .camera-card:fullscreen .camera-actions, .camera-card:-webkit-full-screen .camera-actions { display: flex; }.camera-actions button { width: 26px; height: 25px; border: 1px solid rgba(66, 167, 255, .55); color: #bfe0ff; background: rgba(4,28,78,.88); cursor: pointer; } .camera-actions { position: absolute; right: 5px; bottom: 5px; display: none; gap: 3px; }.camera-card:hover .camera-actions, .camera-card:fullscreen .camera-actions, .camera-card:-webkit-full-screen .camera-actions { display: flex; }.camera-actions button { width: 26px; height: 25px; border: 1px solid rgba(66, 167, 255, .55); color: #bfe0ff; background: rgba(4,28,78,.88); cursor: pointer; }
.empty-slot { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: #466f9c; background: radial-gradient(circle at center, #0b2a61, #050b24 70%); user-select: none; }.empty-slot i { font-size: 28px; }.empty-slot span { font-size: 11px; } .empty-slot { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: #466f9c; background: radial-gradient(circle at center, #0b2a61, #050b24 70%); user-select: none; }.empty-slot i { font-size: 28px; }.empty-slot span { font-size: 11px; }
.control-panel { position: absolute; top: 0; right: 0; bottom: 0; z-index: 4; width: 242px; min-width: 242px; height: auto; margin: 0; padding: 0; overflow: visible; border: 1px solid rgba(63,127,216,.72); border-radius: 2px; background: rgba(7, 24, 78, .42); color: #dcecff; font-size: 14px; line-height: normal; box-sizing: border-box; cursor: move; user-select: none; } .control-panel { position: absolute; top: 0; right: 0; bottom: 0; z-index: 4; width: 242px; min-width: 242px; height: auto; margin: 0; padding: 0; overflow: visible; border: 1px solid rgba(63,127,216,.72); border-radius: 2px; background: rgba(7, 24, 78, .42); color: #dcecff; font-size: 14px; line-height: normal; box-sizing: border-box; cursor: move; user-select: none; }


+ 6
- 1
src/views/video/LivePreviewEmbed.vue View File

@ -41,7 +41,7 @@ function readQuery(route) {
const control = String(query.control == null ? '' : query.control).toLowerCase() const control = String(query.control == null ? '' : query.control).toLowerCase()
return { return {
stationCode: query.stationCode ? String(query.stationCode) : '', stationCode: query.stationCode ? String(query.stationCode) : '',
treeExpanded: tree === '1' || tree === 'open' || tree === 'true',
treeExpanded: !(tree === '0' || tree === 'close' || tree === 'false' || tree === 'collapsed'),
controlExpanded: control === '1' || control === 'open' || control === 'true', controlExpanded: control === '1' || control === 'open' || control === 'true',
layout: LAYOUTS.includes(layout) ? layout : 9 layout: LAYOUTS.includes(layout) ? layout : 9
} }
@ -61,6 +61,11 @@ export default {
layout: query.layout layout: query.layout
} }
}, },
watch: {
'$route.query.stationCode'(value) {
this.stationCode = value ? String(value) : ''
}
},
created() { created() {
this.ensureSession() this.ensureSession()
}, },


+ 88
- 20
src/views/video/components/NvrDeviceTree.vue View File

@ -4,21 +4,43 @@
<span><i class="el-icon-s-platform" /> 设备列表</span> <span><i class="el-icon-s-platform" /> 设备列表</span>
<strong>{{ visibleChannelCount }}</strong> <strong>{{ visibleChannelCount }}</strong>
</div> </div>
<div class="tree-filters">
<el-select v-model="onlineStatus" size="small" placeholder="状态" clearable>
<el-option label="全部" value="" />
<el-option label="在线" value="1" />
<el-option label="离线" value="0" />
</el-select>
<el-input
v-model.trim="keywords"
size="small"
clearable
prefix-icon="el-icon-search"
placeholder="请输入内容"
@input="refreshTree"
/>
<el-button size="small" icon="el-icon-refresh" title="刷新列表" @click="loadTree" />
<div class="tree-filters" :class="{ 'is-agg': useAggApi }">
<div class="tree-filter-row">
<el-select
v-if="useAggApi"
:key="stationSelectKey"
v-model="selectedStationCode"
class="station-select"
size="small"
filterable
placeholder="全部场站"
>
<el-option label="全部场站" value="" />
<el-option
v-for="station in stationList"
:key="station.code"
:label="station.name"
:value="station.code"
/>
</el-select>
<el-select v-model="onlineStatus" class="status-select" size="small" placeholder="状态" clearable>
<el-option label="全部" value="" />
<el-option label="在线" value="1" />
<el-option label="离线" value="0" />
</el-select>
</div>
<div class="tree-filter-row">
<el-input
v-model.trim="keywords"
class="tree-search"
size="small"
clearable
prefix-icon="el-icon-search"
placeholder="请输入内容"
@input="refreshTree"
/>
<el-button class="tree-refresh" size="small" icon="el-icon-refresh" title="刷新列表" @click="loadTree" />
</div>
</div> </div>
<div v-loading="isLoading" class="tree-content"> <div v-loading="isLoading" class="tree-content">
<el-tree <el-tree
@ -56,7 +78,7 @@
<script> <script>
import { getAreaEqubookTreeSelect } from '@/api/videosMonitor/index' import { getAreaEqubookTreeSelect } from '@/api/videosMonitor/index'
import { getChannelTree } from '@/api/portal'
import { getChannelTree, getStationList } from '@/api/portal'
function flatten(nodes, result = []) { function flatten(nodes, result = []) {
const source = nodes || [] const source = nodes || []
@ -100,7 +122,12 @@ export default {
expandedKeys: [], expandedKeys: [],
keywords: '', keywords: '',
onlineStatus: '', onlineStatus: '',
selectedStationCode: this.stationCode || '',
stationList: [],
stationSelectKey: 0,
syncingStation: false,
internalLoading: false, internalLoading: false,
treeRequestId: 0,
treeProps: { children: 'children', label: 'label' } treeProps: { children: 'children', label: 'label' }
} }
}, },
@ -112,21 +139,55 @@ export default {
}, },
watch: { watch: {
onlineStatus: 'refreshTree', onlineStatus: 'refreshTree',
stationCode(value) {
const next = value || ''
if (next === this.selectedStationCode) return
this.syncingStation = true
this.selectedStationCode = next
this.$nextTick(() => { this.syncingStation = false })
},
selectedStationCode(value) {
if (!this.useAggApi || this.syncingStation) return
this.$emit('station-change', value || '')
this.loadTree()
},
selectedCode(value) { selectedCode(value) {
const node = this.channelNodes.find(item => this.toCameraCode(item) === value) const node = this.channelNodes.find(item => this.toCameraCode(item) === value)
if (node && this.$refs.treeRef) this.$refs.treeRef.setCurrentKey(node.id) if (node && this.$refs.treeRef) this.$refs.treeRef.setCurrentKey(node.id)
} }
}, },
mounted() { this.loadTree() },
mounted() {
this.loadStations()
this.loadTree()
},
methods: { methods: {
async loadStations() {
if (!this.useAggApi) return
try {
const response = await getStationList()
const rows = Array.isArray(response && response.data)
? response.data
: (response && response.data && Array.isArray(response.data.rows) ? response.data.rows : [])
this.stationList = rows.map(item => ({
code: String(item.stationCode || item.code || ''),
name: item.stationName || item.name || item.stationCode || item.code || ''
})).filter(item => item.code)
// 选项晚于选中值到达时,下拉会停在编码上,重建一次才显示场站名称
if (this.selectedStationCode) this.stationSelectKey += 1
} catch (error) {
this.stationList = []
}
},
async loadTree() { async loadTree() {
const requestId = ++this.treeRequestId
this.internalLoading = true this.internalLoading = true
try { try {
const queryParams = { patroldeviceName: '', onlineStatus: '', patroldeviceTypeFlag: 'ipc' } const queryParams = { patroldeviceName: '', onlineStatus: '', patroldeviceTypeFlag: 'ipc' }
if (this.useAggApi && this.stationCode) queryParams.stationCode = this.stationCode
if (this.useAggApi && this.selectedStationCode) queryParams.stationCode = this.selectedStationCode
const response = this.useAggApi const response = this.useAggApi
? await getChannelTree(queryParams) ? await getChannelTree(queryParams)
: await getAreaEqubookTreeSelect(queryParams) : await getAreaEqubookTreeSelect(queryParams)
if (requestId !== this.treeRequestId) return
const nodes = Array.isArray(response.data) ? response.data : [] const nodes = Array.isArray(response.data) ? response.data : []
this.treeNodes = nodes this.treeNodes = nodes
this.expandedKeys = [] this.expandedKeys = []
@ -137,9 +198,12 @@ export default {
this.refreshTree() this.refreshTree()
this.$emit('channels-loaded', this.channelNodes.map(this.normalizeChannel)) this.$emit('channels-loaded', this.channelNodes.map(this.normalizeChannel))
} catch (error) { } catch (error) {
if (requestId !== this.treeRequestId) return
this.treeNodes = [] this.treeNodes = []
this.$message.error(error.message || '设备树加载失败') this.$message.error(error.message || '设备树加载失败')
} finally { this.internalLoading = false }
} finally {
if (requestId === this.treeRequestId) this.internalLoading = false
}
}, },
refreshTree() { this.treeData = filterTree(this.treeNodes, this.keywords, this.onlineStatus) }, refreshTree() { this.treeData = filterTree(this.treeNodes, this.keywords, this.onlineStatus) },
toCameraCode(node) { return node.cameraCode || node.channelCode || '' }, toCameraCode(node) { return node.cameraCode || node.channelCode || '' },
@ -170,8 +234,12 @@ export default {
.tree-title { height: 46px; padding: 0 14px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(41,139,255,.35); color: #54ffff; font-size: 14px; } .tree-title { height: 46px; padding: 0 14px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(41,139,255,.35); color: #54ffff; font-size: 14px; }
.tree-title i { margin-right: 6px; } .tree-title i { margin-right: 6px; }
.tree-title strong { min-width: 28px; text-align: center; color: #b7d8ff; } .tree-title strong { min-width: 28px; text-align: center; color: #b7d8ff; }
.tree-filters { display: flex; gap: 6px; padding: 10px 8px; }
.tree-filters { display: flex; flex-direction: column; gap: 6px; padding: 10px 8px; }
.tree-filter-row { display: flex; align-items: center; gap: 6px; min-width: 0; }
.tree-filters .el-select { width: 78px; } .tree-filters .el-select { width: 78px; }
.tree-filters .station-select { flex: 1 1 auto; width: auto; min-width: 0; }
.tree-filters .status-select { flex: 0 0 62px; width: 62px; }
.tree-filters .status-select ::v-deep .el-input__inner { padding-left: 6px; padding-right: 18px; }
.tree-filters .el-input { flex: 1; min-width: 0; } .tree-filters .el-input { flex: 1; min-width: 0; }
.tree-filters .el-button { flex: 0 0 32px; padding: 0; border-color: #2787ed; color: #54ffff; background: #0a2b67; } .tree-filters .el-button { flex: 0 0 32px; padding: 0; border-color: #2787ed; color: #54ffff; background: #0a2b67; }
.tree-filters .el-button:hover, .tree-filters .el-button:focus { border-color: #2787ed; color: #ffffff; background: #176bc2; } .tree-filters .el-button:hover, .tree-filters .el-button:focus { border-color: #2787ed; color: #ffffff; background: #176bc2; }


+ 137
- 57
src/views/video/components/NvrStreamPlayer.vue View File

@ -1,9 +1,9 @@
<template> <template>
<div class="stream-player" :class="{ ready }">
<div class="stream-player" :class="{ ready, 'is-cover': fit === 'cover' }">
<video ref="video" autoplay playsinline :muted="muted" /> <video ref="video" autoplay playsinline :muted="muted" />
<div v-if="!ready" class="stream-placeholder"> <div v-if="!ready" class="stream-placeholder">
<i :class="loading ? 'el-icon-loading' : 'el-icon-video-camera'" /> <i :class="loading ? 'el-icon-loading' : 'el-icon-video-camera'" />
<span>{{ loading ? '正在连接视频流' : placeholder }}</span>
<span>{{ loading ? '正在连接视频流' : (failed ? '' : placeholder) }}</span>
</div> </div>
</div> </div>
</template> </template>
@ -19,10 +19,15 @@ export default {
muted: { type: Boolean, default: true }, muted: { type: Boolean, default: true },
autoplay: { type: Boolean, default: true }, autoplay: { type: Boolean, default: true },
loading: { type: Boolean, default: false }, loading: { type: Boolean, default: false },
placeholder: { type: String, default: '等待打开视频通道' }
failed: { type: Boolean, default: false },
placeholder: { type: String, default: '等待打开视频通道' },
// 同一页面超过浏览器每域名 6 条 HTTP/1.1 连接时,不能再占一条不结束的直播连接。
releaseConnection: { type: Boolean, default: false },
// cover 裁掉多余部分铺满格子,contain 按比例留黑边
fit: { type: String, default: 'contain' }
}, },
data() { data() {
return { hls: null, flv: null, ready: false, hlsRecoveries: 0, fallbackUsed: false, loadTimer: null }
return { hls: null, flv: null, ready: false, hlsRecoveries: 0, fallbackUsed: false, loadTimer: null, attachToken: 0, watchdog: null, released: false }
}, },
watch: { watch: {
source: { source: {
@ -37,6 +42,7 @@ export default {
this.attach() this.attach()
}, },
beforeDestroy() { beforeDestroy() {
this.released = true
this.destroyPlayer() this.destroyPlayer()
}, },
methods: { methods: {
@ -47,137 +53,210 @@ export default {
pause() { pause() {
if (this.$refs.video) this.$refs.video.pause() if (this.$refs.video) this.$refs.video.pause()
}, },
clearTimers() {
clearTimeout(this.loadTimer)
clearInterval(this.watchdog)
this.loadTimer = null
this.watchdog = null
},
// HTTP-FLV 会一直占一条连接。Chrome/Edge 对同一域名的 HTTP/1.1 上限是 6,
// 九宫格后三路就会一直排队。ZLMediaKit 的同路径 WebSocket-FLV 不占这个名额。
// 站端返回的是相对路径,先按当前页面补成完整地址,再换成 ws/wss。
websocketFlvUrl(url) {
if (!url) return ''
let absolute = String(url)
if (!/^(https?|wss?):\/\//i.test(absolute)) {
try {
absolute = new URL(absolute, window.location.href).href
} catch (_) {
return ''
}
}
if (/^wss?:\/\//i.test(absolute)) return absolute
if (!/^https?:\/\//i.test(absolute)) return ''
return absolute.replace(/^http/i, 'ws')
},
releaseVideo(video) {
if (!video) return
video.onloadeddata = null
video.oncanplay = null
video.onplaying = null
video.onerror = null
try { video.pause() } catch (_) { /* element already gone */ }
video.removeAttribute('src')
try { video.load() } catch (_) { /* element already gone */ }
},
destroyPlayer() { destroyPlayer() {
this.clearTimers()
if (this.hls) { if (this.hls) {
this.hls.destroy()
const hls = this.hls
this.hls = null this.hls = null
try { hls.stopLoad() } catch (_) { /* already stopped */ }
try { hls.detachMedia() } catch (_) { /* already detached */ }
try { hls.destroy() } catch (_) { /* player already released */ }
} }
if (this.flv) { if (this.flv) {
try { this.flv.destroy() } catch (_) { /* player already released */ }
const flv = this.flv
this.flv = null this.flv = null
try { flv.pause() } catch (_) { /* already paused */ }
try { flv.unload() } catch (_) { /* already unloaded */ }
try { flv.detachMediaElement() } catch (_) { /* already detached */ }
try { flv.destroy() } catch (_) { /* player already released */ }
} }
const video = this.$refs.video
if (video) {
video.pause()
video.removeAttribute('src')
video.load()
}
this.releaseVideo(this.$refs.video)
this.ready = false this.ready = false
this.hlsRecoveries = 0 this.hlsRecoveries = 0
this.fallbackUsed = false this.fallbackUsed = false
clearTimeout(this.loadTimer)
this.loadTimer = null
},
isCurrent(token) {
return !this.released && token === this.attachToken
},
armWatchdog(token, onTimeout) {
this.clearTimers()
this.watchdog = setInterval(() => {
if (!this.isCurrent(token) || this.ready) {
this.clearTimers()
return
}
this.clearTimers()
onTimeout()
}, 10000)
}, },
attach() { attach() {
this.attachToken += 1
const token = this.attachToken
this.$nextTick(() => { this.$nextTick(() => {
if (!this.isCurrent(token)) return
const video = this.$refs.video const video = this.$refs.video
if (!video) return if (!video) return
const flvUrl = this.source && this.source.flvUrl const flvUrl = this.source && this.source.flvUrl
const hlsUrl = this.source && this.source.hlsUrl const hlsUrl = this.source && this.source.hlsUrl
this.destroyPlayer() this.destroyPlayer()
if (!this.isCurrent(token)) return
const currentToken = token
video.muted = this.muted video.muted = this.muted
const markReady = () => { const markReady = () => {
clearTimeout(this.loadTimer)
this.loadTimer = null
if (!this.isCurrent(currentToken)) return
this.clearTimers()
this.ready = true this.ready = true
this.$emit('ready') this.$emit('ready')
} }
video.onloadeddata = markReady video.onloadeddata = markReady
video.oncanplay = markReady video.oncanplay = markReady
video.onplaying = markReady video.onplaying = markReady
if (hlsUrl) {
this.loadTimer = setTimeout(() => {
if (!this.ready) this.fallbackToFlv(video, flvUrl)
}, 10000)
this.attachHls(video, hlsUrl, flvUrl)
// 拉流地址已经有了但画面一直出不来时,不能停在占位图上。
this.armWatchdog(currentToken, () => {
if (!this.isCurrent(currentToken) || this.ready) return
this.destroyPlayer()
this.$emit('error', new Error('视频流播放超时'))
})
if (this.releaseConnection && flvUrl) {
this.attachFlv(video, flvUrl, currentToken)
}
else if (hlsUrl) {
this.attachHls(video, hlsUrl, flvUrl, currentToken)
}
else if (flvUrl) this.attachFlv(video, flvUrl, currentToken)
else video.onerror = () => {
if (this.isCurrent(currentToken)) this.$emit('error', new Error('视频播放失败'))
} }
else if (flvUrl) this.attachFlv(video, flvUrl)
else video.onerror = () => this.$emit('error', new Error('视频播放失败'))
}) })
}, },
attachFlv(video, url) {
attachFlv(video, url, token = this.attachToken) {
if (!this.isCurrent(token)) return
if (!url || !flvjs.isSupported()) { if (!url || !flvjs.isSupported()) {
this.$emit('error', new Error('当前浏览器不支持 HTTP-FLV 播放')) this.$emit('error', new Error('当前浏览器不支持 HTTP-FLV 播放'))
return return
} }
video.onerror = null video.onerror = null
this.flv = flvjs.createPlayer(
{ type: 'flv', url, isLive: true },
const socketUrl = this.releaseConnection ? this.websocketFlvUrl(url) : ''
const flv = flvjs.createPlayer(
{ type: 'flv', url: socketUrl || url, isLive: true },
{ enableStashBuffer: false, lazyLoad: false, autoCleanupSourceBuffer: true } { enableStashBuffer: false, lazyLoad: false, autoCleanupSourceBuffer: true }
) )
this.flv.attachMediaElement(video)
this.flv.load()
clearTimeout(this.loadTimer)
this.loadTimer = setTimeout(() => {
if (!this.ready && this.flv) {
try { this.flv.destroy() } catch (_) { /* ignore */ }
this.flv = null
this.$emit('error', new Error('HTTP-FLV stream timeout'))
}
}, 10000)
if (this.autoplay) Promise.resolve(this.flv.play()).catch(() => undefined)
this.flv.on(flvjs.Events.ERROR, () => this.$emit('error', new Error('HTTP-FLV 播放失败')))
this.flv = flv
flv.attachMediaElement(video)
flv.load()
if (this.autoplay) Promise.resolve(flv.play()).catch(() => undefined)
flv.on(flvjs.Events.ERROR, () => {
if (this.isCurrent(token) && this.flv === flv) this.$emit('error', new Error('HTTP-FLV 播放失败'))
})
}, },
fallbackToFlv(video, fallbackFlvUrl) {
clearTimeout(this.loadTimer)
this.loadTimer = null
fallbackToFlv(video, fallbackFlvUrl, token = this.attachToken) {
if (!this.isCurrent(token)) return
if (!fallbackFlvUrl || this.fallbackUsed) { if (!fallbackFlvUrl || this.fallbackUsed) {
this.clearTimers()
this.$emit('error', new Error('HLS 播放失败')) this.$emit('error', new Error('HLS 播放失败'))
return return
} }
this.fallbackUsed = true this.fallbackUsed = true
if (this.hls) { if (this.hls) {
this.hls.destroy()
const hls = this.hls
this.hls = null this.hls = null
try { hls.stopLoad() } catch (_) { /* already stopped */ }
try { hls.detachMedia() } catch (_) { /* already detached */ }
try { hls.destroy() } catch (_) { /* already destroyed */ }
} }
this.attachFlv(video, fallbackFlvUrl)
this.attachFlv(video, fallbackFlvUrl, token)
}, },
attachHls(video, url, fallbackFlvUrl = '') {
attachHls(video, url, fallbackFlvUrl = '', token = this.attachToken) {
if (!this.isCurrent(token)) return
if (this.flv) { if (this.flv) {
try { this.flv.destroy() } catch (_) { /* ignore */ }
const flv = this.flv
this.flv = null this.flv = null
try { flv.pause() } catch (_) { /* ignore */ }
try { flv.unload() } catch (_) { /* ignore */ }
try { flv.detachMediaElement() } catch (_) { /* ignore */ }
try { flv.destroy() } catch (_) { /* ignore */ }
} }
if (Hls.isSupported()) { if (Hls.isSupported()) {
// 低延迟会发 blocking playlist reload,progressive 会改成长连接拉流。
// 这两条都会一直占着浏览器到该域名的名额。Chrome/Edge 对同一域名的
// HTTP/1.1 并发上限是 6,九宫格第 7 路起就会一直 pending。
// 超过 6 路时关掉这两项,拉完一片就断开,连接可以还给后面的画面。
const hls = new Hls({ const hls = new Hls({
enableWorker: true, enableWorker: true,
lowLatencyMode: true,
lowLatencyMode: !this.releaseConnection,
progressive: false,
liveDurationInfinity: true, liveDurationInfinity: true,
backBufferLength: 2, backBufferLength: 2,
maxBufferLength: 3, maxBufferLength: 3,
maxMaxBufferLength: 5, maxMaxBufferLength: 5,
manifestLoadingMaxRetry: 3,
levelLoadingMaxRetry: 3,
fragLoadingMaxRetry: 3
manifestLoadingMaxRetry: 0,
levelLoadingMaxRetry: 0,
fragLoadingMaxRetry: 0
}) })
this.hls = hls this.hls = hls
hls.on(Hls.Events.MEDIA_ATTACHED, () => { hls.on(Hls.Events.MEDIA_ATTACHED, () => {
if (this.hls === hls) hls.loadSource(url)
if (this.isCurrent(token) && this.hls === hls) hls.loadSource(url)
}) })
hls.on(Hls.Events.MANIFEST_PARSED, () => { hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (this.autoplay) video.play().catch(() => undefined)
if (this.isCurrent(token) && this.hls === hls && this.autoplay) video.play().catch(() => undefined)
}) })
hls.on(Hls.Events.ERROR, (_, detail) => { hls.on(Hls.Events.ERROR, (_, detail) => {
if (this.hls !== hls || !detail || !detail.fatal) return
if (!this.isCurrent(token) || this.hls !== hls || !detail || !detail.fatal) return
if (detail.type === Hls.ErrorTypes.MEDIA_ERROR && this.hlsRecoveries < 1) { if (detail.type === Hls.ErrorTypes.MEDIA_ERROR && this.hlsRecoveries < 1) {
this.hlsRecoveries += 1 this.hlsRecoveries += 1
hls.recoverMediaError() hls.recoverMediaError()
} else { } else {
hls.destroy()
this.hls = null
this.fallbackToFlv(video, fallbackFlvUrl)
try { hls.stopLoad() } catch (_) { /* already stopped */ }
try { hls.destroy() } catch (_) { /* already destroyed */ }
if (this.hls === hls) this.hls = null
this.fallbackToFlv(video, fallbackFlvUrl, token)
} }
}) })
hls.attachMedia(video) hls.attachMedia(video)
} else if (video.canPlayType('application/vnd.apple.mpegurl')) { } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.onerror = () => { video.onerror = () => {
if (!this.isCurrent(token)) return
video.removeAttribute('src') video.removeAttribute('src')
if (fallbackFlvUrl) this.attachFlv(video, fallbackFlvUrl)
if (fallbackFlvUrl) this.attachFlv(video, fallbackFlvUrl, token)
else this.$emit('error', new Error('HLS 播放失败')) else this.$emit('error', new Error('HLS 播放失败'))
} }
video.src = url video.src = url
if (this.autoplay) video.play().catch(() => undefined) if (this.autoplay) video.play().catch(() => undefined)
} else { } else {
if (fallbackFlvUrl) this.attachFlv(video, fallbackFlvUrl)
if (fallbackFlvUrl) this.attachFlv(video, fallbackFlvUrl, token)
else this.$emit('error', new Error('当前浏览器不支持 HLS 播放')) else this.$emit('error', new Error('当前浏览器不支持 HLS 播放'))
} }
} }
@ -188,6 +267,7 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.stream-player { position: relative; width: 100%; height: 100%; overflow: hidden; background: #050b24; } .stream-player { position: relative; width: 100%; height: 100%; overflow: hidden; background: #050b24; }
video { width: 100%; height: 100%; display: block; object-fit: contain; background: #050b24; } video { width: 100%; height: 100%; display: block; object-fit: contain; background: #050b24; }
.is-cover video { object-fit: cover; }
.stream-placeholder { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; color: #527ca8; background: radial-gradient(circle at center, rgba(12, 47, 103, .45), rgba(3, 9, 32, .92)); } .stream-placeholder { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; color: #527ca8; background: radial-gradient(circle at center, rgba(12, 47, 103, .45), rgba(3, 9, 32, .92)); }
.stream-placeholder i { font-size: 34px; } .stream-placeholder i { font-size: 34px; }
.stream-placeholder span { font-size: 12px; } .stream-placeholder span { font-size: 12px; }


+ 2
- 2
vue.config.js View File

@ -12,7 +12,7 @@ const name = process.env.VUE_APP_TITLE || '若依管理系统' // 网页标题
const port = process.env.port || process.env.npm_config_port || 80 // 端口 const port = process.env.port || process.env.npm_config_port || 80 // 端口
const CopyWebpackPlugin = require("copy-webpack-plugin"); const CopyWebpackPlugin = require("copy-webpack-plugin");
const appBasePath = process.env.VUE_APP_PUBLIC_PATH || "/"; const appBasePath = process.env.VUE_APP_PUBLIC_PATH || "/";
const buildOutputDir = process.env.VUE_APP_OUTPUT_DIR || "dist";
const buildOutputDir = process.env.VUE_APP_OUTPUT_DIR || "dist/app/htjc6";
// vue.config.js 配置说明 // vue.config.js 配置说明
//官方vue.config.js 参考文档 https://cli.vuejs.org/zh/config/#css-loaderoptions //官方vue.config.js 参考文档 https://cli.vuejs.org/zh/config/#css-loaderoptions
@ -30,7 +30,7 @@ module.exports = {
lintOnSave: process.env.NODE_ENV === 'development', lintOnSave: process.env.NODE_ENV === 'development',
// 如果你不需要生产环境的 source map,可以将其设置为 false 以加速生产环境构建。 // 如果你不需要生产环境的 source map,可以将其设置为 false 以加速生产环境构建。
productionSourceMap: false, productionSourceMap: false,
transpileDependencies: ['quill'],
transpileDependencies: ['quill', '@riophae/vue-treeselect'],
// webpack-dev-server 相关配置 // webpack-dev-server 相关配置
devServer: { devServer: {
host: '0.0.0.0', host: '0.0.0.0',


Loading…
Cancel
Save