feat(webrtc): 完善前端会话恢复、设备绑定与开发代理配置
- 控制端新增页面刷新后自动恢复登录会话,并展示恢复中状态 - 支持被控端配对码绑定、设备在线状态标记及离线设备禁用连接 - 优化登录流程,支持双因子验证码输入 - 修复 API 客户端令牌刷新逻辑,增加认证失败统一回调 - 信令地址支持环境变量推导,ICE 服务器支持自定义配置 - 管理后台强制开发态走 Vite 代理,避免跨域和 IP 不可达问题 - 修复 tsconfig.node.json 产物干扰 vite 配置加载的构建问题 - 添加 .env.example 作为环境变量配置模板
This commit is contained in:
12
WebRTCControllerWeb/.env.example
Normal file
12
WebRTCControllerWeb/.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# ============================================================
|
||||
# WebRTC 控制端前端配置模板(复制为 .env 后按需修改,勿提交真实 .env)
|
||||
# ============================================================
|
||||
|
||||
# 后端 API 基地址:开发态留空走 Vite 代理;生产态填浏览器可访问的后端地址。
|
||||
VITE_API_BASE=
|
||||
|
||||
# 信令 WebSocket 地址:留空则按 VITE_API_BASE 推导为 <ws/wss>://host:port/ws/signal
|
||||
VITE_WS_URL=
|
||||
|
||||
# 兜底 STUN 服务器(多个以 ; 分隔)
|
||||
VITE_FALLBACK_ICE=stun:175.178.213.60:3478;stun:47.242.112.133:3478
|
||||
6
WebRTCControllerWeb/.gitignore
vendored
6
WebRTCControllerWeb/.gitignore
vendored
@@ -4,3 +4,9 @@ dist/
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# 本地环境配置(保留 .env.example 作为模板)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env.example
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { store, initProto, logout } from './store/controllerStore';
|
||||
import { store, initProto, logout, restoreSession } from './store/controllerStore';
|
||||
import LoginPanel from './components/LoginPanel.vue';
|
||||
import ConnectionPanel from './components/ConnectionPanel.vue';
|
||||
import RemoteScreen from './components/RemoteScreen.vue';
|
||||
@@ -9,6 +9,8 @@ import StatsBar from './components/StatsBar.vue';
|
||||
|
||||
onMounted(async () => {
|
||||
await initProto();
|
||||
// 页面刷新后若 sessionStorage 中仍有有效令牌,直接恢复会话并连上信令。
|
||||
await restoreSession();
|
||||
});
|
||||
|
||||
async function onLogout() {
|
||||
@@ -18,7 +20,9 @@ async function onLogout() {
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<LoginPanel v-if="!store.loggedIn" />
|
||||
<div v-if="store.restoring" class="restoring">正在恢复登录状态…</div>
|
||||
|
||||
<LoginPanel v-else-if="!store.loggedIn" />
|
||||
|
||||
<template v-else>
|
||||
<header class="topbar">
|
||||
@@ -52,3 +56,14 @@ async function onLogout() {
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.restoring {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,22 +1,58 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { store, connectSignaling, disconnectSignaling, refreshDevices, connectToDevice, disconnectDevice } from '../store/controllerStore';
|
||||
import { ref, computed } from 'vue';
|
||||
import {
|
||||
store, connectSignaling, disconnectSignaling, refreshDevices,
|
||||
connectToDevice, disconnectDevice, bindByPairingCode,
|
||||
} from '../store/controllerStore';
|
||||
|
||||
const selected = ref('');
|
||||
const authType = ref('NONE');
|
||||
const authValue = ref('');
|
||||
|
||||
// 配对绑定
|
||||
const pairCode = ref('');
|
||||
const pairAlias = ref('');
|
||||
const pairBusy = ref(false);
|
||||
const pairMsg = ref('');
|
||||
const pairErr = ref('');
|
||||
|
||||
const selectedDevice = computed(() =>
|
||||
store.controlledDevices.find((d) => d.deviceUid === selected.value) || null
|
||||
);
|
||||
|
||||
function onConnectDevice() {
|
||||
if (!selected.value) {
|
||||
store.error = '请选择要连接的设备';
|
||||
return;
|
||||
}
|
||||
if (selectedDevice.value && !selectedDevice.value.online) {
|
||||
store.error = '该设备当前不在线,请先启动被控端';
|
||||
return;
|
||||
}
|
||||
if (authType.value !== 'NONE' && !authValue.value.trim()) {
|
||||
store.error = '请输入验证码或密码';
|
||||
return;
|
||||
}
|
||||
connectToDevice(selected.value, authType.value, authType.value === 'NONE' ? '' : authValue.value.trim());
|
||||
}
|
||||
|
||||
async function onBind() {
|
||||
pairMsg.value = '';
|
||||
pairErr.value = '';
|
||||
const code = pairCode.value.trim();
|
||||
if (!code) { pairErr.value = '请输入被控端显示的配对码'; return; }
|
||||
pairBusy.value = true;
|
||||
try {
|
||||
await bindByPairingCode(code, pairAlias.value.trim());
|
||||
pairMsg.value = '绑定成功';
|
||||
pairCode.value = '';
|
||||
pairAlias.value = '';
|
||||
} catch (e) {
|
||||
pairErr.value = e.message || '绑定失败';
|
||||
} finally {
|
||||
pairBusy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -36,33 +72,55 @@ function onConnectDevice() {
|
||||
{{ store.signalingConnecting ? '正在连接...' : '连接信令服务器' }}
|
||||
</button>
|
||||
|
||||
<div class="section-title">被控端设备 (CONTROLLED)</div>
|
||||
<button class="btn secondary" style="margin-bottom:12px" @click="refreshDevices" :disabled="!store.signalingConnected">
|
||||
刷新设备列表
|
||||
<div class="section-title">已绑定设备 (CONTROLLED)</div>
|
||||
<button class="btn secondary" style="margin-bottom:12px" @click="refreshDevices" :disabled="store.devicesLoading">
|
||||
{{ store.devicesLoading ? '加载中…' : '刷新设备列表' }}
|
||||
</button>
|
||||
|
||||
<div v-if="store.controlledDevices.length === 0" class="hint">
|
||||
尚无已绑定设备。请在 Android 被控端内通过配对码完成绑定,绑定列表由服务端下发(仅显示你已绑定的设备)。
|
||||
<div v-if="store.devicesError" class="error">{{ store.devicesError }}</div>
|
||||
|
||||
<div v-if="!store.devicesLoading && store.controlledDevices.length === 0" class="hint">
|
||||
尚无已绑定设备。请在被控端获取配对码,并在下方「绑定新设备」中输入完成绑定。
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="id in store.controlledDevices"
|
||||
:key="id"
|
||||
v-for="d in store.controlledDevices"
|
||||
:key="d.deviceUid"
|
||||
class="device-item"
|
||||
:class="{ active: store.targetDeviceId === id }"
|
||||
@click="selected = id"
|
||||
:class="{ active: selected === d.deviceUid, offline: !d.online }"
|
||||
@click="selected = d.deviceUid"
|
||||
>
|
||||
<span>{{ id }}</span>
|
||||
<span style="color:var(--accent-2)">在线</span>
|
||||
<span class="dev-name">
|
||||
{{ d.alias || d.deviceUid }}
|
||||
<small v-if="d.alias" class="dev-uid">{{ d.deviceUid }}</small>
|
||||
</span>
|
||||
<span :style="{ color: d.online ? 'var(--accent-2)' : '#94a3b8' }">
|
||||
{{ d.online ? '在线' : '离线' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="field" style="margin-top:14px">
|
||||
<select class="select" v-model="selected">
|
||||
<option value="">选择目标设备…</option>
|
||||
<option v-for="id in store.controlledDevices" :key="id" :value="id">{{ id }}</option>
|
||||
<option v-for="d in store.controlledDevices" :key="d.deviceUid" :value="d.deviceUid">
|
||||
{{ (d.alias || d.deviceUid) + (d.online ? '(在线)' : '(离线)') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="section-title" style="margin-top:14px">绑定新设备</div>
|
||||
<div class="field">
|
||||
<input class="input" v-model="pairCode" placeholder="被控端显示的配对码" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<input class="input" v-model="pairAlias" placeholder="备注名(可选)" />
|
||||
</div>
|
||||
<button class="btn secondary" @click="onBind" :disabled="pairBusy">
|
||||
{{ pairBusy ? '绑定中…' : '绑定设备' }}
|
||||
</button>
|
||||
<div v-if="pairMsg" class="hint" style="color:var(--accent-2)">{{ pairMsg }}</div>
|
||||
<div v-if="pairErr" class="error">{{ pairErr }}</div>
|
||||
|
||||
<div class="section-title" style="margin-top:14px">连接鉴权</div>
|
||||
<div class="auth-row">
|
||||
<label class="auth-radio"><input type="radio" value="NONE" v-model="authType" /> 免密连接</label>
|
||||
@@ -76,7 +134,11 @@ function onConnectDevice() {
|
||||
<input class="input" v-model="authValue" :placeholder="authType === 'PASSWORD' ? '请输入固定密码' : '请输入动态验证码'" />
|
||||
</div>
|
||||
|
||||
<button class="btn" @click="onConnectDevice" :disabled="!selected || store.rtcConnected">发起远程控制</button>
|
||||
<button
|
||||
class="btn"
|
||||
@click="onConnectDevice"
|
||||
:disabled="!selected || store.rtcConnected || !store.signalingConnected || (selectedDevice && !selectedDevice.online)"
|
||||
>发起远程控制</button>
|
||||
<button class="btn danger" style="margin-top:12px" v-if="store.rtcConnected" @click="disconnectDevice">结束控制</button>
|
||||
|
||||
<div v-if="store.error" class="error">{{ store.error }}</div>
|
||||
@@ -93,4 +155,7 @@ function onConnectDevice() {
|
||||
<style scoped>
|
||||
.auth-row { display: flex; gap: 16px; margin-bottom: 8px; align-items: center; }
|
||||
.auth-radio { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.device-item.offline { opacity: .6; }
|
||||
.dev-name { display: flex; flex-direction: column; gap: 2px; }
|
||||
.dev-uid { color: #94a3b8; font-size: 11px; }
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { store, login, register, connectSignaling } from '../store/controllerSto
|
||||
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const totpCode = ref('');
|
||||
const needTotp = ref(false);
|
||||
const showRegister = ref(false);
|
||||
const regUsername = ref('');
|
||||
const regPassword = ref('');
|
||||
@@ -11,9 +13,17 @@ const regBusy = ref(false);
|
||||
const regError = ref('');
|
||||
|
||||
async function onLogin() {
|
||||
const ok = await login(username.value.trim(), password.value);
|
||||
const ok = await login(username.value.trim(), password.value, totpCode.value.trim() || undefined);
|
||||
if (ok) {
|
||||
needTotp.value = false;
|
||||
totpCode.value = '';
|
||||
// 登录成功即建立信令长连,并自动拉取绑定设备列表。
|
||||
await connectSignaling();
|
||||
return;
|
||||
}
|
||||
// 服务端要求双因子时展开验证码输入框。
|
||||
if (/totp|二次|双因子|验证码/i.test(store.loginError || '')) {
|
||||
needTotp.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +31,8 @@ async function onRegister() {
|
||||
regError.value = '';
|
||||
regBusy.value = true;
|
||||
try {
|
||||
await register({ username: regUsername.value.trim(), password: regPassword.value });
|
||||
// 注册成功后直接登录并连接。
|
||||
// store.register(username, password) —— 注意是两个位置参数。
|
||||
await register(regUsername.value.trim(), regPassword.value);
|
||||
username.value = regUsername.value.trim();
|
||||
password.value = regPassword.value;
|
||||
showRegister.value = false;
|
||||
@@ -48,6 +58,9 @@ async function onRegister() {
|
||||
<label>密码
|
||||
<input v-model="password" type="password" autocomplete="current-password" placeholder="请输入密码" />
|
||||
</label>
|
||||
<label v-if="needTotp">动态验证码
|
||||
<input v-model="totpCode" type="text" inputmode="numeric" autocomplete="one-time-code" placeholder="请输入 6 位动态验证码" />
|
||||
</label>
|
||||
<p v-if="store.loginError" class="login-err">{{ store.loginError }}</p>
|
||||
<button class="btn primary" type="submit" :disabled="store.loginBusy">
|
||||
{{ store.loginBusy ? '登录中…' : '登录' }}
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
// HTTP API 客户端:对接安全信令服务器的账号体系与自助接口。
|
||||
// 令牌存储策略(参考服务端实现约束):
|
||||
// - accessToken:内存中保存(掉线即失,需重新登录);
|
||||
// - refreshToken:sessionStorage(一次性、ses_ 前缀,页面关闭即清除,降低泄露面)。
|
||||
const API_BASE = (import.meta.env.VITE_API_BASE || 'https://www.ttstd.com').replace(/\/$/, '');
|
||||
// HTTP API 客户端:对接 WebRTCSignalServer 的账号体系与主控端自助接口。
|
||||
//
|
||||
// 服务端约定(WebRTCSignalServer):
|
||||
// - 无 context-path,接口路径即 /api/**,端口 8080;
|
||||
// - 鉴权统一使用 Authorization: Bearer <accessToken>;
|
||||
// - accessToken TTL 900s,refreshToken TTL 7 天,refreshToken 一次性轮换;
|
||||
// - CORS 对 /api/** 全开(allowCredentials=true)。
|
||||
//
|
||||
// 令牌存储策略:
|
||||
// - accessToken / refreshToken 均放 sessionStorage,页面关闭即清除,降低泄露面。
|
||||
// API 基地址:生产态由 VITE_API_BASE 提供(浏览器直连后端);
|
||||
// 开发态由 vite.config.js 的代理转发,前端请求相对路径 /api(VITE_API_BASE 留空)。
|
||||
const API_BASE = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
|
||||
|
||||
const ACCESS_KEY = 'ttstd.accessToken';
|
||||
const REFRESH_KEY = 'ttstd.refreshToken';
|
||||
|
||||
let accessToken = sessionStorage.getItem(ACCESS_KEY) || '';
|
||||
let refreshToken = sessionStorage.getItem(REFRESH_KEY) || '';
|
||||
// 刷新单飞:避免并发请求同时触发多次刷新。
|
||||
// 刷新单飞:避免并发请求同时触发多次刷新(refreshToken 是一次性的,并发刷新会互相作废)。
|
||||
let refreshing = null;
|
||||
// 令牌被判定为彻底失效时的回调(由 store 注册,用于跳回登录页)。
|
||||
let onAuthFailed = null;
|
||||
|
||||
export function setAuthFailedHandler(fn) {
|
||||
onAuthFailed = fn;
|
||||
}
|
||||
|
||||
export function getAccessToken() {
|
||||
return accessToken;
|
||||
@@ -36,50 +50,87 @@ export function clearTokens() {
|
||||
sessionStorage.removeItem(REFRESH_KEY);
|
||||
}
|
||||
|
||||
async function request(path, { method = 'POST', body, auth = false } = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
function buildError(data, status) {
|
||||
// 服务端 AuthExceptionHandler 返回 { code, error/message }。
|
||||
const err = new Error(data.error || data.message || 'HTTP ' + status);
|
||||
err.code = data.code;
|
||||
err.status = status;
|
||||
return err;
|
||||
}
|
||||
|
||||
// 单次裸请求,不做刷新重试。
|
||||
async function rawRequest(path, { method = 'POST', body, auth = false } = {}) {
|
||||
const headers = {};
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (auth && accessToken) headers['Authorization'] = 'Bearer ' + accessToken;
|
||||
|
||||
const res = await fetch(API_BASE + path, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
// 可能是 accessToken 失效,由调用方决定是否刷新。
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const err = new Error(data.error || 'UNAUTHORIZED');
|
||||
err.code = data.code || 'UNAUTHORIZED';
|
||||
err.status = 401;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const err = new Error(data.error || ('HTTP ' + res.status));
|
||||
err.code = data.code;
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
throw buildError(data, res.status);
|
||||
}
|
||||
// 部分接口可能返回空体。
|
||||
const text = await res.text();
|
||||
if (!text) return {};
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function login(username, password) {
|
||||
const data = await request('/api/auth/login', { body: { username, password } });
|
||||
/**
|
||||
* 带鉴权的请求:遇到 401 自动刷新一次令牌后重试;
|
||||
* 刷新失败则清空令牌并通知上层跳回登录页。
|
||||
*/
|
||||
async function request(path, options = {}) {
|
||||
try {
|
||||
return await rawRequest(path, options);
|
||||
} catch (e) {
|
||||
if (e.status !== 401 || !options.auth) throw e;
|
||||
try {
|
||||
await refresh();
|
||||
} catch {
|
||||
clearTokens();
|
||||
onAuthFailed && onAuthFailed('登录已过期,请重新登录');
|
||||
throw e;
|
||||
}
|
||||
return rawRequest(path, options);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 认证 /api/auth ---------------- */
|
||||
|
||||
/** 登录。响应:{ accessToken, refreshToken, expiresInSeconds, sessionId, principalId, displayName } */
|
||||
export async function login(username, password, totpCode) {
|
||||
const body = { username, password };
|
||||
if (totpCode) body.totpCode = totpCode;
|
||||
const data = await rawRequest('/api/auth/login', { body });
|
||||
setTokens({ accessToken: data.accessToken, refreshToken: data.refreshToken });
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 注册。响应:{ userId, username } */
|
||||
export async function register(body) {
|
||||
return request('/api/auth/register', { body });
|
||||
return rawRequest('/api/auth/register', { body });
|
||||
}
|
||||
|
||||
/** 退出登录。服务端从 Bearer 令牌解析 sessionId,无需请求体。 */
|
||||
export async function logout() {
|
||||
if (refreshToken) {
|
||||
try { await request('/api/auth/logout', { method: 'POST', body: { refreshToken } }); } catch { /* ignore */ }
|
||||
if (accessToken) {
|
||||
try {
|
||||
await rawRequest('/api/auth/logout', { method: 'POST', auth: true });
|
||||
} catch { /* 忽略:本地令牌无论如何都要清掉 */ }
|
||||
}
|
||||
clearTokens();
|
||||
}
|
||||
|
||||
// 刷新令牌:带单飞锁,并发调用共享同一次刷新结果。
|
||||
/** 刷新令牌:单飞锁,并发调用共享同一次刷新结果。 */
|
||||
export async function refresh() {
|
||||
if (!refreshToken) {
|
||||
clearTokens();
|
||||
@@ -88,10 +139,14 @@ export async function refresh() {
|
||||
if (refreshing) return refreshing;
|
||||
refreshing = (async () => {
|
||||
try {
|
||||
const data = await request('/api/auth/refresh', { body: { refreshToken } });
|
||||
// 服务端可能轮换 refreshToken(一次性),若返回新的则覆盖。
|
||||
// 刷新自身不能再走 request(),否则 401 会递归。
|
||||
const data = await rawRequest('/api/auth/refresh', { body: { refreshToken } });
|
||||
// 服务端轮换 refreshToken(一次性),必须覆盖保存。
|
||||
setTokens({ accessToken: data.accessToken, refreshToken: data.refreshToken });
|
||||
return data;
|
||||
} catch (e) {
|
||||
clearTokens();
|
||||
throw e;
|
||||
} finally {
|
||||
refreshing = null;
|
||||
}
|
||||
@@ -99,17 +154,53 @@ export async function refresh() {
|
||||
return refreshing;
|
||||
}
|
||||
|
||||
// 令牌校验(可选,用于启动恢复时确认 accessToken 是否有效)。
|
||||
/**
|
||||
* 查询当前登录身份,用于页面刷新后恢复会话。
|
||||
* 响应:{ principalId, principalType, displayName, sessionId, signalDeviceId, totpEnabled }
|
||||
*/
|
||||
export async function me() {
|
||||
return request('/api/auth/me', { method: 'GET', auth: true });
|
||||
}
|
||||
|
||||
/* ---------------- 主控端自助 /api/client ---------------- */
|
||||
|
||||
/** 令牌校验(可选)。 */
|
||||
export async function verify() {
|
||||
return request('/api/client/verify', { method: 'GET', auth: true });
|
||||
}
|
||||
|
||||
// 取本机可连接的被控端列表(仅返回已绑定的设备)。
|
||||
/** WebSocket 接入信息:{ wsPath, subprotocol, tokenMethods }。 */
|
||||
export async function wsInfo() {
|
||||
return rawRequest('/api/client/ws-info', { method: 'GET' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 主控端已绑定设备列表。
|
||||
* 响应:{ bindings: [{ bindingId, deviceUid, userId, role, alias, status, online }] }
|
||||
* 服务端已过滤仅返回 status=ACTIVE 的绑定。
|
||||
*/
|
||||
export async function listBindings() {
|
||||
return request('/api/client/bindings', { method: 'GET', auth: true });
|
||||
}
|
||||
|
||||
// 拉取 TURN 短期凭证(服务端开启时返回 iceServers)。
|
||||
/**
|
||||
* 已绑定且当前在线的设备。
|
||||
* 响应:{ devices: [{ deviceUid, alias, online }] }
|
||||
*/
|
||||
export async function listOnlineDevices() {
|
||||
return request('/api/client/devices/online', { method: 'GET', auth: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 用配对码绑定一台被控端设备(配对码由被控端生成,默认 10 分钟有效)。
|
||||
*/
|
||||
export async function redeemPairingCode(code, alias) {
|
||||
const body = { code };
|
||||
if (alias) body.alias = alias;
|
||||
return request('/api/client/pairing/redeem', { method: 'POST', body, auth: true });
|
||||
}
|
||||
|
||||
/** 拉取 TURN 短期凭证(服务端开启时返回 iceServers)。 */
|
||||
export async function fetchTurnCredentials() {
|
||||
try {
|
||||
return await request('/api/client/turn-credentials', { method: 'GET', auth: true });
|
||||
|
||||
@@ -86,11 +86,6 @@ export class SignalingClient {
|
||||
if (this._heartbeatTimer) { clearInterval(this._heartbeatTimer); this._heartbeatTimer = null; }
|
||||
}
|
||||
|
||||
requestDeviceList() {
|
||||
// 服务端不再接受 DEVICE_LIST 枚举;控制端改用 HTTP /api/client/bindings 拉取绑定设备。
|
||||
this.send({ type: 'DEVICE_LIST' });
|
||||
}
|
||||
|
||||
sendOffer(sdp, toDeviceId, authType = null, authValue = null) {
|
||||
const msg = {
|
||||
type: 'OFFER',
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
},
|
||||
// 开发态默认把 /api 与 /ws/signal 代理到信令服务端,避免本地跨域;
|
||||
// 通过 .env 的 VITE_API_BASE 指向后端(如 http://localhost:8080),
|
||||
// 浏览器侧始终走同源相对路径 /api,无需在代码中写死后端地址。
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
// VITE_API_BASE 为空(开发态推荐)时,回退到本机后端。
|
||||
const target = env.VITE_API_BASE || 'http://localhost:8080';
|
||||
const wsTarget = target.replace(/^http/, 'ws');
|
||||
|
||||
return {
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': { target, changeOrigin: true, secure: false },
|
||||
'/ws/signal': { target: wsTarget, ws: true, changeOrigin: true, secure: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user