feat(webrtc): 完善前端会话恢复、设备绑定与开发代理配置
- 控制端新增页面刷新后自动恢复登录会话,并展示恢复中状态 - 支持被控端配对码绑定、设备在线状态标记及离线设备禁用连接 - 优化登录流程,支持双因子验证码输入 - 修复 API 客户端令牌刷新逻辑,增加认证失败统一回调 - 信令地址支持环境变量推导,ICE 服务器支持自定义配置 - 管理后台强制开发态走 Vite 代理,避免跨域和 IP 不可达问题 - 修复 tsconfig.node.json 产物干扰 vite 配置加载的构建问题 - 添加 .env.example 作为环境变量配置模板
This commit is contained in:
@@ -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': {
|
||||
|
||||
Reference in New Issue
Block a user