Browse Source

增加汇聚界面的首页,增加portal的门户,用来进入汇聚系统

inspection_yanyuan
douyage 4 days ago
parent
commit
48c507f1bf
19 changed files with 2566 additions and 9 deletions
  1. +86
    -0
      src/api/portal/index.js
  2. BIN
      src/assets/portal/icon-donghuan.png
  3. BIN
      src/assets/portal/icon-enter.png
  4. BIN
      src/assets/portal/icon-fire.png
  5. BIN
      src/assets/portal/icon-security.png
  6. BIN
      src/assets/portal/icon-total.png
  7. BIN
      src/assets/portal/icon-triangle.png
  8. BIN
      src/assets/portal/panel-title.png
  9. BIN
      src/assets/portal/tree-toggle.png
  10. +34
    -1
      src/layout/components/Navbar.vue
  11. +31
    -7
      src/layout/index.vue
  12. +7
    -0
      src/router/index.js
  13. +4
    -0
      src/store/getters.js
  14. +3
    -1
      src/store/index.js
  15. +70
    -0
      src/store/modules/siteContext.js
  16. +10
    -0
      src/utils/request.js
  17. +85
    -0
      src/views/portal/components/DefectTrendChart.vue
  18. +320
    -0
      src/views/portal/components/PortalDeviceTree.vue
  19. +1916
    -0
      src/views/portal/index.vue

+ 86
- 0
src/api/portal/index.js View File

@ -0,0 +1,86 @@
// 汇聚门户(跨站点汇总)接口。
// 这些接口由中心端后端提供,返回"所有站"的汇总数据;
// 与单站业务接口不同,它们不依赖当前登录用户所属站。
// 后端未就绪时页面以空态展示,不影响其它功能。
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
});
}

BIN
src/assets/portal/icon-donghuan.png View File

Before After
Width: 48  |  Height: 49  |  Size: 4.0 KiB

BIN
src/assets/portal/icon-enter.png View File

Before After
Width: 16  |  Height: 15  |  Size: 655 B

BIN
src/assets/portal/icon-fire.png View File

Before After
Width: 48  |  Height: 49  |  Size: 3.0 KiB

BIN
src/assets/portal/icon-security.png View File

Before After
Width: 48  |  Height: 49  |  Size: 3.5 KiB

BIN
src/assets/portal/icon-total.png View File

Before After
Width: 48  |  Height: 49  |  Size: 3.5 KiB

BIN
src/assets/portal/icon-triangle.png View File

Before After
Width: 27  |  Height: 20  |  Size: 1.2 KiB

BIN
src/assets/portal/panel-title.png View File

Before After
Width: 932  |  Height: 56  |  Size: 85 KiB

BIN
src/assets/portal/tree-toggle.png View File

Before After
Width: 50  |  Height: 217  |  Size: 5.6 KiB

+ 34
- 1
src/layout/components/Navbar.vue View File

