feat(webrtc): 完善前端会话恢复、设备绑定与开发代理配置

- 控制端新增页面刷新后自动恢复登录会话,并展示恢复中状态
- 支持被控端配对码绑定、设备在线状态标记及离线设备禁用连接
- 优化登录流程,支持双因子验证码输入
- 修复 API 客户端令牌刷新逻辑,增加认证失败统一回调
- 信令地址支持环境变量推导,ICE 服务器支持自定义配置
- 管理后台强制开发态走 Vite 代理,避免跨域和 IP 不可达问题
- 修复 tsconfig.node.json 产物干扰 vite 配置加载的构建问题
- 添加 .env.example 作为环境变量配置模板
This commit is contained in:
TongTongStudio
2026-08-03 02:44:52 +08:00
parent e689b999e6
commit e6ed7c8e3e
16 changed files with 465 additions and 116 deletions

View File

@@ -8,20 +8,45 @@ import * as api from '../services/ApiClient';
// 远程视频录制器(基于浏览器原生 MediaRecorder
const videoRecorder = new VideoRecorder();
// 默认 ICE 兜底(当服务端未返回 TURN 凭证时使用仅 STUN可能穿透失败
export const FALLBACK_ICE_SERVERS = [
{ urls: 'stun:175.178.213.60:3478' },
{ urls: 'stun:47.242.112.133:3478' },
];
// 兜底 ICE 服务器:当服务端未返回 TURN 凭证时使用仅 STUN可能穿透失败
// 来自 .env 的 VITE_FALLBACK_ICE(多个以 ; 分隔),未配置则使用内置默认值。
function parseFallbackIce() {
const raw = (import.meta.env.VITE_FALLBACK_ICE || '').trim();
if (raw) {
return raw
.split(';')
.map((s) => s.trim())
.filter(Boolean)
.map((urls) => ({ urls }));
}
return [
{ urls: 'stun:175.178.213.60:3478' },
{ urls: 'stun:47.242.112.133:3478' },
];
}
export const FALLBACK_ICE_SERVERS = parseFallbackIce();
// 信令 WebSocket 地址:服务端 WebSocketConfig 注册的路径为 /ws/signal
// 子协议 signal.v1令牌通过 auth.<token> 子协议携带)。
// 优先 VITE_WS_URL其次由 VITE_API_BASE 推导http(s) -> ws(s) + /ws/signal
// 两者皆空则回退到生产域名,保证未配置 .env 时也不崩。
const DEFAULT_WS_URL = (() => {
if (import.meta.env.VITE_WS_URL) return import.meta.env.VITE_WS_URL;
const base = import.meta.env.VITE_API_BASE;
if (base) return base.replace(/^http/, 'ws').replace(/\/$/, '') + '/ws/signal';
return 'wss://www.ttstd.com/ws/signal';
})();
export const store = reactive({
serverUrl: 'wss://www.ttstd.com/signal',
serverUrl: DEFAULT_WS_URL,
// 登录态
loggedIn: api.isLoggedIn(),
username: '',
loginError: '',
loginBusy: false,
// 会话恢复中(页面刷新后用 /api/auth/me 校验令牌)
restoring: false,
// 本机设备 ID 由服务端 REGISTER_SUCCESS 下发,无需用户填写。
deviceId: '',
@@ -37,7 +62,10 @@ export const store = reactive({
statusText: '未连接',
error: '',
// 已绑定设备:[{ bindingId, deviceUid, alias, role, online }]
controlledDevices: [],
devicesLoading: false,
devicesError: '',
stats: null,
remoteStream: null,
currentResolution: null,
@@ -49,6 +77,14 @@ export const store = reactive({
let signaling = null;
let webrtc = null;
// 令牌彻底失效(刷新也失败)时,由 ApiClient 回调此处统一回到登录页。
api.setAuthFailedHandler((reason) => {
store.error = reason || '登录已过期,请重新登录';
store.loggedIn = false;
store.username = '';
disconnectSignaling();
});
export async function initProto() {
await loadProto();
store.protoReady = true;
@@ -56,13 +92,14 @@ export async function initProto() {
/* ---------------- 登录 / 登出 ---------------- */
export async function login(username, password) {
export async function login(username, password, totpCode) {
store.loginBusy = true;
store.loginError = '';
try {
const data = await api.login(username, password);
// 服务端 LoginResponse 字段为 displayName不是 username。
const data = await api.login(username, password, totpCode);
store.loggedIn = true;
store.username = data.username || username;
store.username = data.displayName || username;
return true;
} catch (e) {
store.loginError = e.message || '登录失败';
@@ -76,11 +113,36 @@ export async function register(username, password) {
return api.register({ username, password });
}
/**
* 页面加载时恢复会话sessionStorage 里有令牌就用 /api/auth/me 校验,
* 有效则直接进主界面并自动连信令,无效则清理令牌回到登录页。
*/
export async function restoreSession() {
if (!api.isLoggedIn()) return false;
store.restoring = true;
try {
const info = await api.me();
store.loggedIn = true;
store.username = info.displayName || '';
await connectSignaling();
return true;
} catch {
api.clearTokens();
store.loggedIn = false;
store.username = '';
return false;
} finally {
store.restoring = false;
}
}
export async function logout() {
// 先断连接再登出,避免服务端已销毁会话后 WS 收到 4003 又触发一次强制登出。
disconnectSignaling();
await api.logout().catch(() => {});
store.loggedIn = false;
store.username = '';
disconnectSignaling();
store.loginError = '';
}
/* ---------------- 信令连接Bearer 由子协议携带) ---------------- */
@@ -154,20 +216,59 @@ export async function loadTurnCredentials() {
return store.iceServers;
}
// 拉取本机可连接的被控端(仅已绑定设备)。
/**
* 拉取当前账号已绑定的被控端列表(服务端只返回 status=ACTIVE 的绑定)。
* 保留 alias / online供 UI 展示备注名与在线状态。
* 同时用 /api/client/devices/online 修正在线标记(该接口以实时 WS 会话为准)。
*/
export async function loadBindings() {
store.devicesLoading = true;
store.devicesError = '';
try {
const data = await api.listBindings();
const list = (data && data.bindings) || [];
// 列表元素可能为 {deviceUid, alias, online} 或纯字符串。
store.controlledDevices = list.map((b) =>
typeof b === 'string' ? b : (b.deviceUid || b.deviceId || '')
).filter(Boolean);
} catch {
// 实时在线设备集合(失败时降级为绑定记录里的 online 字段)。
let onlineSet = null;
try {
const od = await api.listOnlineDevices();
onlineSet = new Set(((od && od.devices) || []).map((d) => d.deviceUid || d));
} catch { /* 降级 */ }
store.controlledDevices = list
.map((b) => {
if (typeof b === 'string') return { deviceUid: b, alias: '', online: false };
const uid = b.deviceUid || b.deviceId || '';
return {
bindingId: b.bindingId,
deviceUid: uid,
alias: b.alias || '',
role: b.role || '',
online: onlineSet ? onlineSet.has(uid) : !!b.online,
};
})
.filter((d) => d.deviceUid);
// 目标设备已被解绑时清空选择。
if (store.targetDeviceId &&
!store.controlledDevices.some((d) => d.deviceUid === store.targetDeviceId)) {
store.targetDeviceId = '';
}
} catch (e) {
store.controlledDevices = [];
store.devicesError = e.message || '获取绑定设备失败';
} finally {
store.devicesLoading = false;
}
}
/** 用被控端出示的配对码绑定新设备,成功后刷新列表。 */
export async function bindByPairingCode(code, alias) {
const res = await api.redeemPairingCode(code, alias);
await loadBindings();
return res;
}
function handleSignalMessage(msg) {
switch ((msg.type || '').toUpperCase()) {
case 'REGISTER_SUCCESS':
@@ -176,9 +277,9 @@ function handleSignalMessage(msg) {
store.statusText = '注册成功 (CONTROLLER)';
break;
case 'DEVICE_LIST': {
// 兜底:若服务端推送 DEVICE_LIST兼容老逻辑
const list = msg.controlled || [];
store.controlledDevices = Array.isArray(list) ? list : [];
// 服务端推送在线设备变动时,仅刷新已绑定列表的在线标记,
// 不直接覆盖DEVICE_LIST 不含 alias/绑定关系)。
loadBindings();
break;
}
case 'ANSWER': {