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

@@ -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

View File

@@ -4,3 +4,9 @@ dist/
.DS_Store .DS_Store
.idea/ .idea/
.vscode/ .vscode/
# 本地环境配置(保留 .env.example 作为模板)
.env
.env.local
.env.*.local
!.env.example

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import { onMounted } from 'vue'; 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 LoginPanel from './components/LoginPanel.vue';
import ConnectionPanel from './components/ConnectionPanel.vue'; import ConnectionPanel from './components/ConnectionPanel.vue';
import RemoteScreen from './components/RemoteScreen.vue'; import RemoteScreen from './components/RemoteScreen.vue';
@@ -9,6 +9,8 @@ import StatsBar from './components/StatsBar.vue';
onMounted(async () => { onMounted(async () => {
await initProto(); await initProto();
// 页面刷新后若 sessionStorage 中仍有有效令牌,直接恢复会话并连上信令。
await restoreSession();
}); });
async function onLogout() { async function onLogout() {
@@ -18,7 +20,9 @@ async function onLogout() {
<template> <template>
<div class="app"> <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> <template v-else>
<header class="topbar"> <header class="topbar">
@@ -52,3 +56,14 @@ async function onLogout() {
</template> </template>
</div> </div>
</template> </template>
<style scoped>
.restoring {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
color: #94a3b8;
font-size: 14px;
}
</style>

View File

@@ -1,22 +1,58 @@
<script setup> <script setup>
import { ref } from 'vue'; import { ref, computed } from 'vue';
import { store, connectSignaling, disconnectSignaling, refreshDevices, connectToDevice, disconnectDevice } from '../store/controllerStore'; import {
store, connectSignaling, disconnectSignaling, refreshDevices,
connectToDevice, disconnectDevice, bindByPairingCode,
} from '../store/controllerStore';
const selected = ref(''); const selected = ref('');
const authType = ref('NONE'); const authType = ref('NONE');
const authValue = ref(''); 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() { function onConnectDevice() {
if (!selected.value) { if (!selected.value) {
store.error = '请选择要连接的设备'; store.error = '请选择要连接的设备';
return; return;
} }
if (selectedDevice.value && !selectedDevice.value.online) {
store.error = '该设备当前不在线,请先启动被控端';
return;
}
if (authType.value !== 'NONE' && !authValue.value.trim()) { if (authType.value !== 'NONE' && !authValue.value.trim()) {
store.error = '请输入验证码或密码'; store.error = '请输入验证码或密码';
return; return;
} }
connectToDevice(selected.value, authType.value, authType.value === 'NONE' ? '' : authValue.value.trim()); 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> </script>
<template> <template>
@@ -36,33 +72,55 @@ function onConnectDevice() {
{{ store.signalingConnecting ? '正在连接...' : '连接信令服务器' }} {{ store.signalingConnecting ? '正在连接...' : '连接信令服务器' }}
</button> </button>
<div class="section-title">被控端设备 (CONTROLLED)</div> <div class="section-title">已绑定设备 (CONTROLLED)</div>
<button class="btn secondary" style="margin-bottom:12px" @click="refreshDevices" :disabled="!store.signalingConnected"> <button class="btn secondary" style="margin-bottom:12px" @click="refreshDevices" :disabled="store.devicesLoading">
刷新设备列表 {{ store.devicesLoading ? '加载中…' : '刷新设备列表' }}
</button> </button>
<div v-if="store.controlledDevices.length === 0" class="hint"> <div v-if="store.devicesError" class="error">{{ store.devicesError }}</div>
尚无已绑定设备请在 Android 被控端内通过配对码完成绑定绑定列表由服务端下发仅显示你已绑定的设备
<div v-if="!store.devicesLoading && store.controlledDevices.length === 0" class="hint">
尚无已绑定设备请在被控端获取配对码并在下方绑定新设备中输入完成绑定
</div> </div>
<div <div
v-for="id in store.controlledDevices" v-for="d in store.controlledDevices"
:key="id" :key="d.deviceUid"
class="device-item" class="device-item"
:class="{ active: store.targetDeviceId === id }" :class="{ active: selected === d.deviceUid, offline: !d.online }"
@click="selected = id" @click="selected = d.deviceUid"
> >
<span>{{ id }}</span> <span class="dev-name">
<span style="color:var(--accent-2)">在线</span> {{ 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>
<div class="field" style="margin-top:14px"> <div class="field" style="margin-top:14px">
<select class="select" v-model="selected"> <select class="select" v-model="selected">
<option value="">选择目标设备</option> <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> </select>
</div> </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="section-title" style="margin-top:14px">连接鉴权</div>
<div class="auth-row"> <div class="auth-row">
<label class="auth-radio"><input type="radio" value="NONE" v-model="authType" /> 免密连接</label> <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' ? '请输入固定密码' : '请输入动态验证码'" /> <input class="input" v-model="authValue" :placeholder="authType === 'PASSWORD' ? '请输入固定密码' : '请输入动态验证码'" />
</div> </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> <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> <div v-if="store.error" class="error">{{ store.error }}</div>
@@ -93,4 +155,7 @@ function onConnectDevice() {
<style scoped> <style scoped>
.auth-row { display: flex; gap: 16px; margin-bottom: 8px; align-items: center; } .auth-row { display: flex; gap: 16px; margin-bottom: 8px; align-items: center; }
.auth-radio { display: inline-flex; align-items: center; gap: 4px; } .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> </style>

View File

@@ -4,6 +4,8 @@ import { store, login, register, connectSignaling } from '../store/controllerSto
const username = ref(''); const username = ref('');
const password = ref(''); const password = ref('');
const totpCode = ref('');
const needTotp = ref(false);
const showRegister = ref(false); const showRegister = ref(false);
const regUsername = ref(''); const regUsername = ref('');
const regPassword = ref(''); const regPassword = ref('');
@@ -11,9 +13,17 @@ const regBusy = ref(false);
const regError = ref(''); const regError = ref('');
async function onLogin() { 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) { if (ok) {
needTotp.value = false;
totpCode.value = '';
// 登录成功即建立信令长连,并自动拉取绑定设备列表。
await connectSignaling(); await connectSignaling();
return;
}
// 服务端要求双因子时展开验证码输入框。
if (/totp|二次|双因子|验证码/i.test(store.loginError || '')) {
needTotp.value = true;
} }
} }
@@ -21,8 +31,8 @@ async function onRegister() {
regError.value = ''; regError.value = '';
regBusy.value = true; regBusy.value = true;
try { 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(); username.value = regUsername.value.trim();
password.value = regPassword.value; password.value = regPassword.value;
showRegister.value = false; showRegister.value = false;
@@ -48,6 +58,9 @@ async function onRegister() {
<label>密码 <label>密码
<input v-model="password" type="password" autocomplete="current-password" placeholder="请输入密码" /> <input v-model="password" type="password" autocomplete="current-password" placeholder="请输入密码" />
</label> </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> <p v-if="store.loginError" class="login-err">{{ store.loginError }}</p>
<button class="btn primary" type="submit" :disabled="store.loginBusy"> <button class="btn primary" type="submit" :disabled="store.loginBusy">
{{ store.loginBusy ? '登录中…' : '登录' }} {{ store.loginBusy ? '登录中…' : '登录' }}

View File

@@ -1,16 +1,30 @@
// HTTP API 客户端:对接安全信令服务器的账号体系与自助接口。 // HTTP API 客户端:对接 WebRTCSignalServer 的账号体系与主控端自助接口。
// 令牌存储策略(参考服务端实现约束): //
// - accessToken内存中保存掉线即失需重新登录 // 服务端约定WebRTCSignalServer
// - refreshTokensessionStorage一次性、ses_ 前缀,页面关闭即清除,降低泄露面)。 // - 无 context-path接口路径即 /api/**,端口 8080
const API_BASE = (import.meta.env.VITE_API_BASE || 'https://www.ttstd.com').replace(/\/$/, ''); // - 鉴权统一使用 Authorization: Bearer <accessToken>
// - accessToken TTL 900srefreshToken TTL 7 天refreshToken 一次性轮换;
// - CORS 对 /api/** 全开allowCredentials=true
//
// 令牌存储策略:
// - accessToken / refreshToken 均放 sessionStorage页面关闭即清除降低泄露面。
// API 基地址:生产态由 VITE_API_BASE 提供(浏览器直连后端);
// 开发态由 vite.config.js 的代理转发,前端请求相对路径 /apiVITE_API_BASE 留空)。
const API_BASE = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
const ACCESS_KEY = 'ttstd.accessToken'; const ACCESS_KEY = 'ttstd.accessToken';
const REFRESH_KEY = 'ttstd.refreshToken'; const REFRESH_KEY = 'ttstd.refreshToken';
let accessToken = sessionStorage.getItem(ACCESS_KEY) || ''; let accessToken = sessionStorage.getItem(ACCESS_KEY) || '';
let refreshToken = sessionStorage.getItem(REFRESH_KEY) || ''; let refreshToken = sessionStorage.getItem(REFRESH_KEY) || '';
// 刷新单飞:避免并发请求同时触发多次刷新。 // 刷新单飞:避免并发请求同时触发多次刷新refreshToken 是一次性的,并发刷新会互相作废)
let refreshing = null; let refreshing = null;
// 令牌被判定为彻底失效时的回调(由 store 注册,用于跳回登录页)。
let onAuthFailed = null;
export function setAuthFailedHandler(fn) {
onAuthFailed = fn;
}
export function getAccessToken() { export function getAccessToken() {
return accessToken; return accessToken;
@@ -36,50 +50,87 @@ export function clearTokens() {
sessionStorage.removeItem(REFRESH_KEY); sessionStorage.removeItem(REFRESH_KEY);
} }
async function request(path, { method = 'POST', body, auth = false } = {}) { function buildError(data, status) {
const headers = { 'Content-Type': 'application/json' }; // 服务端 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; if (auth && accessToken) headers['Authorization'] = 'Bearer ' + accessToken;
const res = await fetch(API_BASE + path, { const res = await fetch(API_BASE + path, {
method, method,
headers, 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) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
const err = new Error(data.error || ('HTTP ' + res.status)); throw buildError(data, res.status);
err.code = data.code; }
err.status = res.status; // 部分接口可能返回空体。
throw err; 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 }); setTokens({ accessToken: data.accessToken, refreshToken: data.refreshToken });
return data; return data;
} }
/** 注册。响应:{ userId, username } */
export async function register(body) { export async function register(body) {
return request('/api/auth/register', { body }); return rawRequest('/api/auth/register', { body });
} }
/** 退出登录。服务端从 Bearer 令牌解析 sessionId无需请求体。 */
export async function logout() { export async function logout() {
if (refreshToken) { if (accessToken) {
try { await request('/api/auth/logout', { method: 'POST', body: { refreshToken } }); } catch { /* ignore */ } try {
await rawRequest('/api/auth/logout', { method: 'POST', auth: true });
} catch { /* 忽略:本地令牌无论如何都要清掉 */ }
} }
clearTokens(); clearTokens();
} }
// 刷新令牌:单飞锁,并发调用共享同一次刷新结果。 /** 刷新令牌:单飞锁,并发调用共享同一次刷新结果。 */
export async function refresh() { export async function refresh() {
if (!refreshToken) { if (!refreshToken) {
clearTokens(); clearTokens();
@@ -88,10 +139,14 @@ export async function refresh() {
if (refreshing) return refreshing; if (refreshing) return refreshing;
refreshing = (async () => { refreshing = (async () => {
try { try {
const data = await request('/api/auth/refresh', { body: { refreshToken } }); // 刷新自身不能再走 request(),否则 401 会递归。
// 服务端可能轮换 refreshToken一次性若返回新的则覆盖。 const data = await rawRequest('/api/auth/refresh', { body: { refreshToken } });
// 服务端轮换 refreshToken一次性必须覆盖保存。
setTokens({ accessToken: data.accessToken, refreshToken: data.refreshToken }); setTokens({ accessToken: data.accessToken, refreshToken: data.refreshToken });
return data; return data;
} catch (e) {
clearTokens();
throw e;
} finally { } finally {
refreshing = null; refreshing = null;
} }
@@ -99,17 +154,53 @@ export async function refresh() {
return refreshing; 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() { export async function verify() {
return request('/api/client/verify', { method: 'GET', auth: true }); 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() { export async function listBindings() {
return request('/api/client/bindings', { method: 'GET', auth: true }); 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() { export async function fetchTurnCredentials() {
try { try {
return await request('/api/client/turn-credentials', { method: 'GET', auth: true }); return await request('/api/client/turn-credentials', { method: 'GET', auth: true });

View File

@@ -86,11 +86,6 @@ export class SignalingClient {
if (this._heartbeatTimer) { clearInterval(this._heartbeatTimer); this._heartbeatTimer = null; } 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) { sendOffer(sdp, toDeviceId, authType = null, authValue = null) {
const msg = { const msg = {
type: 'OFFER', type: 'OFFER',

View File

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

View File

@@ -1,10 +1,24 @@
import { defineConfig } from 'vite'; import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
export default defineConfig({ // 开发态默认把 /api 与 /ws/signal 代理到信令服务端,避免本地跨域;
plugins: [vue()], // 通过 .env 的 VITE_API_BASE 指向后端(如 http://localhost:8080
server: { // 浏览器侧始终走同源相对路径 /api无需在代码中写死后端地址。
host: true, export default defineConfig(({ mode }) => {
port: 5173, 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 },
},
},
};
}); });

View File

@@ -1,5 +1,15 @@
# 管理后台连接的后端信令服务器地址Spring Boot 端口) # ============================================================
# VITE_API_BASE=http://175.178.213.60:8088 # 管理后台前端配置
# ============================================================
# 【代理目标】Vite dev server 把 /api/** 转发到这里(仅开发态生效,浏览器不直接访问)。
# 后端 Spring Boot 地址,按需改成本机或远程信令服务器。
VITE_API_TARGET=http://localhost:8080
# 【请求基地址】留空表示走相对路径 /api同源经上面的代理转发——推荐保持留空。
# 仅在需要浏览器直连后端、绕过代理时才填绝对地址,例如:
# VITE_API_BASE=http://192.168.5.224:8080
# 注意:填了绝对地址就会产生跨域,且用非该 IP 的地址打开页面时可能连不上。
VITE_API_BASE=http://192.168.5.224:8080 VITE_API_BASE=http://192.168.5.224:8080
# 默认管理令牌,需与服务器 application.yml 中 admin.token 一致 # 默认管理令牌,需与服务器 application.yml 中 admin.token 一致

View File

@@ -2,7 +2,20 @@ import axios from 'axios';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import router from '@/router'; import router from '@/router';
const BASE_URL = import.meta.env.VITE_API_BASE || 'http://localhost:8080'; /**
* 请求基地址。
*
* 开发态vite dev强制使用相对路径 '',即请求 /api/**
* 由 Vite dev server 代理转发到后端(见 vite.config.ts 的 server.proxy
* 目标取自 VITE_API_TARGET。这样浏览器侧始终同源从根本上避免
* - 跨域被拦截;
* - .env 里写死的后端 IP如 192.168.5.224)在当前网络/主机下不可达,
* 或用 localhost 打开页面时该 IP 解析不到,浏览器直接抛 "Failed to fetch"。
*
* 生产构建下才使用 VITE_API_BASE若未配置则同样走相对路径
* 由部署侧Nginx 等)反向代理 /api。
*/
const BASE_URL = import.meta.env.DEV ? '' : import.meta.env.VITE_API_BASE || '';
const request = axios.create({ const request = axios.create({
baseURL: BASE_URL, baseURL: BASE_URL,

View File

@@ -7,7 +7,12 @@ declare module '*.vue' {
} }
interface ImportMetaEnv { interface ImportMetaEnv {
/** 请求基地址,留空表示走相对路径 /api由 Vite 代理或部署侧反代转发)。 */
readonly VITE_API_BASE: string; readonly VITE_API_BASE: string;
/** Vite dev server 的 /api 代理目标,仅构建工具侧读取。 */
readonly VITE_API_TARGET: string;
/** 默认管理令牌,需与服务端 application.yml 的 admin.token 一致。 */
readonly VITE_ADMIN_TOKEN: string;
} }
interface ImportMeta { interface ImportMeta {

View File

@@ -5,7 +5,14 @@
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"strict": true "strict": true,
// composite 隐含 declaration会把 vite.config.ts 编译出 vite.config.js / .d.ts。
// 而 Vite 加载配置时 .js 优先级高于 .ts那份陈旧产物会顶掉真正的 vite.config.ts
// (曾导致 server.proxy 失效、接口请求 404 / Failed to fetch
// 这里只做类型检查不产出任何文件tsBuildInfo 也移出源码目录。
"noEmit": true,
"emitDeclarationOnly": false,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts"]
} }

View File

@@ -1,2 +0,0 @@
declare const _default: import("vite").UserConfig;
export default _default;

View File

@@ -1,15 +0,0 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
host: true,
port: 5180,
},
});

View File

@@ -1,16 +1,35 @@
import { defineConfig } from 'vite'; import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import { fileURLToPath, URL } from 'node:url'; import { fileURLToPath, URL } from 'node:url';
export default defineConfig({ // 开发态一律走 Vite 同源代理:前端只请求相对路径 /api/**
plugins: [vue()], // 由 dev server 转发到 VITE_API_TARGET 指向的后端。
resolve: { // 这样浏览器侧不存在跨域,也不会因为「访问 dev server 用的主机名/协议」
alias: { // 与「后端绝对地址」不一致而出现 Failed to fetch。
'@': fileURLToPath(new URL('./src', import.meta.url)), export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
// 代理目标:优先 VITE_API_TARGET兼容旧的 VITE_API_BASE默认本机后端。
const target = env.VITE_API_TARGET || env.VITE_API_BASE || 'http://localhost:8080';
// 启动时打印,便于确认请求究竟被转发到哪台后端。
console.log(`[vite] /api -> ${target}`);
return {
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
}, },
}, server: {
server: { host: true,
host: true, port: 5180,
port: 5180, proxy: {
}, '/api': {
target,
changeOrigin: true,
secure: false,
},
},
},
};
}); });