detail-camera.vue 34 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478
  1. <template>
  2. <view class="container">
  3. <!-- 设备头部信息区域 -->
  4. <view class="device-header">
  5. <view class="device-info-row">
  6. <view class="device-name-container">
  7. <text class="device-name">{{ deviceInfo.name }}</text>
  8. <view class="status-tag" :class="deviceInfo.status === 1 ? 'status-online' : 'status-offline'">
  9. <view class="status-dot" :class="{'offline-dot': deviceInfo.status === 0}"></view>
  10. {{ deviceInfo.status === 1 ? '在线' : '离线' }}
  11. </view>
  12. </view>
  13. <view class="refresh-btn" :class="{'refreshing': isRefreshing}" @tap="refreshData">
  14. <image src="/static/icons/refresh_icon.png" mode="aspectFit" style="width: 22px; height: 22px;">
  15. </image>
  16. </view>
  17. </view>
  18. <view class="device-meta-row">
  19. <view class="device-meta-item">
  20. <view class="meta-icon">
  21. <image src="/static/icons/device_icon.png" mode="aspectFit"
  22. style="width: 36rpx; height: 36rpx;"></image>
  23. </view>
  24. <text class="meta-label">设备编号:</text>
  25. <text class="meta-value">{{ deviceInfo.deviceId }}</text>
  26. </view>
  27. <view class="device-meta-item">
  28. <view class="meta-icon">
  29. <image src="/static/icons/location_icon.png" mode="aspectFit"
  30. style="width: 36rpx; height: 36rpx;"></image>
  31. </view>
  32. <text class="meta-label">安装位置:</text>
  33. <text class="meta-value">{{ deviceInfo.location }}</text>
  34. </view>
  35. <view class="device-meta-item">
  36. <view class="meta-icon">
  37. <image src="/static/icons/clock_icon.png" mode="aspectFit" style="width: 36rpx; height: 36rpx;">
  38. </image>
  39. </view>
  40. <text class="meta-label">最近更新:</text>
  41. <text class="meta-value">{{ formatDate(deviceInfo.lastUpdate) }}</text>
  42. </view>
  43. </view>
  44. </view>
  45. <!-- 视频预览区域 -->
  46. <view class="video-section">
  47. <view class="video-container" :class="{'fullscreen-mode': isFullscreen}">
  48. <image v-if="!isPlaying" src="/static/images/video-placeholder.jpg" mode="aspectFill"
  49. class="video-placeholder"></image>
  50. <!-- 使用跨平台视频播放组件 -->
  51. <!-- #ifdef H5 -->
  52. <view v-if="isPlaying" class="h5-video-wrapper">
  53. <Jessibuca ref="jessibucaRef" :videoUrl="getH5StreamUrl" :hasAudio="true" @error="onVideoError" />
  54. </view>
  55. <!-- #endif -->
  56. <!--wx-->
  57. <!-- <live-player v-if="isPlaying" id="videoPlayer" :src="getMiniProgramStreamUrl" mode="live"
  58. :autoplay="true" :muted="isMuted" object-fit="contain" @statechange="onStateChange"
  59. @error="onVideoError" @fullscreenchange="onFullscreenChange" class="video-player"></live-player> -->
  60. <!-- #ifndef H5 -->
  61. <!-- <live-player v-if="isPlaying" id="appVideoPlayer" :src="getAppStreamUrl" mode="live"
  62. :autoplay="true" :muted="isMuted" object-fit="contain" @statechange="onStateChange"
  63. @error="onVideoError" @fullscreenchange="onFullscreenChange"
  64. :enable-danmu="false"
  65. :show-center-play-btn="false"
  66. :show-play-btn="false"
  67. class="video-player"></live-player> -->
  68. <video class="video-player" v-if="isPlaying" id="myVideo" :src="getAppStreamUrl" :autoplay="true" controls></video>
  69. <!-- #endif -->
  70. <!-- 视频控制层 -->
  71. <view class="video-controls">
  72. <view class="control-row top-controls">
  73. <view class="signal-indicator">
  74. <image src="/static/icons/signal_icon.png" mode="aspectFit"
  75. style="width: 16px; height: 16px;"></image>
  76. <text class="signal-text">信号良好</text>
  77. </view>
  78. <view class="fullscreen-button" @click="toggleFullscreen">
  79. <image src="/static/icons/resize_icon.png" mode="aspectFit"
  80. style="width: 20px; height: 20px;"></image>
  81. </view>
  82. </view>
  83. <view class="control-row center-controls">
  84. <view v-if="!isPlaying" class="play-button" @click="togglePlayState">
  85. <image src="/static/icons/play_icon.png" mode="aspectFit"
  86. style="width: 32px; height: 32px;"></image>
  87. </view>
  88. <view v-else class="center-button-container" @click="togglePlayState">
  89. <view class="pause-icon">
  90. <image src="/static/icons/pause_icon.png" mode="aspectFit"
  91. style="width: 24px; height: 24px;"></image>
  92. </view>
  93. </view>
  94. </view>
  95. <view class="control-row bottom-controls">
  96. <view class="video-time">{{ currentTime }}</view>
  97. </view>
  98. </view>
  99. </view>
  100. </view>
  101. <!-- 告警信息列表 -->
  102. <view class="alerts-section">
  103. <view class="section-title">
  104. <text>告警信息</text>
  105. <view class="alert-badge" v-if="getUnhandledAlerts.length > 0">{{ getUnhandledAlerts.length }}</view>
  106. </view>
  107. <view class="alerts-list" v-if="getUnhandledAlerts.length > 0">
  108. <view v-for="(item, index) in getUnhandledAlerts.slice(0, 3)" :key="index" class="alert-item" :class="{
  109. 'alert-urgent': item.level === 3,
  110. 'alert-warning': item.level === 2,
  111. 'alert-info': item.level === 1
  112. }">
  113. <view class="alert-item-icon">
  114. <image v-if="item.level === 3" src="/static/icons/warning_icon.png" mode="aspectFit"
  115. style="width: 24px; height: 24px;"></image>
  116. <image v-else-if="item.level === 2" src="/static/icons/info_icon.png" mode="aspectFit"
  117. style="width: 24px; height: 24px;"></image>
  118. <image v-else src="/static/icons/success_icon.png" mode="aspectFit"
  119. style="width: 24px; height: 24px;"></image>
  120. </view>
  121. <view class="alert-item-info">
  122. <text class="alert-item-type">{{ item.type }}</text>
  123. <text class="alert-item-level">
  124. {{ item.level === 3 ? '紧急' : item.level === 2 ? '警告' : '提示' }}
  125. </text>
  126. </view>
  127. <view class="alert-item-time">{{ formatSmartTime(item.time) }}</view>
  128. </view>
  129. </view>
  130. <view v-else class="empty-alert">
  131. <text class="empty-text">暂无告警信息</text>
  132. </view>
  133. </view>
  134. </view>
  135. </template>
  136. <script setup>
  137. import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
  138. import {
  139. getDeviceCollectorDetail,
  140. getChannels,
  141. playStart,
  142. pause
  143. } from "@/api/services/device.js"
  144. import {
  145. formatSmartTime,
  146. formatDate,
  147. getFormattedTime
  148. } from '@/utils/dateUtils'
  149. import {
  150. buildPlatformStreamUrls
  151. } from '@/utils/media-utils'
  152. import config from '@/config/config'
  153. // 导入Jessibuca组件
  154. // #ifdef H5
  155. import Jessibuca from '@/components/common/jessibuca.vue'
  156. // #endif
  157. // 响应式数据
  158. const deviceInfo = reactive({
  159. deviceId: '',
  160. name: '设备加载中...',
  161. status: '',
  162. location: '正在获取位置...',
  163. lastUpdate: '',
  164. deviceType: 'weather', // 默认类型,会根据API返回更新
  165. deviceTypeId: null,
  166. streamUrl: '',
  167. channelId: null, // 当前通道ID
  168. originalStreamUrl: '',
  169. })
  170. const isPlaying = ref(false)
  171. const isMuted = ref(false)
  172. const isRecording = ref(false)
  173. const isFullscreen = ref(false)
  174. const isVoiceActive = ref(false)
  175. const isGridView = ref(false)
  176. const isZoomMode = ref(false)
  177. const currentTime = ref('14:30:25')
  178. // 模拟历史录像数据
  179. const recordHistory = ref([{
  180. id: 1,
  181. startTime: '今天 12:30',
  182. duration: '00:15:30',
  183. url: ''
  184. },
  185. {
  186. id: 2,
  187. startTime: '今天 10:15',
  188. duration: '00:05:22',
  189. url: ''
  190. },
  191. {
  192. id: 3,
  193. startTime: '昨天 18:45',
  194. duration: '00:30:10',
  195. url: ''
  196. },
  197. {
  198. id: 4,
  199. startTime: '昨天 14:20',
  200. duration: '00:10:05',
  201. url: ''
  202. }
  203. ])
  204. // 模拟告警数据
  205. const alertHistory = ref([])
  206. const livePlayerContext = ref(null) // 小程序视频上下文
  207. const appLivePlayerContext = ref(null) // App端视频上下文
  208. const isRefreshing = ref(false)
  209. const jessibucaRef = ref(null)
  210. // 计算属性
  211. // 获取所有未处理的告警
  212. const getUnhandledAlerts = computed(() => {
  213. return alertHistory.value.filter(alert => alert.status === 0)
  214. })
  215. // 获取H5环境使用的流地址
  216. const getH5StreamUrl = computed(() => {
  217. // 确保使用安全的 WSS 协议
  218. let url = deviceInfo.originalStreamUrl
  219. if (url && url.startsWith('ws://')) {
  220. console.warn('检测到不安全的 ws:// 协议,自动转换为 wss://')
  221. url = url.replace('ws://', 'wss://')
  222. }
  223. return url
  224. })
  225. // 获取小程序环境使用的流地址
  226. const getMiniProgramStreamUrl = computed(() => {
  227. const {
  228. streamServer
  229. } = config
  230. if (deviceInfo.rtmpUrl) {
  231. return deviceInfo.rtmpUrl
  232. }
  233. if (deviceInfo.hlsUrl) {
  234. return deviceInfo.hlsUrl
  235. }
  236. return streamServer.rtmpServer || streamServer.hlsServer
  237. })
  238. // 获取App(安卓/鸿蒙)环境使用的流地址
  239. const getAppStreamUrl = computed(() => {
  240. const {
  241. streamServer
  242. } = config
  243. // // App端优先使用 RTMP 或 HLS 流
  244. // if (deviceInfo.rtmpUrl) {
  245. // return deviceInfo.rtmpUrl
  246. // }
  247. // if (deviceInfo.hlsUrl) {
  248. // return deviceInfo.hlsUrl
  249. // }
  250. // 如果有原始流地址,尝试转换为 RTMP 或 HLS
  251. if (deviceInfo.originalStreamUrl) {
  252. // 如果是 ws-flv 格式,转换为 RTMP
  253. // if (deviceInfo.originalStreamUrl.includes('.flv')) {
  254. // const rtmpUrl = deviceInfo.originalStreamUrl
  255. // .replace('ws://', 'rtmp://')
  256. // .replace(':6080/rtp/', ':1935/live/')
  257. // .replace('.live.flv', '')
  258. // return rtmpUrl
  259. // }
  260. console.log("deviceInfo.originalStreamUrl",deviceInfo.originalStreamUrl);
  261. }
  262. // return streamServer.rtmpServer || streamServer.hlsServer
  263. return deviceInfo.originalStreamUrl
  264. })
  265. // 方法
  266. // 刷新数据
  267. const refreshData = () => {
  268. isRefreshing.value = true
  269. fetchDeviceInfo()
  270. setTimeout(() => {
  271. isRefreshing.value = false
  272. }, 1000)
  273. }
  274. // 加载Jessibuca脚本
  275. const loadJessibucaScript = () => {
  276. // #ifdef H5
  277. const script = document.createElement('script')
  278. script.src = '/static/js/jessibuca/jessibuca.js'
  279. script.onload = () => {
  280. console.log('Jessibuca 脚本加载成功')
  281. }
  282. script.onerror = (error) => {
  283. console.error('Jessibuca 脚本加载失败:', error)
  284. }
  285. document.head.appendChild(script)
  286. // #endif
  287. // #ifndef H5
  288. // 非 H5 平台不支持 Jessibuca 视频播放
  289. // console.warn('当前平台不支持 Jessibuca 视频播放,请使用 uni-app video 组件')
  290. // #endif
  291. }
  292. // 获取设备信息
  293. const fetchDeviceInfo = () => {
  294. console.log('获取设备信息:', deviceInfo.deviceId)
  295. getDeviceCollectorDetail(deviceInfo.deviceId)
  296. .then(res => {
  297. if (res.data.data && res.data.code === 200) {
  298. const detail = res.data.data
  299. deviceInfo.lastUpdate = getFormattedTime()
  300. uni.setNavigationBarTitle({
  301. title: deviceInfo.name
  302. })
  303. if (detail.alertRecordList && detail.alertRecordList.length > 0) {
  304. alertHistory.value = detail.alertRecordList.map(alert => ({
  305. id: alert.alertId,
  306. time: alert.alertTime,
  307. type: alert.alertContent,
  308. status: alert.processStatus,
  309. level: alert.alertLevel
  310. }))
  311. }
  312. }
  313. })
  314. }
  315. // 根据设备id获取通道列表
  316. const queryChannels = () => {
  317. getChannels(deviceInfo.deviceId)
  318. .then(response => {
  319. console.log('获取通道列表:', response)
  320. const res = response.data
  321. if (res.code === 0 && res.data.total > 0) {
  322. const channels = res.data.list
  323. deviceInfo.channelId = channels[0].deviceId
  324. playStart(deviceInfo.deviceId, deviceInfo.channelId).then(res => {
  325. if (res.data.code !== 0) {
  326. console.error('播放开始失败:', res.message)
  327. uni.showToast({
  328. title: '播放失败: ' + res.message,
  329. icon: 'none'
  330. })
  331. return
  332. }
  333. console.log('播放开始:', res)
  334. // #ifdef H5
  335. let streamUrl = res.data.data.wss_flv
  336. if (streamUrl) {
  337. const urlObj = new URL(streamUrl)
  338. // 替换 hostname
  339. urlObj.hostname = 'nxy.gbdfarm.com'
  340. // 替换端口
  341. urlObj.port = '9000'
  342. deviceInfo.originalStreamUrl = urlObj.toString()
  343. console.log("queryChannels - deviceInfo.originalStreamUrl", deviceInfo.originalStreamUrl)
  344. } else {
  345. console.warn('未获取到 wss_flv 流地址')
  346. }
  347. // #endif
  348. // #ifndef H5
  349. deviceInfo.originalStreamUrl = res.data.data.fmp4 || deviceInfo.originalStreamUrl
  350. // #endif
  351. }).catch(err => {
  352. console.error('播放开始失败:', err)
  353. })
  354. } else {
  355. uni.showToast({
  356. title: '获取通道列表失败: ' + res.data.message,
  357. icon: 'none'
  358. })
  359. return
  360. }
  361. })
  362. .catch(err => {
  363. console.error('获取通道列表错误:', err)
  364. })
  365. }
  366. // 播放/暂停切换
  367. const togglePlayState = () => {
  368. if (!isPlaying.value) {
  369. isPlaying.value = true
  370. // wx
  371. // setTimeout(() => {
  372. // if (livePlayerContext.value) {
  373. // livePlayerContext.value.play({
  374. // success: () => {
  375. // console.log('小程序视频播放成功')
  376. // uni.vibrateShort()
  377. // },
  378. // fail: (err) => {
  379. // console.error('小程序视频播放失败:', err)
  380. // uni.showToast({
  381. // title: '视频播放失败',
  382. // icon: 'none'
  383. // })
  384. // }
  385. // })
  386. // }
  387. // }, 300)
  388. // #ifndef H5
  389. setTimeout(() => {
  390. if (appLivePlayerContext.value) {
  391. appLivePlayerContext.value.play({
  392. success: () => {
  393. console.log('App视频播放成功')
  394. uni.vibrateShort()
  395. },
  396. fail: (err) => {
  397. console.error('App视频播放失败:', err)
  398. uni.showToast({
  399. title: '视频播放失败',
  400. icon: 'none'
  401. })
  402. }
  403. })
  404. }
  405. }, 300)
  406. // #endif
  407. // #ifdef H5
  408. setTimeout(() => {
  409. playStart(deviceInfo.deviceId, deviceInfo.channelId).then(res => {
  410. if (res.data.code !== 0) {
  411. console.error('播放开始失败:', res.message)
  412. uni.showToast({
  413. title: '播放失败: ' + res.message,
  414. icon: 'none'
  415. })
  416. return
  417. }
  418. console.log('播放开始:', res)
  419. // 使用 wss_flv 并替换域名和端口
  420. let streamUrl = res.data.data.wss_flv
  421. if (streamUrl) {
  422. const urlObj = new URL(streamUrl)
  423. urlObj.hostname = 'nxy.gbdfarm.com'
  424. urlObj.port = '9000'
  425. deviceInfo.originalStreamUrl = urlObj.toString()
  426. console.log("togglePlayState - deviceInfo.originalStreamUrl", deviceInfo.originalStreamUrl)
  427. }
  428. }).catch(err => {
  429. console.error('播放开始失败:', err)
  430. })
  431. uni.vibrateShort()
  432. }, 300)
  433. // #endif
  434. } else {
  435. // #ifdef H5
  436. if (jessibucaRef.value) {
  437. jessibucaRef.value.pause()
  438. }
  439. // #endif
  440. // wx
  441. // if (livePlayerContext.value) {
  442. // livePlayerContext.value.pause()
  443. // }
  444. // #ifndef H5
  445. if (appLivePlayerContext.value) {
  446. appLivePlayerContext.value.pause()
  447. }
  448. // #endif
  449. isPlaying.value = false
  450. uni.showToast({
  451. title: '视频已暂停',
  452. icon: 'none',
  453. duration: 1500
  454. })
  455. }
  456. }
  457. // 静音切换
  458. const toggleMute = () => {
  459. isMuted.value = !isMuted.value
  460. // #ifdef H5
  461. if (jessibucaRef.value) {
  462. if (isMuted.value) {
  463. jessibucaRef.value.mute()
  464. } else {
  465. jessibucaRef.value.cancelMute()
  466. }
  467. }
  468. // #endif
  469. // #ifndef H5
  470. // App端的静音通过 live-player 的 muted 属性控制,会自动响应
  471. uni.showToast({
  472. title: isMuted.value ? '已静音' : '已取消静音',
  473. icon: 'none'
  474. })
  475. // #endif
  476. }
  477. // 全屏切换
  478. const toggleFullscreen = () => {
  479. if (!isPlaying.value) {
  480. togglePlayState()
  481. setTimeout(() => {
  482. setFullscreen(true)
  483. }, 500)
  484. } else {
  485. setFullscreen(!isFullscreen.value)
  486. }
  487. }
  488. // 设置全屏状态
  489. const setFullscreen = (fullscreen) => {
  490. isFullscreen.value = fullscreen
  491. // #ifdef H5
  492. if (isFullscreen.value) {
  493. const ua = navigator.userAgent.toLowerCase()
  494. const isMobile = /mobile|android|iphone|ipad/.test(ua)
  495. if (isMobile && jessibucaRef.value) {
  496. jessibucaRef.value.fullscreenSwich()
  497. } else {
  498. setTimeout(() => {
  499. if (jessibucaRef.value) {
  500. jessibucaRef.value.resize()
  501. }
  502. }, 300)
  503. }
  504. if (window.screen && window.screen.orientation && window.screen.orientation.lock) {
  505. window.screen.orientation.lock('landscape').catch(err => {
  506. console.error('无法锁定屏幕方向:', err)
  507. })
  508. }
  509. } else {
  510. if (jessibucaRef.value) {
  511. if (jessibucaRef.value.isFullscreen()) {
  512. jessibucaRef.value.fullscreenSwich()
  513. }
  514. setTimeout(() => {
  515. if (jessibucaRef.value) {
  516. jessibucaRef.value.resize()
  517. }
  518. }, 300)
  519. }
  520. if (window.screen && window.screen.orientation && window.screen.orientation.unlock) {
  521. window.screen.orientation.unlock()
  522. }
  523. }
  524. // #endif
  525. //wx
  526. // if (livePlayerContext.value) {
  527. // if (isFullscreen.value) {
  528. // livePlayerContext.value.requestFullScreen({
  529. // direction: 90,
  530. // success: () => {
  531. // console.log('进入全屏模式成功')
  532. // },
  533. // fail: (err) => {
  534. // console.error('进入全屏模式失败:', err)
  535. // }
  536. // })
  537. // } else {
  538. // livePlayerContext.value.exitFullScreen({
  539. // success: () => {
  540. // console.log('退出全屏模式成功')
  541. // },
  542. // fail: (err) => {
  543. // console.error('退出全屏模式失败:', err)
  544. // }
  545. // })
  546. // }
  547. // }
  548. // #ifndef H5
  549. if (appLivePlayerContext.value) {
  550. if (isFullscreen.value) {
  551. appLivePlayerContext.value.requestFullScreen({
  552. direction: 90,
  553. success: () => {
  554. console.log('App进入全屏模式成功')
  555. },
  556. fail: (err) => {
  557. console.error('App进入全屏模式失败:', err)
  558. }
  559. })
  560. } else {
  561. appLivePlayerContext.value.exitFullScreen({
  562. success: () => {
  563. console.log('App退出全屏模式成功')
  564. },
  565. fail: (err) => {
  566. console.error('App退出全屏模式失败:', err)
  567. }
  568. })
  569. }
  570. }
  571. // #endif
  572. }
  573. // 截图
  574. const takeScreenshot = () => {
  575. // #ifdef H5
  576. if (jessibucaRef.value && isPlaying.value) {
  577. jessibucaRef.value.screenshot()
  578. uni.showToast({
  579. title: '截图已保存',
  580. icon: 'success'
  581. })
  582. } else {
  583. uni.showToast({
  584. title: '请先播放视频',
  585. icon: 'none'
  586. })
  587. }
  588. // #endif
  589. // if (livePlayerContext.value && isPlaying.value) {
  590. // livePlayerContext.value.snapshot({
  591. // success: (res) => {
  592. // console.log('截图成功:', res.tempImagePath)
  593. // uni.saveImageToPhotosAlbum({
  594. // filePath: res.tempImagePath,
  595. // success: () => {
  596. // uni.showToast({
  597. // title: '截图已保存到相册',
  598. // icon: 'success'
  599. // })
  600. // },
  601. // fail: (err) => {
  602. // console.error('保存截图失败:', err)
  603. // uni.showToast({
  604. // title: '保存截图失败',
  605. // icon: 'none'
  606. // })
  607. // }
  608. // })
  609. // },
  610. // fail: (err) => {
  611. // console.error('截图失败:', err)
  612. // uni.showToast({
  613. // title: '截图失败',
  614. // icon: 'none'
  615. // })
  616. // }
  617. // })
  618. // } else {
  619. // uni.showToast({
  620. // title: '请先播放视频',
  621. // icon: 'none'
  622. // })
  623. // }
  624. // #ifndef H5
  625. if (appLivePlayerContext.value && isPlaying.value) {
  626. appLivePlayerContext.value.snapshot({
  627. success: (res) => {
  628. console.log('App截图成功:', res.tempImagePath)
  629. uni.saveImageToPhotosAlbum({
  630. filePath: res.tempImagePath,
  631. success: () => {
  632. uni.showToast({
  633. title: '截图已保存到相册',
  634. icon: 'success'
  635. })
  636. },
  637. fail: (err) => {
  638. console.error('App保存截图失败:', err)
  639. uni.showToast({
  640. title: '保存截图失败',
  641. icon: 'none'
  642. })
  643. }
  644. })
  645. },
  646. fail: (err) => {
  647. console.error('App截图失败:', err)
  648. uni.showToast({
  649. title: '截图失败',
  650. icon: 'none'
  651. })
  652. }
  653. })
  654. } else {
  655. uni.showToast({
  656. title: '请先播放视频',
  657. icon: 'none'
  658. })
  659. }
  660. // #endif
  661. }
  662. // 小程序播放器状态变化处理
  663. const onStateChange = (e) => {
  664. console.log('播放器状态变化:', e.detail)
  665. const state = e.detail.code
  666. switch (state) {
  667. case 2001:
  668. console.log('已连接服务器')
  669. break
  670. case 2002:
  671. console.log('开始拉流')
  672. break
  673. case 2003:
  674. console.log('网络接收到首个视频帧')
  675. break
  676. case 2004:
  677. console.log('视频播放开始')
  678. break
  679. case 2005:
  680. console.log('视频播放进度')
  681. break
  682. case 2006:
  683. console.log('视频播放结束')
  684. isPlaying.value = false
  685. break
  686. case 2007:
  687. console.log('视频播放Loading')
  688. break
  689. case 2008:
  690. console.log('解码器启动')
  691. break
  692. case 2009:
  693. console.log('视频分辨率改变')
  694. break
  695. case -2301:
  696. console.error('网络断连,且重新连接亦不能恢复,播放器已停止')
  697. isPlaying.value = false
  698. uni.showToast({
  699. title: '网络断连,请重试',
  700. icon: 'none'
  701. })
  702. break
  703. case -2302:
  704. console.error('获取加速拉流地址失败')
  705. break
  706. case 2101:
  707. console.error('当前视频帧解码失败')
  708. break
  709. case 2102:
  710. console.error('当前音频帧解码失败')
  711. break
  712. case 2103:
  713. console.warn('网络断连, 已启动自动重连')
  714. break
  715. case 2104:
  716. console.warn('网络断连, 重连中...')
  717. break
  718. case 2105:
  719. console.log('网络断连, 重连成功')
  720. break
  721. case 2106:
  722. console.error('网络断连, 重连失败')
  723. break
  724. case 2107:
  725. console.error('播放器连接超时')
  726. break
  727. case 2108:
  728. console.error('获取点播文件信息失败')
  729. break
  730. default:
  731. console.log('其他状态:', state)
  732. }
  733. }
  734. // 小程序全屏状态变化
  735. const onFullscreenChange = (e) => {
  736. isFullscreen.value = e.detail.fullScreen
  737. console.log('全屏状态变化:', isFullscreen.value)
  738. }
  739. // 初始化流地址
  740. const initStreamUrl = () => {
  741. const originalUrl = deviceInfo.originalStreamUrl
  742. const streamUrls = buildPlatformStreamUrls(originalUrl, {
  743. streamServer: config.streamServer,
  744. fallbackToHls: true
  745. })
  746. // #ifdef H5
  747. deviceInfo.streamUrl = streamUrls.h5Url
  748. // #endif
  749. // #ifdef MP-WEIXIN
  750. // if (streamUrls.miniProgramUrl) {
  751. // deviceInfo.streamUrl = streamUrls.miniProgramUrl
  752. // } else {
  753. // uni.showToast({
  754. // title: '当前视频流不支持小程序播放',
  755. // icon: 'none',
  756. // duration: 3000
  757. // })
  758. // }
  759. // #endif
  760. // #ifndef H5
  761. if (streamUrls.miniProgramUrl) {
  762. deviceInfo.streamUrl = streamUrls.miniProgramUrl
  763. } else {
  764. // App端尝试使用 RTMP 或 HLS
  765. const rtmpUrl = originalUrl
  766. .replace('ws://', 'rtmp://')
  767. .replace(':6080/rtp/', ':1935/live/')
  768. .replace('.live.flv', '')
  769. deviceInfo.streamUrl = rtmpUrl || config.streamServer.rtmpServer
  770. }
  771. // #endif
  772. console.log('初始化流地址:', deviceInfo.streamUrl)
  773. }
  774. // 切换九宫格视图
  775. const toggleGridView = () => {
  776. isGridView.value = !isGridView.value
  777. uni.showToast({
  778. title: isGridView.value ? '切换到多画面模式' : '切换到单画面模式',
  779. icon: 'none'
  780. })
  781. }
  782. // 语音对讲
  783. const toggleVoiceIntercom = () => {
  784. isVoiceActive.value = !isVoiceActive.value
  785. if (isVoiceActive.value) {
  786. uni.showToast({
  787. title: '语音对讲已开启',
  788. icon: 'none'
  789. })
  790. } else {
  791. uni.showToast({
  792. title: '语音对讲已关闭',
  793. icon: 'none'
  794. })
  795. }
  796. }
  797. // 云台控制
  798. const controlPTZ = (direction, isStart) => {
  799. if (isStart) {
  800. console.log(`开始控制云台: ${direction}`)
  801. uni.vibrateShort()
  802. } else {
  803. console.log(`停止控制云台: ${direction}`)
  804. }
  805. }
  806. // 云台复位
  807. const resetPTZ = () => {
  808. console.log('云台复位')
  809. uni.vibrateShort()
  810. uni.showToast({
  811. title: '云台已复位',
  812. icon: 'none'
  813. })
  814. }
  815. // 变焦控制
  816. const controlZoom = (type, isStart) => {
  817. if (isStart) {
  818. console.log(`开始控制变焦: ${type}`)
  819. uni.vibrateShort()
  820. } else {
  821. console.log(`停止控制变焦: ${type}`)
  822. }
  823. }
  824. // 切换缩放模式
  825. const toggleZoom = () => {
  826. isZoomMode.value = !isZoomMode.value
  827. uni.showToast({
  828. title: isZoomMode.value ? '进入缩放模式' : '退出缩放模式',
  829. icon: 'none'
  830. })
  831. }
  832. // 添加预设位
  833. const addPreset = () => {
  834. uni.showModal({
  835. title: '添加预设位',
  836. content: '是否保存当前位置为预设位?',
  837. success: (res) => {
  838. if (res.confirm) {
  839. uni.showToast({
  840. title: '预设位已保存',
  841. icon: 'success'
  842. })
  843. }
  844. }
  845. })
  846. }
  847. // 处理告警点击
  848. const handleAlert = (item) => {
  849. uni.navigateTo({
  850. url: `/pages/alerts/alert-detail?alertId=${item.id}&deviceId=${deviceInfo.deviceId}`
  851. })
  852. }
  853. // 跳转到历史视频页面
  854. const navigateToHistory = () => {
  855. uni.navigateTo({
  856. url: `/pages/video/history?deviceId=${deviceInfo.deviceId}`
  857. })
  858. }
  859. // 视频播放错误处理
  860. const onVideoError = (e) => {
  861. console.error('视频播放错误:', e)
  862. uni.showToast({
  863. title: '视频加载失败,请检查网络连接',
  864. icon: 'none',
  865. duration: 2000
  866. })
  867. // #ifdef H5
  868. setTimeout(() => {
  869. if (isPlaying.value && jessibucaRef.value) {
  870. console.log('尝试重新加载视频流')
  871. if (deviceInfo.originalStreamUrl !== config.streamServer.wsFlvServer) {
  872. deviceInfo.streamUrl = config.streamServer.wsFlvServer
  873. }
  874. }
  875. }, 3000)
  876. // #endif
  877. }
  878. // 更新时间
  879. const startTimeUpdate = () => {
  880. setInterval(() => {
  881. const now = new Date()
  882. const hours = String(now.getHours()).padStart(2, '0')
  883. const minutes = String(now.getMinutes()).padStart(2, '0')
  884. const seconds = String(now.getSeconds()).padStart(2, '0')
  885. currentTime.value = `${hours}:${minutes}:${seconds}`
  886. }, 1000)
  887. }
  888. // 设置全屏状态监听
  889. const setupFullscreenListener = () => {
  890. // Placeholder for fullscreen listener setup
  891. }
  892. // 设置键盘监听
  893. const setupKeyboardListener = () => {
  894. // Placeholder for keyboard listener setup
  895. }
  896. // 生命周期钩子
  897. onMounted(() => {
  898. uni.setNavigationBarTitle({
  899. title: deviceInfo.name
  900. })
  901. startTimeUpdate()
  902. // #ifdef MP-WEIXIN
  903. // livePlayerContext.value = uni.createLivePlayerContext('videoPlayer')
  904. // #endif
  905. // #ifdef APP-PLUS || APP-HARMONY
  906. // appLivePlayerContext.value = uni.createLivePlayerContext('appVideoPlayer')
  907. // #endif
  908. // #ifdef H5
  909. loadJessibucaScript()
  910. // #endif
  911. setupFullscreenListener()
  912. setupKeyboardListener()
  913. initStreamUrl()
  914. })
  915. onBeforeUnmount(() => {
  916. console.log("停止点播")
  917. pause(deviceInfo.deviceId, deviceInfo.channelId).then(res => {
  918. if (res.data.code !== 0) {
  919. console.error('暂停失败:', res.message)
  920. } else {
  921. console.log('视频已暂停')
  922. }
  923. }).catch(err => {
  924. console.error('暂停请求错误:', err)
  925. })
  926. })
  927. // uni-app 生命周期
  928. uni.$once('passDeviceData', (data) => {
  929. console.log('接收到数据', data)
  930. if (data && data.deviceId) {
  931. deviceInfo.deviceId = data.deviceId
  932. deviceInfo.location = data.fieldName
  933. deviceInfo.name = data.deviceName
  934. deviceInfo.status = data.status
  935. deviceInfo.deviceTypeId = data.deviceTypeId || ''
  936. fetchDeviceInfo()
  937. queryChannels()
  938. }
  939. })
  940. </script>
  941. <style>
  942. /* 基础样式 */
  943. .container {
  944. display: flex;
  945. flex-direction: column;
  946. min-height: 100vh;
  947. background-color: #F8FCF9;
  948. padding-bottom: 30rpx;
  949. }
  950. /* 设备头部样式 */
  951. .device-header {
  952. background-color: #FFFFFF;
  953. border-radius: 20rpx;
  954. padding: 26rpx 30rpx 30rpx;
  955. margin: 20rpx 30rpx 20rpx;
  956. box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
  957. }
  958. .device-info-row {
  959. display: flex;
  960. justify-content: space-between;
  961. align-items: flex-start;
  962. margin-bottom: 24rpx;
  963. }
  964. .device-name-container {
  965. display: flex;
  966. flex-direction: column;
  967. align-items: flex-start;
  968. }
  969. .device-name {
  970. font-size: 34rpx;
  971. color: #333333;
  972. font-weight: 600;
  973. margin-bottom: 10rpx;
  974. }
  975. .status-tag {
  976. padding: 4rpx 12rpx 4rpx 24rpx;
  977. border-radius: 30rpx;
  978. font-size: 22rpx;
  979. font-weight: 500;
  980. flex-shrink: 0;
  981. position: relative;
  982. overflow: hidden;
  983. }
  984. .status-online {
  985. background-color: rgba(76, 175, 80, 0.1);
  986. color: #3BB44A;
  987. }
  988. .status-offline {
  989. background-color: rgba(245, 108, 108, 0.1);
  990. color: #F56C6C;
  991. padding-left: 24rpx;
  992. }
  993. .status-dot {
  994. position: absolute;
  995. width: 6rpx;
  996. height: 6rpx;
  997. background-color: #3BB44A;
  998. border-radius: 50%;
  999. top: 50%;
  1000. left: 12rpx;
  1001. transform: translateY(-50%);
  1002. box-shadow: 0 0 4rpx rgba(76, 175, 80, 0.8);
  1003. animation: blink 1.5s infinite;
  1004. display: inline-block;
  1005. }
  1006. .status-dot.offline-dot {
  1007. background-color: #F56C6C;
  1008. box-shadow: 0 0 4rpx rgba(245, 108, 108, 0.8);
  1009. }
  1010. @keyframes blink {
  1011. 0% {
  1012. opacity: 0.4;
  1013. }
  1014. 50% {
  1015. opacity: 1;
  1016. }
  1017. 100% {
  1018. opacity: 0.4;
  1019. }
  1020. }
  1021. .device-meta-row {
  1022. display: flex;
  1023. flex-direction: column;
  1024. }
  1025. .device-meta-item {
  1026. display: flex;
  1027. align-items: center;
  1028. font-size: 26rpx;
  1029. margin-top: 18rpx;
  1030. }
  1031. .meta-icon {
  1032. width: 36rpx;
  1033. height: 36rpx;
  1034. display: flex;
  1035. align-items: center;
  1036. justify-content: center;
  1037. margin-right: 8rpx;
  1038. }
  1039. .meta-label {
  1040. color: #999999;
  1041. min-width: 140rpx;
  1042. font-size: 26rpx;
  1043. }
  1044. .meta-value {
  1045. color: #333333;
  1046. flex: 1;
  1047. font-size: 26rpx;
  1048. }
  1049. /* 视频预览区域 */
  1050. .video-section {
  1051. margin: 0 30rpx 20rpx;
  1052. position: relative;
  1053. z-index: 1;
  1054. }
  1055. .video-container {
  1056. position: relative;
  1057. width: 100%;
  1058. height: 420rpx;
  1059. background-color: #000000;
  1060. border-radius: 16rpx;
  1061. overflow: hidden;
  1062. box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.1);
  1063. transition: all 0.3s ease;
  1064. }
  1065. .video-container.fullscreen-mode {
  1066. position: fixed;
  1067. top: 0;
  1068. left: 0;
  1069. width: 100vw;
  1070. height: 100vh;
  1071. margin: 0;
  1072. z-index: 9999;
  1073. border-radius: 0;
  1074. }
  1075. .video-container.fullscreen-mode .video-controls {
  1076. padding: 30rpx;
  1077. }
  1078. .video-container.fullscreen-mode .top-controls,
  1079. .video-container.fullscreen-mode .bottom-controls {
  1080. opacity: 0;
  1081. transition: opacity 0.3s ease;
  1082. }
  1083. .video-container.fullscreen-mode:hover .top-controls,
  1084. .video-container.fullscreen-mode:hover .bottom-controls {
  1085. opacity: 1;
  1086. }
  1087. .video-player,
  1088. .video-placeholder {
  1089. width: 100%;
  1090. height: 100%;
  1091. object-fit: contain;
  1092. }
  1093. .h5-video-wrapper {
  1094. width: 100%;
  1095. height: 100%;
  1096. }
  1097. /* App端视频播放器样式优化 */
  1098. /* #ifdef APP-PLUS || APP-HARMONY */
  1099. #appVideoPlayer {
  1100. width: 100%;
  1101. height: 100%;
  1102. background-color: #000000;
  1103. }
  1104. /* #endif */
  1105. .video-controls {
  1106. position: absolute;
  1107. top: 0;
  1108. left: 0;
  1109. width: 100%;
  1110. height: 100%;
  1111. display: flex;
  1112. flex-direction: column;
  1113. justify-content: space-between;
  1114. padding: 20rpx;
  1115. box-sizing: border-box;
  1116. background: linear-gradient(to bottom, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0) 30%, rgba(0, 0, 0, 0) 70%, rgba(0, 0, 0, 0.4) 100%);
  1117. }
  1118. .control-row {
  1119. display: flex;
  1120. justify-content: space-between;
  1121. align-items: center;
  1122. width: 100%;
  1123. }
  1124. .top-controls {
  1125. height: 80rpx;
  1126. }
  1127. .center-controls {
  1128. height: 120rpx;
  1129. justify-content: center;
  1130. align-items: center;
  1131. }
  1132. .bottom-controls {
  1133. height: 80rpx;
  1134. }
  1135. .signal-indicator {
  1136. display: flex;
  1137. align-items: center;
  1138. color: #FFFFFF;
  1139. font-size: 24rpx;
  1140. background-color: rgba(0, 0, 0, 0.5);
  1141. padding: 8rpx 16rpx;
  1142. border-radius: 30rpx;
  1143. }
  1144. .signal-indicator svg {
  1145. margin-right: 8rpx;
  1146. }
  1147. .signal-text {
  1148. font-weight: 500;
  1149. }
  1150. .fullscreen-button {
  1151. width: 60rpx;
  1152. height: 60rpx;
  1153. display: flex;
  1154. align-items: center;
  1155. justify-content: center;
  1156. color: #FFFFFF;
  1157. background-color: rgba(0, 0, 0, 0.5);
  1158. border-radius: 50%;
  1159. transition: all 0.2s;
  1160. }
  1161. .fullscreen-button:active {
  1162. background-color: rgba(76, 175, 80, 0.7);
  1163. transform: scale(0.9);
  1164. }
  1165. .video-time {
  1166. color: #FFFFFF;
  1167. font-size: 26rpx;
  1168. background-color: rgba(0, 0, 0, 0.5);
  1169. padding: 6rpx 16rpx;
  1170. border-radius: 30rpx;
  1171. font-weight: 500;
  1172. }
  1173. .play-button {
  1174. width: 100rpx;
  1175. height: 100rpx;
  1176. border-radius: 50%;
  1177. background-color: rgba(255, 255, 255, 0.9);
  1178. display: flex;
  1179. align-items: center;
  1180. justify-content: center;
  1181. box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.5);
  1182. transition: all 0.2s;
  1183. transform: scale(1);
  1184. }
  1185. .play-button:active {
  1186. transform: scale(0.92);
  1187. background-color: rgba(255, 255, 255, 1);
  1188. }
  1189. .center-button-container {
  1190. width: 100%;
  1191. height: 100%;
  1192. display: flex;
  1193. align-items: center;
  1194. justify-content: center;
  1195. }
  1196. .pause-icon {
  1197. width: 80rpx;
  1198. height: 80rpx;
  1199. border-radius: 50%;
  1200. background-color: rgba(0, 0, 0, 0.5);
  1201. display: flex;
  1202. align-items: center;
  1203. justify-content: center;
  1204. opacity: 0;
  1205. transition: opacity 0.3s;
  1206. }
  1207. .center-button-container:active .pause-icon {
  1208. opacity: 1;
  1209. background-color: rgba(76, 175, 80, 0.7);
  1210. }
  1211. .refresh-btn {
  1212. width: 48rpx;
  1213. height: 48rpx;
  1214. background-color: rgba(76, 175, 80, 0.1);
  1215. border-radius: 50%;
  1216. display: flex;
  1217. align-items: center;
  1218. justify-content: center;
  1219. padding: 12rpx;
  1220. transition: transform 0.3s ease;
  1221. }
  1222. .refresh-btn:active {
  1223. transform: rotate(180deg);
  1224. }
  1225. .section-title {
  1226. font-size: 30rpx;
  1227. font-weight: 600;
  1228. color: #333333;
  1229. margin-bottom: 20rpx;
  1230. padding: 0 10rpx;
  1231. display: flex;
  1232. align-items: center;
  1233. }
  1234. .alert-badge {
  1235. background-color: #F56C6C;
  1236. color: #FFFFFF;
  1237. font-size: 22rpx;
  1238. border-radius: 30rpx;
  1239. padding: 2rpx 12rpx;
  1240. margin-left: 12rpx;
  1241. font-weight: normal;
  1242. min-width: 32rpx;
  1243. text-align: center;
  1244. }
  1245. /* 告警信息列表 */
  1246. .alerts-section {
  1247. margin: 0 30rpx;
  1248. background-color: #FFFFFF;
  1249. border-radius: 20rpx;
  1250. padding: 24rpx;
  1251. box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
  1252. }
  1253. .alerts-list {
  1254. display: flex;
  1255. flex-direction: column;
  1256. }
  1257. .alert-item {
  1258. display: flex;
  1259. align-items: center;
  1260. padding: 24rpx 20rpx;
  1261. border-radius: 12rpx;
  1262. margin-bottom: 16rpx;
  1263. box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
  1264. position: relative;
  1265. }
  1266. .alert-urgent {
  1267. background-color: #FEF3F3;
  1268. border-left: 4rpx solid #F56C6C;
  1269. }
  1270. .alert-warning {
  1271. background-color: #FFF8E6;
  1272. border-left: 4rpx solid #E6A23C;
  1273. }
  1274. .alert-info {
  1275. background-color: #F2FAF5;
  1276. border-left: 4rpx solid #67C23A;
  1277. }
  1278. .alert-item-icon {
  1279. width: 50rpx;
  1280. height: 50rpx;
  1281. display: flex;
  1282. align-items: center;
  1283. justify-content: center;
  1284. margin-right: 16rpx;
  1285. }
  1286. .alert-item-info {
  1287. flex: 1;
  1288. display: flex;
  1289. flex-direction: column;
  1290. }
  1291. .alert-item-type {
  1292. font-size: 28rpx;
  1293. color: #333333;
  1294. font-weight: 500;
  1295. margin-bottom: 8rpx;
  1296. }
  1297. .alert-item-level {
  1298. font-size: 24rpx;
  1299. color: #999999;
  1300. }
  1301. .alert-item-time {
  1302. font-size: 24rpx;
  1303. color: #999999;
  1304. margin-left: 16rpx;
  1305. min-width: 100rpx;
  1306. text-align: right;
  1307. }
  1308. .empty-alert {
  1309. padding: 60rpx 0;
  1310. display: flex;
  1311. justify-content: center;
  1312. align-items: center;
  1313. }
  1314. .empty-text {
  1315. font-size: 28rpx;
  1316. color: #999999;
  1317. }
  1318. </style>