<template>
|
|
<div class="portal-screen">
|
|
<!-- 标题头:直接复用系统现有导航栏 -->
|
|
<navbar />
|
|
<!-- 模块导航条:与后台 module-subbar 同款,首页/动环巡检放这里 -->
|
|
<div class="module-subbar">
|
|
<div class="module-links">
|
|
<button
|
|
v-for="item in navItems"
|
|
:key="item.key"
|
|
type="button"
|
|
class="module-link"
|
|
:class="{ active: item.key === 'home' }"
|
|
@click="onNavClick(item)"
|
|
>{{ item.label }}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 主体 -->
|
|
<main class="portal-body">
|
|
<!-- 左列 -->
|
|
<section class="col col-left">
|
|
<!-- 本轮巡检任务统计 -->
|
|
<div class="panel panel-overview">
|
|
<div class="panel-title">
|
|
<img class="title-icon" :src="triangleIcon" alt="">
|
|
<span>本轮巡检任务统计</span>
|
|
<el-select v-model="overviewStationId" class="panel-select" size="mini" placeholder="所属场站">
|
|
<el-option label="全部场站" value="" />
|
|
<el-option
|
|
v-for="station in stationList"
|
|
:key="station.code"
|
|
:label="station.name"
|
|
:value="station.code"
|
|
/>
|
|
</el-select>
|
|
</div>
|
|
<div class="overview-cards">
|
|
<div v-for="card in overviewCards" :key="card.name" class="overview-column">
|
|
<div class="overview-name">{{ card.name }}</div>
|
|
<div class="overview-stats">
|
|
<div
|
|
v-for="item in card.items"
|
|
:key="item.label"
|
|
class="overview-stat"
|
|
:class="[
|
|
{ 'is-defect-carousel': item.defectCarousel },
|
|
item.defectCarousel ? 'severity-' + activeDefectSlide.key : ''
|
|
]"
|
|
@mouseenter="item.defectCarousel && pauseDefectCarousel()"
|
|
@mouseleave="item.defectCarousel && startDefectCarousel()"
|
|
>
|
|
<template v-if="item.defectCarousel">
|
|
<div class="overview-defect-viewport">
|
|
<transition :name="defectSlideDirection === 'next' ? 'defect-slide-next' : 'defect-slide-prev'" mode="out-in">
|
|
<div :key="activeDefectSlide.key" class="overview-defect-content">
|
|
<div class="overview-value">{{ defectValue(card, activeDefectSlide) }}</div>
|
|
<div class="overview-label">{{ activeDefectSlide.label }}</div>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
<div class="overview-dots">
|
|
<button
|
|
v-for="(slide, index) in defectSlides"
|
|
:key="slide.key"
|
|
type="button"
|
|
:class="{ active: index === defectSlideIndex }"
|
|
:aria-label="slide.label"
|
|
@click.stop="setDefectSlide(index)"
|
|
/>
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<div class="overview-value">{{ item.value }}</div>
|
|
<div class="overview-label">{{ item.label }}</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 缺陷发现趋势 -->
|
|
<div class="panel panel-trend">
|
|
<div class="panel-title">
|
|
<img class="title-icon" :src="triangleIcon" alt="">
|
|
<span>缺陷发现趋势</span>
|
|
</div>
|
|
<div class="trend-toolbar">
|
|
<el-select v-model="trendStationId" class="trend-select" size="mini" 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="trendType" class="trend-select" size="mini" placeholder="巡检类型">
|
|
<el-option
|
|
v-for="item in overviewList"
|
|
:key="item.algCategory"
|
|
:label="item.name"
|
|
:value="item.algCategory"
|
|
/>
|
|
</el-select>
|
|
<el-select v-model="trendGranularity" class="trend-select trend-select-short" size="mini" placeholder="周期">
|
|
<el-option label="月" value="month" />
|
|
<el-option label="季度" value="quarter" />
|
|
</el-select>
|
|
<div class="trend-legend">
|
|
<span v-for="item in trendLegend" :key="item.name" class="legend-item">
|
|
<i :style="{ background: item.color }" />{{ item.name }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<defect-trend-chart class="trend-chart" :months="trend.months" :series="trend.series" />
|
|
</div>
|
|
|
|
<!-- 场站系统入口 -->
|
|
<div class="panel panel-stations">
|
|
<div class="panel-title">
|
|
<img class="title-icon" :src="triangleIcon" alt="">
|
|
<span>场站系统入口</span>
|
|
</div>
|
|
<div class="station-grid">
|
|
<div
|
|
v-for="station in stationList"
|
|
:key="station.id"
|
|
class="station-card"
|
|
:class="{ selected: isMonitorStationSelected(station.id) }"
|
|
@click="selectMonitorStation(station)"
|
|
>
|
|
<div class="station-head">
|
|
<span class="station-name" :title="station.name" @click.stop="enterStation(station)">{{ station.name }}</span>
|
|
<img class="enter-icon" :src="enterIcon" alt="" title="进入场站系统" @click.stop="enterStation(station)">
|
|
<span class="head-spacer" />
|
|
<span class="status-dot" :class="station.status === 'abnormal' ? 'abnormal' : 'normal'" />
|
|
<span class="station-status" :class="station.status === 'abnormal' ? 'abnormal' : 'normal'">
|
|
{{ station.status === 'abnormal' ? '异常' : '正常' }}
|
|
</span>
|
|
</div>
|
|
<div class="station-btns">
|
|
<span
|
|
v-for="btn in stationButtons"
|
|
:key="btn.key"
|
|
class="station-btn"
|
|
:class="'btn-' + btn.key"
|
|
@click.stop="selectTrendForStation(station, btn.algCategory)"
|
|
>{{ btn.label }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 右列 -->
|
|
<section class="col col-right">
|
|
<div class="panel panel-video">
|
|
<div class="panel-title">
|
|
<img class="title-icon" :src="triangleIcon" alt="">
|
|
<span>动环监控</span>
|
|
</div>
|
|
<div class="video-filter-row">
|
|
<el-select
|
|
v-model="videoStationId"
|
|
class="panel-select"
|
|
size="mini"
|
|
placeholder="全部场站"
|
|
>
|
|
<el-option label="全部场站" value="" />
|
|
<el-option
|
|
v-for="station in stationList"
|
|
:key="station.id"
|
|
:label="station.name"
|
|
:value="station.id"
|
|
/>
|
|
</el-select>
|
|
</div>
|
|
|
|
<div class="video-monitor-body" :class="{ 'tree-visible': treeVisible }">
|
|
<div class="video-monitor-main">
|
|
<div class="camera-stats">
|
|
<div v-for="item in cameraStats" :key="item.label" class="camera-stat">
|
|
<img class="camera-icon" :src="item.icon" alt="">
|
|
<div class="camera-meta">
|
|
<div class="camera-value">{{ item.value }}</div>
|
|
<div class="camera-label">{{ item.label }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="video-grid">
|
|
<div
|
|
v-for="(video, index) in portalVideoSlots"
|
|
:key="video ? `${index}-${video.id}` : `empty-${index}`"
|
|
class="video-tile"
|
|
:class="{ active: activeVideoIndex === index, empty: !video }"
|
|
@click="selectPortalVideoSlot(index)"
|
|
@dblclick="video && onVideoClick(video)"
|
|
>
|
|
<template v-if="video">
|
|
<video
|
|
:ref="`portalVideo${index}`"
|
|
class="portal-video-player"
|
|
:class="{ hidden: !(video.playUrl || video.flvUrl) || video.playbackError }"
|
|
autoplay
|
|
muted
|
|
playsinline
|
|
/>
|
|
<div v-if="!(video.playUrl || video.flvUrl) || video.loading || video.playbackError" class="video-placeholder">
|
|
<svg viewBox="0 0 48 48" class="video-glyph">
|
|
<rect x="4" y="12" width="28" height="22" rx="3" fill="none" stroke="currentColor" stroke-width="2.4" />
|
|
<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" />
|
|
</svg>
|
|
<span class="video-loading">{{ video.playbackError ? '视频加载失败' : (video.loading ? '正在连接' : '等待视频') }}</span>
|
|
</div>
|
|
<div class="video-mask" />
|
|
<span class="video-dot" :class="video.status === 'abnormal' ? 'abnormal' : 'normal'" />
|
|
<div class="video-labels">
|
|
<div class="video-station">{{ video.stationName }}</div>
|
|
<div class="video-camera">{{ video.cameraName }}</div>
|
|
</div>
|
|
</template>
|
|
<div v-else class="video-placeholder">
|
|
<svg viewBox="0 0 48 48" class="video-glyph">
|
|
<rect x="4" y="12" width="28" height="22" rx="3" fill="none" stroke="currentColor" stroke-width="2.4" />
|
|
<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" />
|
|
</svg>
|
|
<span class="video-loading">等待视频</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<aside v-if="treeMounted" v-show="treeVisible" class="portal-device-tree">
|
|
<portal-device-tree
|
|
:key="portalTreeStationCode || 'all'"
|
|
:selected-code="selectedPortalCameraCode"
|
|
:station-code="portalTreeStationCode"
|
|
@channels-loaded="handlePortalTreeChannels"
|
|
@open="openPortalChannel"
|
|
/>
|
|
</aside>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
class="tree-toggle"
|
|
:class="treeVisible ? 'open' : 'collapsed'"
|
|
:aria-expanded="String(treeVisible)"
|
|
:title="treeVisible ? '隐藏设备树' : '显示设备树'"
|
|
@click="toggleDeviceTree"
|
|
>
|
|
<img :src="treeVisible ? treeToggleIcon : treeCollapseIcon" alt="">
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import {
|
|
getStationList,
|
|
getInspectionOverview,
|
|
getDefectTrend,
|
|
getChannelStats,
|
|
getChannelTree,
|
|
startRealtimeVideo,
|
|
stopRealtimeVideo
|
|
} from "@/api/portal";
|
|
import Hls from "hls.js";
|
|
import flvjs from "flv.js";
|
|
import axios from "axios";
|
|
import { applyViewportScale } from "@/utils/viewportScale";
|
|
import DefectTrendChart from "./components/DefectTrendChart";
|
|
import PortalDeviceTree from "./components/PortalDeviceTree.vue";
|
|
import Navbar from "@/layout/components/Navbar";
|
|
import triangleIcon from "@/assets/portal/icon-triangle.png";
|
|
import enterIcon from "@/assets/portal/icon-enter.png";
|
|
import iconTotal from "@/assets/portal/icon-total.png";
|
|
import iconDonghuan from "@/assets/portal/icon-donghuan.png";
|
|
import iconFire from "@/assets/portal/icon-fire.png";
|
|
import iconSecurity from "@/assets/portal/icon-security.png";
|
|
import treeToggleIcon from "@/assets/portal/tree-toggle.png";
|
|
import treeCollapseIcon from "@/assets/portal/tree-collapse.png";
|
|
|
|
const INSPECTION_CATEGORIES = ["表计", "外观", "红外"];
|
|
const PORTAL_HLS_NO_RETRY_POLICY = {
|
|
maxTimeToFirstByteMs: 10000,
|
|
maxLoadTimeMs: 20000,
|
|
timeoutRetry: null,
|
|
errorRetry: null
|
|
};
|
|
|
|
function pickPortalStream(payload) {
|
|
let data = payload;
|
|
if (data && typeof data === "object" && data.data && typeof data.data === "object") data = data.data;
|
|
if (data && typeof data === "object" && data.data && typeof data.data === "object") data = data.data;
|
|
if (!data || typeof data !== "object") {
|
|
const mediaUrl = typeof payload === "string" ? payload : "";
|
|
return { hlsUrl: /\.m3u8(?:\?|$)/i.test(mediaUrl) ? mediaUrl : "", flvUrl: /\.flv(?:\?|$)/i.test(mediaUrl) ? mediaUrl : "", status: "", streamId: "" };
|
|
}
|
|
const mediaUrl = data.hls || data.hlsUrl || data.url || data.playUrl || data.mediaUrl || data.address || "";
|
|
const hlsUrl = data.hls || data.hlsUrl || (/\.m3u8(?:\?|$)/i.test(mediaUrl) ? mediaUrl : "");
|
|
const flvUrl = data.flv || data.flvUrl || (/\.flv(?:\?|$)/i.test(mediaUrl) ? mediaUrl : "");
|
|
return {
|
|
hlsUrl: typeof hlsUrl === "string" ? hlsUrl : "",
|
|
flvUrl: typeof flvUrl === "string" ? flvUrl : "",
|
|
status: String(data.status || "").toUpperCase(),
|
|
streamId: data.streamId || ""
|
|
};
|
|
}
|
|
|
|
function toCount(value) {
|
|
const count = Number(value);
|
|
return Number.isFinite(count) ? count : 0;
|
|
}
|
|
|
|
function createEmptyOverview() {
|
|
return INSPECTION_CATEGORIES.map(algCategory => ({
|
|
algCategory,
|
|
name: `${algCategory}巡检`,
|
|
rounds: 0,
|
|
completionRate: 0,
|
|
totalDefects: 0,
|
|
slightDefects: 0,
|
|
generalDefects: 0,
|
|
importantDefects: 0
|
|
}));
|
|
}
|
|
|
|
function createEmptyTrend() {
|
|
return {
|
|
months: [],
|
|
series: [
|
|
{ name: "轻微缺陷", data: [] },
|
|
{ name: "一般缺陷", data: [] },
|
|
{ name: "严重缺陷", data: [] },
|
|
{ name: "重大缺陷", data: [] }
|
|
]
|
|
};
|
|
}
|
|
|
|
const DEFECT_SLIDES = [
|
|
{ key: "slight", label: "轻微缺陷", fields: ["slightDefects", "minorDefects"] },
|
|
{ key: "general", label: "一般缺陷", fields: ["generalDefects", "normalDefects"] },
|
|
{ key: "important", label: "重要缺陷", fields: ["importantDefects", "majorDefects"] }
|
|
];
|
|
|
|
// 场站卡片下方的缺陷趋势快捷筛选
|
|
const STATION_BUTTONS = [
|
|
{ key: "meter", label: "表计缺陷", algCategory: "表计" },
|
|
{ key: "appearance", label: "外观缺陷", algCategory: "外观" },
|
|
{ key: "infrared", label: "红外缺陷", algCategory: "红外" }
|
|
];
|
|
|
|
const TREND_LEGEND = [
|
|
{ name: "轻微缺陷", color: "#2f7bff" },
|
|
{ name: "一般缺陷", color: "#3ad07a" },
|
|
{ name: "严重缺陷", color: "#f0c14a" },
|
|
{ name: "重大缺陷", color: "#ef5b5b" }
|
|
];
|
|
|
|
const EMPTY_CAMERA = { total: 0, donghuan: 0, fire: 0, security: 0 };
|
|
|
|
function flattenNodes(nodes, result = []) {
|
|
(nodes || []).forEach(node => {
|
|
result.push(node);
|
|
if (Array.isArray(node.children)) flattenNodes(node.children, result);
|
|
});
|
|
return result;
|
|
}
|
|
|
|
export default {
|
|
name: "PortalScreen",
|
|
components: { DefectTrendChart, Navbar, PortalDeviceTree },
|
|
data() {
|
|
return {
|
|
triangleIcon,
|
|
enterIcon,
|
|
treeToggleIcon,
|
|
treeCollapseIcon,
|
|
navItems: [
|
|
{ key: "home", label: "首页" },
|
|
{ key: "patrol", label: "动环巡检" }
|
|
],
|
|
stationButtons: STATION_BUTTONS,
|
|
defectSlides: DEFECT_SLIDES,
|
|
defectSlideIndex: 0,
|
|
defectSlideDirection: "next",
|
|
defectCarouselTimer: null,
|
|
overviewStationId: "",
|
|
trendStationId: "",
|
|
trendType: "表计",
|
|
trendGranularity: "month",
|
|
trendLegend: TREND_LEGEND,
|
|
overviewList: createEmptyOverview(),
|
|
stationList: [],
|
|
trend: createEmptyTrend(),
|
|
cameraCount: { ...EMPTY_CAMERA },
|
|
portalChannels: [],
|
|
videoList: [],
|
|
videoStationId: "",
|
|
activeVideoIndex: 0,
|
|
treeVisible: false,
|
|
treeMounted: false,
|
|
portalPlayers: {},
|
|
portalStartTimers: {},
|
|
portalStartRequests: {},
|
|
portalRequestSource: null,
|
|
portalAlive: true,
|
|
portalReleased: false,
|
|
overviewRequestSerial: 0,
|
|
trendRequestSerial: 0
|
|
};
|
|
},
|
|
computed: {
|
|
overviewCards() {
|
|
return this.overviewList.map(card => ({
|
|
...card,
|
|
items: [
|
|
{ label: "巡检轮次", value: card.rounds },
|
|
{ label: "巡检完成率", value: card.completionRate },
|
|
{ label: "累计缺陷", value: card.totalDefects },
|
|
{ label: "缺陷等级", defectCarousel: true }
|
|
]
|
|
}));
|
|
},
|
|
activeDefectSlide() {
|
|
return this.defectSlides[this.defectSlideIndex];
|
|
},
|
|
trendQueryKey() {
|
|
return [this.trendStationId, this.trendType, this.trendGranularity].join("|");
|
|
},
|
|
cameraStats() {
|
|
return [
|
|
{ label: "摄像头总数", value: this.cameraCount.total, icon: iconTotal },
|
|
{ label: "动环摄像头", value: this.cameraCount.donghuan, icon: iconDonghuan },
|
|
{ label: "消防摄像头", value: this.cameraCount.fire, icon: iconFire },
|
|
{ label: "安防摄像头", value: this.cameraCount.security, icon: iconSecurity }
|
|
];
|
|
},
|
|
filteredVideos() {
|
|
if (this.videoStationId === "" || this.videoStationId === null || this.videoStationId === undefined) {
|
|
return this.portalChannels.slice();
|
|
}
|
|
const station = this.stationList.find(item => String(item.id) === String(this.videoStationId));
|
|
if (!station) return [];
|
|
return this.portalChannels
|
|
.filter(video => String(video.stationId) === String(this.videoStationId));
|
|
},
|
|
portalTreeStationCode() {
|
|
const station = this.stationList.find(item => String(item.id) === String(this.videoStationId));
|
|
return station && station.code ? station.code : "";
|
|
},
|
|
portalVideoSlots() {
|
|
return Array.from({ length: 4 }, (_, index) => this.videoList[index] || null);
|
|
},
|
|
selectedPortalCameraCode() {
|
|
const video = this.videoList[this.activeVideoIndex];
|
|
return video ? video.cameraCode : "";
|
|
}
|
|
},
|
|
watch: {
|
|
overviewStationId() {
|
|
this.loadInspectionOverview();
|
|
},
|
|
trendQueryKey() {
|
|
this.loadDefectTrend();
|
|
},
|
|
videoStationId(id) {
|
|
this.replacePortalVideos(this.filteredVideos);
|
|
this.loadChannelStats();
|
|
if (id !== "" && id !== null && id !== undefined) this.openDeviceTree();
|
|
}
|
|
},
|
|
mounted() {
|
|
this.startDefectCarousel();
|
|
this.loadPortalData();
|
|
this.$nextTick(() => applyViewportScale());
|
|
},
|
|
beforeDestroy() {
|
|
this.releasePortalPage();
|
|
},
|
|
methods: {
|
|
selectMonitorStation(station) {
|
|
this.videoStationId = station.id;
|
|
this.openDeviceTree();
|
|
},
|
|
openDeviceTree() {
|
|
this.treeMounted = true;
|
|
this.treeVisible = true;
|
|
},
|
|
isMonitorStationSelected(stationId) {
|
|
return String(this.videoStationId) === String(stationId);
|
|
},
|
|
selectTrendForStation(station, algCategory) {
|
|
this.trendStationId = station.code;
|
|
this.trendType = algCategory;
|
|
},
|
|
portalRequestConfig() {
|
|
if (!this.portalRequestSource) this.portalRequestSource = axios.CancelToken.source();
|
|
return { cancelToken: this.portalRequestSource.token };
|
|
},
|
|
cancelPortalRequests() {
|
|
if (!this.portalRequestSource) return;
|
|
this.portalRequestSource.cancel("portal requests cancelled");
|
|
this.portalRequestSource = null;
|
|
},
|
|
isPortalCancel(error) {
|
|
return !this.portalAlive || axios.isCancel(error);
|
|
},
|
|
loadPortalData() {
|
|
return this.loadStations().then(() => Promise.all([
|
|
this.loadInspectionOverview(),
|
|
this.loadDefectTrend(),
|
|
this.loadChannelStats(),
|
|
this.loadVideoChannels()
|
|
]));
|
|
},
|
|
loadStations() {
|
|
return getStationList({}, this.portalRequestConfig()).then(response => {
|
|
const rows = Array.isArray(response && response.data) ? response.data : [];
|
|
this.stationList = rows.map(item => ({
|
|
id: item.id,
|
|
code: item.stationCode,
|
|
name: item.stationName,
|
|
baseUrl: item.baseUrl,
|
|
portalUrl: item.portalUrl,
|
|
enabled: String(item.enabled),
|
|
status: String(item.enabled) === "1" ? "normal" : "abnormal"
|
|
}));
|
|
}).catch((error) => {
|
|
if (this.isPortalCancel(error)) return;
|
|
this.stationList = [];
|
|
});
|
|
},
|
|
loadChannelStats() {
|
|
const params = {};
|
|
const station = this.stationList.find(item => String(item.id) === String(this.videoStationId));
|
|
if (station && station.code) params.stationCode = station.code;
|
|
return getChannelStats(params, this.portalRequestConfig()).then(response => {
|
|
const data = response && response.data ? response.data : {};
|
|
const counts = Array.isArray(data.deviceSourceCounts) ? data.deviceSourceCounts : [];
|
|
const cameraCount = { ...EMPTY_CAMERA, total: toCount(data.totalCount) };
|
|
counts.forEach(item => {
|
|
const source = String(item.deviceSource || "");
|
|
const count = toCount(item.count);
|
|
if (source.includes("动环")) cameraCount.donghuan += count;
|
|
else if (source.includes("消防")) cameraCount.fire += count;
|
|
else if (source.includes("安防")) cameraCount.security += count;
|
|
else cameraCount.security += count;
|
|
});
|
|
this.cameraCount = cameraCount;
|
|
}).catch((error) => {
|
|
if (this.isPortalCancel(error)) return;
|
|
this.cameraCount = { ...EMPTY_CAMERA };
|
|
});
|
|
},
|
|
loadVideoChannels() {
|
|
return getChannelTree({ onlineStatus: "1", patroldeviceTypeFlag: "ipc" }, { skipWorkspaceStation: true, ...this.portalRequestConfig() }).then(response => {
|
|
const nodes = flattenNodes(Array.isArray(response && response.data) ? response.data : []);
|
|
const videos = nodes
|
|
.filter(node => node.channelFlag && (String(node.patroldeviceOnlineStatus || "1") === "1"))
|
|
.map(this.normalizePortalVideo);
|
|
this.portalChannels = videos;
|
|
return this.replacePortalVideos(videos);
|
|
}).catch((error) => {
|
|
if (this.isPortalCancel(error)) return;
|
|
this.portalChannels = [];
|
|
return this.replacePortalVideos([]);
|
|
});
|
|
},
|
|
normalizePortalVideo(node, index) {
|
|
const station = this.stationList.find(item => item.code === node.stationCode) || {};
|
|
return {
|
|
id: node.id || `${node.stationCode || "station"}-${node.channelCode || index}`,
|
|
stationId: station.id,
|
|
stationCode: node.stationCode,
|
|
stationName: node.stationName || station.name || node.stationCode || "未知场站",
|
|
cameraCode: node.cameraCode || node.channelCode,
|
|
cameraName: node.channelName || node.label || node.name || node.channelCode || "未命名通道",
|
|
status: String(node.patroldeviceOnlineStatus || "1") === "1" ? "normal" : "abnormal"
|
|
};
|
|
},
|
|
pickRandomVideos(videos, count = 4) {
|
|
const list = (videos || []).filter(item => !item || item.status !== "abnormal");
|
|
for (let index = list.length - 1; index > 0; index -= 1) {
|
|
const swapIndex = Math.floor(Math.random() * (index + 1));
|
|
const current = list[index];
|
|
list[index] = list[swapIndex];
|
|
list[swapIndex] = current;
|
|
}
|
|
return list.slice(0, count);
|
|
},
|
|
replacePortalVideos(videos) {
|
|
return this.stopPortalVideos().then(() => {
|
|
// 切页后停流才返回时,不能再把播放器挂回去。
|
|
if (!this.portalAlive) return;
|
|
this.videoList = this.pickRandomVideos(videos, 4);
|
|
this.activeVideoIndex = 0;
|
|
this.$nextTick(() => this.startDefaultPortalVideos());
|
|
});
|
|
},
|
|
startDefaultPortalVideos() {
|
|
this.videoList.forEach((video, index) => {
|
|
if (video) this.startPortalVideo(video, index);
|
|
});
|
|
},
|
|
async startPortalVideo(video, index) {
|
|
if (!this.portalAlive || !video || !video.stationCode || !video.cameraCode) return;
|
|
this.clearPortalStartTimer(index);
|
|
this.cancelPortalStart(index);
|
|
this.destroyPortalPlayer(index);
|
|
this.$set(video, "loading", true);
|
|
this.$set(video, "playbackError", false);
|
|
this.$set(video, "playUrl", "");
|
|
this.$set(video, "flvUrl", "");
|
|
const request = startRealtimeVideo({
|
|
stationCode: video.stationCode,
|
|
cameraCode: video.cameraCode
|
|
});
|
|
this.$set(this.portalStartRequests, index, request);
|
|
try {
|
|
const response = await request;
|
|
if (this.portalStartRequests[index] === request) {
|
|
this.$delete(this.portalStartRequests, index);
|
|
}
|
|
if (!this.portalAlive || this.videoList[index] !== video) return;
|
|
const stream = pickPortalStream(response);
|
|
this.$set(video, "streamId", stream.streamId);
|
|
this.$set(video, "playUrl", stream.hlsUrl);
|
|
this.$set(video, "flvUrl", stream.flvUrl);
|
|
if (!stream.hlsUrl && !stream.flvUrl) {
|
|
this.$set(video, "loading", false);
|
|
this.$set(video, "playbackError", true);
|
|
return;
|
|
}
|
|
this.$nextTick(() => this.attachPortalPlayer(video, index));
|
|
} catch (error) {
|
|
if (this.portalStartRequests[index] === request) {
|
|
this.$delete(this.portalStartRequests, index);
|
|
}
|
|
// 切页取消的请求不要再改状态,组件可能已经销毁。
|
|
if (!this.portalAlive || (error && error.code === "ERR_CANCELED")) return;
|
|
this.$set(video, "loading", false);
|
|
this.$set(video, "playbackError", true);
|
|
}
|
|
},
|
|
attachPortalPlayer(video, index) {
|
|
const ref = this.$refs[`portalVideo${index}`];
|
|
const element = Array.isArray(ref) ? ref[0] : ref;
|
|
if (!this.portalAlive || !video || this.videoList[index] !== video) return;
|
|
if (!element) {
|
|
// 切走时视频节点会被拆掉。这里再排一次 nextTick,离开后会一直空转。
|
|
this.$nextTick(() => {
|
|
if (!this.portalAlive || this.videoList[index] !== video) return;
|
|
const retryRef = this.$refs[`portalVideo${index}`];
|
|
const retryElement = Array.isArray(retryRef) ? retryRef[0] : retryRef;
|
|
if (retryElement) this.attachPortalPlayer(video, index);
|
|
});
|
|
return;
|
|
}
|
|
this.destroyPortalPlayer(index);
|
|
const hlsUrl = video.playUrl;
|
|
const flvUrl = video.flvUrl;
|
|
if (hlsUrl && Hls.isSupported()) {
|
|
const hls = new Hls({
|
|
enableWorker: true,
|
|
lowLatencyMode: true,
|
|
liveDurationInfinity: true,
|
|
backBufferLength: 2,
|
|
maxBufferLength: 5,
|
|
autoStartLoad: true,
|
|
manifestLoadPolicy: { default: PORTAL_HLS_NO_RETRY_POLICY },
|
|
playlistLoadPolicy: { default: PORTAL_HLS_NO_RETRY_POLICY },
|
|
fragLoadPolicy: { default: PORTAL_HLS_NO_RETRY_POLICY }
|
|
});
|
|
const player = { hls, element, stopped: false };
|
|
this.$set(this.portalPlayers, index, player);
|
|
const markPlaying = () => {
|
|
if (this.portalPlayers[index] !== player || player.stopped) return;
|
|
this.$set(video, "loading", false);
|
|
this.$set(video, "playbackError", false);
|
|
};
|
|
element.onplaying = markPlaying;
|
|
element.onloadeddata = markPlaying;
|
|
element.oncanplay = markPlaying;
|
|
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
|
if (this.portalPlayers[index] !== player || player.stopped) return;
|
|
hls.loadSource(hlsUrl);
|
|
});
|
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
if (this.portalPlayers[index] !== player || player.stopped) return;
|
|
element.play().catch(() => undefined);
|
|
});
|
|
hls.on(Hls.Events.ERROR, (_, detail) => {
|
|
if (this.portalPlayers[index] !== player || player.stopped || !detail || !detail.fatal) return;
|
|
if (flvUrl) {
|
|
this.attachPortalFlv(video, index, element, flvUrl);
|
|
return;
|
|
}
|
|
this.failPortalPlayer(player, video, index);
|
|
});
|
|
hls.attachMedia(element);
|
|
} else if (hlsUrl && element.canPlayType("application/vnd.apple.mpegurl")) {
|
|
const player = { native: true, element, stopped: false };
|
|
this.$set(this.portalPlayers, index, player);
|
|
element.onplaying = () => {
|
|
if (this.portalPlayers[index] !== player || player.stopped) return;
|
|
this.$set(video, "loading", false);
|
|
this.$set(video, "playbackError", false);
|
|
};
|
|
element.onerror = () => {
|
|
if (this.portalPlayers[index] !== player || player.stopped) return;
|
|
if (flvUrl) {
|
|
this.attachPortalFlv(video, index, element, flvUrl);
|
|
return;
|
|
}
|
|
this.failPortalPlayer(player, video, index);
|
|
};
|
|
element.src = hlsUrl;
|
|
element.play().catch(() => undefined);
|
|
} else if (flvUrl) {
|
|
this.attachPortalFlv(video, index, element, flvUrl);
|
|
} else {
|
|
this.$set(video, "loading", false);
|
|
this.$set(video, "playbackError", true);
|
|
}
|
|
},
|
|
attachPortalFlv(video, index, element, url) {
|
|
if (!url || !flvjs.isSupported()) {
|
|
this.$set(video, "loading", false);
|
|
this.$set(video, "playbackError", true);
|
|
return;
|
|
}
|
|
this.destroyPortalPlayer(index);
|
|
const flv = flvjs.createPlayer(
|
|
{ type: "flv", url, isLive: true },
|
|
{ enableStashBuffer: false, lazyLoad: false, autoCleanupSourceBuffer: true }
|
|
);
|
|
const player = { flv, element, stopped: false };
|
|
this.$set(this.portalPlayers, index, player);
|
|
const markPlaying = () => {
|
|
if (this.portalPlayers[index] !== player || player.stopped) return;
|
|
this.$set(video, "loading", false);
|
|
this.$set(video, "playbackError", false);
|
|
};
|
|
element.onplaying = markPlaying;
|
|
element.onloadeddata = markPlaying;
|
|
element.oncanplay = markPlaying;
|
|
flv.attachMediaElement(element);
|
|
flv.load();
|
|
Promise.resolve(flv.play()).catch(() => undefined);
|
|
flv.on(flvjs.Events.ERROR, () => {
|
|
if (this.portalPlayers[index] !== player || player.stopped) return;
|
|
this.failPortalPlayer(player, video, index);
|
|
});
|
|
},
|
|
failPortalPlayer(player, video, index) {
|
|
if (!player || player.stopped) 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();
|
|
}
|
|
if (this.videoList[index] === video) {
|
|
this.$set(video, "loading", false);
|
|
this.$set(video, "playbackError", true);
|
|
}
|
|
},
|
|
destroyPortalPlayer(index) {
|
|
const player = this.portalPlayers[index];
|
|
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.$delete(this.portalPlayers, index);
|
|
},
|
|
clearPortalStartTimer(index) {
|
|
if (!this.portalStartTimers[index]) return;
|
|
window.clearTimeout(this.portalStartTimers[index]);
|
|
this.$delete(this.portalStartTimers, index);
|
|
},
|
|
cancelPortalStart(index) {
|
|
const request = this.portalStartRequests[index];
|
|
if (!request) return;
|
|
this.$delete(this.portalStartRequests, index);
|
|
if (typeof request.cancel === "function") request.cancel();
|
|
},
|
|
cancelPortalStarts() {
|
|
Object.keys(this.portalStartRequests).forEach(index => this.cancelPortalStart(index));
|
|
},
|
|
stopPortalVideo(video, index) {
|
|
this.clearPortalStartTimer(index);
|
|
this.cancelPortalStart(index);
|
|
this.destroyPortalPlayer(index);
|
|
// 拉流还没成功就切走时,没有可停的流。再发 stop 只会继续占浏览器连接。
|
|
if (!video || !video.stationCode || !video.cameraCode) return Promise.resolve(null);
|
|
if (!video.streamId && !video.playUrl && !video.flvUrl) return Promise.resolve(null);
|
|
return stopRealtimeVideo({ stationCode: video.stationCode, cameraCode: video.cameraCode }).catch(() => null);
|
|
},
|
|
stopPortalVideos() {
|
|
const videos = this.videoList.slice();
|
|
Object.keys(this.portalStartTimers).forEach(videoId => {
|
|
window.clearTimeout(this.portalStartTimers[videoId]);
|
|
this.$delete(this.portalStartTimers, videoId);
|
|
});
|
|
return Promise.all(videos.map((video, index) => this.stopPortalVideo(video, index)));
|
|
},
|
|
selectPortalVideoSlot(index) {
|
|
this.activeVideoIndex = index;
|
|
},
|
|
handlePortalTreeChannels(channels) {
|
|
if (!this.portalChannels.length) {
|
|
this.portalChannels = (channels || []).map(this.normalizePortalVideo);
|
|
}
|
|
},
|
|
async openPortalChannel(channel) {
|
|
if (!channel || !(channel.cameraCode || channel.channelCode)) return;
|
|
const index = this.activeVideoIndex;
|
|
const video = this.normalizePortalVideo(channel, index);
|
|
const current = this.videoList[index];
|
|
if (current && current.stationCode === video.stationCode && current.cameraCode === video.cameraCode) {
|
|
if ((!current.playUrl || current.playbackError) && !current.loading) this.startPortalVideo(current, index);
|
|
return;
|
|
}
|
|
await this.stopPortalVideo(current, index);
|
|
this.$set(this.videoList, index, video);
|
|
this.$nextTick(() => this.startPortalVideo(video, index));
|
|
},
|
|
loadInspectionOverview() {
|
|
const requestSerial = ++this.overviewRequestSerial;
|
|
const params = this.overviewStationId ? { stationCode: this.overviewStationId } : {};
|
|
return getInspectionOverview(params, this.portalRequestConfig()).then(response => {
|
|
if (requestSerial !== this.overviewRequestSerial) return;
|
|
const rows = Array.isArray(response && response.data) ? response.data : [];
|
|
const rowByCategory = rows.reduce((result, item) => {
|
|
result[item.algCategory] = item;
|
|
return result;
|
|
}, {});
|
|
this.overviewList = INSPECTION_CATEGORIES.map(algCategory => {
|
|
const item = rowByCategory[algCategory] || {};
|
|
const inspected = toCount(item.inspectedTaskCount);
|
|
const completed = toCount(item.completedTaskCount);
|
|
return {
|
|
algCategory,
|
|
name: `${algCategory}巡检`,
|
|
rounds: inspected,
|
|
completionRate: inspected > 0 ? Math.round((completed * 100) / inspected) : 0,
|
|
totalDefects: toCount(item.defectCount),
|
|
slightDefects: toCount(item.minorCount),
|
|
generalDefects: toCount(item.generalCount),
|
|
importantDefects: toCount(item.seriousCount) + toCount(item.urgentCount)
|
|
};
|
|
});
|
|
}).catch((error) => {
|
|
if (this.isPortalCancel(error)) return;
|
|
if (requestSerial === this.overviewRequestSerial) {
|
|
this.overviewList = createEmptyOverview();
|
|
}
|
|
});
|
|
},
|
|
loadDefectTrend() {
|
|
const requestSerial = ++this.trendRequestSerial;
|
|
const params = {
|
|
algCategory: this.trendType,
|
|
dateType: this.trendGranularity
|
|
};
|
|
if (this.trendStationId) params.stationCode = this.trendStationId;
|
|
return getDefectTrend(params, this.portalRequestConfig()).then(response => {
|
|
if (requestSerial !== this.trendRequestSerial) return;
|
|
const rows = (Array.isArray(response && response.data) ? response.data : [])
|
|
.slice()
|
|
.sort((a, b) => String(a.date || "").localeCompare(String(b.date || "")));
|
|
this.trend = {
|
|
months: rows.map((item, index) => this.formatTrendDate(item.date, index, rows)),
|
|
series: [
|
|
{ name: "轻微缺陷", data: rows.map(item => toCount(item.minorCount)) },
|
|
{ name: "一般缺陷", data: rows.map(item => toCount(item.generalCount)) },
|
|
{ name: "严重缺陷", data: rows.map(item => toCount(item.seriousCount)) },
|
|
{ name: "重大缺陷", data: rows.map(item => toCount(item.urgentCount)) }
|
|
]
|
|
};
|
|
}).catch((error) => {
|
|
if (this.isPortalCancel(error)) return;
|
|
if (requestSerial === this.trendRequestSerial) {
|
|
this.trend = createEmptyTrend();
|
|
}
|
|
});
|
|
},
|
|
formatTrendDate(date, index, rows) {
|
|
const value = String(date || "");
|
|
if (value.includes("-Q")) return value.replace("-", "年");
|
|
const parts = value.split("-");
|
|
if (parts.length !== 2) return value;
|
|
const previousYear = index > 0 ? String(rows[index - 1].date || "").split("-")[0] : "";
|
|
const month = Number(parts[1]);
|
|
return index === 0 || parts[0] !== previousYear
|
|
? `${parts[0]}年${month}月`
|
|
: `${month}月`;
|
|
},
|
|
toggleDeviceTree() {
|
|
if (this.treeVisible) {
|
|
this.treeVisible = false;
|
|
return;
|
|
}
|
|
this.openDeviceTree();
|
|
},
|
|
defectValue(card, slide) {
|
|
const field = slide.fields.find(key => card[key] !== undefined && card[key] !== null);
|
|
return field ? card[field] : 0;
|
|
},
|
|
setDefectSlide(index) {
|
|
if (index === this.defectSlideIndex) return;
|
|
this.defectSlideDirection = index > this.defectSlideIndex ? "next" : "prev";
|
|
this.defectSlideIndex = index;
|
|
this.startDefectCarousel();
|
|
},
|
|
startDefectCarousel() {
|
|
this.pauseDefectCarousel();
|
|
this.defectCarouselTimer = window.setInterval(() => {
|
|
this.defectSlideDirection = "next";
|
|
this.defectSlideIndex = (this.defectSlideIndex + 1) % this.defectSlides.length;
|
|
}, 3000);
|
|
},
|
|
pauseDefectCarousel() {
|
|
if (this.defectCarouselTimer) {
|
|
window.clearInterval(this.defectCarouselTimer);
|
|
this.defectCarouselTimer = null;
|
|
}
|
|
},
|
|
releasePortalPage() {
|
|
// 点击切换和组件销毁都会走到这里,只释放一次,避免停流发两遍。
|
|
if (this.portalReleased) return;
|
|
this.portalReleased = true;
|
|
// 先标记页面已离开,再取消拉流。否则慢返回的 start 还会去挂播放器。
|
|
this.portalAlive = false;
|
|
this.pauseDefectCarousel();
|
|
this.cancelPortalRequests();
|
|
this.cancelPortalStarts();
|
|
this.stopPortalVideos();
|
|
},
|
|
onNavClick(item) {
|
|
if (item.key === "home") {
|
|
if (this.$route.path !== "/portal") this.$router.push("/portal").catch(() => undefined);
|
|
} else if (item.key === "patrol") {
|
|
// 动环巡检:进入单站系统(即原后台,默认当前部署站的首页),
|
|
// 打上来源标记后单站内保留 module-subbar 横向导航,便于返回门户
|
|
this.$store.dispatch("siteContext/markFromPortal");
|
|
this.$store.dispatch("siteContext/exitSite");
|
|
// 先把首页还没回来的请求清掉,再跳转。否则拉流会占住浏览器连接,动环巡检的列表一直 pending。
|
|
this.releasePortalPage();
|
|
this.$router.push("/index").catch(() => undefined);
|
|
} else if (item.key === "system") {
|
|
this.$router.push("/system/user");
|
|
}
|
|
},
|
|
// 场站入口:跳转后端为该站配置的访问地址(portalUrl)
|
|
enterStation(station) {
|
|
const url = station.portalUrl;
|
|
if (!url) {
|
|
this.$message.warning("该场站暂未配置访问地址");
|
|
return;
|
|
}
|
|
window.open(url, "_blank");
|
|
},
|
|
onVideoClick(video) {
|
|
this.$store.dispatch("siteContext/markFromPortal");
|
|
if (video.stationId !== undefined && video.stationName) {
|
|
this.$store.dispatch("siteContext/enterSite", { id: video.stationId, name: video.stationName });
|
|
}
|
|
this.$router.push({
|
|
path: "/video-preview/index",
|
|
query: {
|
|
stationCode: video.stationCode,
|
|
cameraCode: video.cameraCode
|
|
}
|
|
});
|
|
}
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
$accent: #12d7f8;
|
|
$text-main: #eef4ff;
|
|
$text-sub: #9fb4e8;
|
|
$panel-border: rgba(84, 130, 255, 0.22);
|
|
|
|
.portal-screen {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
width: 100%;
|
|
height: 100vh;
|
|
min-width: 1280px;
|
|
min-height: 680px;
|
|
overflow: auto;
|
|
color: $text-main;
|
|
background:
|
|
radial-gradient(1100px 480px at 50% -8%, rgba(38, 68, 160, 0.5), transparent 62%),
|
|
linear-gradient(160deg, #0d1348 0%, #101b58 46%, #0c1140 100%);
|
|
}
|
|
|
|
/* ---------- 模块导航条:样式搬自 layout/index.vue 的 module-subbar ---------- */
|
|
.module-subbar {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 40px;
|
|
padding: 2px 18px;
|
|
box-sizing: border-box;
|
|
background: linear-gradient(180deg, #151b5b 0%, #111753 58%, #0e1244 100%);
|
|
// 下边缘保留柔和的阴影线
|
|
box-shadow: inset 0 -1px 0 rgba(0, 13, 63, .55);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.module-subbar::before {
|
|
content: "";
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 6px;
|
|
pointer-events: none;
|
|
background: linear-gradient(180deg, rgba(1, 8, 27, .62) 0%, rgba(5, 18, 52, .28) 48%, rgba(5, 18, 52, 0) 100%);
|
|
}
|
|
|
|
.module-links {
|
|
height: 36px;
|
|
margin-left: 120px;
|
|
display: flex;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.module-link {
|
|
position: relative;
|
|
min-width: 90px;
|
|
height: 36px;
|
|
padding: 0 24px;
|
|
border: 0;
|
|
border-right: 0;
|
|
color: #c9dcf8;
|
|
background: transparent;
|
|
font: inherit;
|
|
font-size: 14px;
|
|
font-weight: 900;
|
|
letter-spacing: 1px;
|
|
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
line-height: 36px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
transition: color .2s, background-color .2s;
|
|
}
|
|
|
|
.module-link:not(:last-child)::before {
|
|
content: "";
|
|
position: absolute;
|
|
top: 11%;
|
|
right: 0;
|
|
width: 1px;
|
|
height: 78%;
|
|
background: linear-gradient(180deg, rgba(90, 133, 197, 0) 0%, rgba(90, 133, 197, .42) 50%, rgba(90, 133, 197, 0) 100%);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.module-link:hover {
|
|
color: #54ffff;
|
|
background: transparent;
|
|
}
|
|
|
|
.module-link.active {
|
|
position: relative;
|
|
color: #3FFFFE;
|
|
font-size: 14px;
|
|
letter-spacing: 1px;
|
|
font-weight: 900;
|
|
// 选中聚光灯:自下而上的光晕
|
|
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);
|
|
}
|
|
|
|
/* ---------- 主体 ---------- */
|
|
.portal-body {
|
|
display: flex;
|
|
gap: 14px;
|
|
flex: 1;
|
|
min-height: 0;
|
|
padding: 14px 16px 16px;
|
|
}
|
|
|
|
.col {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.col-left {
|
|
flex: 1 1 50%;
|
|
}
|
|
|
|
.col-right {
|
|
flex: 1 1 50%;
|
|
}
|
|
|
|
.panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
border: 1px solid $panel-border;
|
|
border-radius: 10px;
|
|
background: linear-gradient(180deg, rgba(30, 52, 122, 0.42), rgba(16, 28, 84, 0.3));
|
|
box-shadow: inset 0 0 24px rgba(38, 68, 160, 0.18);
|
|
}
|
|
|
|
/* 面板小标题:使用设计提供的装饰条图 */
|
|
.panel-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex: none;
|
|
height: 36px;
|
|
padding: 0 14px 0 36px;
|
|
margin: 0px 0px 0;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
letter-spacing: 2px;
|
|
line-height: 36px;
|
|
color: #dce7f5;
|
|
background: url("~@/assets/portal/panel-title.png") no-repeat left center;
|
|
background-size: 100% 100%;
|
|
|
|
span {
|
|
display: flex;
|
|
align-items: center;
|
|
height: 100%;
|
|
}
|
|
|
|
.title-icon {
|
|
width: 22px;
|
|
height: 16px;
|
|
}
|
|
|
|
.panel-select {
|
|
display: flex;
|
|
align-items: center;
|
|
margin-top: 0;
|
|
margin-left: auto;
|
|
width: 132px;
|
|
height: 28px;
|
|
}
|
|
}
|
|
|
|
.trend-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex: none;
|
|
margin-top: 8px;
|
|
padding: 4px 14px 8px;
|
|
|
|
.trend-select {
|
|
width: 126px;
|
|
}
|
|
|
|
.trend-select-short {
|
|
width: 72px;
|
|
}
|
|
|
|
.trend-legend {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: nowrap;
|
|
gap: 14px;
|
|
margin-left: auto;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.legend-item {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 12px;
|
|
color: #c3d3f6;
|
|
|
|
i {
|
|
display: block;
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 2px;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* 本轮巡检任务统计 */
|
|
.panel-overview {
|
|
flex: 22 1 0;
|
|
min-height: 0;
|
|
}
|
|
|
|
.overview-cards {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
column-gap: 10px;
|
|
flex: 1;
|
|
min-height: 0;
|
|
padding: 6px 10px 10px;
|
|
}
|
|
|
|
.overview-column {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
}
|
|
|
|
.overview-name {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
flex: none;
|
|
height: 20px;
|
|
margin-bottom: 4px;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
line-height: 20px;
|
|
color: #eef7ff;
|
|
|
|
&::before {
|
|
content: "";
|
|
flex: none;
|
|
width: 3px;
|
|
height: 12px;
|
|
background: linear-gradient(180deg, #18f1ff 0%, #126bff 100%);
|
|
box-shadow: 0 0 8px rgba(24, 241, 255, 0.72);
|
|
}
|
|
}
|
|
|
|
.overview-stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
grid-template-rows: repeat(2, minmax(0, 1fr));
|
|
gap: 6px 0;
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
.overview-stat {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
padding: 4px 4px 6px;
|
|
overflow: hidden;
|
|
text-align: center;
|
|
background:
|
|
linear-gradient(90deg, rgba(36, 98, 202, 0.28), rgba(42, 116, 232, 0.08) 50%, rgba(36, 98, 202, 0.28)),
|
|
linear-gradient(180deg, rgba(14, 53, 132, 0.9) 0%, rgba(10, 38, 106, 0.72) 100%);
|
|
box-shadow:
|
|
inset 0 1px 0 rgba(42, 198, 255, 0.2),
|
|
inset 0 -12px 22px rgba(23, 91, 203, 0.22);
|
|
|
|
&:nth-child(odd)::after {
|
|
content: "";
|
|
position: absolute;
|
|
top: 18%;
|
|
right: 0;
|
|
width: 1px;
|
|
height: 64%;
|
|
background: linear-gradient(180deg, transparent, rgba(38, 180, 255, 0.35), transparent);
|
|
}
|
|
|
|
&:nth-child(1),
|
|
&:nth-child(2) {
|
|
background:
|
|
linear-gradient(90deg, rgba(34, 89, 190, 0.32), rgba(33, 89, 188, 0.1) 50%, rgba(34, 89, 190, 0.32)),
|
|
linear-gradient(180deg, rgba(13, 48, 126, 0.94) 0%, rgba(9, 32, 94, 0.82) 100%);
|
|
}
|
|
}
|
|
|
|
.overview-stat.is-defect-carousel {
|
|
padding: 4px 4px 16px;
|
|
|
|
.overview-defect-viewport {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex: 1;
|
|
width: 100%;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.overview-defect-content {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-direction: column;
|
|
width: 100%;
|
|
}
|
|
|
|
&.severity-slight .overview-value {
|
|
color: #1ad6fb;
|
|
text-shadow: 0 0 12px rgba(26, 214, 251, 0.52);
|
|
}
|
|
|
|
&.severity-general .overview-value {
|
|
color: #ffb849;
|
|
text-shadow: 0 0 12px rgba(255, 184, 73, 0.48);
|
|
}
|
|
|
|
&.severity-important .overview-value {
|
|
color: #f1dc1d;
|
|
text-shadow: 0 0 12px rgba(241, 220, 29, 0.45);
|
|
}
|
|
}
|
|
|
|
.overview-value {
|
|
font-family: "DIN Alternate", "Arial", "Microsoft YaHei", sans-serif;
|
|
font-size: 20px;
|
|
font-weight: 700;
|
|
line-height: 1;
|
|
color: #1ad6fb;
|
|
text-shadow: 0 0 12px rgba(26, 214, 251, 0.52);
|
|
}
|
|
|
|
.overview-label {
|
|
margin-top: 4px;
|
|
overflow: hidden;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
line-height: 1.1;
|
|
color: #dbe6f7;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.overview-dots {
|
|
position: absolute;
|
|
bottom: 4px;
|
|
left: 50%;
|
|
z-index: 1;
|
|
display: flex;
|
|
gap: 4px;
|
|
transform: translateX(-50%);
|
|
|
|
button {
|
|
display: block;
|
|
flex: none;
|
|
width: 5px;
|
|
height: 5px;
|
|
padding: 0;
|
|
border: 0;
|
|
border-radius: 50%;
|
|
outline: 0;
|
|
cursor: pointer;
|
|
background: rgba(157, 176, 211, 0.38);
|
|
|
|
&:hover {
|
|
background: rgba(157, 176, 211, 0.72);
|
|
}
|
|
|
|
&.active {
|
|
background: #02bfff;
|
|
box-shadow: 0 0 6px rgba(2, 191, 255, 0.75);
|
|
}
|
|
}
|
|
}
|
|
|
|
.defect-slide-next-enter-active,
|
|
.defect-slide-next-leave-active,
|
|
.defect-slide-prev-enter-active,
|
|
.defect-slide-prev-leave-active {
|
|
transition: opacity .22s ease, transform .22s ease;
|
|
}
|
|
|
|
.defect-slide-next-enter {
|
|
opacity: 0;
|
|
transform: translateX(18px);
|
|
}
|
|
|
|
.defect-slide-next-leave-to {
|
|
opacity: 0;
|
|
transform: translateX(-18px);
|
|
}
|
|
|
|
.defect-slide-prev-enter {
|
|
opacity: 0;
|
|
transform: translateX(-18px);
|
|
}
|
|
|
|
.defect-slide-prev-leave-to {
|
|
opacity: 0;
|
|
transform: translateX(18px);
|
|
}
|
|
|
|
/* 缺陷发现趋势 */
|
|
.panel-trend {
|
|
flex: 30 1 0;
|
|
}
|
|
|
|
.trend-chart {
|
|
flex: 1;
|
|
min-height: 0;
|
|
padding: 4px 8px 8px;
|
|
}
|
|
|
|
/* 场站系统入口 */
|
|
.panel-stations {
|
|
flex: 26 1 0;
|
|
}
|
|
|
|
.station-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
grid-template-rows: repeat(2, minmax(0, 1fr));
|
|
grid-auto-rows: 1fr;
|
|
gap: 10px;
|
|
flex: 1;
|
|
min-height: 0;
|
|
padding: 8px 14px 14px;
|
|
}
|
|
|
|
.station-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: space-between;
|
|
gap: 7px;
|
|
min-width: 0;
|
|
padding: 9px 11px 8px;
|
|
border: 1px solid rgba(46, 121, 232, 0.18);
|
|
border-radius: 0;
|
|
cursor: pointer;
|
|
background: linear-gradient(90deg, rgba(10, 49, 119, 0.96), rgba(8, 38, 101, 0.92));
|
|
transition: border-color .2s, box-shadow .2s;
|
|
|
|
&.selected {
|
|
border-color: rgba(18, 215, 248, 0.72);
|
|
box-shadow: inset 0 0 16px rgba(18, 215, 248, 0.12), 0 0 10px rgba(18, 215, 248, 0.12);
|
|
}
|
|
}
|
|
|
|
.station-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 7px;
|
|
min-width: 0;
|
|
|
|
.station-name {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: #eef7ff;
|
|
cursor: pointer;
|
|
transition: color 0.2s;
|
|
|
|
&:hover {
|
|
color: $accent;
|
|
}
|
|
}
|
|
|
|
.enter-icon {
|
|
flex: none;
|
|
width: 15px;
|
|
height: 14px;
|
|
object-fit: contain;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.head-spacer {
|
|
flex: 1 1 auto;
|
|
cursor: default;
|
|
}
|
|
|
|
.status-dot,
|
|
.station-status {
|
|
cursor: default;
|
|
}
|
|
|
|
.station-status {
|
|
flex: none;
|
|
font-size: 12px;
|
|
line-height: 1;
|
|
|
|
&.normal {
|
|
color: #20e2a0;
|
|
}
|
|
|
|
&.abnormal {
|
|
color: #ff4e57;
|
|
}
|
|
}
|
|
}
|
|
|
|
.station-btns {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 6px;
|
|
}
|
|
|
|
.station-btn {
|
|
padding: 4px 0 3px;
|
|
border-radius: 0;
|
|
font-size: 12px;
|
|
line-height: 18px;
|
|
color: #edf7ff;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
border: 1px solid transparent;
|
|
transition: all 0.2s;
|
|
|
|
&:hover {
|
|
color: #ffffff;
|
|
filter: brightness(1.25);
|
|
}
|
|
|
|
&.btn-meter {
|
|
background: rgba(12, 91, 151, 0.92);
|
|
border-color: rgba(48, 175, 242, 0.72);
|
|
}
|
|
|
|
&.btn-appearance {
|
|
background: rgba(11, 102, 116, 0.92);
|
|
border-color: rgba(35, 194, 190, 0.66);
|
|
}
|
|
|
|
&.btn-infrared {
|
|
background: rgba(50, 84, 145, 0.94);
|
|
border-color: rgba(104, 150, 220, 0.7);
|
|
}
|
|
}
|
|
|
|
.status-dot {
|
|
flex: none;
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
|
|
&.normal {
|
|
background: #20e2a0;
|
|
box-shadow: 0 0 7px rgba(32, 226, 160, 0.72);
|
|
}
|
|
|
|
&.abnormal {
|
|
background: #ff4e57;
|
|
box-shadow: 0 0 7px rgba(255, 78, 87, 0.72);
|
|
}
|
|
}
|
|
|
|
/* 动环监控 */
|
|
.panel-video {
|
|
position: relative;
|
|
flex: 1;
|
|
}
|
|
|
|
/* 场站筛选与左侧「表计/外观/红外巡检」分类名同一行、同一高度 */
|
|
.video-filter-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
flex: none;
|
|
height: 16px;
|
|
margin: 6px 14px 4px;
|
|
padding: 0;
|
|
|
|
.panel-select {
|
|
position: relative;
|
|
top: -6px;
|
|
width: 132px;
|
|
height: 16px;
|
|
}
|
|
}
|
|
|
|
.video-monitor-body {
|
|
display: flex;
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
.video-monitor-main {
|
|
display: flex;
|
|
flex: 1;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
}
|
|
|
|
.portal-device-tree {
|
|
align-self: flex-start;
|
|
flex: 0 0 46%;
|
|
width: 46%;
|
|
height: calc(100% + 1px);
|
|
min-width: 280px;
|
|
max-width: 360px;
|
|
min-height: 0;
|
|
padding: 0;
|
|
margin: -1px 0 14px;
|
|
overflow: hidden;
|
|
background: transparent !important;
|
|
|
|
::v-deep .nvr-tree {
|
|
width: 100%;
|
|
min-width: 0;
|
|
background: transparent !important;
|
|
}
|
|
|
|
::v-deep .tree-title {
|
|
display: none;
|
|
}
|
|
|
|
::v-deep .tree-filters {
|
|
padding-right: 0;
|
|
padding-left: 0;
|
|
background: transparent !important;
|
|
}
|
|
|
|
::v-deep .tree-content,
|
|
::v-deep .el-tree {
|
|
background: transparent !important;
|
|
}
|
|
}
|
|
|
|
.tree-toggle {
|
|
position: absolute;
|
|
top: 50%;
|
|
right: -1px;
|
|
z-index: 5;
|
|
width: 16px;
|
|
height: 70px;
|
|
padding: 0;
|
|
border: 0;
|
|
outline: 0;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
transform: translateY(-50%);
|
|
|
|
img {
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: fill;
|
|
transition: filter 0.2s;
|
|
}
|
|
|
|
&:hover img,
|
|
&:focus img {
|
|
filter: brightness(1.2) drop-shadow(0 0 6px rgba(32, 218, 255, 0.65));
|
|
}
|
|
|
|
/* 树展开时:按钮贴到树面板左缘,显示收起箭头
|
|
树宽被 min-width/max-width 钳制,按钮用同样的 clamp 规则才能在任何屏幕下对齐树缘 */
|
|
&.open {
|
|
right: clamp(280px, 46%, 360px);
|
|
}
|
|
}
|
|
|
|
:deep(.panel-select.el-select),
|
|
:deep(.trend-select.el-select) {
|
|
height: 28px;
|
|
|
|
.el-input {
|
|
display: flex;
|
|
align-items: center;
|
|
height: 28px;
|
|
line-height: 28px;
|
|
}
|
|
|
|
.el-input.el-input--mini .el-input__inner,
|
|
.el-input .el-input__inner {
|
|
height: 28px !important;
|
|
line-height: 26px !important;
|
|
padding-top: 0;
|
|
padding-bottom: 0;
|
|
background: rgba(16, 30, 84, 0.65);
|
|
border-color: rgba(84, 130, 255, 0.35);
|
|
color: $text-main;
|
|
font-size: 12px;
|
|
|
|
&::placeholder {
|
|
color: rgba(159, 180, 232, 0.65);
|
|
}
|
|
}
|
|
|
|
.el-input__suffix {
|
|
display: flex;
|
|
align-items: center;
|
|
height: 28px;
|
|
}
|
|
|
|
.el-input__icon {
|
|
line-height: 28px;
|
|
}
|
|
}
|
|
|
|
:deep(.video-filter-row .panel-select.el-select) {
|
|
height: 16px;
|
|
|
|
.el-input {
|
|
height: 16px;
|
|
line-height: 16px;
|
|
}
|
|
|
|
.el-input.el-input--mini .el-input__inner,
|
|
.el-input .el-input__inner {
|
|
height: 16px !important;
|
|
line-height: 14px !important;
|
|
}
|
|
|
|
.el-input__suffix,
|
|
.el-input__icon {
|
|
height: 16px;
|
|
line-height: 16px;
|
|
}
|
|
}
|
|
|
|
.camera-stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 12px;
|
|
flex: none;
|
|
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 {
|
|
padding-right: 28px;
|
|
}
|
|
|
|
.camera-stat {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
padding: 12px 16px;
|
|
border-radius: 0;
|
|
background: #0B2E6F;
|
|
|
|
.camera-icon {
|
|
flex: none;
|
|
width: 42px;
|
|
height: 42px;
|
|
object-fit: contain;
|
|
}
|
|
|
|
.camera-value {
|
|
font-family: "DIN Alternate", "Arial", "Microsoft YaHei", sans-serif;
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
line-height: 1;
|
|
color: #25d9ea;
|
|
text-align: center;
|
|
}
|
|
|
|
.camera-label {
|
|
margin-top: 5px;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
line-height: 1.2;
|
|
color: #edf6ff;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
&:first-child .camera-value {
|
|
color: #20e2a0;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 1440px) {
|
|
.station-card {
|
|
padding-right: 8px;
|
|
padding-left: 8px;
|
|
}
|
|
|
|
.station-head {
|
|
gap: 4px;
|
|
|
|
.station-name {
|
|
font-size: 14px;
|
|
}
|
|
|
|
.enter-icon {
|
|
width: 14px;
|
|
}
|
|
|
|
.station-status {
|
|
font-size: 11px;
|
|
}
|
|
}
|
|
|
|
.station-btns {
|
|
gap: 4px;
|
|
}
|
|
|
|
.station-btn {
|
|
font-size: 11px;
|
|
}
|
|
|
|
.status-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
}
|
|
|
|
.camera-stats {
|
|
gap: 8px;
|
|
}
|
|
|
|
.camera-stat {
|
|
gap: 8px;
|
|
padding: 10px 8px;
|
|
|
|
.camera-icon {
|
|
width: 36px;
|
|
height: 36px;
|
|
}
|
|
|
|
.camera-value {
|
|
font-size: 22px;
|
|
}
|
|
|
|
.camera-label {
|
|
font-size: 12px;
|
|
}
|
|
}
|
|
|
|
.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 {
|
|
position: relative;
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
grid-template-rows: repeat(2, 1fr);
|
|
gap: 12px;
|
|
flex: 1;
|
|
min-height: 0;
|
|
padding: 2px 14px 14px;
|
|
}
|
|
|
|
.video-tile {
|
|
position: relative;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
border: 1px solid rgba(84, 130, 255, 0.16);
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: border-color 0.2s, box-shadow 0.2s;
|
|
|
|
&:hover {
|
|
border-color: rgba(18, 215, 248, 0.55);
|
|
box-shadow: 0 0 16px rgba(18, 215, 248, 0.18);
|
|
|
|
.video-glyph {
|
|
color: rgba(18, 215, 248, 0.9);
|
|
}
|
|
}
|
|
|
|
&.active {
|
|
border-color: #54ffff;
|
|
box-shadow:
|
|
inset 0 0 0 1px rgba(84, 255, 255, 0.35),
|
|
0 0 18px rgba(18, 215, 248, 0.2);
|
|
}
|
|
}
|
|
|
|
.video-placeholder {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background:
|
|
radial-gradient(220px 120px at 70% 20%, rgba(38, 68, 160, 0.45), transparent 70%),
|
|
linear-gradient(155deg, #182a6e 0%, #101c56 60%, #0c1140 100%);
|
|
|
|
.video-glyph {
|
|
width: 46px;
|
|
height: 46px;
|
|
color: rgba(84, 130, 255, 0.55);
|
|
transition: color 0.2s;
|
|
}
|
|
}
|
|
|
|
.portal-video-player {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
/* contain:完整显示画面不裁切;树打开格子变窄时两侧留底色而不是切割视频 */
|
|
object-fit: contain;
|
|
background: #050b24;
|
|
|
|
&.hidden {
|
|
visibility: hidden;
|
|
pointer-events: none;
|
|
}
|
|
}
|
|
|
|
.video-loading {
|
|
font-size: 12px;
|
|
color: rgba(188, 213, 255, 0.72);
|
|
}
|
|
|
|
.video-mask {
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
height: 52px;
|
|
background: linear-gradient(180deg, transparent, rgba(6, 10, 38, 0.85));
|
|
}
|
|
|
|
.video-dot {
|
|
position: absolute;
|
|
top: 9px;
|
|
right: 10px;
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
|
|
&.normal {
|
|
background: #2fe08c;
|
|
box-shadow: 0 0 8px rgba(47, 224, 140, 0.85);
|
|
}
|
|
|
|
&.abnormal {
|
|
background: #ff6252;
|
|
box-shadow: 0 0 8px rgba(255, 98, 82, 0.85);
|
|
}
|
|
}
|
|
|
|
.video-labels {
|
|
position: absolute;
|
|
left: 10px;
|
|
bottom: 8px;
|
|
right: 10px;
|
|
|
|
.video-station {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: $text-main;
|
|
}
|
|
|
|
.video-camera {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
margin-top: 2px;
|
|
font-size: 12px;
|
|
color: $text-sub;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<style lang="scss">
|
|
.portal-screen {
|
|
.panel-select.el-select,
|
|
.trend-select.el-select {
|
|
height: 28px;
|
|
|
|
.el-input {
|
|
height: 28px;
|
|
line-height: 28px;
|
|
}
|
|
|
|
.el-input__inner {
|
|
height: 25px !important;
|
|
line-height: 26px !important;
|
|
padding-top: 0;
|
|
padding-bottom: 0;
|
|
}
|
|
|
|
.el-input__suffix,
|
|
.el-input__icon {
|
|
height: 28px;
|
|
line-height: 28px;
|
|
}
|
|
}
|
|
}
|
|
</style>
|