feat: 增强WebSocket连接管理,添加用户身份验证和流媒体URL解析功能

This commit is contained in:
zhanghao 2026-08-26 14:11:17 +08:00
parent 7eaec22372
commit db5c3b61ef
3 changed files with 119 additions and 14 deletions

View File

@ -1,3 +1,5 @@
import { getToken } from '../utils/auth';
const MEDIA_HEADER_SIZE = 26; const MEDIA_HEADER_SIZE = 26;
const MEDIA_MAGIC = [0x43, 0x4d, 0x56, 0x46]; // CMVF const MEDIA_MAGIC = [0x43, 0x4d, 0x56, 0x46]; // CMVF
const MEDIA_MIME_TYPES = { const MEDIA_MIME_TYPES = {
@ -18,6 +20,11 @@ class WebSocketManager {
this.maxQueueSize = 100; this.maxQueueSize = 100;
this.status = false; this.status = false;
this.channelSubscriptions = new Set(); this.channelSubscriptions = new Set();
this.connectionUserId = null;
}
resolveUserId() {
return typeof this.userId === 'function' ? this.userId() : this.userId;
} }
connect() { connect() {
@ -25,8 +32,19 @@ class WebSocketManager {
console.error('浏览器不支持 WebSocket'); console.error('浏览器不支持 WebSocket');
return; return;
} }
if (!this.url) {
console.error('WebSocket 地址未配置');
return;
}
this.ws = new WebSocket(`${this.url}?userId=${this.userId}`, [], { perMessageDeflate: true }); const userId = this.resolveUserId();
if (typeof this.userId === 'function' && !userId) return;
const connectionUrl = new URL(this.url, window.location.href);
if (userId) connectionUrl.searchParams.set('userId', userId);
const token = getToken();
if (token) connectionUrl.searchParams.set('token', token);
this.connectionUserId = userId || null;
this.ws = new WebSocket(connectionUrl.toString(), [], { perMessageDeflate: true });
this.ws.binaryType = 'arraybuffer'; // 确保接收 ArrayBuffer this.ws.binaryType = 'arraybuffer'; // 确保接收 ArrayBuffer
this.ws.onopen = () => { this.ws.onopen = () => {
@ -168,6 +186,12 @@ class WebSocketManager {
this.channelSubscriptions.delete(payload.channel); this.channelSubscriptions.delete(payload.channel);
} }
} }
const userId = this.resolveUserId();
if (this.ws && this.ws.readyState !== WebSocket.CLOSED && this.connectionUserId !== userId) {
this.ws.close(4001, 'user identity changed');
return;
}
if (!this.ws || this.ws.readyState === WebSocket.CLOSED) this.connect();
if (this.ws && this.ws.readyState === 1) { if (this.ws && this.ws.readyState === 1) {
this.ws.send(JSON.stringify(payload)); this.ws.send(JSON.stringify(payload));
} else if (payload?.type !== 'channel_subscription') { } else if (payload?.type !== 'channel_subscription') {

View File

@ -40,6 +40,8 @@
import { inject, reactive, toRefs, watch, onUnmounted, ref, nextTick } from 'vue'; import { inject, reactive, toRefs, watch, onUnmounted, ref, nextTick } from 'vue';
import { getResourceDevice } from '@/api/resource/device'; import { getResourceDevice } from '@/api/resource/device';
import { getRgbStreamUrl } from "@/api/device/camera"; import { getRgbStreamUrl } from "@/api/device/camera";
import { getToken } from '@/utils/auth';
import { runtimeConfig } from '../../../../../runtime/config';
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
const props = defineProps({ const props = defineProps({
@ -94,6 +96,38 @@ function getVideoRef(method) {
return method === 'getRGBImageStream' ? colorVideo : depthVideo; return method === 'getRGBImageStream' ? colorVideo : depthVideo;
} }
function resolveRgbStreamUrl(value) {
const rawValue = typeof value === 'object' && value !== null
? value.url || value.streamUrl || value.path
: value;
const streamPath = String(rawValue || '').trim();
if (!streamPath) throw new Error('RGB video stream URL is empty');
if (/^https?:\/\//i.test(streamPath)) return streamPath;
const baseUrl = String(
runtimeConfig.apiBaseURL || import.meta.env.VITE_APP_BASE_API || ''
).replace(/\/$/, '');
if (!baseUrl || streamPath === baseUrl || streamPath.startsWith(`${baseUrl}/`)) {
return streamPath;
}
if (streamPath.startsWith('/')) return `${baseUrl}${streamPath}`;
return `${baseUrl}/${streamPath}`;
}
function streamFetchOptions(url, signal) {
const options = {
signal,
cache: 'no-store',
credentials: 'same-origin',
};
const token = getToken();
const requestUrl = new URL(url, window.location.href);
if (token && requestUrl.origin === window.location.origin) {
options.headers = { Authorization: `Bearer ${token}` };
}
return options;
}
async function startDirectRgbStream() { async function startDirectRgbStream() {
if (!data.robotId || !data.cameraId || !colorVideo.value) return; if (!data.robotId || !data.cameraId || !colorVideo.value) return;
rgbStreamAbortController?.abort(); rgbStreamAbortController?.abort();
@ -105,8 +139,7 @@ async function startDirectRgbStream() {
try { try {
const response = await getRgbStreamUrl(data.robotId, data.cameraId); const response = await getRgbStreamUrl(data.robotId, data.cameraId);
if (generation !== rgbStreamGeneration || !data.form.rgbCamera) return; if (generation !== rgbStreamGeneration || !data.form.rgbCamera) return;
const relativeUrl = response.data; const streamUrl = resolveRgbStreamUrl(response?.data);
const baseUrl = import.meta.env.VITE_APP_BASE_API.replace(/\/$/, '');
const video = colorVideo.value; const video = colorVideo.value;
if (!video) return; if (!video) return;
const player = createMediaPlayer( const player = createMediaPlayer(
@ -116,7 +149,7 @@ async function startDirectRgbStream() {
); );
rgbStreamAbortController = new AbortController(); rgbStreamAbortController = new AbortController();
consumeDirectRgbStream( consumeDirectRgbStream(
`${baseUrl}${relativeUrl}`, streamUrl,
generation, generation,
player, player,
rgbStreamAbortController.signal rgbStreamAbortController.signal
@ -131,7 +164,7 @@ async function startDirectRgbStream() {
async function consumeDirectRgbStream(url, generation, player, signal) { async function consumeDirectRgbStream(url, generation, player, signal) {
try { try {
const response = await fetch(url, { signal, cache: 'no-store' }); const response = await fetch(url, streamFetchOptions(url, signal));
if (!response.ok || !response.body) { if (!response.ok || !response.body) {
throw new Error(`RGB video stream request failed: HTTP ${response.status}`); throw new Error(`RGB video stream request failed: HTTP ${response.status}`);
} }
@ -320,6 +353,21 @@ function maintainLivePlayback(state) {
} }
} }
function appendLegacyVideoFrame(method, value) {
const video = getVideoRef(method).value;
const frame = String(value || '').trim();
if (!video || !frame) return;
// Older edge services send each MP4 frame as a Base64 string instead of a CMVF packet.
const source = /^(data:|blob:|https?:\/\/)/i.test(frame)
? frame
: `data:video/mp4;base64,${frame}`;
destroyMediaPlayer(method);
if (video.src !== source) video.src = source;
video.play().catch(() => {});
streamStatus[method] = 'live';
}
function destroyMediaPlayer(method) { function destroyMediaPlayer(method) {
const state = mediaPlayers.get(method); const state = mediaPlayers.get(method);
if (!state) return; if (!state) return;
@ -355,10 +403,12 @@ async function setSubscription(enabled, method, force = false) {
streamStatus[method] = 'connecting'; streamStatus[method] = 'connecting';
if (!data.callbacks[channel]) { if (!data.callbacks[channel]) {
const callback = (payload) => { const callback = (payload) => {
if (payload?.media) { if (payload === '500') {
appendMediaPacket(method, payload);
} else if (payload === '500') {
scheduleRestart(method); scheduleRestart(method);
} else if (payload?.media) {
appendMediaPacket(method, payload);
} else if (typeof payload === 'string' || typeof payload?.data === 'string') {
appendLegacyVideoFrame(method, typeof payload === 'string' ? payload : payload.data);
} }
}; };
// 先注册前端回调,再通知后端启动,避免丢失初始化片段。 // 先注册前端回调,再通知后端启动,避免丢失初始化片段。

View File

@ -1,7 +1,11 @@
<template> <template>
<div class="app-container"> <div class="app-container resource-device-page">
<el-form :inline="true" :model="query"> <el-form :inline="true" :model="query">
<el-form-item label="机器人ID"><el-input v-model="query.robotId" clearable @keyup.enter="search" /></el-form-item> <el-form-item label="机器人ID">
<el-select v-model="query.robotId" filterable clearable :loading="robotsLoading" placeholder="请选择机器人" style="width: 190px">
<el-option v-for="robot in robots" :key="robot.robotId" :label="robotLabel(robot)" :value="robot.robotId" />
</el-select>
</el-form-item>
<el-form-item label="设备用途"><el-input v-model="query.deviceRole" clearable @keyup.enter="search" /></el-form-item> <el-form-item label="设备用途"><el-input v-model="query.deviceRole" clearable @keyup.enter="search" /></el-form-item>
<el-form-item label="在线状态"> <el-form-item label="在线状态">
<el-select v-model="onlineFilter" clearable style="width: 120px"> <el-select v-model="onlineFilter" clearable style="width: 120px">
@ -11,7 +15,8 @@
<el-form-item><el-button type="primary" icon="Search" @click="search">查询</el-button><el-button icon="Refresh" @click="reset">重置</el-button></el-form-item> <el-form-item><el-button type="primary" icon="Search" @click="search">查询</el-button><el-button icon="Refresh" @click="reset">重置</el-button></el-form-item>
</el-form> </el-form>
<el-table v-loading="loading" :data="rows" row-key="id"> <div class="device-table-wrap">
<el-table v-loading="loading" :data="rows" row-key="id" height="100%">
<el-table-column label="机器人" prop="robotId" min-width="200" show-overflow-tooltip /> <el-table-column label="机器人" prop="robotId" min-width="200" show-overflow-tooltip />
<el-table-column label="设备" min-width="210"> <el-table-column label="设备" min-width="210">
<template #default="{ row }"><strong>{{ row.deviceName || row.deviceId }}</strong><div class="secondary">{{ row.deviceId }}</div></template> <template #default="{ row }"><strong>{{ row.deviceName || row.deviceId }}</strong><div class="secondary">{{ row.deviceId }}</div></template>
@ -29,6 +34,7 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div>
<pagination v-show="total > 0" :total="total" v-model:page="query.pageNum" v-model:limit="query.pageSize" @pagination="load" /> <pagination v-show="total > 0" :total="total" v-model:page="query.pageNum" v-model:limit="query.pageSize" @pagination="load" />
<el-drawer v-model="configOpen" title="设备配置" size="min(720px, 94vw)" destroy-on-close> <el-drawer v-model="configOpen" title="设备配置" size="min(720px, 94vw)" destroy-on-close>
@ -49,13 +55,16 @@ import { onActivated, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { listResourceDevices, updateResourceDevice } from '@/api/resource/device' import { listResourceDevices, updateResourceDevice } from '@/api/resource/device'
import { listResourceRobots } from '@/api/resource/robot'
const kindNames = { 1: '移动底盘', 2: '机械臂', 3: '电池', 4: '仿生头', 5: '摄像头', 6: 'CAN总线', 7: '灵巧手', 8: '夹爪', 9: '麦克风', 10: '电机', 11: '电机系统', 12: '机器人本体', 13: '扬声器' } const kindNames = { 1: '移动底盘', 2: '机械臂', 3: '电池', 4: '仿生头', 5: '摄像头', 6: 'CAN总线', 7: '灵巧手', 8: '夹爪', 9: '麦克风', 10: '电机', 11: '电机系统', 12: '机器人本体', 13: '扬声器' }
const router = useRouter() const router = useRouter()
const loading = ref(false), saving = ref(false), rows = ref([]), total = ref(0), configOpen = ref(false), onlineFilter = ref('') const loading = ref(false), saving = ref(false), rows = ref([]), total = ref(0), configOpen = ref(false), onlineFilter = ref('')
const robots = ref([]), robotsLoading = ref(false)
const query = reactive({ pageNum: 1, pageSize: 20, robotId: '', deviceRole: '', onlineOnly: false }) const query = reactive({ pageNum: 1, pageSize: 20, robotId: '', deviceRole: '', onlineOnly: false })
const active = reactive({}) const active = reactive({})
const kindName = value => kindNames[value] || `未知(${value ?? '-'})` const kindName = value => kindNames[value] || `未知(${value ?? '-'})`
const robotLabel = robot => robot.robotName ? `${robot.robotName} (${robot.robotId})` : robot.robotId
const controlPath = device => ({ const controlPath = device => ({
2: 'mechanical_arm', 4: 'head', 5: 'camera', 7: 'hand', 9: 'microphone', 13: 'speaker', 2: 'mechanical_arm', 4: 'head', 5: 'camera', 7: 'hand', 9: 'microphone', 13: 'speaker',
})[device.deviceKind] })[device.deviceKind]
@ -79,6 +88,18 @@ async function load() {
total.value = onlineFilter.value === 'offline' ? rows.value.length : (res.total ?? rows.value.length) total.value = onlineFilter.value === 'offline' ? rows.value.length : (res.total ?? rows.value.length)
} finally { loading.value = false } } finally { loading.value = false }
} }
async function loadRobots() {
robotsLoading.value = true
try {
const res = await listResourceRobots({ pageNum: 1, pageSize: 1000 })
robots.value = (res.rows || res.data || []).filter(robot => robot?.robotId)
} catch (error) {
robots.value = []
console.error('加载机器人列表失败:', error)
} finally {
robotsLoading.value = false
}
}
function search() { query.pageNum = 1; load() } function search() { query.pageNum = 1; load() }
function reset() { Object.assign(query, { pageNum: 1, robotId: '', deviceRole: '', onlineOnly: false }); onlineFilter.value = ''; load() } function reset() { Object.assign(query, { pageNum: 1, robotId: '', deviceRole: '', onlineOnly: false }); onlineFilter.value = ''; load() }
function openConfig(row) { function openConfig(row) {
@ -94,11 +115,21 @@ async function saveConfig() {
load() load()
} finally { saving.value = false } } finally { saving.value = false }
} }
onMounted(load) onMounted(() => {
onActivated(load) load()
loadRobots()
})
onActivated(() => {
load()
if (!robots.value.length) loadRobots()
})
</script> </script>
<style scoped> <style scoped>
.resource-device-page { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.resource-device-page > .el-form { flex: 0 0 auto; }
.device-table-wrap { flex: 1 1 auto; min-height: 0; overflow: hidden; }
.resource-device-page :deep(.pagination-container) { flex: 0 0 auto; padding: 16px 0 0; }
.secondary { color: var(--el-text-color-secondary); font-size: 12px; margin-top: 3px; } .secondary { color: var(--el-text-color-secondary); font-size: 12px; margin-top: 3px; }
.identity { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; } .identity { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
</style> </style>