You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

2070 lines
60 KiB

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