|
|
@@ -0,0 +1,722 @@
|
|
|
+<template>
|
|
|
+ <div class="camera-player-container">
|
|
|
+ <!-- 1. 摄像头配置表单 -->
|
|
|
+ <el-card
|
|
|
+ shadow="hover"
|
|
|
+ class="config-card"
|
|
|
+ >
|
|
|
+ <template #header>
|
|
|
+ <div class="card-header">
|
|
|
+ <span>摄像头配置</span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <el-form
|
|
|
+ ref="cameraFormRef"
|
|
|
+ :model="cameraForm"
|
|
|
+ :rules="formRules"
|
|
|
+ label-width="120px"
|
|
|
+ class="config-form"
|
|
|
+ status-icon
|
|
|
+ >
|
|
|
+<!-- <!– 摄像头IP –>-->
|
|
|
+<!-- <el-form-item-->
|
|
|
+<!-- label="摄像头IP"-->
|
|
|
+<!-- prop="ip"-->
|
|
|
+<!-- >-->
|
|
|
+<!-- <el-input-->
|
|
|
+<!-- v-model="cameraForm.ip"-->
|
|
|
+<!-- placeholder="例如:192.168.1.100"-->
|
|
|
+<!-- clearable-->
|
|
|
+<!-- max-length="15"-->
|
|
|
+<!-- />-->
|
|
|
+<!-- </el-form-item>-->
|
|
|
+
|
|
|
+<!-- <!– RTSP端口(默认554) –>-->
|
|
|
+<!-- <el-form-item-->
|
|
|
+<!-- label="RTSP端口"-->
|
|
|
+<!-- prop="port"-->
|
|
|
+<!-- >-->
|
|
|
+<!-- <el-input-->
|
|
|
+<!-- v-model="cameraForm.port"-->
|
|
|
+<!-- placeholder="默认554"-->
|
|
|
+<!-- clearable-->
|
|
|
+<!-- max-length="5"-->
|
|
|
+<!-- oninput="this.value = this.value.replace(/[^0-9]/g, '')"-->
|
|
|
+<!-- />-->
|
|
|
+<!-- </el-form-item>-->
|
|
|
+
|
|
|
+<!-- <!– 摄像头账号密码 –>-->
|
|
|
+<!-- <el-form-item-->
|
|
|
+<!-- label="用户名"-->
|
|
|
+<!-- prop="username"-->
|
|
|
+<!-- >-->
|
|
|
+<!-- <el-input-->
|
|
|
+<!-- v-model="cameraForm.username"-->
|
|
|
+<!-- placeholder="摄像头登录账号"-->
|
|
|
+<!-- clearable-->
|
|
|
+<!-- max-length="32"-->
|
|
|
+<!-- />-->
|
|
|
+<!-- </el-form-item>-->
|
|
|
+<!-- <el-form-item-->
|
|
|
+<!-- label="密码"-->
|
|
|
+<!-- prop="password"-->
|
|
|
+<!-- >-->
|
|
|
+<!-- <el-input-->
|
|
|
+<!-- v-model="cameraForm.password"-->
|
|
|
+<!-- placeholder="摄像头登录密码"-->
|
|
|
+<!-- type="password"-->
|
|
|
+<!-- clearable-->
|
|
|
+<!-- max-length="32"-->
|
|
|
+<!-- />-->
|
|
|
+<!-- </el-form-item>-->
|
|
|
+
|
|
|
+<!-- <!– 通道号(默认1) –>-->
|
|
|
+<!-- <el-form-item-->
|
|
|
+<!-- label="通道号"-->
|
|
|
+<!-- prop="channel"-->
|
|
|
+<!-- >-->
|
|
|
+<!-- <el-input-->
|
|
|
+<!-- v-model="cameraForm.channel"-->
|
|
|
+<!-- placeholder="默认1(主码流)"-->
|
|
|
+<!-- clearable-->
|
|
|
+<!-- max-length="2"-->
|
|
|
+<!-- oninput="this.value = this.value.replace(/[^0-9]/g, '')"-->
|
|
|
+<!-- />-->
|
|
|
+<!-- </el-form-item>-->
|
|
|
+
|
|
|
+ <!-- 操作按钮 -->
|
|
|
+ <el-form-item>
|
|
|
+ <el-button
|
|
|
+ type="primary"
|
|
|
+ :loading="isPreviewLoading"
|
|
|
+ :disabled="isPreviewing"
|
|
|
+ @click="handleStartPreview"
|
|
|
+ >
|
|
|
+ 启动预览
|
|
|
+ </el-button>
|
|
|
+ <el-button
|
|
|
+ type="danger"
|
|
|
+ :disabled="!isPreviewing"
|
|
|
+ style="margin-left: 10px"
|
|
|
+ @click="handleStopPreview"
|
|
|
+ >
|
|
|
+ 停止预览
|
|
|
+ </el-button>
|
|
|
+ <el-button
|
|
|
+ type="info"
|
|
|
+ style="margin-left: 10px"
|
|
|
+ @click="handleGetActiveStreams"
|
|
|
+ >
|
|
|
+ 查看活跃流
|
|
|
+ </el-button>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <!-- 2. 视频播放区域 -->
|
|
|
+ <el-card
|
|
|
+ shadow="hover"
|
|
|
+ class="video-card"
|
|
|
+ >
|
|
|
+ <template #header>
|
|
|
+ <div class="card-header">
|
|
|
+ <span>实时预览({{ cameraForm.ip || '未配置' }})</span>
|
|
|
+ <el-tooltip
|
|
|
+ content="当前HLS流地址"
|
|
|
+ placement="right"
|
|
|
+ >
|
|
|
+ <el-button
|
|
|
+ v-if="fullHlsUrl"
|
|
|
+ type="text"
|
|
|
+ size="small"
|
|
|
+ :disabled="!isPreviewing"
|
|
|
+ @click="copyHlsUrl"
|
|
|
+ >
|
|
|
+ 复制流地址
|
|
|
+ </el-button>
|
|
|
+ </el-tooltip>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <!-- 错误提示 -->
|
|
|
+ <el-alert
|
|
|
+ v-if="previewError"
|
|
|
+ type="error"
|
|
|
+ description="预览失败:{{ previewErrorMsg }}"
|
|
|
+ show-icon
|
|
|
+ closable
|
|
|
+ style="margin-bottom: 16px"
|
|
|
+ @close="handleClearError"
|
|
|
+ />
|
|
|
+
|
|
|
+ <!-- 视频容器(video始终渲染,避免ref空值) -->
|
|
|
+ <div class="video-container">
|
|
|
+ <video
|
|
|
+ v-show="isPreviewing && !previewError"
|
|
|
+ ref="videoRef"
|
|
|
+ autoplay
|
|
|
+ controls
|
|
|
+ muted
|
|
|
+ class="video-player"
|
|
|
+ :poster="previewPoster"
|
|
|
+ />
|
|
|
+ <div
|
|
|
+ v-show="!isPreviewing || previewError"
|
|
|
+ class="video-placeholder"
|
|
|
+ >
|
|
|
+ <p class="placeholder-text">
|
|
|
+ {{ previewError ? '预览异常,请检查配置或网络' : '请配置摄像头信息并启动预览' }}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <!-- 3. 活跃流列表弹窗 -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="streamListVisible"
|
|
|
+ title="活跃流列表"
|
|
|
+ width="500px"
|
|
|
+ :before-close="handleDialogClose"
|
|
|
+ >
|
|
|
+ <el-table
|
|
|
+ :data="activeStreamList"
|
|
|
+ border
|
|
|
+ stripe
|
|
|
+ :empty-text="activeStreamList.length === 0 ? '暂无活跃流' : ''"
|
|
|
+ >
|
|
|
+ <el-table-column
|
|
|
+ label="流ID"
|
|
|
+ prop="streamId"
|
|
|
+ align="center"
|
|
|
+ />
|
|
|
+ <el-table-column
|
|
|
+ label="操作"
|
|
|
+ align="center"
|
|
|
+ >
|
|
|
+ <template #default="scope">
|
|
|
+ <el-button
|
|
|
+ type="text"
|
|
|
+ @click="handlePlayStream(scope.row.streamId)"
|
|
|
+ >
|
|
|
+ 播放该流
|
|
|
+ </el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { nextTick, onBeforeUnmount, reactive, ref } from 'vue'
|
|
|
+import {
|
|
|
+ ElAlert,
|
|
|
+ ElButton,
|
|
|
+ ElCard,
|
|
|
+ ElDialog,
|
|
|
+ ElForm,
|
|
|
+ ElFormItem,
|
|
|
+ ElInput,
|
|
|
+ ElMessage,
|
|
|
+ ElTable,
|
|
|
+ ElTableColumn,
|
|
|
+ ElTooltip
|
|
|
+} from 'element-plus'
|
|
|
+import Hls from 'hls.js'
|
|
|
+// 导入后端接口(确保接口基础路径已适配环境变量)
|
|
|
+import {getStreamList, requestHLSFile, startStream, stopStream} from '@/api/stream'
|
|
|
+import { useRoute, useRouter } from 'vue-router'
|
|
|
+import { useUserStore } from '@/pinia/modules/user'
|
|
|
+const route = useRoute()
|
|
|
+
|
|
|
+const userStore = useUserStore()
|
|
|
+
|
|
|
+const userInfo = userStore.userInfo
|
|
|
+
|
|
|
+// 1. 响应式数据(明确区分相对路径和完整路径)
|
|
|
+const videoRef = ref(null) // 视频标签引用(始终存在)
|
|
|
+const cameraFormRef = ref(null) // 表单引用
|
|
|
+const isPreviewLoading = ref(false) // 预览加载状态
|
|
|
+const isPreviewing = ref(false) // 是否正在预览
|
|
|
+const previewError = ref(false) // 预览错误状态
|
|
|
+const previewErrorMsg = ref('') // 错误提示文本
|
|
|
+const hlsRelativeUrl = ref('') // 后端返回的HLS相对路径(如/stream/hls/xxx.m3u8)
|
|
|
+const fullHlsUrl = ref('') // 完整HLS地址(环境变量+相对路径)
|
|
|
+const hlsInstance = ref(null) // HLS播放器实例
|
|
|
+const streamListVisible = ref(false) // 活跃流弹窗显示状态
|
|
|
+const activeStreamList = ref([]) // 活跃流列表
|
|
|
+// 视频占位图(加载中显示)
|
|
|
+const previewPoster = ref('https://via.placeholder.com/1280x720?text=视频预览加载中...')
|
|
|
+
|
|
|
+// 2. 摄像头配置表单(默认值适配海康摄像头)
|
|
|
+const cameraForm = reactive({
|
|
|
+ ip: route.query.ip, // 默认摄像头IP
|
|
|
+ port: route.query.port, // RTSP默认端口
|
|
|
+ username: route.query.username, // 海康默认用户名
|
|
|
+ password: route.query.password, // 海康默认密码(首次登录需修改)
|
|
|
+ channel: route.query.channel, // 默认通道号
|
|
|
+})
|
|
|
+
|
|
|
+// 3. 表单校验规则(严格校验格式)
|
|
|
+const formRules = reactive({
|
|
|
+ ip: [
|
|
|
+ { required: true, message: '请输入摄像头IP', trigger: 'blur' },
|
|
|
+ {
|
|
|
+ pattern: /^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$/,
|
|
|
+ message: 'IP格式错误(如192.168.1.1)',
|
|
|
+ trigger: 'blur'
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ port: [
|
|
|
+ { required: true, message: '请输入RTSP端口', trigger: 'blur' },
|
|
|
+ { pattern: /^[0-9]{1,5}$/, message: '端口需为1-65535的数字', trigger: 'blur' },
|
|
|
+ {
|
|
|
+ validator: (rule, value, callback) => {
|
|
|
+ if (Number(value) < 1 || Number(value) > 65535) {
|
|
|
+ callback(new Error('端口范围:1-65535'))
|
|
|
+ } else {
|
|
|
+ callback()
|
|
|
+ }
|
|
|
+ },
|
|
|
+ trigger: 'blur'
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ username: [
|
|
|
+ { required: true, message: '请输入摄像头用户名', trigger: 'blur' },
|
|
|
+ { max: 32, message: '用户名长度不超过32位', trigger: 'blur' }
|
|
|
+ ],
|
|
|
+ password: [
|
|
|
+ { required: true, message: '请输入摄像头密码', trigger: 'blur' },
|
|
|
+ { max: 32, message: '密码长度不超过32位', trigger: 'blur' }
|
|
|
+ ],
|
|
|
+ channel: [
|
|
|
+ { required: true, message: '请输入通道号', trigger: 'blur' },
|
|
|
+ { pattern: /^[1-9]\d?$/, message: '通道号:1-99', trigger: 'blur' }
|
|
|
+ ],
|
|
|
+})
|
|
|
+
|
|
|
+// 4. 工具函数:生成RTSP地址(适配海康,可修改为其他品牌)
|
|
|
+const generateRTSPUrl = () => {
|
|
|
+ const { ip, port, username, password, channel } = cameraForm
|
|
|
+ // 密码编码(处理特殊字符如@、#)
|
|
|
+ const encodedPwd = encodeURIComponent(password)
|
|
|
+ // 海康RTSP格式:rtsp://user:pass@ip:port/Streaming/Channels/通道号+码流类型
|
|
|
+ // 例:1通道主码流 → /Streaming/Channels/101,子码流→102
|
|
|
+ return `rtsp://${username}:${encodedPwd}@${ip}:${port}/stream${channel}`
|
|
|
+}
|
|
|
+
|
|
|
+// 1. 先封装一个「延迟函数」(可复用):延迟指定毫秒后 resolve
|
|
|
+const delay = (ms) => {
|
|
|
+ return new Promise(resolve => setTimeout(resolve, ms))
|
|
|
+}
|
|
|
+
|
|
|
+// 5. 核心方法:启动预览(完整日志+双斜杠修复+环境变量适配)
|
|
|
+const handleStartPreview = async() => {
|
|
|
+ console.log('=== 【启动预览】流程开始 ===')
|
|
|
+
|
|
|
+ // 步骤1:表单校验(确保配置合法)
|
|
|
+ let formValid = false
|
|
|
+ try {
|
|
|
+ console.log('1. 执行表单校验...')
|
|
|
+ formValid = await cameraFormRef.value.validate()
|
|
|
+ console.log('1. 表单校验结果:', formValid) // 预期为true
|
|
|
+ } catch (err) {
|
|
|
+ console.error('1. 表单校验失败:', err) // 打印具体校验错误(如IP格式、必填项缺失)
|
|
|
+ ElMessage.warning('请完善摄像头配置(如IP需符合192.168.1.1格式、端口1-65535)')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!formValid) {
|
|
|
+ console.log('1. 表单校验未通过,终止预览流程')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 步骤2:初始化状态(清空错误+开启加载)
|
|
|
+ console.log('2. 初始化预览状态...')
|
|
|
+ isPreviewLoading.value = true // 开启按钮加载
|
|
|
+ handleClearError() // 清除之前的预览错误
|
|
|
+ console.log('2. 状态初始化完成:isPreviewLoading=', isPreviewLoading.value, 'previewError=', previewError.value)
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 步骤3:生成RTSP地址和流ID(确保与后端目录命名一致)
|
|
|
+ console.log('3. 生成RTSP地址和流ID...')
|
|
|
+ const rtspUrl = generateRTSPUrl() // 生成摄像头RTSP拉流地址(适配海康/大华)
|
|
|
+ // 流ID格式:cam_IP_通道号_时间戳后4位(与后端生成的HLS目录名完全匹配)
|
|
|
+ const streamId = `cam_${cameraForm.ip}_${cameraForm.channel}_${userInfo.ID}`
|
|
|
+ console.log('3. 生成结果:')
|
|
|
+ console.log(' - RTSP地址:', rtspUrl) // 示例:rtsp://admin:123456@192.168.110.190:554/Streaming/Channels/101
|
|
|
+ console.log(' - 流ID:', streamId) // 示例:cam_192.168.110.190_1(与后端HLS目录名一致)
|
|
|
+
|
|
|
+ // 步骤4:调用后端startStream接口(请求RTSP转HLS)
|
|
|
+ console.log('4. 调用后端startStream接口...')
|
|
|
+ console.log(' - 接口请求参数:', { rtspUrl, streamId })
|
|
|
+ const response = await startStream({ rtspUrl, streamId })
|
|
|
+ hlsRelativeUrl.value = response.data.hlsUrl
|
|
|
+ // 此时 hlsRelativeUrl.value 应为:/stream/hls/cam_xxx/stream.m3u8(后端返回的相对路径,含文件名)
|
|
|
+ console.log('后端返回的HLS相对路径:', hlsRelativeUrl.value)
|
|
|
+
|
|
|
+ // 步骤2:等待2秒(关键:延迟2秒)
|
|
|
+ console.log('开始等待5秒...')
|
|
|
+ await delay(5000) // 等待5000毫秒(5秒),await 会阻塞后续代码
|
|
|
+ console.log('5秒等待结束,准备执行下一个方法')
|
|
|
+
|
|
|
+ // 关键:只拼接后端相关地址,不涉及前端地址(localhost:8080)
|
|
|
+ fullHlsUrl.value = joinPath(
|
|
|
+ import.meta.env.VITE_BASE_PATH, // 后端IP:http://127.0.0.1(不含端口)
|
|
|
+ import.meta.env.VITE_SERVER_PORT, // 后端端口:8889
|
|
|
+ hlsRelativeUrl.value // 后端返回的相对路径:/stream/hls/.../stream.m3u8
|
|
|
+ )
|
|
|
+ console.log('最终正确HLS地址:', fullHlsUrl.value)
|
|
|
+
|
|
|
+ // 步骤6:等待DOM更新(确保video标签已渲染,避免ref空值)
|
|
|
+ console.log('6. 等待DOM更新(确保video元素可获取)...')
|
|
|
+ await nextTick() // 等待Vue DOM更新完成
|
|
|
+ const videoDom = videoRef.value
|
|
|
+ if (!videoDom) throw new Error('DOM更新后仍未找到video元素,请检查模板中video标签的ref是否为"videoRef"')
|
|
|
+ console.log('6. DOM更新完成:video元素已获取,准备初始化播放器')
|
|
|
+
|
|
|
+ // 步骤7:初始化HLS播放器(传入正确的完整地址)
|
|
|
+ console.log('7. 初始化HLS播放器...')
|
|
|
+ await initHLSPlayer(fullHlsUrl.value) // 传入修复后的完整HLS地址
|
|
|
+ console.log('7. HLS播放器初始化成功(流已开始加载)')
|
|
|
+
|
|
|
+ // 步骤8:更新预览状态(标记为正在预览)
|
|
|
+ isPreviewing.value = true
|
|
|
+ console.log('8. 预览状态更新:isPreviewing=', isPreviewing.value)
|
|
|
+ ElMessage.success('预览启动成功!可查看视频区域播放')
|
|
|
+ console.log('=== 【启动预览】流程全部完成(成功) ===')
|
|
|
+ } catch (err) {
|
|
|
+ // 步骤9:错误处理(分场景提示,方便调试)
|
|
|
+ console.error('=== 【启动预览】流程出错 ===', err) // 打印完整错误栈
|
|
|
+ previewError.value = true // 标记预览错误状态
|
|
|
+
|
|
|
+ // 提取错误详情(区分后端错误、前端错误、网络错误)
|
|
|
+ if (err.response) {
|
|
|
+ // 后端返回错误(如404、500)
|
|
|
+ previewErrorMsg.value = `后端接口错误(状态码:${err.response.status}):${err.response.data.message || '后端未返回错误信息'}`
|
|
|
+ } else if (err.message) {
|
|
|
+ // 前端已知错误(如表单校验、DOM未找到、地址拼接异常)
|
|
|
+ previewErrorMsg.value = err.message
|
|
|
+ } else {
|
|
|
+ // 未知错误
|
|
|
+ previewErrorMsg.value = '未知预览错误,请检查:1. 摄像头是否在线 2. 后端服务是否正常 3. 网络是否连通'
|
|
|
+ }
|
|
|
+ console.error('5. 错误详情提示:', previewErrorMsg.value)
|
|
|
+ ElMessage.error('预览启动失败:' + previewErrorMsg.value) // 前端用户提示
|
|
|
+ } finally {
|
|
|
+ // 步骤10:无论成功/失败,结束加载状态(避免按钮一直loading)
|
|
|
+ isPreviewLoading.value = false
|
|
|
+ console.log('10. 预览加载状态重置:isPreviewLoading=', isPreviewLoading.value)
|
|
|
+ console.log('=== 【启动预览】流程最终结束 ===\n')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 路径处理工具函数:正确拼接 IP+端口(冒号分隔),其他路径用斜杠分隔
|
|
|
+const joinPath = (...parts) => {
|
|
|
+ // 1. 过滤空字符串和无效片段
|
|
|
+ const validParts = parts.filter(part => typeof part === 'string' && part.trim() !== '')
|
|
|
+ if (validParts.length === 0) return ''
|
|
|
+
|
|
|
+ // 2. 处理第一个片段(可能包含协议+IP,如 http://127.0.0.1)
|
|
|
+ const firstPart = validParts[0].trim()
|
|
|
+ const restParts = validParts.slice(1)
|
|
|
+
|
|
|
+ // 3. 单独拼接「IP+端口」(若第二个片段是端口,用冒号连接)
|
|
|
+ let fullPath = firstPart
|
|
|
+ if (restParts.length > 0) {
|
|
|
+ const secondPart = restParts[0].trim()
|
|
|
+ // 判断第一个片段是否是「协议+IP」(如 http://127.0.0.1),且第二个片段是纯数字(端口)
|
|
|
+ const isProtocolIp = /^https?:\/\/\d+\.\d+\.\d+\.\d+$/.test(firstPart)
|
|
|
+ const isPort = /^\d{1,5}$/.test(secondPart)
|
|
|
+
|
|
|
+ if (isProtocolIp && isPort) {
|
|
|
+ // IP+端口:用冒号连接(如 http://127.0.0.1 + 8889 → http://127.0.0.1:8889)
|
|
|
+ fullPath += `:${secondPart}`
|
|
|
+ // 剩余片段从第三个开始处理
|
|
|
+ restParts.shift()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4. 处理剩余路径片段(用斜杠连接,避免重复斜杠)
|
|
|
+ restParts.forEach(part => {
|
|
|
+ const trimmedPart = part.trim()
|
|
|
+ // 去除片段开头的斜杠(避免和前面的路径重复)
|
|
|
+ const normalizedPart = trimmedPart.replace(/^\/+/, '')
|
|
|
+ // 若当前路径末尾没有斜杠,添加一个斜杠
|
|
|
+ if (!fullPath.endsWith('/')) {
|
|
|
+ fullPath += '/'
|
|
|
+ }
|
|
|
+ fullPath += normalizedPart
|
|
|
+ })
|
|
|
+
|
|
|
+ return fullPath
|
|
|
+}
|
|
|
+
|
|
|
+// 核心:初始化HLS播放器(适配封装axios)
|
|
|
+const initHLSPlayer = async(hlsRelativeUrl) => {
|
|
|
+ // 1. 销毁旧实例
|
|
|
+ if (hlsInstance.value) {
|
|
|
+ hlsInstance.value.destroy()
|
|
|
+ hlsInstance.value = null
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 获取video元素
|
|
|
+ const video = videoRef.value
|
|
|
+ if (!video) throw new Error('视频元素未找到')
|
|
|
+ video.src = ''
|
|
|
+ video.load()
|
|
|
+
|
|
|
+ // 3. 生成完整HLS相对路径(基于封装axios的baseURL)
|
|
|
+ // const hlsRelativeUrl = getFullHlsUrl(streamId)
|
|
|
+ console.log('HLS请求地址(基于axios baseURL):', hlsRelativeUrl)
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 4. 浏览器兼容性处理
|
|
|
+ if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
|
|
+ // Safari原生支持:通过axios获取Blob,创建URL给video
|
|
|
+ const blob = await requestHLSFile(hlsRelativeUrl)
|
|
|
+ const blobUrl = URL.createObjectURL(blob)
|
|
|
+ video.src = blobUrl
|
|
|
+ await video.play().catch(err => {
|
|
|
+ console.warn('Safari自动播放失败:', err)
|
|
|
+ ElMessage.info('请点击视频区域开始播放')
|
|
|
+ })
|
|
|
+ // 组件卸载时释放Blob URL(避免内存泄漏)
|
|
|
+ onBeforeUnmount(() => {
|
|
|
+ URL.revokeObjectURL(blobUrl)
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 其他浏览器:用hls.js,适配axios请求
|
|
|
+ if (!Hls.isSupported()) throw new Error('浏览器不支持HLS播放')
|
|
|
+
|
|
|
+ hlsInstance.value = new Hls({
|
|
|
+ enableWorker: true,
|
|
|
+ lowLatencyMode: true,
|
|
|
+ // 关键:让hls.js使用我们封装的axios请求HLS文件
|
|
|
+ fetchSetup: (context, init) => {
|
|
|
+ // 复用封装axios的请求配置(如baseURL、跨域、错误处理)
|
|
|
+ return requestHLSFile(context.url).then(blob => {
|
|
|
+ // 将Blob转换为hls.js可识别的Response对象
|
|
|
+ return new Response(blob, {
|
|
|
+ headers: {
|
|
|
+ 'Content-Type': context.url.endsWith('.m3u8')
|
|
|
+ ? 'application/x-mpegURL'
|
|
|
+ : 'video/MP2T'
|
|
|
+ }
|
|
|
+ })
|
|
|
+ })
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ // 绑定视频并加载流
|
|
|
+ hlsInstance.value.attachMedia(video)
|
|
|
+ hlsInstance.value.on(Hls.Events.MANIFEST_PARSED, async() => {
|
|
|
+ console.log('HLS流解析完成')
|
|
|
+ await video.play().catch(err => {
|
|
|
+ console.warn('自动播放失败:', err)
|
|
|
+ ElMessage.info('请点击视频区域开始播放')
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ // 加载HLS流(传入相对路径,axios会自动拼接baseURL)
|
|
|
+ hlsInstance.value.loadSource(hlsRelativeUrl)
|
|
|
+ } catch (err) {
|
|
|
+ previewError.value = true
|
|
|
+ previewErrorMsg.value = `HLS播放初始化失败:${err.message}`
|
|
|
+ throw err
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 7. 核心方法:停止预览(资源清理)
|
|
|
+const handleStopPreview = async() => {
|
|
|
+ if (!isPreviewing.value) return
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 调用后端停止流
|
|
|
+ const streamId = `cam_${cameraForm.ip}_${cameraForm.channel}_${userInfo.ID}`
|
|
|
+ await stopStream({ streamId }).then(res => {
|
|
|
+ console.log('后端停止流成功:', res)
|
|
|
+ }).catch(err => {
|
|
|
+ console.warn('后端停止流失败:', err)
|
|
|
+ ElMessage.warning('后端流停止异常,已清理前端')
|
|
|
+ })
|
|
|
+
|
|
|
+ // 清理前端资源
|
|
|
+ if (hlsInstance.value) {
|
|
|
+ hlsInstance.value.destroy()
|
|
|
+ hlsInstance.value = null
|
|
|
+ }
|
|
|
+ const video = videoRef.value
|
|
|
+ if (video) {
|
|
|
+ video.src = ''
|
|
|
+ video.pause()
|
|
|
+ video.load()
|
|
|
+ }
|
|
|
+
|
|
|
+ // 重置状态
|
|
|
+ isPreviewing.value = false
|
|
|
+ hlsRelativeUrl.value = ''
|
|
|
+ fullHlsUrl.value = ''
|
|
|
+ handleClearError()
|
|
|
+
|
|
|
+ ElMessage.success('预览已停止')
|
|
|
+ } catch (err) {
|
|
|
+ ElMessage.error('停止失败:' + (err.message || '未知错误'))
|
|
|
+ console.error('停止预览错误:', err)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 8. 辅助方法:获取活跃流列表
|
|
|
+const handleGetActiveStreams = async() => {
|
|
|
+ try {
|
|
|
+ const response = await getStreamList()
|
|
|
+ if (!response || !response.data || !Array.isArray(response.data.data)) {
|
|
|
+ throw new Error('后端返回格式异常')
|
|
|
+ }
|
|
|
+ // 去重并格式化列表
|
|
|
+ activeStreamList.value = [...new Set(response.data.data)].map(id => ({ streamId: id }))
|
|
|
+ streamListVisible.value = true
|
|
|
+ } catch (err) {
|
|
|
+ const errMsg = err.response?.data?.message || err.message || '获取失败'
|
|
|
+ ElMessage.error('获取活跃流失败:' + errMsg)
|
|
|
+ console.error('获取活跃流错误:', err)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 9. 辅助方法:播放指定活跃流
|
|
|
+const handlePlayStream = async(streamId) => {
|
|
|
+ if (!streamId) {
|
|
|
+ ElMessage.warning('无效流ID')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // 解析流ID(格式:cam_IP_通道号_时间戳)
|
|
|
+ const match = streamId.match(/cam_([\d.]+)_(\d+)_?\d*/)
|
|
|
+ if (!match || match.length < 3) {
|
|
|
+ ElMessage.warning('流ID格式错误,无法解析')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // 填充表单并启动预览
|
|
|
+ cameraForm.ip = match[1]
|
|
|
+ cameraForm.channel = match[2]
|
|
|
+ streamListVisible.value = false
|
|
|
+ // 延迟启动(确保弹窗关闭后DOM更新)
|
|
|
+ setTimeout(handleStartPreview, 300)
|
|
|
+}
|
|
|
+
|
|
|
+// 10. 辅助方法:复制HLS地址(兼容旧浏览器)
|
|
|
+const copyHlsUrl = async() => {
|
|
|
+ if (!fullHlsUrl.value) return
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 现代浏览器:navigator.clipboard
|
|
|
+ await navigator.clipboard.writeText(fullHlsUrl.value)
|
|
|
+ ElMessage.success('地址已复制:' + fullHlsUrl.value.slice(0, 50) + '...')
|
|
|
+ } catch (err) {
|
|
|
+ // 旧浏览器:临时输入框
|
|
|
+ const input = document.createElement('input')
|
|
|
+ input.value = fullHlsUrl.value
|
|
|
+ document.body.appendChild(input)
|
|
|
+ input.select()
|
|
|
+ document.execCommand('copy')
|
|
|
+ document.body.removeChild(input)
|
|
|
+ ElMessage.success('地址已复制(兼容模式)')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 11. 辅助方法:清除错误状态
|
|
|
+const handleClearError = () => {
|
|
|
+ previewError.value = false
|
|
|
+ previewErrorMsg.value = ''
|
|
|
+}
|
|
|
+
|
|
|
+// 12. 辅助方法:关闭弹窗时重置
|
|
|
+const handleDialogClose = () => {
|
|
|
+ streamListVisible.value = false
|
|
|
+}
|
|
|
+
|
|
|
+// 13. 生命周期:卸载时清理资源
|
|
|
+onBeforeUnmount(() => {
|
|
|
+ if (isPreviewing.value) {
|
|
|
+ handleStopPreview().catch(err => console.warn('卸载时停止预览错误:', err))
|
|
|
+ }
|
|
|
+ if (hlsInstance.value) {
|
|
|
+ hlsInstance.value.destroy()
|
|
|
+ hlsInstance.value = null
|
|
|
+ }
|
|
|
+ const video = videoRef.value
|
|
|
+ if (video) {
|
|
|
+ video.src = ''
|
|
|
+ video.pause()
|
|
|
+ }
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.camera-player-container {
|
|
|
+ padding: 20px;
|
|
|
+ max-width: 1400px;
|
|
|
+ margin: 0 auto;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+.config-card, .video-card {
|
|
|
+ margin-bottom: 20px;
|
|
|
+ border-radius: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.config-form {
|
|
|
+ margin-top: 15px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 视频容器:16:9比例,避免变形 */
|
|
|
+.video-container {
|
|
|
+ width: 100%;
|
|
|
+ background: #000;
|
|
|
+ border-radius: 4px;
|
|
|
+ overflow: hidden;
|
|
|
+ aspect-ratio: 16/9;
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+
|
|
|
+.video-player {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ object-fit: contain; /* 保持视频比例 */
|
|
|
+}
|
|
|
+
|
|
|
+/* 占位符:居中显示 */
|
|
|
+.video-placeholder {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #aaa;
|
|
|
+ background: #000;
|
|
|
+}
|
|
|
+
|
|
|
+.placeholder-text {
|
|
|
+ font-size: 16px;
|
|
|
+ padding: 0 20px;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+
|
|
|
+.card-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+
|
|
|
+/* 输入框聚焦效果 */
|
|
|
+:deep(.el-input__wrapper):focus-within {
|
|
|
+ box-shadow: 0 0 0 2px rgba(144, 147, 153, 0.2);
|
|
|
+}
|
|
|
+
|
|
|
+/* 按钮hover效果 */
|
|
|
+:deep(.el-button):hover:not(:disabled) {
|
|
|
+ transform: translateY(-1px);
|
|
|
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
|
+}
|
|
|
+</style>
|