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_MAGIC = [0x43, 0x4d, 0x56, 0x46]; // CMVF
|
||||
const MEDIA_MIME_TYPES = {
|
||||
@ -18,6 +20,11 @@ class WebSocketManager {
|
||||
this.maxQueueSize = 100;
|
||||
this.status = false;
|
||||
this.channelSubscriptions = new Set();
|
||||
this.connectionUserId = null;
|
||||
}
|
||||
|
||||
resolveUserId() {
|
||||
return typeof this.userId === 'function' ? this.userId() : this.userId;
|
||||
}
|
||||
|
||||
connect() {
|
||||
@ -25,8 +32,19 @@ class WebSocketManager {
|
||||
console.error('浏览器不支持 WebSocket');
|
||||
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.onopen = () => {
|
||||
@ -168,6 +186,12 @@ class WebSocketManager {
|
||||
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) {
|
||||
this.ws.send(JSON.stringify(payload));
|
||||
} else if (payload?.type !== 'channel_subscription') {
|
||||
|
||||
@ -40,6 +40,8 @@
|
||||
import { inject, reactive, toRefs, watch, onUnmounted, ref, nextTick } from 'vue';
|
||||
import { getResourceDevice } from '@/api/resource/device';
|
||||
import { getRgbStreamUrl } from "@/api/device/camera";
|
||||
import { getToken } from '@/utils/auth';
|
||||
import { runtimeConfig } from '../../../../../runtime/config';
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const props = defineProps({
|
||||
@ -94,6 +96,38 @@ function getVideoRef(method) {
|
||||
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() {
|
||||
if (!data.robotId || !data.cameraId || !colorVideo.value) return;
|
||||
rgbStreamAbortController?.abort();
|
||||
@ -105,8 +139,7 @@ async function startDirectRgbStream() {
|
||||
try {
|
||||
const response = await getRgbStreamUrl(data.robotId, data.cameraId);
|
||||
if (generation !== rgbStreamGeneration || !data.form.rgbCamera) return;
|
||||
const relativeUrl = response.data;
|
||||
const baseUrl = import.meta.env.VITE_APP_BASE_API.replace(/\/$/, '');
|
||||
const streamUrl = resolveRgbStreamUrl(response?.data);
|
||||
const video = colorVideo.value;
|
||||
if (!video) return;
|
||||
const player = createMediaPlayer(
|
||||
@ -116,7 +149,7 @@ async function startDirectRgbStream() {
|
||||
);
|
||||
rgbStreamAbortController = new AbortController();
|
||||
consumeDirectRgbStream(
|
||||
`${baseUrl}${relativeUrl}`,
|
||||
streamUrl,
|
||||
generation,
|
||||
player,
|
||||
rgbStreamAbortController.signal
|
||||
@ -131,7 +164,7 @@ async function startDirectRgbStream() {
|
||||
|
||||
async function consumeDirectRgbStream(url, generation, player, signal) {
|
||||
try {
|
||||
const response = await fetch(url, { signal, cache: 'no-store' });
|
||||
const response = await fetch(url, streamFetchOptions(url, signal));
|
||||
if (!response.ok || !response.body) {
|
||||
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) {
|
||||
const state = mediaPlayers.get(method);
|
||||
if (!state) return;
|
||||
@ -355,10 +403,12 @@ async function setSubscription(enabled, method, force = false) {
|
||||
streamStatus[method] = 'connecting';
|
||||
if (!data.callbacks[channel]) {
|
||||
const callback = (payload) => {
|
||||
if (payload?.media) {
|
||||
appendMediaPacket(method, payload);
|
||||
} else if (payload === '500') {
|
||||
if (payload === '500') {
|
||||
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>
|
||||
<div class="app-container">
|
||||
<div class="app-container resource-device-page">
|
||||
<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-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>
|
||||
|
||||
<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="设备" min-width="210">
|
||||
<template #default="{ row }"><strong>{{ row.deviceName || row.deviceId }}</strong><div class="secondary">{{ row.deviceId }}</div></template>
|
||||
@ -28,7 +33,8 @@
|
||||
<el-button link type="primary" icon="Setting" @click="openConfig(row)">配置</el-button>
|
||||
</template>
|
||||
</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" />
|
||||
|
||||
<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 { ElMessage } from 'element-plus'
|
||||
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 router = useRouter()
|
||||
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 active = reactive({})
|
||||
const kindName = value => kindNames[value] || `未知(${value ?? '-'})`
|
||||
const robotLabel = robot => robot.robotName ? `${robot.robotName} (${robot.robotId})` : robot.robotId
|
||||
const controlPath = device => ({
|
||||
2: 'mechanical_arm', 4: 'head', 5: 'camera', 7: 'hand', 9: 'microphone', 13: 'speaker',
|
||||
})[device.deviceKind]
|
||||
@ -79,6 +88,18 @@ async function load() {
|
||||
total.value = onlineFilter.value === 'offline' ? rows.value.length : (res.total ?? rows.value.length)
|
||||
} 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 reset() { Object.assign(query, { pageNum: 1, robotId: '', deviceRole: '', onlineOnly: false }); onlineFilter.value = ''; load() }
|
||||
function openConfig(row) {
|
||||
@ -94,11 +115,21 @@ async function saveConfig() {
|
||||
load()
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
onActivated(load)
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadRobots()
|
||||
})
|
||||
onActivated(() => {
|
||||
load()
|
||||
if (!robots.value.length) loadRobots()
|
||||
})
|
||||
</script>
|
||||
|
||||
<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; }
|
||||
.identity { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user