mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-26 17:13:54 +08:00
Merge pull request #1331 from CaixyPromise/feature/connect-server-and-api
feat: 新增智控台管理websocket服务器端【配置拉取】和【重启】
This commit is contained in:
@@ -71,10 +71,15 @@ public class RenExceptionHandler {
|
|||||||
List<ObjectError> allErrors = ex.getBindingResult().getAllErrors();
|
List<ObjectError> allErrors = ex.getBindingResult().getAllErrors();
|
||||||
String errorMsg = allErrors.stream()
|
String errorMsg = allErrors.stream()
|
||||||
.filter(Objects::nonNull)
|
.filter(Objects::nonNull)
|
||||||
.map(DefaultMessageSourceResolvable::getDefaultMessage)
|
.map(err -> {
|
||||||
|
String msg = err.getDefaultMessage();
|
||||||
|
return (msg != null && !msg.trim().isEmpty()) ? msg : null;
|
||||||
|
})
|
||||||
|
.filter(Objects::nonNull)
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElse("");
|
.orElse("请求参数错误!");
|
||||||
return new Result<Void>().error(400, errorMsg);
|
|
||||||
|
return new Result<Void>().error(ErrorCode.PARAM_VALUE_NULL, errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,8 @@ public class DateUtils {
|
|||||||
* 时间格式(yyyy-MM-dd HH:mm:ss)
|
* 时间格式(yyyy-MM-dd HH:mm:ss)
|
||||||
*/
|
*/
|
||||||
public final static String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
|
public final static String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
|
||||||
|
public final static String DATE_TIME_MILLIS_PATTERN = "yyyy-MM-dd HH:mm:ss.SSS";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 日期格式化 日期格式为:yyyy-MM-dd
|
* 日期格式化 日期格式为:yyyy-MM-dd
|
||||||
@@ -63,6 +65,19 @@ public class DateUtils {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static String getDateTimeNow() {
|
||||||
|
return getDateTimeNow(DATE_TIME_PATTERN);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getDateTimeNow(String pattern) {
|
||||||
|
return format(new Date(), pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String millsToSecond(long mills) {
|
||||||
|
return String.format("%.3f", mills / 1000.0);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取简短的时间字符串:10秒前返回刚刚,多少秒前,几小时前,超过一周返回年月日时分秒
|
* 获取简短的时间字符串:10秒前返回刚刚,多少秒前,几小时前,超过一周返回年月日时分秒
|
||||||
* @param date
|
* @param date
|
||||||
|
|||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
package xiaozhi.modules.sys.controller;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.socket.WebSocketHttpHeaders;
|
||||||
|
import xiaozhi.common.annotation.LogOperation;
|
||||||
|
import xiaozhi.common.constant.Constant;
|
||||||
|
import xiaozhi.common.exception.RenException;
|
||||||
|
import xiaozhi.common.utils.Result;
|
||||||
|
import xiaozhi.modules.sys.dto.EmitSeverActionDTO;
|
||||||
|
import xiaozhi.modules.sys.dto.ServerActionPayloadDTO;
|
||||||
|
import xiaozhi.modules.sys.dto.ServerActionResponseDTO;
|
||||||
|
import xiaozhi.modules.sys.enums.ServerActionEnum;
|
||||||
|
import xiaozhi.modules.sys.service.SysParamsService;
|
||||||
|
import xiaozhi.modules.sys.utils.WebSocketClientManager;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务端管理控制器
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("admin/server")
|
||||||
|
@Tag(name = "服务端管理")
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ServerSideManageController
|
||||||
|
{
|
||||||
|
private final SysParamsService sysParamsService;
|
||||||
|
private static final ObjectMapper objectMapper;
|
||||||
|
static {
|
||||||
|
objectMapper = new ObjectMapper();
|
||||||
|
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "获取Ws服务端列表")
|
||||||
|
@GetMapping("/server-list")
|
||||||
|
@RequiresPermissions("sys:role:superAdmin")
|
||||||
|
public Result<List<String>> getWsServerList() {
|
||||||
|
String cachedWs = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
|
||||||
|
return new Result<List<String>>().ok(Arrays.asList(cachedWs.split(";")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "通知python服务端更新配置")
|
||||||
|
@PostMapping("/emit-action")
|
||||||
|
@LogOperation("通知python服务端更新配置")
|
||||||
|
// @RequiresPermissions("sys:role:superAdmin")
|
||||||
|
public Result<Boolean> emitServerAction(@RequestBody @Valid EmitSeverActionDTO emitSeverActionDTO) {
|
||||||
|
if (emitSeverActionDTO.getAction() == null) {
|
||||||
|
throw new RenException("无效服务端操作");
|
||||||
|
}
|
||||||
|
String wsText = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
|
||||||
|
if (StringUtils.isBlank(wsText)) {
|
||||||
|
throw new RenException("未配置服务端WebSocket地址");
|
||||||
|
}
|
||||||
|
String targetWs = emitSeverActionDTO.getTargetWs();
|
||||||
|
String[] wsList = wsText.split(";");
|
||||||
|
// 找到需要发起的
|
||||||
|
if (StringUtils.isBlank(targetWs) || !Arrays.asList(wsList).contains(targetWs)) {
|
||||||
|
throw new RenException("目标WebSocket地址不存在");
|
||||||
|
}
|
||||||
|
return new Result<Boolean>().ok(emitServerActionByWs(targetWs, emitSeverActionDTO.getAction()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Boolean emitServerActionByWs(String targetWsUri, ServerActionEnum actionEnum) {
|
||||||
|
if (StringUtils.isBlank(targetWsUri) || actionEnum == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String serverSK = sysParamsService.getValue(Constant.SERVER_SECRET, true);
|
||||||
|
WebSocketHttpHeaders headers = new WebSocketHttpHeaders();
|
||||||
|
headers.add("Authorization", "Bearer " + serverSK);
|
||||||
|
headers.add("device-id", serverSK);
|
||||||
|
|
||||||
|
try (WebSocketClientManager client = new WebSocketClientManager.Builder()
|
||||||
|
.connectTimeout(3, TimeUnit.SECONDS)
|
||||||
|
.maxSessionDuration(5, TimeUnit.SECONDS)
|
||||||
|
.uri(targetWsUri)
|
||||||
|
.headers(headers)
|
||||||
|
.build()) {
|
||||||
|
// 如果连接成功则发送一个json数据包并等待服务端响应
|
||||||
|
client.sendJson(
|
||||||
|
ServerActionPayloadDTO.build(
|
||||||
|
actionEnum,
|
||||||
|
Map.of("secret", serverSK)
|
||||||
|
));
|
||||||
|
// 等待服务端响应并持续监听信息
|
||||||
|
client.listener((jsonText)-> {
|
||||||
|
if (StringUtils.isBlank(jsonText)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return ServerActionResponseDTO.isSuccess(objectMapper.readValue(jsonText, ServerActionResponseDTO.class));
|
||||||
|
}
|
||||||
|
catch (JsonProcessingException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception e) {
|
||||||
|
// 捕获全部错误,由全局异常处理器返回
|
||||||
|
throw new RenException("WebSocket连接失败或连接超时");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-14
@@ -111,7 +111,7 @@ public class SysParamsController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证WebSocket地址列表
|
* 验证WebSocket地址列表
|
||||||
*
|
*
|
||||||
* @param urls WebSocket地址列表,以分号分隔
|
* @param urls WebSocket地址列表,以分号分隔
|
||||||
* @return 验证结果
|
* @return 验证结果
|
||||||
*/
|
*/
|
||||||
@@ -143,6 +143,19 @@ public class SysParamsController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/delete")
|
||||||
|
@Operation(summary = "删除")
|
||||||
|
@LogOperation("删除")
|
||||||
|
@RequiresPermissions("sys:role:superAdmin")
|
||||||
|
public Result<Void> delete(@RequestBody String[] ids) {
|
||||||
|
// 效验数据
|
||||||
|
AssertUtils.isArrayEmpty(ids, "id");
|
||||||
|
|
||||||
|
sysParamsService.delete(ids);
|
||||||
|
configService.getConfig(false);
|
||||||
|
return new Result<Void>();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证OTA地址
|
* 验证OTA地址
|
||||||
*/
|
*/
|
||||||
@@ -182,17 +195,4 @@ public class SysParamsController {
|
|||||||
throw new RenException("OTA接口验证失败:" + e.getMessage());
|
throw new RenException("OTA接口验证失败:" + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/delete")
|
|
||||||
@Operation(summary = "删除")
|
|
||||||
@LogOperation("删除")
|
|
||||||
@RequiresPermissions("sys:role:superAdmin")
|
|
||||||
public Result<Void> delete(@RequestBody String[] ids) {
|
|
||||||
// 效验数据
|
|
||||||
AssertUtils.isArrayEmpty(ids, "id");
|
|
||||||
|
|
||||||
sysParamsService.delete(ids);
|
|
||||||
configService.getConfig(false);
|
|
||||||
return new Result<Void>();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package xiaozhi.modules.sys.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import xiaozhi.modules.sys.enums.ServerActionEnum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送python服务端操作DTO
|
||||||
|
*
|
||||||
|
* @Author CAIXYPROMISE
|
||||||
|
* @since 2025/5/15 4:55
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class EmitSeverActionDTO
|
||||||
|
{
|
||||||
|
@Schema(description = "目标ws地址")
|
||||||
|
@NotEmpty(message = "目标ws地址不能为空")
|
||||||
|
private String targetWs;
|
||||||
|
|
||||||
|
@Schema(description = "指定操作")
|
||||||
|
@NotNull(message = "操作不能为空")
|
||||||
|
private ServerActionEnum action;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package xiaozhi.modules.sys.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import xiaozhi.modules.sys.enums.ServerActionEnum;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务端动作DTO
|
||||||
|
*
|
||||||
|
* @Author CAIXYPROMISE
|
||||||
|
* @since 2025/5/15 5:10
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ServerActionPayloadDTO
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 类型(智控台发往服务端的都是server)
|
||||||
|
*/
|
||||||
|
private String type;
|
||||||
|
/**
|
||||||
|
* 动作
|
||||||
|
*/
|
||||||
|
private ServerActionEnum action;
|
||||||
|
/**
|
||||||
|
* 内容
|
||||||
|
*/
|
||||||
|
private Map<String, Object> content;
|
||||||
|
|
||||||
|
public static ServerActionPayloadDTO build(ServerActionEnum action, Map<String, Object> content) {
|
||||||
|
ServerActionPayloadDTO serverActionPayloadDTO = new ServerActionPayloadDTO();
|
||||||
|
serverActionPayloadDTO.setAction(action);
|
||||||
|
serverActionPayloadDTO.setContent(content);
|
||||||
|
serverActionPayloadDTO.setType("server");
|
||||||
|
return serverActionPayloadDTO;
|
||||||
|
}
|
||||||
|
// 私有化
|
||||||
|
private ServerActionPayloadDTO() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package xiaozhi.modules.sys.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import xiaozhi.modules.sys.enums.ServerActionResponseEnum;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务端动作响应体
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ServerActionResponseDTO
|
||||||
|
{
|
||||||
|
private ServerActionResponseEnum status;
|
||||||
|
private String message;
|
||||||
|
private String type;
|
||||||
|
private Map<String, Object> content; // 后续这个字段可以移除,并把这个类作为基类,针对业务写自己的content类型
|
||||||
|
public static final String DEFAULT_TYPE_FORM_SERVER = "server";
|
||||||
|
|
||||||
|
public static Boolean isSuccess(ServerActionResponseDTO actionResponseDTO) {
|
||||||
|
System.out.println(actionResponseDTO);
|
||||||
|
if (actionResponseDTO == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (actionResponseDTO.getStatus() == null || !actionResponseDTO.getStatus().equals(ServerActionResponseEnum.SUCCESS)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Object actionType = actionResponseDTO.getContent().get("action");
|
||||||
|
if (actionType == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return actionResponseDTO.getType() != null && actionResponseDTO.getType().equals(DEFAULT_TYPE_FORM_SERVER);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package xiaozhi.modules.sys.enums;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonValue;
|
||||||
|
import xiaozhi.common.exception.RenException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务端动作枚举
|
||||||
|
*
|
||||||
|
* @Author CAIXYPROMISE
|
||||||
|
* @since 2025/5/15 4:57
|
||||||
|
*/
|
||||||
|
public enum ServerActionEnum
|
||||||
|
{
|
||||||
|
RESTART("restart"),
|
||||||
|
UPDATE_CONFIG("update_config");
|
||||||
|
|
||||||
|
private final String value;
|
||||||
|
|
||||||
|
ServerActionEnum(String value)
|
||||||
|
{
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonValue
|
||||||
|
public String getValue()
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonCreator
|
||||||
|
public static ServerActionEnum fromValue(String value)
|
||||||
|
{
|
||||||
|
for (ServerActionEnum action : ServerActionEnum.values())
|
||||||
|
{
|
||||||
|
if (action.value.equalsIgnoreCase(value))
|
||||||
|
{
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
package xiaozhi.modules.sys.enums;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonValue;
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务端调用响应枚举
|
||||||
|
*
|
||||||
|
* @Author CAIXYPROMISE
|
||||||
|
* @since 2025/5/15 5:50
|
||||||
|
*/
|
||||||
|
public enum ServerActionResponseEnum
|
||||||
|
{
|
||||||
|
SUCCESS("success"), FAIL("fail");
|
||||||
|
private final String value;
|
||||||
|
|
||||||
|
ServerActionResponseEnum(String value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonValue
|
||||||
|
public String getValue()
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonCreator
|
||||||
|
public static ServerActionResponseEnum fromValue(String value) {
|
||||||
|
ServerActionResponseEnum byValue = getByValue(value);
|
||||||
|
if (byValue == null) {
|
||||||
|
throw new IllegalArgumentException("Unknown enum value: " + value);
|
||||||
|
}
|
||||||
|
return byValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ServerActionResponseEnum getByValue(String value) {
|
||||||
|
if (StringUtils.isBlank(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (ServerActionResponseEnum action : ServerActionResponseEnum.values()) {
|
||||||
|
if (action.value.equals(value)) {
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
package xiaozhi.modules.sys.utils;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.util.StopWatch;
|
||||||
|
import org.springframework.web.socket.*;
|
||||||
|
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
|
||||||
|
import org.springframework.web.socket.handler.AbstractWebSocketHandler;
|
||||||
|
import xiaozhi.common.utils.DateUtils;
|
||||||
|
|
||||||
|
import java.io.Closeable;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocketClientResource:支持 try-with-resources 模式
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class WebSocketClientManager implements Closeable
|
||||||
|
{
|
||||||
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
// 全局回调线程池
|
||||||
|
private static final ExecutorService CALLBACK_EXECUTOR = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors(), new ThreadFactory() {
|
||||||
|
private final AtomicInteger cnt = new AtomicInteger();
|
||||||
|
|
||||||
|
public Thread newThread(Runnable r)
|
||||||
|
{
|
||||||
|
Thread t = new Thread(r, "ws-callback-" + cnt.getAndIncrement());
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
private volatile WebSocketSession session;
|
||||||
|
private final BlockingQueue<String> textMessageQueue;
|
||||||
|
private final BlockingQueue<byte[]> binaryMessageQueue;
|
||||||
|
private final CompletableFuture<Void> errorFuture;
|
||||||
|
private final long maxSessionDuration;
|
||||||
|
private final TimeUnit maxSessionDurationUnit;
|
||||||
|
|
||||||
|
private volatile Consumer<String> onText;
|
||||||
|
private volatile Consumer<byte[]> onBinary;
|
||||||
|
private volatile Consumer<Throwable> onError;
|
||||||
|
|
||||||
|
private final String uri;
|
||||||
|
private final WebSocketHttpHeaders headers;
|
||||||
|
private final long connectTimeout;
|
||||||
|
private final TimeUnit connectUnit;
|
||||||
|
private final int queueCapacity;
|
||||||
|
|
||||||
|
// 私有构造,仅由 Builder 调用
|
||||||
|
private WebSocketClientManager(Builder b) {
|
||||||
|
this.uri = b.uri;
|
||||||
|
this.headers = b.headers != null ? b.headers : new WebSocketHttpHeaders();
|
||||||
|
this.connectTimeout = b.connectTimeout;
|
||||||
|
this.connectUnit = b.connectUnit;
|
||||||
|
this.maxSessionDuration = b.maxSessionDuration;
|
||||||
|
this.maxSessionDurationUnit = b.maxSessionDurationUnit;
|
||||||
|
this.queueCapacity = b.queueCapacity;
|
||||||
|
this.textMessageQueue = new LinkedBlockingQueue<>(queueCapacity);
|
||||||
|
this.binaryMessageQueue = new LinkedBlockingQueue<>(queueCapacity);
|
||||||
|
this.errorFuture = new CompletableFuture<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static WebSocketClientManager build(Builder b) throws InterruptedException, ExecutionException, TimeoutException, IOException {
|
||||||
|
WebSocketClientManager ws = new WebSocketClientManager(b);
|
||||||
|
StandardWebSocketClient client = new StandardWebSocketClient();
|
||||||
|
CompletableFuture<WebSocketSession> future = client.execute(ws.new InternalHandler(b.uri), b.headers, URI.create(b.uri));
|
||||||
|
WebSocketSession sess = future.get(b.connectTimeout, b.connectUnit);
|
||||||
|
if (sess == null || !sess.isOpen())
|
||||||
|
{
|
||||||
|
throw new IOException("握手失败或会话未打开");
|
||||||
|
}
|
||||||
|
ws.session = sess;
|
||||||
|
return ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送 Text
|
||||||
|
*/
|
||||||
|
public void sendText(String text) throws IOException {
|
||||||
|
session.sendMessage(new TextMessage(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sendBinary(byte[] data) throws IOException {
|
||||||
|
session.sendMessage(new BinaryMessage(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sendJson(Object payload) throws IOException {
|
||||||
|
String json = OBJECT_MAPPER.writeValueAsString(payload);
|
||||||
|
session.sendMessage(new TextMessage(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private <T> List<T> listenerCustom(
|
||||||
|
BlockingQueue<T> queue,
|
||||||
|
Predicate<T> predicate)
|
||||||
|
throws InterruptedException, TimeoutException, ExecutionException
|
||||||
|
{
|
||||||
|
List<T> collected = new ArrayList<>();
|
||||||
|
long deadline = System.currentTimeMillis() + maxSessionDurationUnit.toMillis(maxSessionDuration);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (errorFuture.isDone()) {
|
||||||
|
errorFuture.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
long remaining = deadline - System.currentTimeMillis();
|
||||||
|
if (remaining <= 0) {
|
||||||
|
throw new TimeoutException("等待批量消息超时");
|
||||||
|
}
|
||||||
|
|
||||||
|
T msg = queue.poll(remaining, TimeUnit.MILLISECONDS);
|
||||||
|
if (msg == null) {
|
||||||
|
throw new TimeoutException("等待批量消息超时");
|
||||||
|
}
|
||||||
|
|
||||||
|
collected.add(msg);
|
||||||
|
if (predicate.test(msg)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
return collected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步接收多条消息,直到 predicate 为 true 或超时抛异常;
|
||||||
|
* @return 返回监听期间的所有消息列表
|
||||||
|
*/
|
||||||
|
public List<String> listener(Predicate<String> predicate)
|
||||||
|
throws InterruptedException, TimeoutException, ExecutionException
|
||||||
|
{
|
||||||
|
return listenerCustom(textMessageQueue, predicate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<byte[]> listenerBinary(Predicate<byte[]> predicate)
|
||||||
|
throws InterruptedException, TimeoutException, ExecutionException
|
||||||
|
{
|
||||||
|
return listenerCustom(binaryMessageQueue, predicate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册文本回调
|
||||||
|
*/
|
||||||
|
public WebSocketClientManager onText(Consumer<String> c) {
|
||||||
|
this.onText = c;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册二进制回调
|
||||||
|
*/
|
||||||
|
public WebSocketClientManager onBinary(Consumer<byte[]> c) {
|
||||||
|
this.onBinary = c;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册错误回调
|
||||||
|
*/
|
||||||
|
public WebSocketClientManager onError(Consumer<Throwable> c) {
|
||||||
|
this.onError = c;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭会话,try-with-resources / finally 自动调用
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
try {
|
||||||
|
if (session != null && session.isOpen()) {
|
||||||
|
session.close(CloseStatus.NORMAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (IOException ignored) {}
|
||||||
|
textMessageQueue.clear();
|
||||||
|
binaryMessageQueue.clear();
|
||||||
|
errorFuture.completeExceptionally(new IOException("WebSocket 已关闭"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private class InternalHandler extends AbstractWebSocketHandler {
|
||||||
|
private final String targetUri;
|
||||||
|
private final StopWatch stopWatch;
|
||||||
|
|
||||||
|
InternalHandler(String targetUri) {
|
||||||
|
this.targetUri = targetUri;
|
||||||
|
this.stopWatch = new StopWatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接建立时回调
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void afterConnectionEstablished(WebSocketSession session) {
|
||||||
|
// 保存会话
|
||||||
|
WebSocketClientManager.this.session = session;
|
||||||
|
this.stopWatch.start();
|
||||||
|
log.info("ws连接成功, 目标URI: {}, 连接时间: {}", targetUri, DateUtils.getDateTimeNow(DateUtils.DATE_TIME_MILLIS_PATTERN));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理文本消息
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
|
||||||
|
String payload = message.getPayload();
|
||||||
|
// 入队
|
||||||
|
textMessageQueue.offer(payload);
|
||||||
|
// 回调用户注册的 onText
|
||||||
|
if (onText != null) {
|
||||||
|
CALLBACK_EXECUTOR.submit(() -> onText.accept(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理二进制消息
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) throws Exception {
|
||||||
|
ByteBuffer buf = message.getPayload();
|
||||||
|
byte[] data = new byte[buf.remaining()];
|
||||||
|
buf.get(data);
|
||||||
|
// 入队
|
||||||
|
binaryMessageQueue.offer(data);
|
||||||
|
// 回调用户注册的 onBinary
|
||||||
|
if (onBinary != null) {
|
||||||
|
CALLBACK_EXECUTOR.submit(() -> onBinary.accept(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 传输错误时回调
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
|
||||||
|
super.handleTransportError(session, exception);
|
||||||
|
// 保持原有逻辑:完成 errorFuture、回调 onError、关闭会话、异步通知连接失败
|
||||||
|
errorFuture.completeExceptionally(exception);
|
||||||
|
if (onError != null) {
|
||||||
|
CALLBACK_EXECUTOR.submit(() -> onError.accept(exception));
|
||||||
|
}
|
||||||
|
session.close(CloseStatus.SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接关闭时回调
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
|
||||||
|
super.afterConnectionClosed(session, status);
|
||||||
|
if (stopWatch.isRunning()) {
|
||||||
|
stopWatch.stop();
|
||||||
|
}
|
||||||
|
log.info("ws连接关闭, 目标URI: {}, 关闭时间: {}, 连接总时长: {}s",
|
||||||
|
targetUri, DateUtils.getDateTimeNow(DateUtils.DATE_TIME_MILLIS_PATTERN), DateUtils.millsToSecond(stopWatch.getTotalTimeMillis()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Builder {
|
||||||
|
private String uri; // 目标 WS URI
|
||||||
|
private long connectTimeout = 3; // 请求连接等待时间
|
||||||
|
private TimeUnit connectUnit = TimeUnit.SECONDS; // 请求连接等待时间单位
|
||||||
|
private long maxSessionDuration = 5; // 最大连线时间,默认5秒
|
||||||
|
private TimeUnit maxSessionDurationUnit = TimeUnit.SECONDS; // 最大连线时间单位
|
||||||
|
private int queueCapacity = 100; // 消息队列容量
|
||||||
|
private WebSocketHttpHeaders headers; // 请求头
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 目标 WS URI
|
||||||
|
*/
|
||||||
|
public Builder uri(String uri) {
|
||||||
|
this.uri = Objects.requireNonNull(uri);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder headers(WebSocketHttpHeaders h) {
|
||||||
|
this.headers = h;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder connectTimeout(long t, TimeUnit u) {
|
||||||
|
this.connectTimeout = t;
|
||||||
|
this.connectUnit = u;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder maxSessionDuration(long t, TimeUnit u) {
|
||||||
|
this.maxSessionDuration = t;
|
||||||
|
this.maxSessionDurationUnit = u;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder queueCapacity(int c) {
|
||||||
|
this.queueCapacity = c;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public WebSocketClientManager build() throws InterruptedException, ExecutionException, TimeoutException, IOException {
|
||||||
|
return WebSocketClientManager.build(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,5 +130,37 @@ export default {
|
|||||||
})
|
})
|
||||||
}).send()
|
}).send()
|
||||||
},
|
},
|
||||||
|
// 获取ws服务端列表
|
||||||
|
getWsServerList(params, callback) {
|
||||||
|
RequestService.sendRequest()
|
||||||
|
.url(`${getServiceUrl()}/admin/server/server-list`)
|
||||||
|
.method('GET')
|
||||||
|
.success((res) => {
|
||||||
|
RequestService.clearRequestTime()
|
||||||
|
callback(res)
|
||||||
|
})
|
||||||
|
.fail((err) => {
|
||||||
|
console.error('获取ws服务端列表失败:', err)
|
||||||
|
RequestService.reAjaxFun(() => {
|
||||||
|
this.getWsServerList(params, callback)
|
||||||
|
})
|
||||||
|
}).send();
|
||||||
|
},
|
||||||
|
// 发送ws服务器动作指令
|
||||||
|
sendWsServerAction(data, callback) {
|
||||||
|
RequestService.sendRequest()
|
||||||
|
.url(`${getServiceUrl()}/admin/server/emit-action`)
|
||||||
|
.method('POST')
|
||||||
|
.data(data)
|
||||||
|
.success((res) => {
|
||||||
|
RequestService.clearRequestTime()
|
||||||
|
callback(res)
|
||||||
|
})
|
||||||
|
.fail((err) => {
|
||||||
|
RequestService.reAjaxFun(() => {
|
||||||
|
this.sendWsServerAction(data, callback)
|
||||||
|
})
|
||||||
|
}).send();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,9 @@
|
|||||||
<el-dropdown-item @click.native="goProviderManagement">
|
<el-dropdown-item @click.native="goProviderManagement">
|
||||||
供应器管理
|
供应器管理
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item @click.native="goServerSideManagement">
|
||||||
|
服务端管理
|
||||||
|
</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,6 +148,9 @@ export default {
|
|||||||
goProviderManagement() {
|
goProviderManagement() {
|
||||||
this.$router.push('/provider-management')
|
this.$router.push('/provider-management')
|
||||||
},
|
},
|
||||||
|
goServerSideManagement() {
|
||||||
|
this.$router.push('/server-side-management')
|
||||||
|
},
|
||||||
// 获取用户信息
|
// 获取用户信息
|
||||||
fetchUserInfo() {
|
fetchUserInfo() {
|
||||||
userApi.getUserInfo(({ data }) => {
|
userApi.getUserInfo(({ data }) => {
|
||||||
|
|||||||
@@ -80,6 +80,18 @@ const routes = [
|
|||||||
title: '参数管理'
|
title: '参数管理'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: '/server-side-management',
|
||||||
|
name: 'ServerSideManager',
|
||||||
|
component: function () {
|
||||||
|
return import('../views/ServerSideManager.vue')
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
title: '服务端管理'
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/ota-management',
|
path: '/ota-management',
|
||||||
name: 'OtaManagement',
|
name: 'OtaManagement',
|
||||||
|
|||||||
@@ -0,0 +1,597 @@
|
|||||||
|
<template>
|
||||||
|
<div class="welcome">
|
||||||
|
<HeaderBar/>
|
||||||
|
|
||||||
|
<div class="operation-bar">
|
||||||
|
<h2 class="page-title">服务端管理</h2>
|
||||||
|
<div class="right-operations">
|
||||||
|
<el-input placeholder="请输入服务端ws地址查询" v-model="searchCode" class="search-input"
|
||||||
|
@keyup.enter.native="handleSearch" clearable/>
|
||||||
|
<el-button class="btn-search" @click="handleSearch">搜索</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main-wrapper">
|
||||||
|
<div class="content-panel">
|
||||||
|
<div class="content-area">
|
||||||
|
<el-card class="params-card" shadow="never">
|
||||||
|
<el-table ref="paramsTable" :data="paramsList" class="transparent-table" v-loading="loading"
|
||||||
|
element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading"
|
||||||
|
element-loading-background="rgba(255, 255, 255, 0.7)"
|
||||||
|
:header-cell-class-name="headerCellClassName">
|
||||||
|
<el-table-column label="选择" align="center" width="120">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<el-checkbox v-model="scope.row.selected"></el-checkbox>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="ws地址" prop="address" align="center"></el-table-column>
|
||||||
|
<el-table-column label="操作" prop="operator" align="center" show-overflow-tooltip>
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<el-button size="medium" type="text" @click="emitAction(scope.row, actionMap.restart)">重启</el-button>
|
||||||
|
<el-button size="medium" type="text" @click="emitAction(scope.row, actionMap.update_config)">更新配置</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="table_bottom">
|
||||||
|
<div class="custom-pagination">
|
||||||
|
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
|
||||||
|
<el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`"
|
||||||
|
:value="item">
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">
|
||||||
|
首页
|
||||||
|
</button>
|
||||||
|
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<button v-for="page in visiblePages" :key="page" class="pagination-btn"
|
||||||
|
:class="{ active: page === currentPage }" @click="goToPage(page)">
|
||||||
|
{{ page }}
|
||||||
|
</button>
|
||||||
|
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
<span class="total-text">共{{ total }}条记录</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<el-footer>
|
||||||
|
<version-footer/>
|
||||||
|
</el-footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Api from "@/apis/api";
|
||||||
|
import HeaderBar from "@/components/HeaderBar.vue";
|
||||||
|
import ParamDialog from "@/components/ParamDialog.vue";
|
||||||
|
import VersionFooter from "@/components/VersionFooter.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {HeaderBar, ParamDialog, VersionFooter},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
searchCode: "",
|
||||||
|
paramsList: [],
|
||||||
|
actionMap: {
|
||||||
|
restart: {
|
||||||
|
value: 'restart',
|
||||||
|
title: "重启服务端",
|
||||||
|
message: "确定要重启服务端吗?",
|
||||||
|
confirmText: "重启",
|
||||||
|
},
|
||||||
|
update_config: {
|
||||||
|
value: 'update_config',
|
||||||
|
title: "更新配置",
|
||||||
|
message: "确定要更新配置吗?",
|
||||||
|
confirmText: "更新",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
currentPage: 1,
|
||||||
|
loading: false,
|
||||||
|
pageSize: 10,
|
||||||
|
pageSizeOptions: [10, 20, 50, 100],
|
||||||
|
total: 0,
|
||||||
|
dialogVisible: false,
|
||||||
|
dialogTitle: "新增参数",
|
||||||
|
isAllSelected: false,
|
||||||
|
sensitive_keys: ["api_key", "personal_access_token", "access_token", "token", "secret", "access_key_secret", "secret_key"],
|
||||||
|
paramForm: {
|
||||||
|
id: null,
|
||||||
|
paramCode: "",
|
||||||
|
paramValue: "",
|
||||||
|
remark: ""
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.fetchParams();
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
pageCount() {
|
||||||
|
return Math.ceil(this.total / this.pageSize);
|
||||||
|
},
|
||||||
|
visiblePages() {
|
||||||
|
const pages = [];
|
||||||
|
const maxVisible = 3;
|
||||||
|
let start = Math.max(1, this.currentPage - 1);
|
||||||
|
let end = Math.min(this.pageCount, start + maxVisible - 1);
|
||||||
|
|
||||||
|
if (end - start + 1 < maxVisible) {
|
||||||
|
start = Math.max(1, end - maxVisible + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handlePageSizeChange(val) {
|
||||||
|
this.pageSize = val;
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.fetchParams();
|
||||||
|
},
|
||||||
|
fetchParams() {
|
||||||
|
this.loading = true;
|
||||||
|
Api.admin.getWsServerList(
|
||||||
|
{},
|
||||||
|
({data}) => {
|
||||||
|
this.loading = false;
|
||||||
|
if (data.code === 0) {
|
||||||
|
this.paramsList = data.data.map(item => ({address: item}));
|
||||||
|
this.total = data.data.length;
|
||||||
|
} else {
|
||||||
|
this.$message.error({
|
||||||
|
message: data.msg || '获取参数列表失败',
|
||||||
|
showClose: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
handleSearch() {
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.fetchParams();
|
||||||
|
},
|
||||||
|
emitAction(rowItem, actionItem) {
|
||||||
|
if (actionItem === undefined || rowItem.address === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 弹开询问框
|
||||||
|
this.$confirm(actionItem.message, actionItem.title, {
|
||||||
|
confirmButtonText: actionItem.confirmText, // 确认按钮文本
|
||||||
|
}).then(() => {
|
||||||
|
// 用户点击了确认按钮
|
||||||
|
Api.admin.sendWsServerAction({
|
||||||
|
targetWs: rowItem.address,
|
||||||
|
action: actionItem.value
|
||||||
|
}, ({data}) => {
|
||||||
|
if (data.code !== 0) {
|
||||||
|
this.$message.error({
|
||||||
|
message: data.msg || '操作失败',
|
||||||
|
showClose: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.$message.success({
|
||||||
|
message: `${actionItem.title}成功`,
|
||||||
|
showClose: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
headerCellClassName({columnIndex}) {
|
||||||
|
if (columnIndex === 0) {
|
||||||
|
return "custom-selection-header";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
goFirst() {
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.fetchParams();
|
||||||
|
},
|
||||||
|
goPrev() {
|
||||||
|
if (this.currentPage > 1) {
|
||||||
|
this.currentPage--;
|
||||||
|
this.fetchParams();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
goNext() {
|
||||||
|
if (this.currentPage < this.pageCount) {
|
||||||
|
this.currentPage++;
|
||||||
|
this.fetchParams();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
goToPage(page) {
|
||||||
|
this.currentPage = page;
|
||||||
|
this.fetchParams();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.welcome {
|
||||||
|
min-width: 900px;
|
||||||
|
min-height: 506px;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
flex-direction: column;
|
||||||
|
background-size: cover;
|
||||||
|
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd) center;
|
||||||
|
-webkit-background-size: cover;
|
||||||
|
-o-background-size: cover;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-wrapper {
|
||||||
|
margin: 5px 22px;
|
||||||
|
border-radius: 15px;
|
||||||
|
min-height: calc(100vh - 24vh);
|
||||||
|
height: auto;
|
||||||
|
max-height: 80vh;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
position: relative;
|
||||||
|
background: rgba(237, 242, 255, 0.5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 24px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-operations {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-search {
|
||||||
|
background: linear-gradient(135deg, #6b8cff, #a966ff);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 15px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 600px;
|
||||||
|
overflow: auto;
|
||||||
|
background-color: white;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.params-card {
|
||||||
|
background: white;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
::v-deep .el-card__body {
|
||||||
|
padding: 15px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table_bottom {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl_btn {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding-left: 26px;
|
||||||
|
|
||||||
|
.el-button {
|
||||||
|
min-width: 72px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 7px 12px 7px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--primary {
|
||||||
|
background: #5f70f3;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--danger {
|
||||||
|
background: #fd5b63;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.el-select {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn:first-child,
|
||||||
|
.pagination-btn:nth-child(2),
|
||||||
|
.pagination-btn:nth-last-child(2),
|
||||||
|
.pagination-btn:nth-child(3) {
|
||||||
|
min-width: 60px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
background: #dee7ff;
|
||||||
|
color: #606266;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #d7dce6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn:not(:first-child):not(:nth-child(3)):not(:nth-child(2)):not(:nth-last-child(2)) {
|
||||||
|
min-width: 28px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: #606266;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(245, 247, 250, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn.active {
|
||||||
|
background: #5f70f3 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
border-color: #5f70f3 !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #6d7cf5 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-text {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.transparent-table) {
|
||||||
|
background: white;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.el-table__body-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table__header-wrapper {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table__header th {
|
||||||
|
background: white !important;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table__body tr {
|
||||||
|
background-color: white;
|
||||||
|
|
||||||
|
td {
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
:deep(.el-checkbox__inner) {
|
||||||
|
background-color: #eeeeee !important;
|
||||||
|
border-color: #cccccc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-checkbox__inner:hover) {
|
||||||
|
border-color: #cccccc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
|
||||||
|
background-color: #5f70f3 !important;
|
||||||
|
border-color: #5f70f3 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1144px) {
|
||||||
|
.table_bottom {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.transparent-table) {
|
||||||
|
.el-table__body tr {
|
||||||
|
td {
|
||||||
|
padding-top: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
& + tr {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table .el-button--text) {
|
||||||
|
color: #7079aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table .el-button--text:hover) {
|
||||||
|
color: #5a64b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--success {
|
||||||
|
background: #5bc98c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table .cell) {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-size-select {
|
||||||
|
width: 100px;
|
||||||
|
margin-right: 10px;
|
||||||
|
|
||||||
|
:deep(.el-input__inner) {
|
||||||
|
height: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
background: #dee7ff;
|
||||||
|
color: #606266;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__suffix) {
|
||||||
|
right: 6px;
|
||||||
|
width: 15px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
top: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__suffix-inner) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-icon-arrow-up:before) {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
border-left: 6px solid transparent;
|
||||||
|
border-right: 6px solid transparent;
|
||||||
|
border-top: 9px solid #606266;
|
||||||
|
position: relative;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table) {
|
||||||
|
.el-table__body-wrapper {
|
||||||
|
transition: height 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table {
|
||||||
|
--table-max-height: calc(100vh - 40vh);
|
||||||
|
max-height: var(--table-max-height);
|
||||||
|
|
||||||
|
.el-table__body-wrapper {
|
||||||
|
max-height: calc(var(--table-max-height) - 40px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-loading-mask) {
|
||||||
|
background-color: rgba(255, 255, 255, 0.6) !important;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-loading-spinner .circular) {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-loading-spinner .path) {
|
||||||
|
stroke: #6b8cff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-loading-text) {
|
||||||
|
color: #6b8cff !important;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -273,9 +273,10 @@ class ConnectionHandler:
|
|||||||
await self.websocket.send(
|
await self.websocket.send(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"type": "server_response",
|
"type": "server",
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "服务器重启中...",
|
"message": "服务器重启中...",
|
||||||
|
"content": {'action': "restart"}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -302,9 +303,10 @@ class ConnectionHandler:
|
|||||||
await self.websocket.send(
|
await self.websocket.send(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"type": "server_response",
|
"type": "server",
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": f"Restart failed: {str(e)}",
|
"message": f"Restart failed: {str(e)}",
|
||||||
|
'content': {'action': "restart"}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -95,9 +95,10 @@ async def handleTextMessage(conn, message):
|
|||||||
await conn.websocket.send(
|
await conn.websocket.send(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"type": "config_update_response",
|
"type": "server",
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": "无法获取服务器实例",
|
"message": "无法获取服务器实例",
|
||||||
|
"content": {"action": "update_config"}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -107,9 +108,10 @@ async def handleTextMessage(conn, message):
|
|||||||
await conn.websocket.send(
|
await conn.websocket.send(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"type": "config_update_response",
|
"type": "server",
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": "更新服务器配置失败",
|
"message": "更新服务器配置失败",
|
||||||
|
"content": {"action": "update_config"}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -119,9 +121,10 @@ async def handleTextMessage(conn, message):
|
|||||||
await conn.websocket.send(
|
await conn.websocket.send(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"type": "config_update_response",
|
"type": "server",
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "配置更新成功",
|
"message": "配置更新成功",
|
||||||
|
"content": {"action": "update_config"}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -130,9 +133,10 @@ async def handleTextMessage(conn, message):
|
|||||||
await conn.websocket.send(
|
await conn.websocket.send(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"type": "config_update_response",
|
"type": "server",
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": f"更新配置失败: {str(e)}",
|
"message": f"更新配置失败: {str(e)}",
|
||||||
|
"content": {"action": "update_config"}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user