feat: 增强WebSocket连接管理,添加用户身份验证和流媒体URL解析功能
This commit is contained in:
parent
7eaec22372
commit
db5c3b61ef
@ -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') {
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// 先注册前端回调,再通知后端启动,避免丢失初始化片段。
|
// 先注册前端回调,再通知后端启动,避免丢失初始化片段。
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user