Compare commits

...

3 Commits

Author SHA1 Message Date
air
2ce3e8c27e 【类 型】:feat
【原  因】:飞机操作模块 添加视频显示功能
【过  程】:地图 视频 大小屏幕切换 显示隐藏等
【影  响】:

# 类型 包含:
# feat:新功能(feature)
# fix:修补bug
# docs:文档(documentation)
# style: 格式(不影响代码运行的变动)
# refactor:重构(即不是新增功能,也不是修改bug的代码变动)
# test:增加测试
# chore:构建过程或辅助工具的变动
2025-09-17 20:24:25 +08:00
air
48f5d54f55 【类 型】:test
【原  因】:video 视频组件 测试文件删除
【过  程】:
【影  响】:

# 类型 包含:
# feat:新功能(feature)
# fix:修补bug
# docs:文档(documentation)
# style: 格式(不影响代码运行的变动)
# refactor:重构(即不是新增功能,也不是修改bug的代码变动)
# test:增加测试
# chore:构建过程或辅助工具的变动
2025-09-17 18:37:42 +08:00
air
7e2842b6d7 【类 型】:feat
【原  因】:增加摄像头 图像显示组件
【过  程】:
【影  响】:

# 类型 包含:
# feat:新功能(feature)
# fix:修补bug
# docs:文档(documentation)
# style: 格式(不影响代码运行的变动)
# refactor:重构(即不是新增功能,也不是修改bug的代码变动)
# test:增加测试
# chore:构建过程或辅助工具的变动
2025-09-17 17:45:55 +08:00
5 changed files with 314 additions and 67 deletions

View File

@ -2,7 +2,7 @@
<div class="w-100 h-100 mainBox">
<!-- 弹出框 -->
<el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="320px" top="30vh" @close="closeCallback"
@open="openCallback">
@open="openCallback" :append-to-body="true">
<!-- 起飞设置弹出框 -->
<template v-if="dialogItem == 'takeoffBox'">
<div class="flex mse mac">

View File

@ -24,7 +24,9 @@ export default {
takeoffLonLats: [], // 线
isflow: false, //
currentStyleIndex: 0, //
guidedMarker: null //
guidedMarker: null, //
styleSwitcherControlRef: null, //
fullscreenControlRef: null // enableZoom
}
},
props: {
@ -85,6 +87,38 @@ export default {
}
},
watch: {
/** 动态开关地图样式切换控件 */
enableSwitch (val) {
if (!this.map) return
if (val) {
if (!this.styleSwitcherControlRef) {
const control = new MapboxStyleSwitcherControl(this.mapStyleList, 'iconfont icon-duozhang f-s-20', this.currentStyleIndex)
this.map.addControl(control, 'top-right')
this.styleSwitcherControlRef = control
}
} else {
if (this.styleSwitcherControlRef) {
try { this.map.removeControl(this.styleSwitcherControlRef) } catch (e) { /* noop */ }
this.styleSwitcherControlRef = null
}
}
},
/** 动态开关全屏控件enableZoom */
enableZoom (val) {
if (!this.map) return
if (val) {
if (!this.fullscreenControlRef) {
const control = new CustomFullscreenControl(this.handleResize)
this.map.addControl(control, 'top-right')
this.fullscreenControlRef = control
}
} else {
if (this.fullscreenControlRef) {
try { this.map.removeControl(this.fullscreenControlRef) } catch (e) { /* noop */ }
this.fullscreenControlRef = null
}
}
}
},
async mounted () {
//
@ -211,18 +245,19 @@ export default {
//
if (this.enblueScale) {
this.map.addControl(new mapboxgl.ScaleControl(), 'bottom-right')
this.map.addControl(new mapboxgl.ScaleControl(), 'bottom-left')
}
//
if (this.enableZoom) {
this.map.addControl(new CustomFullscreenControl(this.handleResize), 'top-right')
this.fullscreenControlRef = new CustomFullscreenControl(this.handleResize)
this.map.addControl(this.fullscreenControlRef, 'top-right')
}
//
//
if (this.enableSwitch) {
const styleSwitcherControl = new MapboxStyleSwitcherControl(this.mapStyleList, 'iconfont icon-duozhang f-s-20', this.currentStyleIndex)
this.map.addControl(styleSwitcherControl, 'top-right')
this.styleSwitcherControlRef = new MapboxStyleSwitcherControl(this.mapStyleList, 'iconfont icon-duozhang f-s-20', this.currentStyleIndex)
this.map.addControl(this.styleSwitcherControlRef, 'top-right')
}
//

116
src/components/VideoBox.vue Normal file
View File

@ -0,0 +1,116 @@
<template>
<div class="video-container" :style="containerStyle">
<video
ref="video"
class="video-player"
:style="{ objectFit: fit }"
autoplay
:controls="showControls"
playsinline
muted
></video>
<div class="overlay">
<div class="overlay-inner">
<slot name="content"></slot>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'VideoBox',
props: {
fit: {
type: String,
default: 'contain',
validator: v => ['contain', 'cover', 'fill', 'none', 'scale-down'].includes(v)
},
showControls: {
type: Boolean,
default: false
},
width: {
type: [String, Number],
default: '100%'
},
height: {
type: [String, Number],
default: '100%'
}
},
data () {
return {
url: 'http://82.156.122.87:80/rtc/v1/whep/?app=live&stream=083AF27BB2D0',
sdk: null
}
},
computed: {
containerStyle () {
const toCssSize = v => (typeof v === 'number' ? `${v}px` : v)
return {
width: toCssSize(this.width),
height: toCssSize(this.height)
}
}
},
mounted () {
this.startPlay()
},
methods: {
async startPlay () {
if (this.sdk) {
this.sdk.close()
this.sdk = null
}
this.sdk = new window.SrsRtcWhipWhepAsync()
this.$refs.video.srcObject = this.sdk.stream
try {
const session = await this.sdk.play(this.url)
console.log('播放成功session:', session)
} catch (e) {
console.error('播放失败', e)
}
}
}
}
</script>
<style lang="scss" scoped>
@import "@/styles/theme.scss";
.video-container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
position: relative;
.video-player {
width: 100%;
height: 100%;
background-color: #000;
}
/* 覆盖层:承载插槽内容,叠在视频之上 */
.overlay {
position: absolute;
inset: 0;
z-index: 90; /* 与各叠加控件的 z-index 习惯保持一致 */
pointer-events: none; /* 默认透传,避免遮挡视频区域的交互 */
}
/* 让插槽内容的绝对定位以 .overlay 为参照,不做默认偏移 */
.overlay-inner {
position: static; /* 不改变定位上下文,让内部绝对定位元素参照 .overlay */
width: 100%;
height: 100%;
pointer-events: auto; /* 允许控件交互 */
}
}
</style>