@ -8,6 +8,12 @@
<top-nav id="topmenu-container" class="topmenu-container" v-if="topNav" />
<div v-else class="navbar-spacer" />
<!-- 当前站点上下文从门户跳转进入单站时显示 -->
<div v-if="inSite" class="site-context-tag">
当前站{{ siteName }}
<i class="el-icon-close" title="退出单站视图" @click="exitStation" />
</div>
<div class="right-menu">
<template v-if="device !== 'mobile'">
<!-- <search id="header-search" class="right-menu-item" />
@ -73,7 +79,7 @@ export default {
};
},
computed: {
...mapGetters(["name", "device"]),
...mapGetters(["name", "device", "inSite", "siteName"]),
topNav: {
get() {
return this.$store.state.settings.topNav;
@ -104,6 +110,10 @@ export default {
if (this.frameworkUserPoller) window.clearInterval(this.frameworkUserPoller)
},
methods: {
exitStation() {
this.$store.dispatch("siteContext/exitSite");
this.$router.push("/portal");
},
loadFrameworkUser() {
// (宿) name
const user = getEmbeddedUser();
@ -260,6 +270,29 @@ export default {
vertical-align: top;
}
.site-context-tag {
display: flex;
align-items: center;
gap: 6px;
margin-left: 12px;
margin-right: 10px;
padding: 4px 10px;
border-radius: 13px;
background: rgba(18, 215, 248, 0.12);
border: 1px solid rgba(18, 215, 248, 0.35);
font-size: 12px;
color: #7fe6f8;
white-space: nowrap;
.el-icon-close {
cursor: pointer;
&:hover {
color: #ffffff;
}
}
}
.right-menu {
height: 100%;
display: flex;


+ 31
- 7
src/layout/index.vue View File

@ -11,6 +11,7 @@
<div :class="{hasTagsView:needTagsView,sidebarHide:sidebar.hide}" class="main-container">
<div class="layout-header" :class="{'fixed-header':fixedHeader}">
<navbar />
<!-- 横向导航条位置始终保留仅从汇聚门户跳转进入时显示导航按钮 -->
<div class="module-subbar">
<div class="module-links">
<button
@ -38,7 +39,7 @@
import RightPanel from '@/components/RightPanel'
import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components'
import ResizeMixin from './mixin/ResizeHandler'
import { mapState } from 'vuex'
import { mapState, mapGetters } from 'vuex'
import variables from '@/assets/styles/variables.scss'
export default {
@ -54,10 +55,7 @@ export default {
data() {
return {
// 0=160px1=200px2=240px3=280px
sidebarWidthLevel: 0,
moduleLinks: [
{ key: 'home', label: '', path: '/index' }
]
sidebarWidthLevel: 0
}
},
mixins: [ResizeMixin],
@ -85,8 +83,21 @@ export default {
isPatrolWorkspace() {
return this.$route.matched.some(route => route.meta && route.meta.patrolWorkspace)
},
...mapGetters(['fromPortal']),
//
moduleLinks() {
if (!this.fromPortal) return []
return [
{ key: 'home', label: '首页', path: '/portal' },
{ key: 'patrol', label: '动环巡检', path: '/index' },
// { key: 'system', label: '', path: '/system/user' }
{ key: 'system', label: '系统管理', path: '' }
]
},
activeModule() {
return 'home'
if (this.$route.path.startsWith('/system')) return 'system'
return 'patrol'
},
variables() {
return variables;
@ -217,10 +228,23 @@ export default {
font-size: 14px;
letter-spacing: 1px;
font-weight: 900;
background: transparent;
//
background: radial-gradient(ellipse 90% 78% at 50% 108%, rgba(63, 255, 254, 0.28), rgba(63, 255, 254, 0.08) 52%, transparent 76%);
box-shadow: none;
}
.module-link.active::after {
content: "";
position: absolute;
left: 14%;
right: 14%;
bottom: 0;
height: 2px;
border-radius: 2px;
background: linear-gradient(90deg, rgba(63, 255, 254, 0) 0%, #3ffef0 50%, rgba(63, 255, 254, 0) 100%);
box-shadow: 0 0 14px rgba(63, 255, 254, 0.85);
}
.fixed-header {
position: fixed;
top: 0;


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

@ -30,6 +30,13 @@ import Layout from '@/layout'
// 公共路由
export const constantRoutes = [
{
// 汇聚门户首页:跨站点汇总大屏,独立于后台 Layout 的全屏页面
path: '/portal',
component: () => import('@/views/portal/index'),
hidden: true,
meta: { title: '动环智能巡检系统' }
},
{
path: '/xfdh',
component: Layout,


+ 4
- 0
src/store/getters.js View File

@ -17,5 +17,9 @@ const getters = {
sidebarRouters:state => state.permission.sidebarRouters,
language: state => state.app.language,
systemParams: state => state.app.systemParams,
siteId: state => state.siteContext.siteId,
siteName: state => state.siteContext.siteName,
inSite: state => state.siteContext.siteId !== undefined && state.siteContext.siteId !== null,
fromPortal: state => state.siteContext.fromPortal,
}
export default getters

+ 3
- 1
src/store/index.js View File

@ -6,6 +6,7 @@ import user from './modules/user'
import tagsView from './modules/tagsView'
import permission from './modules/permission'
import settings from './modules/settings'
import siteContext from './modules/siteContext'
import getters from './getters'
Vue.use(Vuex)
@ -17,7 +18,8 @@ const store = new Vuex.Store({
user,
tagsView,
permission,
settings
settings,
siteContext
},
getters
})


+ 70
- 0
src/store/modules/siteContext.js View File

@ -0,0 +1,70 @@
// 汇聚门户的站点上下文:
// 门户首页(汇聚视图)展示所有站的数据;从"场站系统入口"进入某个站后,
// siteContext 记录当前站,后续业务请求由 request 拦截器统一携带 siteId,
// 后端据此只返回该站的数据(接口未支持该参数前会自动忽略,行为不变)。
const SITE_KEY = "portal_site";
// 是否从汇聚门户跳转进入单站视图(会话级标记,直接输地址打开时不带)
const FROM_KEY = "portal_from";
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"
};
},
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);
}
}
},
actions: {
// 标记本次会话由门户跳转进入
markFromPortal({ commit }) {
commit("SET_FROM_PORTAL", true);
},
// 进入单站视图
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,
// 是否处于单站视图
inSite: state => state.siteId !== undefined && state.siteId !== null
}
};
export default siteContext;

+ 10
- 0
src/utils/request.js View File

@ -36,6 +36,16 @@ service.interceptors.request.use(config => {
config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带自定义token 请根据实际情况自行修改
}
config.headers['ticket'] = store.getters.secondTicket;
// 站点上下文:从汇聚门户进入单站视图后,业务请求统一携带 siteId 供后端按站过滤。
// 汇总接口(/portal/*、/agg/*)本身面向全部站,不注入;接口未支持该参数的后端会自动忽略。
const siteId = store.getters.siteId;
if (siteId !== undefined && siteId !== null && !config.url.startsWith('/portal') && !config.url.startsWith('/agg/') && (config.headers || {}).noSiteId !== true) {
if (config.method === 'get') {
config.params = Object.assign({}, config.params, { siteId })
} else if (config.data && typeof config.data === 'object' && !(config.data instanceof FormData)) {
config.data = Object.assign({}, config.data, { siteId })
}
}
// get请求映射params参数
if (config.method === 'get' && config.params) {
let url = config.url + '?' + tansParams(config.params);


+ 85
- 0
src/views/portal/components/DefectTrendChart.vue View File

@ -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>

+ 320
- 0
src/views/portal/components/PortalDeviceTree.vue View File

@ -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: 0 0 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>

+ 1916
- 0
src/views/portal/index.vue
File diff suppressed because it is too large
View File


Loading…
Cancel
Save