feat: 增加网页后台管理
This commit is contained in:
@@ -0,0 +1,46 @@
|
|||||||
|
package com.ttstd.signaling.config;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台管理接口鉴权过滤器:仅保护 /api/admin 下的接口,
|
||||||
|
* 校验请求头 {@code X-Admin-Token} 是否与配置的令牌一致。
|
||||||
|
* 令牌可通过 {@code admin.token} 配置项或环境变量 {@code ADMIN_TOKEN} 设置。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class AdminAuthFilter extends OncePerRequestFilter {
|
||||||
|
@Value("${admin.token:webrtc-admin-token}")
|
||||||
|
private String adminToken;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
String uri = request.getRequestURI();
|
||||||
|
if (!uri.startsWith("/api/admin")) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String token = request.getHeader("X-Admin-Token");
|
||||||
|
if (adminToken != null && !adminToken.isBlank() && adminToken.equals(token)) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
} else {
|
||||||
|
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||||
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
|
response.getWriter().write("{\"code\":401,\"message\":\"未授权:无效的管理员令牌\"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.ttstd.signaling.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为后台管理 REST 接口(/api/**)开放跨域,便于 WebRTCSignalServerWeb 在独立端口访问。
|
||||||
|
* WebSocket 握手本身已在 {@link WebSocketConfig} 中允许全部来源。
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class CorsConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addCorsMappings(CorsRegistry registry) {
|
||||||
|
registry.addMapping("/api/**")
|
||||||
|
.allowedOriginPatterns("*")
|
||||||
|
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||||
|
.allowedHeaders("*")
|
||||||
|
.allowCredentials(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package com.ttstd.signaling.controller;
|
||||||
|
|
||||||
|
import com.ttstd.signaling.manager.ConnectionRequestManager;
|
||||||
|
import com.ttstd.signaling.manager.SessionManager;
|
||||||
|
import com.ttstd.signaling.manager.SignalMetrics;
|
||||||
|
import com.ttstd.signaling.model.DeviceInfo;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台管理 REST 接口:为 WebRTCSignalServerWeb 提供服务器运行态查询。
|
||||||
|
* 所有接口均以 {@code /api/admin} 为前缀,并由 {@link AdminAuthFilter} 统一鉴权。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin")
|
||||||
|
public class AdminController {
|
||||||
|
|
||||||
|
private final SessionManager sessionManager;
|
||||||
|
private final ConnectionRequestManager connectionRequestManager;
|
||||||
|
private final SignalMetrics metrics;
|
||||||
|
private final long startTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
public AdminController(SessionManager sessionManager,
|
||||||
|
ConnectionRequestManager connectionRequestManager,
|
||||||
|
SignalMetrics metrics) {
|
||||||
|
this.sessionManager = sessionManager;
|
||||||
|
this.connectionRequestManager = connectionRequestManager;
|
||||||
|
this.metrics = metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仪表盘汇总数据:设备在线情况、待确认连接、流量指标与运行时长。 */
|
||||||
|
@GetMapping("/dashboard")
|
||||||
|
public Map<String, Object> dashboard() {
|
||||||
|
List<DeviceInfo> devices = sessionManager.getAllDeviceDetails();
|
||||||
|
long controllers = devices.stream()
|
||||||
|
.filter(d -> "CONTROLLER".equals(d.deviceType()) && d.online()).count();
|
||||||
|
long controlled = devices.stream()
|
||||||
|
.filter(d -> "CONTROLLED".equals(d.deviceType()) && d.online()).count();
|
||||||
|
long online = devices.stream().filter(DeviceInfo::online).count();
|
||||||
|
long offline = devices.size() - online;
|
||||||
|
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("serverStartTime", startTime);
|
||||||
|
data.put("currentTime", System.currentTimeMillis());
|
||||||
|
data.put("uptimeMs", System.currentTimeMillis() - startTime);
|
||||||
|
data.put("totalDevices", devices.size());
|
||||||
|
data.put("onlineDevices", online);
|
||||||
|
data.put("offlineDevices", offline);
|
||||||
|
data.put("onlineControllers", controllers);
|
||||||
|
data.put("onlineControlled", controlled);
|
||||||
|
data.put("pendingConnections", connectionRequestManager.getPendingRequests().size());
|
||||||
|
data.put("metrics", metrics.snapshot());
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设备列表,可按 deviceType(CONTROLLER / CONTROLLED)过滤。 */
|
||||||
|
@GetMapping("/devices")
|
||||||
|
public List<DeviceInfo> devices(@RequestParam(required = false) String type) {
|
||||||
|
List<DeviceInfo> all = sessionManager.getAllDeviceDetails();
|
||||||
|
if (type == null || type.isBlank()) {
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
final String upper = type.toUpperCase();
|
||||||
|
return all.stream()
|
||||||
|
.filter(d -> upper.equals(d.deviceType()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前处于“待被控端确认”状态的连接请求。 */
|
||||||
|
@GetMapping("/connections")
|
||||||
|
public List<Map<String, Object>> connections() {
|
||||||
|
return connectionRequestManager.getPendingRequests();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务器连接治理配置项(去重窗口、确认超时、WebSocket 端点等)。 */
|
||||||
|
@GetMapping("/config")
|
||||||
|
public Map<String, Object> config() {
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("dedupWindowMs", connectionRequestManager.getDedupWindowMs());
|
||||||
|
data.put("requestTimeoutMs", connectionRequestManager.getRequestTimeoutMs());
|
||||||
|
data.put("websocketEndpoint", "/ws/signal");
|
||||||
|
data.put("serverPort", 8080);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 健康检查。 */
|
||||||
|
@GetMapping("/health")
|
||||||
|
public Map<String, Object> health() {
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("status", "UP");
|
||||||
|
data.put("currentTime", System.currentTimeMillis());
|
||||||
|
data.put("onlineDevices", sessionManager.getOnlineCount());
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package com.ttstd.signaling.handler;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.ttstd.signaling.manager.ConnectionRequestManager;
|
import com.ttstd.signaling.manager.ConnectionRequestManager;
|
||||||
import com.ttstd.signaling.manager.SessionManager;
|
import com.ttstd.signaling.manager.SessionManager;
|
||||||
|
import com.ttstd.signaling.manager.SignalMetrics;
|
||||||
import com.ttstd.signaling.model.DeviceType;
|
import com.ttstd.signaling.model.DeviceType;
|
||||||
import com.ttstd.signaling.model.SignalMessage;
|
import com.ttstd.signaling.model.SignalMessage;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -26,10 +27,14 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
|
|||||||
|
|
||||||
private final SessionManager sessionManager;
|
private final SessionManager sessionManager;
|
||||||
private final ConnectionRequestManager connectionRequestManager;
|
private final ConnectionRequestManager connectionRequestManager;
|
||||||
|
private final SignalMetrics metrics;
|
||||||
|
|
||||||
public SignalWebSocketHandler(SessionManager sessionManager, ConnectionRequestManager connectionRequestManager) {
|
public SignalWebSocketHandler(SessionManager sessionManager,
|
||||||
|
ConnectionRequestManager connectionRequestManager,
|
||||||
|
SignalMetrics metrics) {
|
||||||
this.sessionManager = sessionManager;
|
this.sessionManager = sessionManager;
|
||||||
this.connectionRequestManager = connectionRequestManager;
|
this.connectionRequestManager = connectionRequestManager;
|
||||||
|
this.metrics = metrics;
|
||||||
this.connectionRequestManager.setSender(this::sendToDevice);
|
this.connectionRequestManager.setSender(this::sendToDevice);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +59,7 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
|
|||||||
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
|
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
|
||||||
String payload = message.getPayload();
|
String payload = message.getPayload();
|
||||||
logger.debug("Received message: {}", payload);
|
logger.debug("Received message: {}", payload);
|
||||||
|
metrics.incMessage();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
SignalMessage signalMessage = objectMapper.readValue(payload, SignalMessage.class);
|
SignalMessage signalMessage = objectMapper.readValue(payload, SignalMessage.class);
|
||||||
@@ -75,16 +81,19 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
|
|||||||
// 客户端心跳保活消息,无需处理,仅用于防止中间代理因空闲超时断开连接
|
// 客户端心跳保活消息,无需处理,仅用于防止中间代理因空闲超时断开连接
|
||||||
break;
|
break;
|
||||||
case "OFFER":
|
case "OFFER":
|
||||||
|
metrics.incOffer();
|
||||||
// 连接请求:统一经 ConnectionRequestManager 做校验/去重/待确认跟踪后再转发
|
// 连接请求:统一经 ConnectionRequestManager 做校验/去重/待确认跟踪后再转发
|
||||||
handleConnectionRequest(signalMessage);
|
handleConnectionRequest(signalMessage);
|
||||||
break;
|
break;
|
||||||
case "ANSWER":
|
case "ANSWER":
|
||||||
|
metrics.incAnswer();
|
||||||
// 被控端已接受:清理待确认状态并转发 Answer 给主控端
|
// 被控端已接受:清理待确认状态并转发 Answer 给主控端
|
||||||
connectionRequestManager.completePendingRequest(
|
connectionRequestManager.completePendingRequest(
|
||||||
signalMessage.getToDeviceId(), signalMessage.getFromDeviceId());
|
signalMessage.getToDeviceId(), signalMessage.getFromDeviceId());
|
||||||
forwardMessage(signalMessage, true);
|
forwardMessage(signalMessage, true);
|
||||||
break;
|
break;
|
||||||
case "CONNECTION_REJECTED":
|
case "CONNECTION_REJECTED":
|
||||||
|
metrics.incRejected();
|
||||||
// 被控端已拒绝:清理待确认状态并转发拒绝通知给主控端
|
// 被控端已拒绝:清理待确认状态并转发拒绝通知给主控端
|
||||||
connectionRequestManager.completePendingRequest(
|
connectionRequestManager.completePendingRequest(
|
||||||
signalMessage.getToDeviceId(), signalMessage.getFromDeviceId());
|
signalMessage.getToDeviceId(), signalMessage.getFromDeviceId());
|
||||||
@@ -109,12 +118,14 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
|
|||||||
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
|
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
|
||||||
logger.info("WebSocket connection closed: {} ({})", session.getId(), status);
|
logger.info("WebSocket connection closed: {} ({})", session.getId(), status);
|
||||||
sessionManager.unregisterSession(session);
|
sessionManager.unregisterSession(session);
|
||||||
|
metrics.recordSessionCount(sessionManager.getOnlineCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
|
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
|
||||||
logger.error("Transport error on session {}: {}", session.getId(), exception.getMessage());
|
logger.error("Transport error on session {}: {}", session.getId(), exception.getMessage());
|
||||||
sessionManager.unregisterSession(session);
|
sessionManager.unregisterSession(session);
|
||||||
|
metrics.recordSessionCount(sessionManager.getOnlineCount());
|
||||||
if (session.isOpen()) {
|
if (session.isOpen()) {
|
||||||
session.close(CloseStatus.SERVER_ERROR);
|
session.close(CloseStatus.SERVER_ERROR);
|
||||||
}
|
}
|
||||||
@@ -132,6 +143,8 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
|
|||||||
try {
|
try {
|
||||||
DeviceType deviceType = DeviceType.valueOf(deviceTypeStr.toUpperCase());
|
DeviceType deviceType = DeviceType.valueOf(deviceTypeStr.toUpperCase());
|
||||||
sessionManager.registerDevice(deviceId, deviceType, session);
|
sessionManager.registerDevice(deviceId, deviceType, session);
|
||||||
|
metrics.incRegistered();
|
||||||
|
metrics.recordSessionCount(sessionManager.getOnlineCount());
|
||||||
|
|
||||||
// 回复注册成功
|
// 回复注册成功
|
||||||
Map<String, Object> response = new HashMap<>();
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.ScheduledFuture;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,6 +68,29 @@ public class ConnectionRequestManager {
|
|||||||
return from + "->" + to;
|
return from + "->" + to;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public long getDedupWindowMs() {
|
||||||
|
return DEDUP_WINDOW_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getRequestTimeoutMs() {
|
||||||
|
return REQUEST_TIMEOUT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回当前处于“待被控端确认”状态的连接请求列表(供后台管理接口使用)。 */
|
||||||
|
public List<Map<String, Object>> getPendingRequests() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
List<Map<String, Object>> result = new ArrayList<>();
|
||||||
|
for (PendingRequest p : pendingRequests.values()) {
|
||||||
|
Map<String, Object> m = new HashMap<>();
|
||||||
|
m.put("fromDeviceId", p.fromDeviceId);
|
||||||
|
m.put("toDeviceId", p.toDeviceId);
|
||||||
|
m.put("createdAt", p.createdAt);
|
||||||
|
m.put("elapsedMs", now - p.createdAt);
|
||||||
|
result.add(m);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验 OFFER 的合法性(发送方必须为 CONTROLLER,接收方必须为 CONTROLLED)。
|
* 校验 OFFER 的合法性(发送方必须为 CONTROLLER,接收方必须为 CONTROLLED)。
|
||||||
*
|
*
|
||||||
@@ -143,11 +170,13 @@ public class ConnectionRequestManager {
|
|||||||
private static class PendingRequest {
|
private static class PendingRequest {
|
||||||
final String fromDeviceId;
|
final String fromDeviceId;
|
||||||
final String toDeviceId;
|
final String toDeviceId;
|
||||||
java.util.concurrent.ScheduledFuture<?> timeoutTask;
|
final long createdAt;
|
||||||
|
ScheduledFuture<?> timeoutTask;
|
||||||
|
|
||||||
PendingRequest(String fromDeviceId, String toDeviceId) {
|
PendingRequest(String fromDeviceId, String toDeviceId) {
|
||||||
this.fromDeviceId = fromDeviceId;
|
this.fromDeviceId = fromDeviceId;
|
||||||
this.toDeviceId = toDeviceId;
|
this.toDeviceId = toDeviceId;
|
||||||
|
this.createdAt = System.currentTimeMillis();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.ttstd.signaling.manager;
|
package com.ttstd.signaling.manager;
|
||||||
|
|
||||||
|
import com.ttstd.signaling.model.DeviceInfo;
|
||||||
import com.ttstd.signaling.model.DeviceType;
|
import com.ttstd.signaling.model.DeviceType;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -7,8 +8,10 @@ import org.springframework.stereotype.Component;
|
|||||||
import org.springframework.web.socket.WebSocketSession;
|
import org.springframework.web.socket.WebSocketSession;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@@ -22,11 +25,14 @@ public class SessionManager {
|
|||||||
private final Map<String, DeviceType> deviceTypes = new ConcurrentHashMap<>();
|
private final Map<String, DeviceType> deviceTypes = new ConcurrentHashMap<>();
|
||||||
// session.getId() -> deviceId (用于断开连接时清理)
|
// session.getId() -> deviceId (用于断开连接时清理)
|
||||||
private final Map<String, String> sessionToDevice = new ConcurrentHashMap<>();
|
private final Map<String, String> sessionToDevice = new ConcurrentHashMap<>();
|
||||||
|
// deviceId -> 注册(上线)时间戳
|
||||||
|
private final Map<String, Long> connectTime = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
public void registerDevice(String deviceId, DeviceType deviceType, WebSocketSession session) {
|
public void registerDevice(String deviceId, DeviceType deviceType, WebSocketSession session) {
|
||||||
sessions.put(deviceId, session);
|
sessions.put(deviceId, session);
|
||||||
deviceTypes.put(deviceId, deviceType);
|
deviceTypes.put(deviceId, deviceType);
|
||||||
sessionToDevice.put(session.getId(), deviceId);
|
sessionToDevice.put(session.getId(), deviceId);
|
||||||
|
connectTime.put(deviceId, System.currentTimeMillis());
|
||||||
logger.info("Device registered: {} ({})", deviceId, deviceType);
|
logger.info("Device registered: {} ({})", deviceId, deviceType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +41,7 @@ public class SessionManager {
|
|||||||
if (deviceId != null) {
|
if (deviceId != null) {
|
||||||
sessions.remove(deviceId);
|
sessions.remove(deviceId);
|
||||||
deviceTypes.remove(deviceId);
|
deviceTypes.remove(deviceId);
|
||||||
|
connectTime.remove(deviceId);
|
||||||
logger.info("Device unregistered: {}", deviceId);
|
logger.info("Device unregistered: {}", deviceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,4 +78,32 @@ public class SessionManager {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int getOnlineCount() {
|
||||||
|
int count = 0;
|
||||||
|
for (WebSocketSession session : sessions.values()) {
|
||||||
|
if (session.isOpen()) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getConnectedAt(String deviceId) {
|
||||||
|
return connectTime.get(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回全部已注册设备的明细(含当前是否在线)。 */
|
||||||
|
public List<DeviceInfo> getAllDeviceDetails() {
|
||||||
|
Set<String> allIds = new HashSet<>();
|
||||||
|
allIds.addAll(sessions.keySet());
|
||||||
|
allIds.addAll(deviceTypes.keySet());
|
||||||
|
List<DeviceInfo> result = new ArrayList<>();
|
||||||
|
for (String id : allIds) {
|
||||||
|
DeviceType type = deviceTypes.get(id);
|
||||||
|
boolean online = isDeviceOnline(id);
|
||||||
|
result.add(new DeviceInfo(id, type == null ? null : type.name(), online, connectTime.get(id)));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.ttstd.signaling.manager;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
/**
|
||||||
|
* 信令流量与连接指标采集器(进程内计数)。
|
||||||
|
* 由 {@link com.ttstd.signaling.handler.SignalWebSocketHandler} 在关键事件处累加。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class SignalMetrics {
|
||||||
|
private final AtomicLong totalMessages = new AtomicLong(0);
|
||||||
|
private final AtomicLong totalOffers = new AtomicLong(0);
|
||||||
|
private final AtomicLong totalAnswers = new AtomicLong(0);
|
||||||
|
private final AtomicLong totalRejected = new AtomicLong(0);
|
||||||
|
private final AtomicLong totalRegistered = new AtomicLong(0);
|
||||||
|
private final AtomicLong peakSessions = new AtomicLong(0);
|
||||||
|
public void incMessage() {
|
||||||
|
totalMessages.incrementAndGet();
|
||||||
|
}
|
||||||
|
public void incOffer() {
|
||||||
|
totalOffers.incrementAndGet();
|
||||||
|
}
|
||||||
|
public void incAnswer() {
|
||||||
|
totalAnswers.incrementAndGet();
|
||||||
|
}
|
||||||
|
public void incRejected() {
|
||||||
|
totalRejected.incrementAndGet();
|
||||||
|
}
|
||||||
|
public void incRegistered() {
|
||||||
|
totalRegistered.incrementAndGet();
|
||||||
|
}
|
||||||
|
/** 记录历史峰值在线会话数(无锁 CAS 更新)。 */
|
||||||
|
public void recordSessionCount(long count) {
|
||||||
|
long cur;
|
||||||
|
while ((cur = peakSessions.get()) < count) {
|
||||||
|
if (peakSessions.compareAndSet(cur, count)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public Map<String, Object> snapshot() {
|
||||||
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
|
map.put("totalMessages", totalMessages.get());
|
||||||
|
map.put("totalOffers", totalOffers.get());
|
||||||
|
map.put("totalAnswers", totalAnswers.get());
|
||||||
|
map.put("totalRejected", totalRejected.get());
|
||||||
|
map.put("totalRegistered", totalRegistered.get());
|
||||||
|
map.put("peakSessions", peakSessions.get());
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.ttstd.signaling.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备管理视图对象:用于在后台管理接口中序列化设备信息。
|
||||||
|
*/
|
||||||
|
public record DeviceInfo(
|
||||||
|
String deviceId,
|
||||||
|
String deviceType,
|
||||||
|
boolean online,
|
||||||
|
Long connectedAt) {
|
||||||
|
}
|
||||||
@@ -5,6 +5,11 @@ spring:
|
|||||||
application:
|
application:
|
||||||
name: webrtc-signal-server
|
name: webrtc-signal-server
|
||||||
|
|
||||||
|
# 后台管理接口鉴权令牌;可通过环境变量 ADMIN_TOKEN 覆盖。
|
||||||
|
# WebRTCSignalServerWeb 登录时使用的默认令牌即为此值。
|
||||||
|
admin:
|
||||||
|
token: ${ADMIN_TOKEN:webrtc-admin-token}
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
com.ttstd.signaling: DEBUG
|
com.ttstd.signaling: DEBUG
|
||||||
|
|||||||
5
WebRTCSignalServerWeb/.env
Normal file
5
WebRTCSignalServerWeb/.env
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# 管理后台连接的后端信令服务器地址(Spring Boot 端口)
|
||||||
|
VITE_API_BASE=http://175.178.213.60:8088
|
||||||
|
|
||||||
|
# 默认管理令牌,需与服务器 application.yml 中 admin.token 一致
|
||||||
|
VITE_ADMIN_TOKEN=webrtc-admin-token
|
||||||
7
WebRTCSignalServerWeb/.gitignore
vendored
Normal file
7
WebRTCSignalServerWeb/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
88
WebRTCSignalServerWeb/README.md
Normal file
88
WebRTCSignalServerWeb/README.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# WebRTCSignalServerWeb · 信令服务器后台管理系统
|
||||||
|
|
||||||
|
基于 **Vue 3 + Vite + TypeScript + Pinia + Vue Router + Element Plus** 的管理后台,
|
||||||
|
技术栈与目录组织参考 [`vue-vben-admin`](https://github.com/vbenjs/vue-vben-admin),
|
||||||
|
用于管理 `WebRTCSignalServer`(Spring Boot WebSocket 信令服务器)。
|
||||||
|
|
||||||
|
## 功能概览
|
||||||
|
|
||||||
|
| 页面 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 仪表盘 | 在线设备 / 主控端 / 被控端 / 待确认连接统计、设备类型分布饼图、信令流量柱状图、最近在线设备 |
|
||||||
|
| 设备管理 | 查看已注册设备(ID、类型、在线状态、上线时间),支持按类型筛选与自动刷新 |
|
||||||
|
| 连接请求 | 查看主控端发起、等待被控端确认的连接请求(去重 / 超时治理后的待决列表) |
|
||||||
|
| 服务器信息 | 运行时长(实时)、在线数、峰值会话、累计注册与信令流量明细 |
|
||||||
|
| 系统配置 | 连接治理参数(去重窗口、确认超时、WebSocket 端点)与管理令牌说明 |
|
||||||
|
|
||||||
|
数据来源于服务器新增的 `GET /api/admin/**` 接口,由 `AdminAuthFilter` 统一鉴权。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
WebRTCSignalServerWeb/
|
||||||
|
├─ public/ # 静态资源(favicon 等)
|
||||||
|
├─ src/
|
||||||
|
│ ├─ api/ # axios 实例与管理接口封装
|
||||||
|
│ ├─ components/ # 通用组件(StatCard)
|
||||||
|
│ ├─ layout/ # 基础布局 + 侧边栏 + 顶栏
|
||||||
|
│ ├─ router/ # 路由与鉴权守卫
|
||||||
|
│ ├─ stores/ # Pinia 状态(用户/令牌)
|
||||||
|
│ ├─ styles/ # 全局样式
|
||||||
|
│ ├─ views/ # 各业务页面
|
||||||
|
│ ├─ App.vue / main.ts
|
||||||
|
├─ .env # 后端地址与默认令牌
|
||||||
|
├─ vite.config.ts / tsconfig.json
|
||||||
|
└─ package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 启动信令服务器(提供 API)
|
||||||
|
|
||||||
|
服务器需 Java 21+(当前 `pom.xml` 指定 Java 25)与 Maven:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd WebRTCSignalServer
|
||||||
|
mvn spring-boot:run
|
||||||
|
# 默认端口 8080,管理令牌默认 admin.token = webrtc-admin-token
|
||||||
|
```
|
||||||
|
|
||||||
|
> 可在 `src/main/resources/application.yml` 修改 `admin.token`,或用环境变量
|
||||||
|
> `ADMIN_TOKEN` 覆盖。
|
||||||
|
|
||||||
|
### 2. 启动管理后台
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd WebRTCSignalServerWeb
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5180
|
||||||
|
```
|
||||||
|
|
||||||
|
构建生产包:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # 产物在 dist/
|
||||||
|
npm run preview # 本地预览构建产物
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
`.env` 关键变量:
|
||||||
|
|
||||||
|
| 变量 | 默认值 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `VITE_API_BASE` | `http://localhost:8080` | 后端信令服务器地址 |
|
||||||
|
| `VITE_ADMIN_TOKEN` | `webrtc-admin-token` | 默认管理令牌(需与服务器一致) |
|
||||||
|
|
||||||
|
登录时使用 `admin.token` 作为令牌;鉴权通过后会以 `X-Admin-Token`
|
||||||
|
请求头随后续接口发送。令牌错误将返回 401 并跳转登录页。
|
||||||
|
|
||||||
|
## 接口一览(服务器侧新增)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/admin/dashboard` | 仪表盘汇总数据 |
|
||||||
|
| GET | `/api/admin/devices?type=` | 设备列表(可按 CONTROLLER / CONTROLLED 过滤) |
|
||||||
|
| GET | `/api/admin/connections` | 待确认连接请求 |
|
||||||
|
| GET | `/api/admin/config` | 连接治理配置项 |
|
||||||
|
| GET | `/api/admin/health` | 健康检查(登录校验也走此接口) |
|
||||||
13
WebRTCSignalServerWeb/index.html
Normal file
13
WebRTCSignalServerWeb/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>WebRTC 信令服务器 · 管理后台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2043
WebRTCSignalServerWeb/package-lock.json
generated
Normal file
2043
WebRTCSignalServerWeb/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
WebRTCSignalServerWeb/package.json
Normal file
27
WebRTCSignalServerWeb/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "webrtc-signal-server-web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "WebRTC 信令服务器后台管理系统(参考 vue-vben-admin 技术栈:Vue3 + Vite + TypeScript + Pinia + Element Plus)",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview --port 5180"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"axios": "^1.7.7",
|
||||||
|
"echarts": "^5.5.1",
|
||||||
|
"element-plus": "^2.8.4",
|
||||||
|
"pinia": "^2.2.4",
|
||||||
|
"vue": "^3.5.12",
|
||||||
|
"vue-router": "^4.4.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.1.4",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.10",
|
||||||
|
"vue-tsc": "^2.1.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
1357
WebRTCSignalServerWeb/pnpm-lock.yaml
generated
Normal file
1357
WebRTCSignalServerWeb/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
7
WebRTCSignalServerWeb/public/favicon.svg
Normal file
7
WebRTCSignalServerWeb/public/favicon.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||||
|
<rect width="32" height="32" rx="6" fill="#1b1f2d"/>
|
||||||
|
<path d="M6 22c4-10 16-10 20 0" fill="none" stroke="#3b82f6" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<circle cx="16" cy="11" r="3.2" fill="#3b82f6"/>
|
||||||
|
<circle cx="9" cy="20" r="2" fill="#67c23a"/>
|
||||||
|
<circle cx="23" cy="20" r="2" fill="#67c23a"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 401 B |
3
WebRTCSignalServerWeb/src/App.vue
Normal file
3
WebRTCSignalServerWeb/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
71
WebRTCSignalServerWeb/src/api/admin.ts
Normal file
71
WebRTCSignalServerWeb/src/api/admin.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import request from './request';
|
||||||
|
|
||||||
|
export type DeviceType = 'CONTROLLER' | 'CONTROLLED' | null;
|
||||||
|
|
||||||
|
export interface DeviceInfo {
|
||||||
|
deviceId: string;
|
||||||
|
deviceType: DeviceType;
|
||||||
|
online: boolean;
|
||||||
|
connectedAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardData {
|
||||||
|
serverStartTime: number;
|
||||||
|
currentTime: number;
|
||||||
|
uptimeMs: number;
|
||||||
|
totalDevices: number;
|
||||||
|
onlineDevices: number;
|
||||||
|
offlineDevices: number;
|
||||||
|
onlineControllers: number;
|
||||||
|
onlineControlled: number;
|
||||||
|
pendingConnections: number;
|
||||||
|
metrics: {
|
||||||
|
totalMessages: number;
|
||||||
|
totalOffers: number;
|
||||||
|
totalAnswers: number;
|
||||||
|
totalRejected: number;
|
||||||
|
totalRegistered: number;
|
||||||
|
peakSessions: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingConnection {
|
||||||
|
fromDeviceId: string;
|
||||||
|
toDeviceId: string;
|
||||||
|
createdAt: number;
|
||||||
|
elapsedMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerConfig {
|
||||||
|
dedupWindowMs: number;
|
||||||
|
requestTimeoutMs: number;
|
||||||
|
websocketEndpoint: string;
|
||||||
|
serverPort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDashboard(): Promise<DashboardData> {
|
||||||
|
return request.get('/api/admin/dashboard') as unknown as Promise<DashboardData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDevices(type?: string): Promise<DeviceInfo[]> {
|
||||||
|
return request.get('/api/admin/devices', { params: type ? { type } : {} }) as unknown as Promise<
|
||||||
|
DeviceInfo[]
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConnections(): Promise<PendingConnection[]> {
|
||||||
|
return request.get('/api/admin/connections') as unknown as Promise<PendingConnection[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfig(): Promise<ServerConfig> {
|
||||||
|
return request.get('/api/admin/config') as unknown as Promise<ServerConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHealth(): Promise<{ status: string }> {
|
||||||
|
return request.get('/api/admin/health') as unknown as Promise<{ status: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验令牌有效性(用于登录时验证)。 */
|
||||||
|
export function verifyToken(token: string): Promise<unknown> {
|
||||||
|
return request.get('/api/admin/health', { headers: { 'X-Admin-Token': token } });
|
||||||
|
}
|
||||||
33
WebRTCSignalServerWeb/src/api/request.ts
Normal file
33
WebRTCSignalServerWeb/src/api/request.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import router from '@/router';
|
||||||
|
|
||||||
|
const request = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE || 'http://localhost:8080',
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
request.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('admin_token');
|
||||||
|
if (token) {
|
||||||
|
config.headers['X-Admin-Token'] = token;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
request.interceptors.response.use(
|
||||||
|
(resp) => resp.data,
|
||||||
|
(error) => {
|
||||||
|
const status = error.response?.status;
|
||||||
|
if (status === 401) {
|
||||||
|
localStorage.removeItem('admin_token');
|
||||||
|
ElMessage.error('登录已失效,请重新登录');
|
||||||
|
router.replace({ name: 'Login' });
|
||||||
|
} else {
|
||||||
|
ElMessage.error(error.response?.data?.message || error.message || '请求失败');
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default request;
|
||||||
55
WebRTCSignalServerWeb/src/components/StatCard.vue
Normal file
55
WebRTCSignalServerWeb/src/components/StatCard.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<el-card shadow="hover" class="stat-card">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-icon" :style="{ background: color || '#3b82f6' }">
|
||||||
|
<el-icon><component :is="icon" /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-body">
|
||||||
|
<div class="stat-value">{{ value }}</div>
|
||||||
|
<div class="stat-title">{{ title }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
icon: string;
|
||||||
|
color?: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stat-card {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.stat-icon {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
.stat-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #909399;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
16
WebRTCSignalServerWeb/src/env.d.ts
vendored
Normal file
16
WebRTCSignalServerWeb/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue';
|
||||||
|
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE: string;
|
||||||
|
readonly VITE_ADMIN_TOKEN: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
49
WebRTCSignalServerWeb/src/layout/BasicLayout.vue
Normal file
49
WebRTCSignalServerWeb/src/layout/BasicLayout.vue
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<div class="layout">
|
||||||
|
<SideBar :collapsed="collapsed" />
|
||||||
|
<div class="layout-main">
|
||||||
|
<HeaderBar :collapsed="collapsed" @toggle="collapsed = !collapsed" />
|
||||||
|
<div class="layout-content">
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<transition name="fade" mode="out-in">
|
||||||
|
<component :is="Component" />
|
||||||
|
</transition>
|
||||||
|
</router-view>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import SideBar from './components/SideBar.vue';
|
||||||
|
import HeaderBar from './components/HeaderBar.vue';
|
||||||
|
|
||||||
|
const collapsed = ref(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.layout-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.layout-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--admin-bg);
|
||||||
|
}
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
116
WebRTCSignalServerWeb/src/layout/components/HeaderBar.vue
Normal file
116
WebRTCSignalServerWeb/src/layout/components/HeaderBar.vue
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<template>
|
||||||
|
<header class="header">
|
||||||
|
<div class="left">
|
||||||
|
<el-icon class="icon-btn" @click="emit('toggle')">
|
||||||
|
<component :is="collapsed ? 'Expand' : 'Fold'" />
|
||||||
|
</el-icon>
|
||||||
|
<el-breadcrumb separator="/">
|
||||||
|
<el-breadcrumb-item>首页</el-breadcrumb-item>
|
||||||
|
<el-breadcrumb-item v-for="b in breadcrumb" :key="b">{{ b }}</el-breadcrumb-item>
|
||||||
|
</el-breadcrumb>
|
||||||
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
<el-tooltip content="刷新" placement="bottom">
|
||||||
|
<el-icon class="icon-btn" @click="refresh"><Refresh /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip content="全屏" placement="bottom">
|
||||||
|
<el-icon class="icon-btn" @click="toggleFullscreen"><FullScreen /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-dropdown trigger="click">
|
||||||
|
<span class="user">
|
||||||
|
<el-avatar :size="30" style="background: var(--admin-primary)">
|
||||||
|
{{ user.username.charAt(0).toUpperCase() }}
|
||||||
|
</el-avatar>
|
||||||
|
<span class="username">{{ user.username }}</span>
|
||||||
|
<el-icon><ArrowDown /></el-icon>
|
||||||
|
</span>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item disabled>{{ user.username }}</el-dropdown-item>
|
||||||
|
<el-dropdown-item divided @click="logout">
|
||||||
|
<el-icon><SwitchButton /></el-icon> 退出登录
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useUserStore } from '@/stores/user';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
defineProps<{ collapsed: boolean }>();
|
||||||
|
const emit = defineEmits<{ toggle: [] }>();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const user = useUserStore();
|
||||||
|
|
||||||
|
const breadcrumb = computed(() =>
|
||||||
|
route.meta.title ? [route.meta.title as string] : [],
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleFullscreen() {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.documentElement.requestFullscreen();
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
router.go(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
user.logout();
|
||||||
|
ElMessage.success('已退出登录');
|
||||||
|
router.replace({ name: 'Login' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header {
|
||||||
|
height: 60px;
|
||||||
|
background: var(--admin-header-bg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 16px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.icon-btn {
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #5b6573;
|
||||||
|
}
|
||||||
|
.icon-btn:hover {
|
||||||
|
color: var(--admin-primary);
|
||||||
|
}
|
||||||
|
.user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.username {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
81
WebRTCSignalServerWeb/src/layout/components/SideBar.vue
Normal file
81
WebRTCSignalServerWeb/src/layout/components/SideBar.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="sidebar" :class="{ 'is-collapsed': collapsed }">
|
||||||
|
<div class="logo">
|
||||||
|
<span class="logo-icon">📡</span>
|
||||||
|
<span v-show="!collapsed" class="logo-text">信令管理后台</span>
|
||||||
|
</div>
|
||||||
|
<el-menu
|
||||||
|
router
|
||||||
|
:default-active="activeMenu"
|
||||||
|
:collapse="collapsed"
|
||||||
|
:collapse-transition="false"
|
||||||
|
background-color="#1b1f2d"
|
||||||
|
text-color="#cbd5e1"
|
||||||
|
active-text-color="#ffffff"
|
||||||
|
class="menu"
|
||||||
|
>
|
||||||
|
<el-menu-item v-for="m in menus" :key="m.path" :index="m.path">
|
||||||
|
<el-icon><component :is="m.icon" /></el-icon>
|
||||||
|
<template #title>{{ m.title }}</template>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
defineProps<{ collapsed: boolean }>();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const activeMenu = computed(() => route.path);
|
||||||
|
|
||||||
|
const menus = [
|
||||||
|
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer' },
|
||||||
|
{ path: '/devices', title: '设备管理', icon: 'Monitor' },
|
||||||
|
{ path: '/connections', title: '连接请求', icon: 'Connection' },
|
||||||
|
{ path: '/system/info', title: '服务器信息', icon: 'Cpu' },
|
||||||
|
{ path: '/system/config', title: '系统配置', icon: 'Setting' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar {
|
||||||
|
width: 220px;
|
||||||
|
background: var(--admin-sidebar-bg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: width 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.sidebar.is-collapsed {
|
||||||
|
width: 64px;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 18px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.logo-icon {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
.menu {
|
||||||
|
border-right: none;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
:deep(.el-menu-item.is-active) {
|
||||||
|
background: var(--admin-sidebar-active) !important;
|
||||||
|
}
|
||||||
|
:deep(.el-menu-item:hover) {
|
||||||
|
background: #2a3142 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
20
WebRTCSignalServerWeb/src/main.ts
Normal file
20
WebRTCSignalServerWeb/src/main.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
|
import ElementPlus from 'element-plus';
|
||||||
|
import 'element-plus/dist/index.css';
|
||||||
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
|
||||||
|
import App from './App.vue';
|
||||||
|
import router from './router';
|
||||||
|
import './styles/index.css';
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
|
||||||
|
app.use(createPinia());
|
||||||
|
app.use(router);
|
||||||
|
app.use(ElementPlus);
|
||||||
|
|
||||||
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||||
|
app.component(key, component);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.mount('#app');
|
||||||
70
WebRTCSignalServerWeb/src/router/index.ts
Normal file
70
WebRTCSignalServerWeb/src/router/index.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import type { RouteRecordRaw } from 'vue-router';
|
||||||
|
import BasicLayout from '@/layout/BasicLayout.vue';
|
||||||
|
import { useUserStore } from '@/stores/user';
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: () => import('@/views/login/Login.vue'),
|
||||||
|
meta: { public: true, title: '登录' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: BasicLayout,
|
||||||
|
redirect: '/dashboard',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: () => import('@/views/dashboard/Dashboard.vue'),
|
||||||
|
meta: { title: '仪表盘', icon: 'Odometer' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'devices',
|
||||||
|
name: 'Devices',
|
||||||
|
component: () => import('@/views/devices/DeviceList.vue'),
|
||||||
|
meta: { title: '设备管理', icon: 'Monitor' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'connections',
|
||||||
|
name: 'Connections',
|
||||||
|
component: () => import('@/views/connections/ConnectionList.vue'),
|
||||||
|
meta: { title: '连接请求', icon: 'Connection' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'system/info',
|
||||||
|
name: 'SystemInfo',
|
||||||
|
component: () => import('@/views/system/ServerInfo.vue'),
|
||||||
|
meta: { title: '服务器信息', icon: 'Cpu' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'system/config',
|
||||||
|
name: 'SystemConfig',
|
||||||
|
component: () => import('@/views/system/Settings.vue'),
|
||||||
|
meta: { title: '系统配置', icon: 'Setting' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
const user = useUserStore();
|
||||||
|
const token = user.token || localStorage.getItem('admin_token');
|
||||||
|
if (!to.meta.public && !token) {
|
||||||
|
return { name: 'Login', query: { redirect: to.fullPath } };
|
||||||
|
}
|
||||||
|
if (to.name === 'Login' && token) {
|
||||||
|
return { name: 'Dashboard' };
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
34
WebRTCSignalServerWeb/src/stores/user.ts
Normal file
34
WebRTCSignalServerWeb/src/stores/user.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { verifyToken } from '@/api/admin';
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'admin_token';
|
||||||
|
const USER_KEY = 'admin_user';
|
||||||
|
|
||||||
|
export const useUserStore = defineStore('user', () => {
|
||||||
|
const token = ref<string>(localStorage.getItem(TOKEN_KEY) || '');
|
||||||
|
const username = ref<string>(localStorage.getItem(USER_KEY) || 'admin');
|
||||||
|
|
||||||
|
function setToken(value: string) {
|
||||||
|
token.value = value;
|
||||||
|
localStorage.setItem(TOKEN_KEY, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUser(value: string) {
|
||||||
|
username.value = value;
|
||||||
|
localStorage.setItem(USER_KEY, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录:先用令牌请求 /health 校验有效性,成功后再保存。 */
|
||||||
|
async function login(value: string) {
|
||||||
|
await verifyToken(value);
|
||||||
|
setToken(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
token.value = '';
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token, username, setToken, setUser, login, logout };
|
||||||
|
});
|
||||||
55
WebRTCSignalServerWeb/src/styles/index.css
Normal file
55
WebRTCSignalServerWeb/src/styles/index.css
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
:root {
|
||||||
|
--admin-bg: #f0f2f5;
|
||||||
|
--admin-sidebar-bg: #1b1f2d;
|
||||||
|
--admin-sidebar-active: #3b82f6;
|
||||||
|
--admin-header-bg: #ffffff;
|
||||||
|
--admin-primary: #3b82f6;
|
||||||
|
--admin-text: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||||
|
'Microsoft YaHei', sans-serif;
|
||||||
|
background: var(--admin-bg);
|
||||||
|
color: var(--admin-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">连接请求</h2>
|
||||||
|
<span class="text-muted">
|
||||||
|
主控端发起、等待被控端确认的连接请求(共 {{ connections.length }} 条)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-switch v-model="autoRefresh" active-text="自动刷新" />
|
||||||
|
<el-button :icon="'Refresh'" :loading="loading" @click="load">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-empty v-if="!connections.length" description="当前没有待确认的连接请求" />
|
||||||
|
|
||||||
|
<el-table v-else :data="connections" stripe>
|
||||||
|
<el-table-column label="主控端 (From)" prop="fromDeviceId" min-width="200" />
|
||||||
|
<el-table-column label="被控端 (To)" prop="toDeviceId" min-width="200" />
|
||||||
|
<el-table-column label="发起时间" min-width="180">
|
||||||
|
<template #default="{ row }">{{ fmtTime(row.createdAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="已等待" width="140">
|
||||||
|
<template #default="{ row }">{{ (row.elapsedMs / 1000).toFixed(1) }} 秒</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="120">
|
||||||
|
<template #default>
|
||||||
|
<el-tag type="warning" effect="dark">待确认</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||||
|
import { getConnections } from '@/api/admin';
|
||||||
|
import type { PendingConnection } from '@/api/admin';
|
||||||
|
|
||||||
|
const connections = ref<PendingConnection[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const autoRefresh = ref(true);
|
||||||
|
let timer: number | undefined;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
connections.value = await getConnections();
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTimer() {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
if (autoRefresh.value) timer = window.setInterval(load, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(autoRefresh, startTimer);
|
||||||
|
|
||||||
|
function fmtTime(ts: number) {
|
||||||
|
return new Date(ts).toLocaleString('zh-CN');
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
load();
|
||||||
|
startTimer();
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-title {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
188
WebRTCSignalServerWeb/src/views/dashboard/Dashboard.vue
Normal file
188
WebRTCSignalServerWeb/src/views/dashboard/Dashboard.vue
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">仪表盘</h2>
|
||||||
|
<span class="text-muted">实时展示信令服务器运行态,每 5 秒自动刷新</span>
|
||||||
|
</div>
|
||||||
|
<div class="page-actions">
|
||||||
|
<span class="text-muted">最后更新:{{ lastUpdate }}</span>
|
||||||
|
<el-button :icon="'Refresh'" :loading="loading" @click="load">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :xs="12" :sm="12" :md="6">
|
||||||
|
<StatCard title="在线设备" :value="data?.onlineDevices ?? 0" icon="Monitor" color="#3b82f6" />
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="12" :md="6">
|
||||||
|
<StatCard title="在线主控端" :value="data?.onlineControllers ?? 0" icon="VideoCamera" color="#67c23a" />
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="12" :md="6">
|
||||||
|
<StatCard title="在线被控端" :value="data?.onlineControlled ?? 0" icon="Cellphone" color="#e6a23c" />
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="12" :md="6">
|
||||||
|
<StatCard title="待确认连接" :value="data?.pendingConnections ?? 0" icon="Connection" color="#f56c6c" />
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="16" class="charts-row">
|
||||||
|
<el-col :xs="24" :md="10">
|
||||||
|
<el-card shadow="hover" header="设备类型分布">
|
||||||
|
<div ref="pieRef" class="chart"></div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="14">
|
||||||
|
<el-card shadow="hover" header="信令流量统计">
|
||||||
|
<div ref="barRef" class="chart"></div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-card shadow="hover" header="最近在线设备">
|
||||||
|
<el-table :data="devices.slice(0, 8)" stripe size="default">
|
||||||
|
<el-table-column prop="deviceId" label="设备 ID" min-width="180" />
|
||||||
|
<el-table-column label="类型" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.deviceType === 'CONTROLLER'" type="success">主控端</el-tag>
|
||||||
|
<el-tag v-else-if="row.deviceType === 'CONTROLLED'" type="warning">被控端</el-tag>
|
||||||
|
<el-tag v-else type="info">未知</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.online ? 'success' : 'danger'" effect="dark">
|
||||||
|
{{ row.online ? '在线' : '离线' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="上线时间" min-width="180">
|
||||||
|
<template #default="{ row }">{{ fmtTime(row.connectedAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||||
|
import * as echarts from 'echarts';
|
||||||
|
import { getDashboard, getDevices } from '@/api/admin';
|
||||||
|
import type { DashboardData, DeviceInfo } from '@/api/admin';
|
||||||
|
import StatCard from '@/components/StatCard.vue';
|
||||||
|
|
||||||
|
const data = ref<DashboardData | null>(null);
|
||||||
|
const devices = ref<DeviceInfo[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const lastUpdate = ref('—');
|
||||||
|
const pieRef = ref<HTMLDivElement>();
|
||||||
|
const barRef = ref<HTMLDivElement>();
|
||||||
|
let pieChart: echarts.ECharts | null = null;
|
||||||
|
let barChart: echarts.ECharts | null = null;
|
||||||
|
let timer: number | undefined;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const [d, devs] = await Promise.all([getDashboard(), getDevices()]);
|
||||||
|
data.value = d;
|
||||||
|
devices.value = devs;
|
||||||
|
lastUpdate.value = new Date().toLocaleTimeString('zh-CN');
|
||||||
|
renderCharts();
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCharts() {
|
||||||
|
if (!data.value) return;
|
||||||
|
const d = data.value;
|
||||||
|
pieChart?.setOption({
|
||||||
|
tooltip: { trigger: 'item' },
|
||||||
|
legend: { bottom: 0 },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '设备分布',
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['42%', '70%'],
|
||||||
|
avoidLabelOverlap: true,
|
||||||
|
label: { show: true, formatter: '{b}: {c}' },
|
||||||
|
data: [
|
||||||
|
{ value: d.onlineControllers, name: '在线主控端', itemStyle: { color: '#67c23a' } },
|
||||||
|
{ value: d.onlineControlled, name: '在线被控端', itemStyle: { color: '#e6a23c' } },
|
||||||
|
{ value: d.offlineDevices, name: '离线设备', itemStyle: { color: '#c0c4cc' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const m = d.metrics;
|
||||||
|
barChart?.setOption({
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
grid: { left: 40, right: 20, top: 30, bottom: 30 },
|
||||||
|
xAxis: { type: 'category', data: ['注册', 'OFFER', 'ANSWER', '拒绝', '消息总量'] },
|
||||||
|
yAxis: { type: 'value' },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
barWidth: '48%',
|
||||||
|
data: [m.totalRegistered, m.totalOffers, m.totalAnswers, m.totalRejected, m.totalMessages],
|
||||||
|
itemStyle: { borderRadius: [4, 4, 0, 0], color: '#3b82f6' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initCharts() {
|
||||||
|
if (pieRef.value) pieChart = echarts.init(pieRef.value);
|
||||||
|
if (barRef.value) barChart = echarts.init(barRef.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResize() {
|
||||||
|
pieChart?.resize();
|
||||||
|
barChart?.resize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(ts: number | null) {
|
||||||
|
return ts ? new Date(ts).toLocaleString('zh-CN') : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initCharts();
|
||||||
|
load();
|
||||||
|
timer = window.setInterval(load, 5000);
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
pieChart?.dispose();
|
||||||
|
barChart?.dispose();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.page-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.charts-row {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.chart {
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
131
WebRTCSignalServerWeb/src/views/devices/DeviceList.vue
Normal file
131
WebRTCSignalServerWeb/src/views/devices/DeviceList.vue
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">设备管理</h2>
|
||||||
|
<span class="text-muted">共 {{ devices.length }} 台已注册设备</span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-select v-model="filterType" placeholder="全部类型" clearable style="width: 150px">
|
||||||
|
<el-option label="主控端" value="CONTROLLER" />
|
||||||
|
<el-option label="被控端" value="CONTROLLED" />
|
||||||
|
</el-select>
|
||||||
|
<el-switch v-model="autoRefresh" active-text="自动刷新" />
|
||||||
|
<el-button :icon="'Refresh'" :loading="loading" @click="load">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="devices" stripe>
|
||||||
|
<el-table-column prop="deviceId" label="设备 ID" min-width="200" />
|
||||||
|
<el-table-column label="类型" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.deviceType === 'CONTROLLER'" type="success">主控端</el-tag>
|
||||||
|
<el-tag v-else-if="row.deviceType === 'CONTROLLED'" type="warning">被控端</el-tag>
|
||||||
|
<el-tag v-else type="info">未知</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="status-dot" :class="row.online ? 'online' : 'offline'"></span>
|
||||||
|
<el-tag :type="row.online ? 'success' : 'danger'" effect="dark" size="small">
|
||||||
|
{{ row.online ? '在线' : '离线' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="上线时间" min-width="180">
|
||||||
|
<template #default="{ row }">{{ fmtTime(row.connectedAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="120" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" :icon="'CopyDocument'" @click="copyId(row.deviceId)">
|
||||||
|
复制 ID
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||||
|
import { getDevices } from '@/api/admin';
|
||||||
|
import type { DeviceInfo } from '@/api/admin';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
const devices = ref<DeviceInfo[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const filterType = ref<string>('');
|
||||||
|
const autoRefresh = ref(true);
|
||||||
|
let timer: number | undefined;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
devices.value = await getDevices(filterType.value || undefined);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTimer() {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
if (autoRefresh.value) timer = window.setInterval(load, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(autoRefresh, startTimer);
|
||||||
|
watch(filterType, load);
|
||||||
|
|
||||||
|
function copyId(id: string) {
|
||||||
|
navigator.clipboard
|
||||||
|
?.writeText(id)
|
||||||
|
.then(() => ElMessage.success('已复制设备 ID'))
|
||||||
|
.catch(() => ElMessage.error('复制失败'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(ts: number | null) {
|
||||||
|
return ts ? new Date(ts).toLocaleString('zh-CN') : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
load();
|
||||||
|
startTimer();
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-title {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
.status-dot.online {
|
||||||
|
background: #67c23a;
|
||||||
|
}
|
||||||
|
.status-dot.offline {
|
||||||
|
background: #f56c6c;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
116
WebRTCSignalServerWeb/src/views/login/Login.vue
Normal file
116
WebRTCSignalServerWeb/src/views/login/Login.vue
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-wrap">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-logo">📡</div>
|
||||||
|
<h1>WebRTC 信令服务器</h1>
|
||||||
|
<p>后台管理系统</p>
|
||||||
|
</div>
|
||||||
|
<el-form @submit.prevent="onSubmit">
|
||||||
|
<el-form-item>
|
||||||
|
<el-input
|
||||||
|
v-model="token"
|
||||||
|
size="large"
|
||||||
|
placeholder="请输入管理令牌 (X-Admin-Token)"
|
||||||
|
:prefix-icon="'Key'"
|
||||||
|
@keyup.enter="onSubmit"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
class="login-btn"
|
||||||
|
:loading="loading"
|
||||||
|
@click="onSubmit"
|
||||||
|
>
|
||||||
|
登 录
|
||||||
|
</el-button>
|
||||||
|
<p class="hint">
|
||||||
|
默认令牌与服务器 <code>application.yml</code> 中的
|
||||||
|
<code>admin.token</code> 一致(当前默认:<code>webrtc-admin-token</code>)。
|
||||||
|
</p>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useUserStore } from '@/stores/user';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const user = useUserStore();
|
||||||
|
const token = ref(import.meta.env.VITE_ADMIN_TOKEN || 'webrtc-admin-token');
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (!token.value.trim()) {
|
||||||
|
ElMessage.warning('请输入管理令牌');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await user.login(token.value.trim());
|
||||||
|
ElMessage.success('登录成功');
|
||||||
|
const redirect = (route.query.redirect as string) || '/dashboard';
|
||||||
|
router.replace(redirect);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { message?: string } } };
|
||||||
|
ElMessage.error(err?.response?.data?.message || '令牌校验失败');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-wrap {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #1b1f2d 0%, #2b3a67 100%);
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
width: 380px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 36px 32px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.brand-logo {
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
.brand h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 10px 0 4px;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
.brand p {
|
||||||
|
margin: 0;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.login-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.hint code {
|
||||||
|
background: #f0f2f5;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
95
WebRTCSignalServerWeb/src/views/system/ServerInfo.vue
Normal file
95
WebRTCSignalServerWeb/src/views/system/ServerInfo.vue
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :xs="12" :sm="8" :md="6">
|
||||||
|
<StatCard title="当前在线" :value="data?.onlineDevices ?? 0" icon="Monitor" color="#3b82f6" />
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="8" :md="6">
|
||||||
|
<StatCard title="峰值会话" :value="data?.metrics.peakSessions ?? 0" icon="TrendCharts" color="#67c23a" />
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="8" :md="6">
|
||||||
|
<StatCard title="累计注册" :value="data?.metrics.totalRegistered ?? 0" icon="User" color="#e6a23c" />
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="8" :md="6">
|
||||||
|
<StatCard title="消息总量" :value="data?.metrics.totalMessages ?? 0" icon="ChatDotRound" color="#f56c6c" />
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-card shadow="hover" header="运行信息" class="info-card">
|
||||||
|
<el-descriptions :column="2" border>
|
||||||
|
<el-descriptions-item label="运行状态">
|
||||||
|
<el-tag type="success" effect="dark">运行中</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="服务端口">{{ config?.serverPort ?? '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="启动时间">
|
||||||
|
{{ data ? fmtTime(data.serverStartTime) : '—' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="已运行时长">{{ uptime }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="在线 / 总数">
|
||||||
|
{{ data?.onlineDevices ?? 0 }} / {{ data?.totalDevices ?? 0 }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="WebSocket 端点">
|
||||||
|
{{ config?.websocketEndpoint ?? '—' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="hover" header="信令流量明细" class="info-card">
|
||||||
|
<el-descriptions :column="3" border>
|
||||||
|
<el-descriptions-item label="注册总数">{{ data?.metrics.totalRegistered ?? 0 }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="OFFER 总数">{{ data?.metrics.totalOffers ?? 0 }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="ANSWER 总数">{{ data?.metrics.totalAnswers ?? 0 }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="拒绝总数">{{ data?.metrics.totalRejected ?? 0 }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="消息总量">{{ data?.metrics.totalMessages ?? 0 }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="峰值会话">{{ data?.metrics.peakSessions ?? 0 }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
|
||||||
|
import { getDashboard, getConfig } from '@/api/admin';
|
||||||
|
import type { DashboardData, ServerConfig } from '@/api/admin';
|
||||||
|
import StatCard from '@/components/StatCard.vue';
|
||||||
|
|
||||||
|
const data = ref<DashboardData | null>(null);
|
||||||
|
const config = ref<ServerConfig | null>(null);
|
||||||
|
const now = ref(Date.now());
|
||||||
|
let timer: number | undefined;
|
||||||
|
|
||||||
|
const uptime = computed(() => {
|
||||||
|
if (!data.value) return '—';
|
||||||
|
return fmtDuration(now.value - data.value.serverStartTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
function fmtDuration(ms: number) {
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
return `${h} 小时 ${m} 分 ${sec} 秒`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(ts: number) {
|
||||||
|
return new Date(ts).toLocaleString('zh-CN');
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const [d, c] = await Promise.all([getDashboard(), getConfig()]);
|
||||||
|
data.value = d;
|
||||||
|
config.value = c;
|
||||||
|
timer = window.setInterval(() => {
|
||||||
|
now.value = Date.now();
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.info-card {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
60
WebRTCSignalServerWeb/src/views/system/Settings.vue
Normal file
60
WebRTCSignalServerWeb/src/views/system/Settings.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<el-card shadow="hover" header="连接治理配置" class="info-card">
|
||||||
|
<el-descriptions :column="1" border>
|
||||||
|
<el-descriptions-item label="WebSocket 端点">
|
||||||
|
{{ config?.websocketEndpoint ?? '—' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="服务端口">{{ config?.serverPort ?? '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="OFFER 去重窗口">
|
||||||
|
{{ config ? (config.dedupWindowMs / 1000).toFixed(0) + ' 秒' : '—' }}
|
||||||
|
<span class="text-muted">(同一连接在此窗口内的重复 OFFER 将被忽略)</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="连接确认超时">
|
||||||
|
{{ config ? (config.requestTimeoutMs / 1000).toFixed(0) + ' 秒' : '—' }}
|
||||||
|
<span class="text-muted">(超时未确认则通知主控端请求失败)</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="hover" header="管理令牌 (admin.token)" class="info-card">
|
||||||
|
<el-alert type="info" :closable="false" show-icon>
|
||||||
|
<template #title>如何修改后台登录令牌</template>
|
||||||
|
<p>
|
||||||
|
管理接口受 <code>X-Admin-Token</code> 头保护,令牌由服务器
|
||||||
|
<code>application.yml</code> 中的 <code>admin.token</code> 配置,
|
||||||
|
也可通过环境变量 <code>ADMIN_TOKEN</code> 覆盖。
|
||||||
|
</p>
|
||||||
|
<p>当前前端默认令牌取自 <code>.env</code> 的 <code>VITE_ADMIN_TOKEN</code>,需与服务器保持一致。</p>
|
||||||
|
<p>示例:</p>
|
||||||
|
<pre>admin:
|
||||||
|
token: ${ADMIN_TOKEN:webrtc-admin-token}</pre>
|
||||||
|
<p class="text-muted">修改后重启信令服务器即可生效。</p>
|
||||||
|
</el-alert>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { getConfig } from '@/api/admin';
|
||||||
|
import type { ServerConfig } from '@/api/admin';
|
||||||
|
|
||||||
|
const config = ref<ServerConfig | null>(null);
|
||||||
|
onMounted(async () => {
|
||||||
|
config.value = await getConfig();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.info-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
background: #f0f2f5;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
24
WebRTCSignalServerWeb/tsconfig.json
Normal file
24
WebRTCSignalServerWeb/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
11
WebRTCSignalServerWeb/tsconfig.node.json
Normal file
11
WebRTCSignalServerWeb/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
1
WebRTCSignalServerWeb/tsconfig.node.tsbuildinfo
Normal file
1
WebRTCSignalServerWeb/tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
1
WebRTCSignalServerWeb/tsconfig.tsbuildinfo
Normal file
1
WebRTCSignalServerWeb/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/main.ts","./src/api/admin.ts","./src/api/request.ts","./src/router/index.ts","./src/stores/user.ts","./src/app.vue","./src/components/statcard.vue","./src/layout/basiclayout.vue","./src/layout/components/headerbar.vue","./src/layout/components/sidebar.vue","./src/views/connections/connectionlist.vue","./src/views/dashboard/dashboard.vue","./src/views/devices/devicelist.vue","./src/views/login/login.vue","./src/views/system/serverinfo.vue","./src/views/system/settings.vue"],"errors":true,"version":"5.9.3"}
|
||||||
2
WebRTCSignalServerWeb/vite.config.d.ts
vendored
Normal file
2
WebRTCSignalServerWeb/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
declare const _default: import("vite").UserConfig;
|
||||||
|
export default _default;
|
||||||
15
WebRTCSignalServerWeb/vite.config.js
Normal file
15
WebRTCSignalServerWeb/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
16
WebRTCSignalServerWeb/vite.config.ts
Normal file
16
WebRTCSignalServerWeb/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user