chore(config): 新增 WebRTC 信令地址环境变量,放宽 pnpm 版本约束并移除 overrides

This commit is contained in:
tongtong committed 2026-11-09 19:04:38 +08:00
1 parent e49473758c
commit 2bd5b89380
7 files changed
+419 -15

No files matched your search

+2
View File
@@ -13,3 +13,5 @@ VITE_AI_ENABLED=true
# 统一认证登录开关
VITE_OIDC_ENABLED=false
VITE_WEBRTC_SIGNAL_URL=wss://api.ttstd.com/ws/signal
+1 -15
View File
@@ -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": "有来开源组织",
+17
View File
@@ -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
+18
View File
@@ -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);
}
+308
View File
@@ -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>
+1
View File
@@ -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;