chore(config): 新增 WebRTC 信令地址环境变量,放宽 pnpm 版本约束并移除 overrides
This commit is contained in:
1 parent
e49473758c
commit
2bd5b89380
7 files changed
+419
-15
No files matched your search
@@ -13,3 +13,5 @@ VITE_AI_ENABLED=true
|
||||
|
||||
# 统一认证登录开关
|
||||
VITE_OIDC_ENABLED=false
|
||||
|
||||
VITE_WEBRTC_SIGNAL_URL=wss://api.ttstd.com/ws/signal
|
||||
+1
-15
@@ -131,21 +131,7 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": "^24.0.0",
|
||||
"pnpm": "10.34.5"
|
||||
},
|
||||
"packageManager": "pnpm@10.34.5",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"follow-redirects": "^1.16.2",
|
||||
"source-map-js": "^1.2.2",
|
||||
"brace-expansion@1": "^1.1.21",
|
||||
"brace-expansion@2": "^2.1.7",
|
||||
"brace-expansion@5": "^5.0.12",
|
||||
"vite": "~8.2.4",
|
||||
"fast-uri@3": "^3.1.8",
|
||||
"@modelcontextprotocol/server@2": "~2.3.0",
|
||||
"glob@10": "^10.5.0"
|
||||
}
|
||||
"pnpm": ">=10.34.5 <13"
|
||||
},
|
||||
"repository": "https://gitee.com/youlaiorg/vue3-element-admin.git",
|
||||
"author": "有来开源组织",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
verifyDepsBeforeRun: false
|
||||
|
||||
allowBuilds:
|
||||
'@parcel/watcher': false
|
||||
core-js-pure: false
|
||||
es5-ext: false
|
||||
|
||||
overrides:
|
||||
follow-redirects: ^1.16.2
|
||||
source-map-js: ^1.2.2
|
||||
brace-expansion@1: ^1.1.21
|
||||
brace-expansion@2: ^2.1.7
|
||||
brace-expansion@5: ^5.0.12
|
||||
vite: ~8.2.4
|
||||
fast-uri@3: ^3.1.8
|
||||
'@modelcontextprotocol/server@2': ~2.3.0
|
||||
glob@10: ^10.5.0
|
||||
@@ -0,0 +1,18 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
export async function openRemoteControlSignal(sn: string) {
|
||||
const defaultUrl = new URL(
|
||||
"/ws/signal",
|
||||
import.meta.env.VITE_APP_API_URL || window.location.origin,
|
||||
);
|
||||
if (defaultUrl.protocol === "https:") defaultUrl.protocol = "wss:";
|
||||
const url = new URL(import.meta.env.VITE_WEBRTC_SIGNAL_URL || defaultUrl.href);
|
||||
if (url.protocol !== "wss:") throw new Error("远程协助需要 WSS 信令服务器");
|
||||
const ticket = await request<unknown, string>({
|
||||
url: "/api/v1/device/remote-control/ticket",
|
||||
method: "post",
|
||||
params: { sn },
|
||||
});
|
||||
url.searchParams.set("ticket", ticket);
|
||||
return new WebSocket(url);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export type TouchMode = 0 | 1 | 2;
|
||||
|
||||
export function readTouchMode(data: ArrayBuffer): TouchMode {
|
||||
const bytes = new Uint8Array(data);
|
||||
let position = 0;
|
||||
function readVarint() {
|
||||
let value = 0;
|
||||
for (let shift = 0; shift <= 28 && position < bytes.length; shift += 7) {
|
||||
const byte = bytes[position++];
|
||||
value += (byte & 127) * 2 ** shift;
|
||||
if ((byte & 128) === 0) return value;
|
||||
}
|
||||
throw new Error("Invalid control report");
|
||||
}
|
||||
|
||||
try {
|
||||
while (position < bytes.length) {
|
||||
const tag = readVarint();
|
||||
switch (tag & 7) {
|
||||
case 0: {
|
||||
const value = readVarint();
|
||||
if (tag >> 3 === 17) return value === 1 || value === 2 ? value : 0;
|
||||
break;
|
||||
}
|
||||
case 2: {
|
||||
const length = readVarint();
|
||||
position += length;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export type Point = { x: number; y: number };
|
||||
|
||||
export function encodeTouch(action: number, start: Point, end?: Point, detail?: number) {
|
||||
const bytes = new Uint8Array(48);
|
||||
const view = new DataView(bytes.buffer);
|
||||
let offset = 0;
|
||||
function varint(field: number, value: number) {
|
||||
bytes[offset++] = field << 3;
|
||||
while (value >= 128) {
|
||||
bytes[offset++] = (value & 127) | 128;
|
||||
value >>>= 7;
|
||||
}
|
||||
bytes[offset++] = value;
|
||||
}
|
||||
function coordinate(field: number, value: number) {
|
||||
bytes[offset++] = (field << 3) | 1;
|
||||
view.setFloat64(offset, value, true);
|
||||
offset += 8;
|
||||
}
|
||||
varint(1, action);
|
||||
if (action === 2 && end) {
|
||||
coordinate(4, start.x);
|
||||
coordinate(5, start.y);
|
||||
coordinate(6, end.x);
|
||||
coordinate(7, end.y);
|
||||
varint(8, detail ?? 300);
|
||||
} else {
|
||||
coordinate(2, start.x);
|
||||
coordinate(3, start.y);
|
||||
if (action === 5) varint(11, detail ?? 0);
|
||||
}
|
||||
return bytes.slice(0, offset);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-card>
|
||||
<template #header>设备远程协助</template>
|
||||
<el-space wrap>
|
||||
<el-select
|
||||
v-model="sn"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
placeholder="搜索并选择设备 SN"
|
||||
:remote-method="searchDevices"
|
||||
:loading="searching"
|
||||
:disabled="active"
|
||||
style="width: 300px"
|
||||
@visible-change="loadOptions"
|
||||
>
|
||||
<el-option
|
||||
v-for="device in devices"
|
||||
:key="device.id"
|
||||
:value="device.serialno!"
|
||||
:label="device.snName ? device.serialno + ' · ' + device.snName : device.serialno!"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button type="primary" :disabled="!sn || active" @click="start">请求协助</el-button>
|
||||
<el-button :disabled="!active" @click="stop('已结束协助')">结束协助</el-button>
|
||||
</el-space>
|
||||
<p>管理员可直接连接在线设备,无需老人逐次确认。</p>
|
||||
<p role="status">{{ status }}</p>
|
||||
<video
|
||||
ref="video"
|
||||
class="remote-screen"
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
@pointerdown="pointerDown"
|
||||
@pointermove="pointerMove"
|
||||
@pointerup="pointerEnd($event, false)"
|
||||
@pointercancel="pointerEnd($event, true)"
|
||||
@lostpointercapture="pointerEnd($event, true)"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onDeactivated, ref } from "vue";
|
||||
import DeviceAPI from "@/api/device";
|
||||
import type { DeviceItem } from "@/api/device/types";
|
||||
import { openRemoteControlSignal } from "@/api/remote-control";
|
||||
import { encodeTouch, readTouchMode } from "./controlProtocol";
|
||||
import type { Point, TouchMode } from "./controlProtocol";
|
||||
|
||||
interface Signal {
|
||||
type: string;
|
||||
fromDeviceId?: string;
|
||||
deviceType?: string;
|
||||
sessionId?: string;
|
||||
payload?: string;
|
||||
}
|
||||
|
||||
const sn = ref("");
|
||||
const devices = ref<DeviceItem[]>([]);
|
||||
const searching = ref(false);
|
||||
const active = ref(false);
|
||||
const status = ref("请选择要协助的设备");
|
||||
const video = ref<HTMLVideoElement>();
|
||||
let searchVersion = 0;
|
||||
let version = 0;
|
||||
let socket: WebSocket | undefined;
|
||||
let peer: RTCPeerConnection | undefined;
|
||||
let channel: RTCDataChannel | undefined;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let mode: TouchMode = 0;
|
||||
let gesture:
|
||||
{ pointer: number; start: Point; time: number; lastMove: number; mode: TouchMode } | undefined;
|
||||
|
||||
async function searchDevices(keywords = "") {
|
||||
const attempt = ++searchVersion;
|
||||
searching.value = true;
|
||||
try {
|
||||
const page = await DeviceAPI.getPage({ pageNum: 1, pageSize: 30, keywords });
|
||||
if (attempt === searchVersion) {
|
||||
devices.value = page.list.filter((device) => device.serialno && device.status !== 0);
|
||||
}
|
||||
} catch {
|
||||
if (attempt === searchVersion) status.value = "无法加载设备列表";
|
||||
} finally {
|
||||
if (attempt === searchVersion) searching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadOptions(open: boolean) {
|
||||
if (open && !devices.value.length) void searchDevices();
|
||||
}
|
||||
|
||||
function stop(reason: string) {
|
||||
version++;
|
||||
clearTimeout(timer);
|
||||
gesture = undefined;
|
||||
mode = 0;
|
||||
channel?.close();
|
||||
channel = undefined;
|
||||
peer?.close();
|
||||
peer = undefined;
|
||||
socket?.close();
|
||||
socket = undefined;
|
||||
if (video.value) video.value.srcObject = null;
|
||||
active.value = false;
|
||||
status.value = reason;
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const target = sn.value;
|
||||
if (!target || active.value) return;
|
||||
const attempt = ++version;
|
||||
active.value = true;
|
||||
status.value = "正在连接设备";
|
||||
timer = setTimeout(() => stop("等待设备建立连接超时"), 125000);
|
||||
try {
|
||||
const signal = await openRemoteControlSignal(target);
|
||||
if (version !== attempt) {
|
||||
signal.close();
|
||||
return;
|
||||
}
|
||||
socket = signal;
|
||||
let sessionId: string | undefined;
|
||||
const localCandidates: RTCIceCandidateInit[] = [];
|
||||
const remoteCandidates: Signal[] = [];
|
||||
const send = (type: string, payload: unknown) => {
|
||||
if (signal.readyState === WebSocket.OPEN) {
|
||||
signal.send(
|
||||
JSON.stringify({ type, toDeviceId: target, sessionId, payload: JSON.stringify(payload) }),
|
||||
);
|
||||
}
|
||||
};
|
||||
signal.onclose = () => {
|
||||
if (version === attempt) stop("信令连接已关闭");
|
||||
};
|
||||
signal.onerror = () => {
|
||||
if (version === attempt) stop("信令连接失败");
|
||||
};
|
||||
// Serialize ANSWER and candidates so ICE is applied after the remote SDP.
|
||||
let queue = Promise.resolve();
|
||||
signal.onmessage = (event) => {
|
||||
queue = queue
|
||||
.then(async () => {
|
||||
if (version !== attempt) return;
|
||||
const message = JSON.parse(event.data) as Signal;
|
||||
if (message.type === "REGISTER_SUCCESS" && !peer) {
|
||||
const relay = JSON.parse(message.payload || "{}") as RTCIceServer;
|
||||
const connection = new RTCPeerConnection({
|
||||
iceServers: [
|
||||
{ urls: "stun:stun.l.google.com:19302" },
|
||||
...(relay.urls ? [relay] : []),
|
||||
],
|
||||
});
|
||||
peer = connection;
|
||||
connection.addTransceiver("video", { direction: "recvonly" });
|
||||
const control = connection.createDataChannel("control_channel", {
|
||||
ordered: true,
|
||||
protocol: "control_channel",
|
||||
});
|
||||
channel = control;
|
||||
control.binaryType = "arraybuffer";
|
||||
control.onmessage = (report) => {
|
||||
if (version === attempt && report.data instanceof ArrayBuffer) {
|
||||
mode = readTouchMode(report.data) || mode;
|
||||
}
|
||||
};
|
||||
connection.ontrack = ({ streams, track }) => {
|
||||
if (version === attempt && video.value)
|
||||
video.value.srcObject = streams[0] ?? new MediaStream([track]);
|
||||
};
|
||||
connection.onconnectionstatechange = () => {
|
||||
if (version !== attempt) return;
|
||||
if (connection.connectionState === "connected") {
|
||||
clearTimeout(timer);
|
||||
status.value = "协助已连接";
|
||||
} else if (["failed", "closed"].includes(connection.connectionState)) {
|
||||
stop("远程连接已断开");
|
||||
}
|
||||
};
|
||||
connection.onicecandidate = ({ candidate }) => {
|
||||
if (version !== attempt || !candidate) return;
|
||||
if (sessionId) send("ICE_CANDIDATE", candidate.toJSON());
|
||||
else if (localCandidates.length < 128) localCandidates.push(candidate.toJSON());
|
||||
};
|
||||
await connection.setLocalDescription(await connection.createOffer());
|
||||
if (version === attempt) send("OFFER", { sdp: connection.localDescription?.sdp });
|
||||
return;
|
||||
}
|
||||
if (message.fromDeviceId === "server") {
|
||||
if (message.type === "SESSION_ENDED") stop("远程协助已结束");
|
||||
if (message.type === "CONNECTION_REJECTED") stop("设备拒绝了请求或已有协助会话");
|
||||
if (message.type === "TARGET_OFFLINE") stop("设备信令未上线,请检查设备网络和运行状态");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!peer ||
|
||||
message.fromDeviceId !== target ||
|
||||
message.deviceType !== "CONTROLLED" ||
|
||||
!message.sessionId ||
|
||||
(sessionId && sessionId !== message.sessionId)
|
||||
)
|
||||
return;
|
||||
if (message.type === "CONNECTION_REJECTED") {
|
||||
stop("设备拒绝了请求");
|
||||
} else if (message.type === "ANSWER" && !sessionId) {
|
||||
const connection = peer;
|
||||
await connection.setRemoteDescription({
|
||||
type: "answer",
|
||||
sdp: JSON.parse(message.payload || "{}").sdp,
|
||||
});
|
||||
if (version !== attempt) return;
|
||||
sessionId = message.sessionId;
|
||||
for (const candidate of localCandidates.splice(0)) send("ICE_CANDIDATE", candidate);
|
||||
for (const candidate of remoteCandidates.splice(0)) {
|
||||
if (candidate.sessionId === sessionId)
|
||||
await connection.addIceCandidate(JSON.parse(candidate.payload || "{}"));
|
||||
}
|
||||
} else if (message.type === "ICE_CANDIDATE") {
|
||||
if (sessionId) await peer.addIceCandidate(JSON.parse(message.payload || "{}"));
|
||||
else if (remoteCandidates.length < 128) remoteCandidates.push(message);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (version === attempt) stop("远程连接协商失败");
|
||||
});
|
||||
};
|
||||
} catch {
|
||||
if (version === attempt) stop("无法发起协助,请检查设备和远控权限");
|
||||
}
|
||||
}
|
||||
|
||||
function point(event: PointerEvent, clamp: boolean): Point | undefined {
|
||||
const element = video.value;
|
||||
if (!element?.videoWidth || !element.videoHeight) return;
|
||||
const box = element.getBoundingClientRect();
|
||||
const scale = Math.min(box.width / element.videoWidth, box.height / element.videoHeight);
|
||||
if (scale <= 0) return;
|
||||
const width = element.videoWidth * scale;
|
||||
const height = element.videoHeight * scale;
|
||||
const x = (event.clientX - box.left - (box.width - width) / 2) / width;
|
||||
const y = (event.clientY - box.top - (box.height - height) / 2) / height;
|
||||
if (!clamp && (x < 0 || x > 1 || y < 0 || y > 1)) return;
|
||||
return { x: Math.min(1, Math.max(0, x)), y: Math.min(1, Math.max(0, y)) };
|
||||
}
|
||||
|
||||
function sendTouch(action: number, start: Point, end?: Point, detail?: number) {
|
||||
if (channel?.readyState === "open") channel.send(encodeTouch(action, start, end, detail));
|
||||
}
|
||||
|
||||
function pointerDown(event: PointerEvent) {
|
||||
if (gesture || !mode || event.button !== 0 || channel?.readyState !== "open") return;
|
||||
const start = point(event, false);
|
||||
if (!start) return;
|
||||
video.value?.setPointerCapture(event.pointerId);
|
||||
gesture = { pointer: event.pointerId, start, time: performance.now(), lastMove: 0, mode };
|
||||
if (mode === 2) sendTouch(5, start, undefined, 0);
|
||||
}
|
||||
|
||||
function pointerMove(event: PointerEvent) {
|
||||
if (
|
||||
gesture?.pointer !== event.pointerId ||
|
||||
gesture.mode !== 2 ||
|
||||
!channel ||
|
||||
channel.bufferedAmount > 4096
|
||||
)
|
||||
return;
|
||||
const now = performance.now();
|
||||
const current = point(event, true);
|
||||
if (!current || now - gesture.lastMove < 16) return;
|
||||
gesture.lastMove = now;
|
||||
sendTouch(5, current, undefined, 2);
|
||||
}
|
||||
|
||||
function pointerEnd(event: PointerEvent, cancelled: boolean) {
|
||||
const current = gesture;
|
||||
if (current?.pointer !== event.pointerId) return;
|
||||
gesture = undefined;
|
||||
const end = point(event, true) ?? current.start;
|
||||
if (current.mode === 2) {
|
||||
sendTouch(5, end, undefined, cancelled ? 3 : 1);
|
||||
} else if (!cancelled) {
|
||||
const duration = performance.now() - current.time;
|
||||
if (Math.abs(end.x - current.start.x) < 0.02 && Math.abs(end.y - current.start.y) < 0.02) {
|
||||
sendTouch(duration >= 400 ? 4 : 1, current.start);
|
||||
} else {
|
||||
sendTouch(2, current.start, end, 300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDeactivated(() => stop("已离开远程协助页面"));
|
||||
onBeforeUnmount(() => stop("已离开远程协助页面"));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.remote-screen {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: min(70vh, 680px);
|
||||
touch-action: none;
|
||||
object-fit: contain;
|
||||
background: #111;
|
||||
}
|
||||
</style>
|
||||
Vendored
+1
@@ -7,6 +7,7 @@ interface ImportMetaEnv {
|
||||
readonly VITE_APP_PORT: string;
|
||||
readonly VITE_APP_BASE_API: string;
|
||||
readonly VITE_APP_API_URL: string;
|
||||
readonly VITE_WEBRTC_SIGNAL_URL?: string;
|
||||
readonly VITE_APP_TITLE?: string;
|
||||
readonly VITE_APP_TENANT_ENABLED?: string;
|
||||
readonly VITE_MOCK_DEV_SERVER: boolean;
|
||||
|
||||
Reference in new issue
Block a user