View File

@ -1,16 +1,70 @@
<template>
<div class="h-100">
<!-- 地图组件 -->
<map-box ref="mapbox" :enableShowNofly="true" :enableGuided="true" :enableFollow="true"
:enblueScale="!$store.state.app.isWideScreen" @longPress="handleLongPress" @map-ready="onMapReady">
<template #content>
<div v-show="mapReady">
<BatteryStatus :plane="plane" />
<PlaneStatus :plane="plane" />
<ControllerTabs :plane="plane" @mapXOffset="mapXOffset" @makeRoute="makeRoute" @clearRoute="clearRoute" />
</div>
</template>
</map-box>
<div class="h-100 stage">
<!-- 主视图与画中画容器 -->
<!-- MapBox 容器作为主视图或小窗显示始终挂载 -->
<div
v-show="mainView === 'map' || (mainView === 'video' && pipVisible)"
class="stage-item map"
:class="{ main: mainView === 'map', pip: mainView === 'video' && pipVisible }"
:style="pipCssVars"
>
<map-box
ref="mapbox"
:enableShowNofly="true"
:enableGuided="true"
:enableFollow="true"
:enableSwitch="mainView === 'map'"
:enableZoom="mainView === 'map'"
:enblueScale="!$store.state.app.isWideScreen"
@longPress="handleLongPress"
@map-ready="onMapReady"
>
<template #content>
<!-- 仅在地图为主视图时渲染叠加控件小窗时不渲染 -->
<div v-if="mapReady && mainView === 'map'">
<BatteryStatus :plane="plane" />
<PlaneStatus :plane="plane" />
<ControllerTabs :plane="plane" @mapXOffset="mapXOffset" @makeRoute="makeRoute" @clearRoute="clearRoute" />
</div>
</template>
</map-box>
</div>
<!-- VideoBox 容器作为主视图或小窗显示始终挂载 -->
<div
v-show="mainView === 'video' || (mainView === 'map' && pipVisible)"
class="stage-item video"
:class="{ main: mainView === 'video', pip: mainView === 'map' && pipVisible }"
:style="pipCssVars"
>
<video-box
ref="videobox"
fit="cover"
:show-controls="false"
width="100%"
height="100%"
>
<template #content>
<!-- 仅在视频为主视图时渲染叠加控件小窗时不渲染 -->
<div v-if="mainView === 'video'">
<BatteryStatus :plane="plane" />
<PlaneStatus :plane="plane" />
<ControllerTabs :plane="plane" @mapXOffset="mapXOffset" @makeRoute="makeRoute" @clearRoute="clearRoute" />
</div>
</template>
</video-box>
</div>
<!-- 顶部工具条切换主视图 / 显示/隐藏 小窗 -->
<div class="stage-toolbar top-right">
<el-button size="mini" @click="toggleMainView">
{{ mainView === 'map' ? '切到视频' : '切到地图' }}
</el-button>
<el-button size="mini" @click="pipVisible = !pipVisible">
{{ pipVisible ? '隐藏小窗' : '显示小窗' }}
</el-button>
</div>
<!-- 弹出框 -->
<el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="320px" top="30vh" @close="closeCallback"
@open="openCallback">
@ -46,6 +100,7 @@
<script>
import MapBox from '@/components/MapBox'
import VideoBox from '@/components/VideoBox'
import ControllerTabs from '@/components/ControllerTabs'
import BatteryStatus from '@/components/BatteryStatus'
import PlaneStatus from '@/components/PlaneStatus'
@ -56,6 +111,11 @@ export default {
name: 'Planes',
data () {
return {
//
mainView: 'map', // 'map' | 'video'
pipVisible: true,
pipPos: 'top-right', // 'top-right'|'top-left'|'bottom-right'|'bottom-left'
pipSize: { w: 400, h: 225 }, // 16:9
dialogTitle: '', //
dialogItem: '', //
dialogVisible: false, //
@ -69,11 +129,17 @@ export default {
},
components: {
MapBox,
VideoBox,
ControllerTabs,
BatteryStatus,
PlaneStatus
},
computed: {
pipCssVars () {
const w = typeof this.pipSize.w === 'number' ? `${this.pipSize.w}px` : this.pipSize.w
const h = typeof this.pipSize.h === 'number' ? `${this.pipSize.h}px` : this.pipSize.h
return { '--pip-w': w, '--pip-h': h }
},
plane () {
if (this.$store.state.airList.length > 0) {
return this.$store.state.airList.find(plane => plane.id === this.planesId)
@ -112,6 +178,16 @@ export default {
}
},
methods: {
/** 切换主视图 */
toggleMainView () {
this.mainView = this.mainView === 'map' ? 'video' : 'map'
this.$nextTick(() => {
//
if (this.$refs.mapbox && typeof this.$refs.mapbox.handleResize === 'function') {
this.$refs.mapbox.handleResize()
}
})
},
/** 弹出框 关闭事件回调 */
closeCallback () {
if (this.dialogItem === 'guidedBox' && this.isReserveGuidedMaker === false) { //
@ -144,10 +220,10 @@ export default {
}
this.guidedAlt = height
//
//
const planeCoords = [[center.lon, center.lat]]
// 线
// 线
this.$refs.mapbox.drawGuidedLines({
centerPoint: { lng: center.lon, lat: center.lat },
endPoint: { lng: lonLat.lon, lat: lonLat.lat },
@ -214,7 +290,10 @@ export default {
* @param {*} y 正数向上移动 负数向下移动
*/
mapXOffset (x, y) {
this.$refs.mapbox.mapXOffset(x, y)
//
if (this.mainView === 'map' && this.$refs.mapbox && typeof this.$refs.mapbox.mapXOffset === 'function') {
this.$refs.mapbox.mapXOffset(x, y)
}
},
/**
* @description: 发布 mqtt 信息
@ -304,4 +383,66 @@ export default {
</script>
<style lang="scss" scoped></style>
<style lang="scss" scoped>
.stage {
position: relative;
}
.stage-item {
position: absolute;
overflow: hidden;
transition: all .2s ease;
}
.stage-item.main {
inset: 0;
z-index: 10;
border-radius: 0;
box-shadow: none;
}
.stage-item.pip {
width: var(--pip-w, 400px);
height: var(--pip-h, 225px);
z-index: 20;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,.25);
}
/* 小窗位置在右上基础上下移28px、左移43px */
.stage-item.pip {
top: 40px; /* 12 + 28 */
right: 55px; /* 12 + 43 */
}
/* 工具条也移动到相应位置(与小窗同样偏移) */
.stage-toolbar.top-right {
top: 40px; /* 12 + 28 */
right: 55px; /* 12 + 43 */
}
.stage-toolbar {
position: absolute;
z-index: 30;
display: flex;
gap: 8px;
}
/* 当地图为主视图时,避免叠加控件大面积拦截地图交互 */
/* 仅对 map 主视图容器内的控件做定向约束 */
.stage .map :deep(.mainBox) {
pointer-events: none; /* 默认不拦截,让地图可拖拽 */
}
.stage .map :deep(.mainBox .taButGroup),
.stage .map :deep(.mainBox .tabContent),
.stage .map :deep(.el-dialog),
.stage .map :deep(.el-button),
.stage .map :deep(.el-slider),
.stage .map :deep(.el-input),
.stage .map :deep(.el-select) {
pointer-events: auto; /* 仅在具体控件上启用交互 */
}
/* 确保电量条、状态面板只占据自身区域,且可交互 */
.stage .map :deep(.batteryBar),
.stage .map :deep(.plane-mode),
.stage .map :deep(.mainBox .tag) {
pointer-events: auto;
}
</style>

View File

@ -1,45 +0,0 @@
<template>
<div>
<video
ref="video"
autoplay
controls
playsinline
muted
width="640"
height="360"
></video>
</div>
</template>
<script>
export default {
data () {
return {
url: 'http://82.156.122.87:80/rtc/v1/whep/?app=live&stream=083AF27BB2D0',
sdk: null
}
},
mounted () {
this.startPlay()
},
methods: {
async startPlay () {
if (this.sdk) {
this.sdk.close()
this.sdk = null
}
this.sdk = new window.SrsRtcWhipWhepAsync()
this.$refs.video.srcObject = this.sdk.stream
try {
const session = await this.sdk.play(this.url)
console.log('播放成功session:', session)
} catch (e) {
console.error('播放失败', e)
}
}
}
}
</script>