fix: 修复 WebRTC 控制稳定性与输入事件重复问题
- 修复安卓端 401 拦截器线程问题,确保 Toast 在主线程执行 - 修复 Web 端按键/触摸重复触发:增加按下状态跟踪,防止悬停触发动作和长按重复 - 修复 Web 端指针取消时未释放触摸状态导致被控端卡死 - 修复 TURN 未启用时返回 401 导致客户端异常登出,改为返回 enabled=false - 修复后台管理端 401 后路由守卫跳转失败 - 增加控制指令调试日志用于排查问题
This commit is contained in:
@@ -214,10 +214,11 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
|
|||||||
@Override
|
@Override
|
||||||
protected void initData() {
|
protected void initData() {
|
||||||
// 任意 REST 接口 401 -> 直接回登录页(与 WebSocket 4001/4003 行为一致)。
|
// 任意 REST 接口 401 -> 直接回登录页(与 WebSocket 4001/4003 行为一致)。
|
||||||
UnauthorizedInterceptor.setHandler(msg -> {
|
// 注意:该回调运行在 OkHttp 拦截器线程,必须切回主线程再操作 Toast / UI。
|
||||||
|
UnauthorizedInterceptor.setHandler(msg -> runOnUiThread(() -> {
|
||||||
Toast.makeText(MainActivity.this, msg, Toast.LENGTH_LONG).show();
|
Toast.makeText(MainActivity.this, msg, Toast.LENGTH_LONG).show();
|
||||||
logoutAndReset();
|
logoutAndReset();
|
||||||
});
|
}));
|
||||||
|
|
||||||
// 令牌就绪 -> 建立信令 WebSocket
|
// 令牌就绪 -> 建立信令 WebSocket
|
||||||
viewModel.getAccessToken().observe(this, token -> {
|
viewModel.getAccessToken().observe(this, token -> {
|
||||||
@@ -303,7 +304,9 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
|
|||||||
loadDevices();
|
loadDevices();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 合并设备列表(在线设备优先置于顶部),用于列表展示。 */
|
/**
|
||||||
|
* 合并设备列表(在线设备优先置于顶部),用于列表展示。
|
||||||
|
*/
|
||||||
private void mergeDevices(List<BindingItem> items) {
|
private void mergeDevices(List<BindingItem> items) {
|
||||||
for (BindingItem item : items) {
|
for (BindingItem item : items) {
|
||||||
int idx = -1;
|
int idx = -1;
|
||||||
@@ -323,7 +326,9 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
|
|||||||
deviceList.sort((a, b) -> Boolean.compare(b.isOnline(), a.isOnline()));
|
deviceList.sort((a, b) -> Boolean.compare(b.isOnline(), a.isOnline()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 设备列表为空时显示占位提示。 */
|
/**
|
||||||
|
* 设备列表为空时显示占位提示。
|
||||||
|
*/
|
||||||
private void updateDeviceEmpty() {
|
private void updateDeviceEmpty() {
|
||||||
boolean empty = deviceList.isEmpty();
|
boolean empty = deviceList.isEmpty();
|
||||||
binding.tvDeviceEmpty.setVisibility(empty ? View.VISIBLE : View.GONE);
|
binding.tvDeviceEmpty.setVisibility(empty ? View.VISIBLE : View.GONE);
|
||||||
|
|||||||
@@ -29,11 +29,19 @@ const selectedResolution = ref(0);
|
|||||||
const fpsOptions = ref([15, 24, 30, 60]);
|
const fpsOptions = ref([15, 24, 30, 60]);
|
||||||
const selectedFps = ref(0); // 0 表示尚未同步到被控端当前帧率
|
const selectedFps = ref(0); // 0 表示尚未同步到被控端当前帧率
|
||||||
|
|
||||||
|
// 记录当前处于按下状态的按键:pointerleave/pointerup 在指针未按下时也会触发,
|
||||||
|
// 若无此跟踪,鼠标仅扫过按钮就会向被控端发送孤立的 KEY UP。
|
||||||
|
const pressedButtons = new Set();
|
||||||
|
|
||||||
function pressDown(code) {
|
function pressDown(code) {
|
||||||
if (!store.dataChannelOpen) return;
|
if (!store.dataChannelOpen) return;
|
||||||
|
if (pressedButtons.has(code)) return;
|
||||||
|
pressedButtons.add(code);
|
||||||
sendKeyDown(code);
|
sendKeyDown(code);
|
||||||
}
|
}
|
||||||
function pressUp(code) {
|
function pressUp(code) {
|
||||||
|
if (!pressedButtons.has(code)) return;
|
||||||
|
pressedButtons.delete(code);
|
||||||
if (!store.dataChannelOpen) return;
|
if (!store.dataChannelOpen) return;
|
||||||
sendKeyUp(code);
|
sendKeyUp(code);
|
||||||
}
|
}
|
||||||
@@ -71,18 +79,26 @@ function lookupKeyCode(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 固定按键:按下/抬起分离(如电源键需要真实按下-抬起配对)
|
// 固定按键:按下/抬起分离(如电源键需要真实按下-抬起配对)
|
||||||
|
// pressedKeys 跟踪已按下的物理键,避免长按自动重复触发重复 DOWN、
|
||||||
|
// 以及无对应 DOWN 的孤立 UP(如在窗口外按下、回到窗口内才抬起)。
|
||||||
|
const pressedKeys = new Set();
|
||||||
|
|
||||||
function onKeyCaptureDown(e) {
|
function onKeyCaptureDown(e) {
|
||||||
if (!store.dataChannelOpen) return;
|
if (!store.dataChannelOpen) return;
|
||||||
const m = lookupKeyCode(e);
|
const m = lookupKeyCode(e);
|
||||||
if (!m) return;
|
if (!m) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (pressedKeys.has(m.code)) return; // 浏览器长按自动重复,忽略
|
||||||
|
pressedKeys.add(m.code);
|
||||||
sendKeyDown(m.code);
|
sendKeyDown(m.code);
|
||||||
}
|
}
|
||||||
function onKeyCaptureUp(e) {
|
function onKeyCaptureUp(e) {
|
||||||
if (!store.dataChannelOpen) return;
|
|
||||||
const m = lookupKeyCode(e);
|
const m = lookupKeyCode(e);
|
||||||
if (!m) return;
|
if (!m) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!pressedKeys.has(m.code)) return;
|
||||||
|
pressedKeys.delete(m.code);
|
||||||
|
if (!store.dataChannelOpen) return;
|
||||||
sendKeyUp(m.code);
|
sendKeyUp(m.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ const TOUCH_SLOP = 0.02;
|
|||||||
const SAMPLE_MS = 16;
|
const SAMPLE_MS = 16;
|
||||||
|
|
||||||
let startX = 0, startY = 0, startTime = 0, isLongPressed = false, longTimer = null, lastMoveTs = 0;
|
let startX = 0, startY = 0, startTime = 0, isLongPressed = false, longTimer = null, lastMoveTs = 0;
|
||||||
|
// 指针是否处于按下状态:未按下时 pointermove/pointerup 不得产生任何指令,
|
||||||
|
// 否则鼠标仅悬停划过画面就会向被控端发送大量 ACTION_MOVE。
|
||||||
|
let isDown = false;
|
||||||
|
|
||||||
function clearLong() {
|
function clearLong() {
|
||||||
if (longTimer) { clearTimeout(longTimer); longTimer = null; }
|
if (longTimer) { clearTimeout(longTimer); longTimer = null; }
|
||||||
@@ -53,6 +56,7 @@ function clearLong() {
|
|||||||
function onDown(e) {
|
function onDown(e) {
|
||||||
if (!active.value) return;
|
if (!active.value) return;
|
||||||
overlayRef.value.setPointerCapture && overlayRef.value.setPointerCapture(e.pointerId);
|
overlayRef.value.setPointerCapture && overlayRef.value.setPointerCapture(e.pointerId);
|
||||||
|
isDown = true;
|
||||||
isLongPressed = false;
|
isLongPressed = false;
|
||||||
const p = mapRelative(e.clientX, e.clientY);
|
const p = mapRelative(e.clientX, e.clientY);
|
||||||
startX = p.x; startY = p.y; startTime = Date.now(); lastMoveTs = 0;
|
startX = p.x; startY = p.y; startTime = Date.now(); lastMoveTs = 0;
|
||||||
@@ -65,7 +69,7 @@ function onDown(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onMove(e) {
|
function onMove(e) {
|
||||||
if (!active.value) return;
|
if (!active.value || !isDown) return;
|
||||||
const p = mapRelative(e.clientX, e.clientY);
|
const p = mapRelative(e.clientX, e.clientY);
|
||||||
if (Math.abs(p.x - startX) > TOUCH_SLOP || Math.abs(p.y - startY) > TOUCH_SLOP) clearLong();
|
if (Math.abs(p.x - startX) > TOUCH_SLOP || Math.abs(p.y - startY) > TOUCH_SLOP) clearLong();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -76,7 +80,8 @@ function onMove(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onUp(e) {
|
function onUp(e) {
|
||||||
if (!active.value) return;
|
if (!active.value || !isDown) return;
|
||||||
|
isDown = false;
|
||||||
clearLong();
|
clearLong();
|
||||||
const p = mapRelative(e.clientX, e.clientY);
|
const p = mapRelative(e.clientX, e.clientY);
|
||||||
sendMotionEvent(1, p.x, p.y); // ACTION_UP
|
sendMotionEvent(1, p.x, p.y); // ACTION_UP
|
||||||
@@ -88,6 +93,15 @@ function onUp(e) {
|
|||||||
else sendSwipe(startX, startY, p.x, p.y, Math.max(dur, 1));
|
else sendSwipe(startX, startY, p.x, p.y, Math.max(dur, 1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onCancel() {
|
||||||
|
clearLong();
|
||||||
|
// 按下过程中指针被取消(如系统手势接管):补发 ACTION_UP 释放,避免被控端卡在按下态。
|
||||||
|
if (isDown && active.value) {
|
||||||
|
isDown = false;
|
||||||
|
sendMotionEvent(1, startX, startY); // ACTION_UP
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -101,7 +115,7 @@ function onUp(e) {
|
|||||||
@pointerdown="onDown"
|
@pointerdown="onDown"
|
||||||
@pointermove="onMove"
|
@pointermove="onMove"
|
||||||
@pointerup="onUp"
|
@pointerup="onUp"
|
||||||
@pointercancel="clearLong"
|
@pointercancel="onCancel"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div v-if="!hasStream" class="placeholder">
|
<div v-if="!hasStream" class="placeholder">
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ export class WebRtcController {
|
|||||||
if (!this.dataChannel || this.dataChannel.readyState !== 'open') return false;
|
if (!this.dataChannel || this.dataChannel.readyState !== 'open') return false;
|
||||||
const bytes = encodeControlMessage(fields);
|
const bytes = encodeControlMessage(fields);
|
||||||
this.dataChannel.send(bytes);
|
this.dataChannel.send(bytes);
|
||||||
|
console.debug('[控制指令]', describeControlMessage(fields), `bytes=${bytes.length}`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,6 +328,26 @@ export class WebRtcController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 将待发送的控制指令格式化为可读日志文本(坐标保留 4 位小数)。
|
||||||
|
function describeControlMessage(f) {
|
||||||
|
const pt = (x, y) => `(${Number(x).toFixed(4)}, ${Number(y).toFixed(4)})`;
|
||||||
|
switch (f.action) {
|
||||||
|
case 1: return `模拟点击 TOUCH ${pt(f.x, f.y)}`;
|
||||||
|
case 2: return `模拟滑动 SWIPE ${pt(f.x1, f.y1)} -> ${pt(f.x2, f.y2)} duration=${f.duration}ms`;
|
||||||
|
case 3: {
|
||||||
|
const ka = f.keyAction === 0 ? 'DOWN' : f.keyAction === 1 ? 'UP' : 'CLICK';
|
||||||
|
return `模拟按键 KEY keyCode=${f.keyCode} keyAction=${ka}`;
|
||||||
|
}
|
||||||
|
case 4: return `模拟长按 LONG_PRESS ${pt(f.x, f.y)}`;
|
||||||
|
case 5: {
|
||||||
|
const ma = f.motionAction === 0 ? 'DOWN' : f.motionAction === 1 ? 'UP' : f.motionAction === 2 ? 'MOVE' : String(f.motionAction);
|
||||||
|
return `触摸轨迹 MOTION ${ma} ${pt(f.x, f.y)}`;
|
||||||
|
}
|
||||||
|
case 6: return `切换分辨率 SET_RESOLUTION ${f.width}x${f.height}@${f.fps}fps`;
|
||||||
|
default: return `未知指令 action=${f.action}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 将字节/秒格式化为可读速率
|
// 将字节/秒格式化为可读速率
|
||||||
function formatSpeed(bps) {
|
function formatSpeed(bps) {
|
||||||
if (!bps || bps <= 0) return '0 B/s';
|
if (!bps || bps <= 0) return '0 B/s';
|
||||||
|
|||||||
@@ -391,10 +391,11 @@ curl -X POST https://www.ttstd.com/api/client/pairing/redeem \
|
|||||||
|
|
||||||
| 方法 | 路径 | 令牌 | 说明 |
|
| 方法 | 路径 | 令牌 | 说明 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| GET | `/api/client/turn-credentials` | 用户/设备 | 返回 `iceServers`(含 urls/username/credential)、`expiresAt`、`ttlSeconds` |
|
| GET | `/api/client/turn-credentials` | 用户/设备 | 返回 `enabled`、`iceServers`(含 urls/username/credential)、`expiresAt`、`ttlSeconds` |
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"enabled": true,
|
||||||
"iceServers": [
|
"iceServers": [
|
||||||
{ "urls": "turn:turn.ttstd.com:3478?transport=udp",
|
{ "urls": "turn:turn.ttstd.com:3478?transport=udp",
|
||||||
"username": "1730000000:ab12cd34ef56",
|
"username": "1730000000:ab12cd34ef56",
|
||||||
@@ -406,6 +407,7 @@ curl -X POST https://www.ttstd.com/api/client/pairing/redeem \
|
|||||||
```
|
```
|
||||||
|
|
||||||
> 客户端应将 `iceServers` 直接传入 `RTCPeerConnection` 配置;凭证过期后重新 GET 本接口刷新。
|
> 客户端应将 `iceServers` 直接传入 `RTCPeerConnection` 配置;凭证过期后重新 GET 本接口刷新。
|
||||||
|
> 服务端未启用 TURN 时返回 200 + `{"enabled": false, "iceServers": []}`(不会返回 401),客户端据此降级为仅 STUN。
|
||||||
|
|
||||||
#### 骚扰举报(P2 风控)
|
#### 骚扰举报(P2 风控)
|
||||||
被控端遭遇骚扰时可举报某主控端账号,管理员在 `/api/admin/abuse-reports` 查看并处理。
|
被控端遭遇骚扰时可举报某主控端账号,管理员在 `/api/admin/abuse-reports` 查看并处理。
|
||||||
|
|||||||
@@ -210,11 +210,24 @@ public class ClientController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 TURN 短期凭证(ICE servers)。主控端与被控端在建立 PeerConnection 前调用。
|
* 获取 TURN 短期凭证(ICE servers)。主控端与被控端在建立 PeerConnection 前调用。
|
||||||
|
*
|
||||||
|
* <p>TURN 未启用时返回 200 + 空 iceServers(enabled=false),而不是 401:
|
||||||
|
* 「服务未开启」并非认证失败,返回 401 会触发各客户端的令牌刷新/强制登出逻辑
|
||||||
|
* (Web/Flutter 会无意义轮换 refreshToken,Android 端 401 拦截器会清理凭据)。
|
||||||
*/
|
*/
|
||||||
@GetMapping("/turn-credentials")
|
@GetMapping("/turn-credentials")
|
||||||
public Map<String, Object> turnCredentials(HttpServletRequest request) {
|
public Map<String, Object> turnCredentials(HttpServletRequest request) {
|
||||||
AuthPrincipal principal = requirePrincipal(request);
|
AuthPrincipal principal = requirePrincipal(request);
|
||||||
return turnCredentialService.issue(principal.principalType() + ":" + principal.principalId());
|
if (!turnCredentialService.isEnabled()) {
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("enabled", false);
|
||||||
|
body.put("iceServers", List.of());
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>(
|
||||||
|
turnCredentialService.issue(principal.principalType() + ":" + principal.principalId()));
|
||||||
|
body.put("enabled", true);
|
||||||
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 骚扰举报(P2 风控) ====================
|
// ==================== 骚扰举报(P2 风控) ====================
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import router from '@/router';
|
import router from '@/router';
|
||||||
|
import { useUserStore } from '@/stores/user';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 请求基地址。
|
* 请求基地址。
|
||||||
@@ -39,10 +40,11 @@ request.interceptors.response.use(
|
|||||||
(error) => {
|
(error) => {
|
||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
localStorage.removeItem('admin_token');
|
// 清空 store 内存与 localStorage 中的令牌,避免路由守卫因内存 token 残留把登录页弹回首页
|
||||||
|
useUserStore().logout();
|
||||||
ElMessage.error('登录已失效,请重新登录');
|
ElMessage.error('登录已失效,请重新登录');
|
||||||
if (router.currentRoute.value.name !== 'login') {
|
if (router.currentRoute.value.name !== 'Login') {
|
||||||
router.replace({ name: 'login' });
|
router.replace({ name: 'Login' });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(error.response?.data?.message || error.message || '请求失败');
|
ElMessage.error(error.response?.data?.message || error.message || '请求失败');
|
||||||
|
|||||||
Reference in New Issue
Block a user