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_OIDC_ENABLED=false
|
||||||
|
|
||||||
|
VITE_WEBRTC_SIGNAL_URL=wss://api.ttstd.com/ws/signal
|
||||||
+1
-15
@@ -131,21 +131,7 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^24.0.0",
|
"node": "^24.0.0",
|
||||||
"pnpm": "10.34.5"
|
"pnpm": ">=10.34.5 <13"
|
||||||
},
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"repository": "https://gitee.com/youlaiorg/vue3-element-admin.git",
|
"repository": "https://gitee.com/youlaiorg/vue3-element-admin.git",
|
||||||
"author": "有来开源组织",
|
"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_PORT: string;
|
||||||
readonly VITE_APP_BASE_API: string;
|
readonly VITE_APP_BASE_API: string;
|
||||||
readonly VITE_APP_API_URL: string;
|
readonly VITE_APP_API_URL: string;
|
||||||
|
readonly VITE_WEBRTC_SIGNAL_URL?: string;
|
||||||
readonly VITE_APP_TITLE?: string;
|
readonly VITE_APP_TITLE?: string;
|
||||||
readonly VITE_APP_TENANT_ENABLED?: string;
|
readonly VITE_APP_TENANT_ENABLED?: string;
|
||||||
readonly VITE_MOCK_DEV_SERVER: boolean;
|
readonly VITE_MOCK_DEV_SERVER: boolean;
|
||||||
|
|||||||
Reference in new issue
Block a user