| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
0018aa4654 | 修改浏览器标签 | 14 hours ago |
|
|
453f8c7619 | 修改汇聚系统下的样式 | 14 hours ago |
|
|
339d6b3145 | 做汇聚系统 | 21 hours ago |
|
|
5c2c45e6e5 | 隐藏巡检任务,巡视方案,点位管理的新增修改按钮 | 3 days ago |
|
|
48c507f1bf | 增加汇聚界面的首页,增加portal的门户,用来进入汇聚系统 | 4 days ago |
| @ -0,0 +1,129 @@ | |||
| // 汇聚门户(跨站点汇总)接口。 | |||
| // 这些接口由中心端后端提供,返回"所有站"的汇总数据; | |||
| // 与单站业务接口不同,它们不依赖当前登录用户所属站。 | |||
| // 后端未就绪时页面以空态展示,不影响其它功能。 | |||
| import request from "@/utils/request"; | |||
| // 场站列表 | |||
| // 返回 data: [{ id, stationCode, stationName, baseUrl, portalUrl, enabled }] | |||
| export function getStationList(query) { | |||
| return request({ | |||
| url: "/agg/station/all", | |||
| method: "get", | |||
| params: query, | |||
| noAlert: true | |||
| }); | |||
| } | |||
| // 本轮巡检任务统计(表计/外观/红外) | |||
| // 返回 data: [{ algCategory, inspectedTaskCount, completedTaskCount, defectCount, ... }] | |||
| export function getInspectionOverview(query) { | |||
| return request({ | |||
| url: "/agg/statistic/inspection", | |||
| method: "get", | |||
| params: query, | |||
| noAlert: true | |||
| }); | |||
| } | |||
| // 缺陷发现趋势(最近 12 个月或 4 个季度) | |||
| // 返回 data: [{ date, minorCount, generalCount, seriousCount, urgentCount, ... }] | |||
| export function getDefectTrend(query) { | |||
| return request({ | |||
| url: "/agg/statistic/defect", | |||
| method: "get", | |||
| params: query, | |||
| noAlert: true | |||
| }); | |||
| } | |||
| // 通道设备来源统计 | |||
| // 返回 data: { totalCount, deviceSourceCounts: [{ deviceSource, count }] } | |||
| export function getChannelStats(query) { | |||
| return request({ | |||
| url: "/agg/statistic/channel", | |||
| method: "get", | |||
| params: query, | |||
| noAlert: true | |||
| }); | |||
| } | |||
| // 通道树 | |||
| // 返回 data: 区域、巡检设备、通道三级树 | |||
| export function getChannelTree(query) { | |||
| return request({ | |||
| url: "/agg/tree/channel", | |||
| method: "get", | |||
| params: query, | |||
| noAlert: true | |||
| }); | |||
| } | |||
| // 汇聚侧实时视频启动 | |||
| // 返回 data: { stationCode, cameraCode, streamId, hls, flv, status } | |||
| export function startRealtimeVideo(query) { | |||
| const { stationCode, ...data } = query || {}; | |||
| return request({ | |||
| url: "/agg/video/realtime/start", | |||
| method: "post", | |||
| params: { stationCode }, | |||
| data, | |||
| noAlert: true | |||
| }); | |||
| } | |||
| // 汇聚侧实时视频停止 | |||
| // 入参按后端要求传 stationCode + cameraCode | |||
| export function stopRealtimeVideo(query) { | |||
| const { stationCode, ...data } = query || {}; | |||
| return request({ | |||
| url: "/agg/video/realtime/stop", | |||
| method: "post", | |||
| params: { stationCode }, | |||
| data, | |||
| noAlert: true | |||
| }); | |||
| } | |||
| // 汇聚侧云台控制:按摄像头所属站点代理转发 | |||
| // stationCode 取当前控制摄像头所在站(而非顶部站名筛选),以查询串方式传递 | |||
| export function aggPtzControl(payload) { | |||
| const { stationCode, ...data } = payload || {}; | |||
| return request({ | |||
| url: "/agg/proxy/patrol/preset/ptzControl", | |||
| method: "post", | |||
| params: { stationCode }, | |||
| data, | |||
| noAlert: true | |||
| }); | |||
| } | |||
| // 汇聚侧预置位列表:路径与单站一致(cameraCode 按 # 拆成 device/domain 放路径上),仅追加 ?stationCode | |||
| export function aggPresetList(payload) { | |||
| const { stationCode, cameraCode } = payload || {}; | |||
| const value = String(cameraCode || ""); | |||
| const separatorIndex = value.indexOf("#"); | |||
| if (separatorIndex <= 0 || separatorIndex === value.length - 1) { | |||
| return Promise.reject(new Error("摄像机编码格式不正确")); | |||
| } | |||
| const device = encodeURIComponent(value.slice(0, separatorIndex)); | |||
| const domain = encodeURIComponent(value.slice(separatorIndex + 1)); | |||
| return request({ | |||
| url: `/agg/proxy/patrol/preset/presetList/${device}/${domain}`, | |||
| method: "get", | |||
| params: { stationCode }, | |||
| noAlert: true | |||
| }); | |||
| } | |||
| // 汇聚侧预置位保存:按摄像头所属站点代理转发 | |||
| export function aggPresetCreate(payload) { | |||
| const { stationCode, ...data } = payload || {}; | |||
| return request({ | |||
| url: "/agg/proxy/patrol/preset/presetCreate", | |||
| method: "post", | |||
| params: { stationCode }, | |||
| data, | |||
| noAlert: true | |||
| }); | |||
| } | |||
| @ -0,0 +1,86 @@ | |||
| // 汇聚门户的站点上下文: | |||
| // 门户首页(汇聚视图)展示所有站的数据;从"场站系统入口"进入某个站后, | |||
| // siteContext 记录当前站,后续业务请求由 request 拦截器统一携带 siteId, | |||
| // 后端据此只返回该站的数据(接口未支持该参数前会自动忽略,行为不变)。 | |||
| const SITE_KEY = "portal_site"; | |||
| // 是否从汇聚门户跳转进入单站视图(会话级标记,直接输地址打开时不带) | |||
| const FROM_KEY = "portal_from"; | |||
| // 汇聚门户工作台顶部"站名"下拉选中的站(会话级) | |||
| const WORKSPACE_STATION_KEY = "portal_ws_station"; | |||
| function readSite() { | |||
| try { | |||
| const raw = sessionStorage.getItem(SITE_KEY); | |||
| return raw ? JSON.parse(raw) : null; | |||
| } catch (e) { | |||
| return null; | |||
| } | |||
| } | |||
| const siteContext = { | |||
| namespaced: true, | |||
| state: () => { | |||
| const site = readSite(); | |||
| return { | |||
| siteId: site ? site.id : undefined, | |||
| siteName: site ? site.name : "", | |||
| fromPortal: sessionStorage.getItem(FROM_KEY) === "1", | |||
| workspaceStationCode: sessionStorage.getItem(WORKSPACE_STATION_KEY) || "" | |||
| }; | |||
| }, | |||
| mutations: { | |||
| SET_FROM_PORTAL(state, value) { | |||
| state.fromPortal = !!value; | |||
| if (value) { | |||
| sessionStorage.setItem(FROM_KEY, "1"); | |||
| } else { | |||
| sessionStorage.removeItem(FROM_KEY); | |||
| } | |||
| }, | |||
| SET_SITE(state, site) { | |||
| state.siteId = site && site.id !== undefined ? site.id : undefined; | |||
| state.siteName = (site && site.name) || ""; | |||
| if (site && site.id !== undefined) { | |||
| sessionStorage.setItem(SITE_KEY, JSON.stringify({ id: site.id, name: site.name || "" })); | |||
| } else { | |||
| sessionStorage.removeItem(SITE_KEY); | |||
| } | |||
| }, | |||
| SET_WORKSPACE_STATION(state, code) { | |||
| state.workspaceStationCode = code || ""; | |||
| if (code) { | |||
| sessionStorage.setItem(WORKSPACE_STATION_KEY, code); | |||
| } else { | |||
| sessionStorage.removeItem(WORKSPACE_STATION_KEY); | |||
| } | |||
| } | |||
| }, | |||
| actions: { | |||
| // 标记本次会话由门户跳转进入 | |||
| markFromPortal({ commit }) { | |||
| commit("SET_FROM_PORTAL", true); | |||
| }, | |||
| // 工作台顶部站名下拉选择 | |||
| setWorkspaceStation({ commit }, code) { | |||
| commit("SET_WORKSPACE_STATION", code); | |||
| }, | |||
| // 进入单站视图 | |||
| enterSite({ commit }, site) { | |||
| commit("SET_SITE", site); | |||
| }, | |||
| // 退出单站视图,回到汇聚门户 | |||
| exitSite({ commit }) { | |||
| commit("SET_SITE", null); | |||
| } | |||
| }, | |||
| getters: { | |||
| fromPortal: state => state.fromPortal, | |||
| siteId: state => state.siteId, | |||
| siteName: state => state.siteName, | |||
| workspaceStationCode: state => state.workspaceStationCode, | |||
| // 是否处于单站视图 | |||
| inSite: state => state.siteId !== undefined && state.siteId !== null | |||
| } | |||
| }; | |||
| export default siteContext; | |||
| @ -0,0 +1,85 @@ | |||
| <template> | |||
| <div ref="chartEl" class="defect-trend-chart" /> | |||
| </template> | |||
| <script> | |||
| import * as echarts from "echarts"; | |||
| import resizeMixin from "@/views/dashboard/mixins/resize"; | |||
| // 系列固定顺序与配色(一般/严重/重大/轻微缺陷) | |||
| const SERIES_STYLE = { | |||
| 轻微缺陷: "#2f7bff", | |||
| 一般缺陷: "#3ad07a", | |||
| 严重缺陷: "#f0c14a", | |||
| 重大缺陷: "#ef5b5b" | |||
| }; | |||
| export default { | |||
| name: "DefectTrendChart", | |||
| mixins: [resizeMixin], | |||
| props: { | |||
| months: { type: Array, default: () => [] }, | |||
| series: { type: Array, default: () => [] } | |||
| }, | |||
| data() { | |||
| return { chart: null }; | |||
| }, | |||
| computed: { | |||
| option() { | |||
| return { | |||
| tooltip: { | |||
| trigger: "axis", | |||
| axisPointer: { type: "shadow" }, | |||
| backgroundColor: "rgba(13,22,72,.92)", | |||
| borderColor: "rgba(84,130,255,.4)", | |||
| textStyle: { color: "#dce7ff", fontSize: 12 } | |||
| }, | |||
| legend: { show: false }, | |||
| grid: { left: 8, right: 12, top: 12, bottom: 4, containLabel: true }, | |||
| xAxis: { | |||
| type: "category", | |||
| data: this.months, | |||
| axisLine: { lineStyle: { color: "rgba(84,130,255,.35)" } }, | |||
| axisTick: { show: false }, | |||
| axisLabel: { interval: 0, color: "#9fb4e8", fontSize: 11 } | |||
| }, | |||
| yAxis: { | |||
| type: "value", | |||
| splitLine: { lineStyle: { color: "rgba(84,130,255,.14)", type: "dashed" } }, | |||
| axisLabel: { color: "#9fb4e8", fontSize: 11 } | |||
| }, | |||
| series: this.series.map(item => ({ | |||
| name: item.name, | |||
| type: "bar", | |||
| stack: "total", | |||
| barMaxWidth: 28, | |||
| itemStyle: { color: SERIES_STYLE[item.name] || "#4d8bff" }, | |||
| data: item.data | |||
| })) | |||
| }; | |||
| } | |||
| }, | |||
| mounted() { | |||
| this.chart = echarts.init(this.$refs.chartEl); | |||
| this.chart.setOption(this.option); | |||
| }, | |||
| beforeDestroy() { | |||
| if (this.chart) { | |||
| this.chart.dispose(); | |||
| this.chart = null; | |||
| } | |||
| }, | |||
| watch: { | |||
| option() { | |||
| if (this.chart) this.chart.setOption(this.option, true); | |||
| } | |||
| } | |||
| }; | |||
| </script> | |||
| <style scoped> | |||
| .defect-trend-chart { | |||
| width: 100%; | |||
| height: 100%; | |||
| } | |||
| </style> | |||
| @ -0,0 +1,320 @@ | |||
| <template> | |||
| <aside class="portal-tree"> | |||
| <div class="tree-title"> | |||
| <span><i class="el-icon-s-platform" /> 设备列表</span> | |||
| <strong>{{ channelNodes.length }}</strong> | |||
| </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> | |||
| <div v-loading="internalLoading" class="tree-content"> | |||
| <el-tree | |||
| ref="treeRef" | |||
| :data="treeData" | |||
| :props="treeProps" | |||
| node-key="id" | |||
| highlight-current | |||
| :default-expanded-keys="expandedKeys" | |||
| @node-click="handleNodeClick" | |||
| > | |||
| <el-tooltip | |||
| slot-scope="{ data }" | |||
| :content="data.label || data.name || ''" | |||
| placement="top-start" | |||
| :offset="6" | |||
| :open-delay="300" | |||
| :append-to-body="true" | |||
| popper-class="portal-tree-tooltip" | |||
| effect="dark" | |||
| > | |||
| <span class="custom-tree-node"> | |||
| <i :class="nodeIcon(data)" /> | |||
| <span class="node-label">{{ data.label || data.name }}</span> | |||
| </span> | |||
| </el-tooltip> | |||
| </el-tree> | |||
| <div v-if="!internalLoading && !treeData.length" class="tree-empty"> | |||
| <i class="el-icon-video-camera-solid" /> | |||
| <span>没有匹配的设备通道</span> | |||
| </div> | |||
| </div> | |||
| </aside> | |||
| </template> | |||
| <script> | |||
| import { getChannelTree } from "@/api/portal"; | |||
| function flatten(nodes, result = []) { | |||
| (nodes || []).forEach(node => { | |||
| result.push(node); | |||
| if (Array.isArray(node.children)) flatten(node.children, result); | |||
| }); | |||
| return result; | |||
| } | |||
| function filterTree(nodes, keyword, onlineStatus) { | |||
| return (nodes || []).reduce((result, node) => { | |||
| const children = filterTree(node.children, keyword, onlineStatus); | |||
| const text = `${node.label || ""} ${node.name || ""} ${node.stationName || ""} ${node.patroldeviceCode || ""} ${node.channelCode || ""}`.toLowerCase(); | |||
| const matchesKeyword = !keyword || text.includes(keyword.toLowerCase()); | |||
| const matchesStatus = node.areaFlag === "Yes" || !onlineStatus || String(node.patroldeviceOnlineStatus || "") === onlineStatus; | |||
| if ((matchesKeyword && matchesStatus) || children.length) result.push({ ...node, children }); | |||
| return result; | |||
| }, []); | |||
| } | |||
| export default { | |||
| name: "PortalDeviceTree", | |||
| props: { | |||
| selectedCode: { type: String, default: "" } | |||
| }, | |||
| data() { | |||
| return { | |||
| treeNodes: [], | |||
| treeData: [], | |||
| expandedKeys: [], | |||
| keywords: "", | |||
| onlineStatus: "1", | |||
| internalLoading: false, | |||
| treeProps: { children: "children", label: "label" } | |||
| }; | |||
| }, | |||
| computed: { | |||
| channelNodes() { | |||
| return flatten(this.treeNodes).filter(node => node.channelFlag); | |||
| } | |||
| }, | |||
| watch: { | |||
| onlineStatus: "refreshTree", | |||
| selectedCode(value) { | |||
| const node = this.channelNodes.find(item => this.toCameraCode(item) === value); | |||
| if (node && this.$refs.treeRef) this.$refs.treeRef.setCurrentKey(node.id); | |||
| } | |||
| }, | |||
| mounted() { | |||
| this.loadTree(); | |||
| }, | |||
| methods: { | |||
| async loadTree() { | |||
| this.internalLoading = true; | |||
| try { | |||
| const response = await getChannelTree({ onlineStatus: this.onlineStatus, patroldeviceTypeFlag: "ipc" }); | |||
| this.treeNodes = Array.isArray(response && response.data) ? response.data : []; | |||
| this.expandedKeys = []; | |||
| const walk = nodes => (nodes || []).forEach(node => { | |||
| if (node.children && node.children.length) { | |||
| this.expandedKeys.push(node.id); | |||
| walk(node.children); | |||
| } | |||
| }); | |||
| walk(this.treeNodes); | |||
| this.refreshTree(); | |||
| this.$emit("channels-loaded", this.channelNodes.map(this.normalizeChannel)); | |||
| } catch (error) { | |||
| this.treeNodes = []; | |||
| this.treeData = []; | |||
| this.$message.error(error.message || "设备树加载失败"); | |||
| } finally { | |||
| this.internalLoading = false; | |||
| } | |||
| }, | |||
| refreshTree() { | |||
| this.treeData = filterTree(this.treeNodes, this.keywords, this.onlineStatus); | |||
| }, | |||
| toCameraCode(node) { | |||
| return node.cameraCode || node.channelCode || ""; | |||
| }, | |||
| normalizeChannel(node) { | |||
| return { | |||
| ...node, | |||
| cameraCode: this.toCameraCode(node), | |||
| cameraName: node.channelName || node.label || node.name, | |||
| name: node.channelName || node.label || node.name, | |||
| channel: node.channelCode | |||
| }; | |||
| }, | |||
| handleNodeClick(node) { | |||
| if (!node.channelFlag) return; | |||
| this.$emit("open", this.normalizeChannel(node)); | |||
| }, | |||
| nodeIcon(node) { | |||
| if (node.channelFlag) return "el-icon-video-camera"; | |||
| if (node.patroldeviceType === 13) return "el-icon-position"; | |||
| if (node.areaFlag === "Yes") return "el-icon-location-outline"; | |||
| return "el-icon-connection"; | |||
| } | |||
| } | |||
| }; | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .portal-tree { | |||
| width: 100%; | |||
| min-width: 260px; | |||
| height: 100%; | |||
| display: flex; | |||
| flex-direction: column; | |||
| border: 1px solid rgba(63, 127, 216, 0.72); | |||
| border-radius: 2px; | |||
| background: transparent; | |||
| color: #d7e9ff; | |||
| box-sizing: border-box; | |||
| font-family: "PingFang SC", "Microsoft YaHei", sans-serif !important; | |||
| } | |||
| .tree-title { | |||
| height: 46px; | |||
| padding: 0 14px; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: space-between; | |||
| border-bottom: 1px solid rgba(41, 139, 255, 0.35); | |||
| color: #54ffff; | |||
| font-size: 14px; | |||
| } | |||
| .tree-title i { | |||
| margin-right: 6px; | |||
| } | |||
| .tree-title strong { | |||
| min-width: 28px; | |||
| text-align: center; | |||
| color: #b7d8ff; | |||
| } | |||
| .tree-filters { | |||
| display: flex; | |||
| gap: 6px; | |||
| padding: 10px 8px; | |||
| } | |||
| .tree-filters .el-select { | |||
| width: 78px; | |||
| } | |||
| .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-content { | |||
| flex: 1; | |||
| min-height: 0; | |||
| overflow: auto; | |||
| padding: 8px; | |||
| scrollbar-width: thin; | |||
| scrollbar-color: #2f7fe8 rgba(6, 27, 79, 0.7); | |||
| } | |||
| .tree-content::-webkit-scrollbar { | |||
| width: 8px; | |||
| height: 8px; | |||
| } | |||
| .tree-content::-webkit-scrollbar-track { | |||
| background: rgba(6, 27, 79, 0.7); | |||
| } | |||
| .tree-content::-webkit-scrollbar-thumb { | |||
| background: #2f7fe8; | |||
| border: 1px solid #0d327d; | |||
| border-radius: 4px; | |||
| } | |||
| .custom-tree-node { | |||
| display: flex; | |||
| align-items: center; | |||
| width: max-content; | |||
| min-width: 100%; | |||
| gap: 6px; | |||
| font-size: 14px; | |||
| white-space: nowrap; | |||
| } | |||
| .node-label { | |||
| white-space: nowrap; | |||
| } | |||
| .tree-empty { | |||
| padding: 40px 0; | |||
| text-align: center; | |||
| color: #7298c3; | |||
| } | |||
| .tree-empty i { | |||
| display: block; | |||
| margin-bottom: 10px; | |||
| font-size: 28px; | |||
| } | |||
| ::v-deep .el-tree { | |||
| display: inline-block; | |||
| width: max-content; | |||
| min-width: 100%; | |||
| background: transparent; | |||
| color: inherit; | |||
| font-family: "PingFang SC", "Microsoft YaHei", sans-serif !important; | |||
| font-size: 14px; | |||
| } | |||
| ::v-deep .el-tree-node__content, | |||
| ::v-deep .custom-tree-node, | |||
| ::v-deep .node-label { | |||
| font-family: "PingFang SC", "Microsoft YaHei", sans-serif !important; | |||
| font-weight: 400 !important; | |||
| } | |||
| ::v-deep .el-tree-node__content { | |||
| display: flex; | |||
| height: 24px; | |||
| } | |||
| ::v-deep .el-tree-node__expand-icon.is-leaf { | |||
| display: inline-block; | |||
| width: 12px; | |||
| } | |||
| ::v-deep .el-tree-node__content:hover, | |||
| ::v-deep .el-tree-node:focus > .el-tree-node__content { | |||
| background: transparent; | |||
| } | |||
| ::v-deep .el-tree-node.is-current > .el-tree-node__content { | |||
| color: #54ffff; | |||
| background: transparent; | |||
| } | |||
| </style> | |||
| <style lang="scss"> | |||
| .portal-tree-tooltip.el-tooltip__popper { | |||
| color: #eaf6ff; | |||
| background: #176bc2; | |||
| border: 1px solid #4ba3ff; | |||
| } | |||
| .portal-tree-tooltip.el-tooltip__popper[x-placement^="top"] .popper__arrow { | |||
| border-top-color: #176bc2; | |||
| } | |||
| </style> | |||