diff --git a/src/plugins/websocket.js b/src/plugins/websocket.js index 2f2ee77..f3c51e4 100644 --- a/src/plugins/websocket.js +++ b/src/plugins/websocket.js @@ -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') { diff --git a/src/views/device/register/components/Camera/index.vue b/src/views/device/register/components/Camera/index.vue index 27bc45a..86dd8f9 100644 --- a/src/views/device/register/components/Camera/index.vue +++ b/src/views/device/register/components/Camera/index.vue @@ -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); } }; // 先注册前端回调,再通知后端启动,避免丢失初始化片段。 diff --git a/src/views/resource/device/index.vue b/src/views/resource/device/index.vue index a28535e..05c90e5 100644 --- a/src/views/resource/device/index.vue +++ b/src/views/resource/device/index.vue @@ -1,7 +1,11 @@ - + + @@ -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() +})