优化ui 添加转发诊断 移除vue前端

This commit is contained in:
qaq
2025-07-07 11:52:52 +08:00
parent 0adc09f5de
commit ac4f078014
59 changed files with 1458 additions and 30089 deletions
-41
View File
@@ -11,47 +11,6 @@ on:
- '.github/workflows/docker-build.yml'
jobs:
build-vue:
if: contains(github.event.head_commit.message, '[skip vue]') == false
name: Build & Push Vue Frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Check for Vue changes
uses: dorny/paths-filter@v2
id: vue-changes
with:
filters: |
vue:
- 'vue-frontend/**'
- name: Set up Node.js
if: steps.vue-changes.outputs.vue == 'true'
uses: actions/setup-node@v4
with:
node-version: 20
- name: Set up Docker Buildx
if: steps.vue-changes.outputs.vue == 'true'
uses: docker/setup-buildx-action@v2
- name: Log in to DockerHub
if: steps.vue-changes.outputs.vue == 'true'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Build and push Vue multi-arch image
if: steps.vue-changes.outputs.vue == 'true'
run: |
docker buildx build \
--platform linux/amd64,linux/arm64 \
--push \
-t bqlpfy/vue-frontend \
./vue-frontend
build-vite:
if: contains(github.event.head_commit.message, '[skip vite]') == false
name: Build & Push Vite Frontend
+2 -2
View File
@@ -55,8 +55,8 @@ services:
start_period: 90s
frontend:
image: bqlpfy/vue-frontend:latest
container_name: vue-frontend
image: bqlpfy/vite-frontend:latest
container_name: vite-frontend
restart: unless-stopped
ports:
- "${FRONTEND_PORT}:80"
+2 -2
View File
@@ -55,8 +55,8 @@ services:
start_period: 90s
frontend:
image: bqlpfy/vue-frontend:latest
container_name: vue-frontend
image: bqlpfy/vite-frontend:latest
container_name: vite-frontend
restart: unless-stopped
ports:
- "${FRONTEND_PORT}:80"
BIN
View File
Binary file not shown.
-4
View File
@@ -1,4 +0,0 @@
{
"addr": "127.0.0.1:6365",
"secret": "0fb968b83a234441b290eb17e872b928"
}
BIN
View File
Binary file not shown.
-3
View File
@@ -1,3 +0,0 @@
{
"services": null
}
-34
View File
@@ -145,29 +145,7 @@ generate_random() {
LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c16
}
# UI选择函数
select_ui() {
echo "🎨 请选择前端UI:"
echo "1. 新UI"
echo "2. 旧UI"
read -p "请输入选项 (1-2,默认2): " ui_choice
case $ui_choice in
1)
USE_NEW_UI=true
;;
*)
USE_NEW_UI=false
;;
esac
}
# 配置前端镜像
configure_frontend_image() {
if [[ "$USE_NEW_UI" == "true" ]]; then
sed -i.bak 's|bqlpfy/vue-frontend|bqlpfy/vite-frontend|g' docker-compose.yml
rm -f docker-compose.yml.bak
fi
}
# 获取用户输入的配置参数
get_config_params() {
@@ -209,12 +187,6 @@ install_panel() {
curl -L -o gost.sql "$GOST_SQL_URL"
echo "✅ 下载完成"
# UI选择
select_ui
# 根据UI选择修改docker-compose.yml中的前端镜像
configure_frontend_image
# 自动检测并配置 IPv6 支持
if check_ipv6_support; then
echo "🚀 系统支持 IPv6,自动启用 IPv6 配置..."
@@ -258,12 +230,6 @@ update_panel() {
curl -L -o docker-compose.yml "$DOCKER_COMPOSE_URL"
echo "✅ 下载完成"
# UI选择
select_ui
# 根据UI选择修改docker-compose.yml中的前端镜像
configure_frontend_image
# 自动检测并配置 IPv6 支持
if check_ipv6_support; then
echo "🚀 系统支持 IPv6,自动启用 IPv6 配置..."
@@ -0,0 +1,52 @@
package com.admin.common.task;
import com.admin.common.utils.WebSocketServer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* WebSocket资源清理定时任务
* 定期清理已完成的请求、无效的session锁等,防止内存泄漏
*/
@Slf4j
@Component
public class WebSocketCleanupTask {
/**
* 每5分钟执行一次轻量级清理
* 清理已完成的请求和无效的session锁
*/
@Scheduled(fixedRate = 5 * 60 * 1000) // 5分钟
public void lightweightCleanup() {
try {
log.debug("开始执行WebSocket轻量级清理...");
// 清理已完成的请求
WebSocketServer.cleanupCompletedRequests();
// 清理无效的session锁
WebSocketServer.cleanupInvalidSessionLocks();
} catch (Exception e) {
log.error("WebSocket轻量级清理失败", e);
}
}
/**
* 每30分钟执行一次全面清理
* 包括所有类型的资源清理
*/
@Scheduled(fixedRate = 30 * 60 * 1000) // 30分钟
public void fullCleanup() {
try {
log.info("开始执行WebSocket全面清理...");
// 执行全面清理
WebSocketServer.performFullCleanup();
} catch (Exception e) {
log.error("WebSocket全面清理失败", e);
}
}
}
@@ -41,6 +41,9 @@ public class WebSocketServer extends TextWebSocketHandler {
// 存储等待响应的请求,key为requestId,value为CompletableFuture
private static final ConcurrentHashMap<String, CompletableFuture<GostDto>> pendingRequests = new ConcurrentHashMap<>();
// 存储请求ID与节点ID的映射关系,用于清理特定节点的请求
private static final ConcurrentHashMap<String, Long> requestNodeMapping = new ConcurrentHashMap<>();
//接受客户端消息
@Override
@@ -66,6 +69,9 @@ public class WebSocketServer extends TextWebSocketHandler {
if (requestId != null) {
CompletableFuture<GostDto> future = pendingRequests.remove(requestId);
// 同时清理请求-节点映射关系
requestNodeMapping.remove(requestId);
if (future != null) {
GostDto result = new GostDto();
@@ -123,29 +129,60 @@ public class WebSocketServer extends TextWebSocketHandler {
if (!Objects.equals(type, "1")) {
// 网页管理员连接
activeSessions.add(session);
log.info("管理员连接建立,sessionId: {}", session.getId());
} else {
// 客户端节点连接
Long nodeId = Long.valueOf(id);
String version = (String) session.getAttributes().get("nodeVersion");
log.info("节点 {} 连接建立,开始更新状态", nodeId);
// 先添加到会话映射
nodeSessions.put(nodeId, session);
// 更新节点状态为在线
Node byId = nodeService.getById(nodeId);
if (byId != null) {
byId.setStatus(1);
nodeService.updateById(byId);
Node node = nodeService.getById(nodeId);
if (node != null) {
// 更新状态和版本信息
node.setStatus(1);
if (version != null) {
node.setVersion(version);
}
boolean updateResult = nodeService.updateById(node);
// 广播节点上线状态给所有管理员
JSONObject res = new JSONObject();
res.put("id", id);
res.put("type", "status");
res.put("data", 1);
broadcastMessage(res.toJSONString());
if (updateResult) {
log.info("节点 {} 状态更新为在线成功,版本: {}", nodeId, version);
// 广播节点上线状态给所有管理员
JSONObject res = new JSONObject();
res.put("id", id);
res.put("type", "status");
res.put("data", 1);
broadcastMessage(res.toJSONString());
} else {
log.error("节点 {} 状态更新失败", nodeId);
}
} else {
log.error("节点 {} 不存在,无法更新状态", nodeId);
// 移除无效的会话
nodeSessions.remove(nodeId);
}
}
} catch (Exception e) {
log.error("建立连接时发生异常: {}", e.getMessage(), e);
// 异常情况下,确保清理会话
try {
String id = session.getAttributes().get("id").toString();
String type = session.getAttributes().get("type").toString();
if (Objects.equals(type, "1")) {
Long nodeId = Long.valueOf(id);
nodeSessions.remove(nodeId);
log.warn("由于异常,移除节点 {} 的会话", nodeId);
}
} catch (Exception cleanupException) {
log.error("清理异常会话时出错: {}", cleanupException.getMessage());
}
}
}
@@ -157,36 +194,46 @@ public class WebSocketServer extends TextWebSocketHandler {
String type = session.getAttributes().get("type").toString();
String sessionId = session.getId();
log.info("连接关闭,ID: {}, 类型: {}, 状态: {}", id, type, status);
if (!Objects.equals(type, "1")) {
// 连接关闭
activeSessions.remove(session);
// 管理员连接关闭
boolean removed = activeSessions.remove(session);
log.info("管理员连接关闭,sessionId: {}, 移除结果: {}", sessionId, removed);
} else {
// 客户端节点连接关闭
Long nodeId = Long.valueOf(id);
nodeSessions.remove(nodeId);
WebSocketSession removedSession = nodeSessions.remove(nodeId);
log.info("节点 {} 连接关闭,开始更新状态为离线", nodeId);
// 更新节点状态为离线
Node byId = nodeService.getById(nodeId);
if (byId != null) {
byId.setStatus(0);
nodeService.updateById(byId);
Node node = nodeService.getById(nodeId);
if (node != null) {
node.setStatus(0);
boolean updateResult = nodeService.updateById(node);
JSONObject res = new JSONObject();
res.put("id", id);
res.put("type", "status");
res.put("data", 0);
broadcastMessage(res.toJSONString());
if (updateResult) {
log.info("节点 {} 状态更新为离线成功", nodeId);
JSONObject res = new JSONObject();
res.put("id", id);
res.put("type", "status");
res.put("data", 0);
broadcastMessage(res.toJSONString());
} else {
log.error("节点 {} 状态更新为离线失败", nodeId);
}
} else {
log.warn("节点 {} 不存在,无法更新离线状态", nodeId);
}
// 清理该节点的待处理请求
clearPendingRequestsForNode(nodeId);
}
// 清理session锁对象
sessionLocks.remove(sessionId);
// 清理该节点的待处理请求
if (Objects.equals(type, "1")) {
clearPendingRequestsForNode(Long.valueOf(id));
}
} catch (Exception e) {
log.error("关闭连接时发生异常: {}", e.getMessage(), e);
@@ -237,6 +284,50 @@ public class WebSocketServer extends TextWebSocketHandler {
});
}
}
/**
* 清理无效的session锁(定期清理任务)
*/
public static void cleanupInvalidSessionLocks() {
java.util.List<String> invalidSessionIds = new java.util.ArrayList<>();
sessionLocks.keySet().forEach(sessionId -> {
boolean isValidSession = false;
// 检查是否为有效的管理员session
for (WebSocketSession adminSession : activeSessions) {
if (adminSession != null && sessionId.equals(adminSession.getId())) {
isValidSession = true;
break;
}
}
// 检查是否为有效的节点session
if (!isValidSession) {
for (WebSocketSession nodeSession : nodeSessions.values()) {
if (nodeSession != null && sessionId.equals(nodeSession.getId())) {
isValidSession = true;
break;
}
}
}
if (!isValidSession) {
invalidSessionIds.add(sessionId);
}
});
int cleanedCount = 0;
for (String sessionId : invalidSessionIds) {
if (sessionLocks.remove(sessionId) != null) {
cleanedCount++;
}
}
if (cleanedCount > 0) {
log.info("清理了 {} 个无效的session锁", cleanedCount);
}
}
// 广播消息
public static void broadcastMessage(String message) {
@@ -247,31 +338,254 @@ public class WebSocketServer extends TextWebSocketHandler {
/**
* 清理指定节点的待处理请求
* 只清理属于该节点的未完成请求,避免影响其他节点的请求
*/
private static void clearPendingRequestsForNode(Long nodeId) {
// 完成所有待处理的请求,设置为连接断开错误
pendingRequests.entrySet().removeIf(entry -> {
CompletableFuture<GostDto> future = entry.getValue();
if (!future.isDone()) {
GostDto errorResult = new GostDto();
errorResult.setMsg("节点连接已断开");
future.complete(errorResult);
if (nodeId == null) {
log.warn("节点ID为空,无法清理待处理请求");
return;
}
java.util.concurrent.atomic.AtomicInteger clearedCount = new java.util.concurrent.atomic.AtomicInteger(0);
java.util.List<String> requestIdsToRemove = new java.util.ArrayList<>();
// 找出属于该节点的请求
requestNodeMapping.entrySet().forEach(entry -> {
String requestId = entry.getKey();
Long mappedNodeId = entry.getValue();
if (nodeId.equals(mappedNodeId)) {
CompletableFuture<GostDto> future = pendingRequests.get(requestId);
if (future != null && !future.isDone()) {
// 完成该请求并设置错误信息
GostDto errorResult = new GostDto();
errorResult.setMsg("节点连接已断开");
future.complete(errorResult);
clearedCount.incrementAndGet();
}
requestIdsToRemove.add(requestId);
}
return true; // 移除所有请求
});
// 批量清理映射关系和请求
requestIdsToRemove.forEach(requestId -> {
pendingRequests.remove(requestId);
requestNodeMapping.remove(requestId);
});
if (clearedCount.get() > 0) {
log.info("清理了节点 {} 的 {} 个待处理请求", nodeId, clearedCount.get());
} else {
log.debug("节点 {} 没有待处理的请求需要清理", nodeId);
}
}
/**
* 检查节点的实际连接状态,如果状态不一致则修复
*/
public static boolean checkAndFixNodeStatus(NodeService nodeService, Long nodeId) {
try {
WebSocketSession session = nodeSessions.get(nodeId);
boolean isConnected = session != null && session.isOpen();
Node node = nodeService.getById(nodeId);
if (node != null) {
int currentStatus = node.getStatus();
int expectedStatus = isConnected ? 1 : 0;
if (currentStatus != expectedStatus) {
log.warn("节点 {} 状态不一致,数据库状态: {}, 实际连接状态: {}, 正在修复...",
nodeId, currentStatus, expectedStatus);
node.setStatus(expectedStatus);
boolean updateResult = nodeService.updateById(node);
if (updateResult) {
log.info("节点 {} 状态修复成功,更新为: {}", nodeId, expectedStatus);
// 广播状态变更
JSONObject res = new JSONObject();
res.put("id", nodeId.toString());
res.put("type", "status");
res.put("data", expectedStatus);
broadcastMessage(res.toJSONString());
return true;
} else {
log.error("节点 {} 状态修复失败", nodeId);
}
}
}
return isConnected;
} catch (Exception e) {
log.error("检查节点 {} 状态时发生异常: {}", nodeId, e.getMessage(), e);
return false;
}
}
/**
* 获取节点的实际连接状态
*/
public static boolean isNodeConnected(Long nodeId) {
WebSocketSession session = nodeSessions.get(nodeId);
return session != null && session.isOpen();
}
/**
* 获取所有在线节点的ID列表
*/
public static java.util.Set<Long> getConnectedNodeIds() {
return nodeSessions.entrySet().stream()
.filter(entry -> entry.getValue() != null && entry.getValue().isOpen())
.map(java.util.Map.Entry::getKey)
.collect(java.util.stream.Collectors.toSet());
}
/**
* 获取指定节点的待处理请求数量
*/
public static int getPendingRequestCount(Long nodeId) {
if (nodeId == null) {
return 0;
}
return (int) requestNodeMapping.entrySet().stream()
.filter(entry -> nodeId.equals(entry.getValue()))
.map(java.util.Map.Entry::getKey)
.filter(requestId -> {
CompletableFuture<GostDto> future = pendingRequests.get(requestId);
return future != null && !future.isDone();
})
.count();
}
/**
* 获取所有待处理请求的总数
*/
public static int getTotalPendingRequestCount() {
return (int) pendingRequests.entrySet().stream()
.filter(entry -> entry.getValue() != null && !entry.getValue().isDone())
.count();
}
/**
* 清理所有已完成但未被移除的请求(定期清理任务)
*/
public static void cleanupCompletedRequests() {
java.util.List<String> completedRequestIds = new java.util.ArrayList<>();
pendingRequests.entrySet().forEach(entry -> {
String requestId = entry.getKey();
CompletableFuture<GostDto> future = entry.getValue();
if (future != null && future.isDone()) {
completedRequestIds.add(requestId);
}
});
int cleanedCount = 0;
for (String requestId : completedRequestIds) {
if (pendingRequests.remove(requestId) != null) {
requestNodeMapping.remove(requestId);
cleanedCount++;
}
}
if (cleanedCount > 0) {
log.info("清理了 {} 个已完成的请求", cleanedCount);
}
}
/**
* 获取WebSocket连接统计信息
*/
public static java.util.Map<String, Object> getConnectionStats() {
java.util.Map<String, Object> stats = new java.util.HashMap<>();
// 节点连接统计
int totalNodes = nodeSessions.size();
int onlineNodes = (int) nodeSessions.entrySet().stream()
.filter(entry -> entry.getValue() != null && entry.getValue().isOpen())
.count();
// 管理员连接统计
int adminConnections = activeSessions.size();
// 请求统计
int totalPendingRequests = getTotalPendingRequestCount();
int totalMappings = requestNodeMapping.size();
stats.put("totalNodes", totalNodes);
stats.put("onlineNodes", onlineNodes);
stats.put("offlineNodes", totalNodes - onlineNodes);
stats.put("adminConnections", adminConnections);
stats.put("totalPendingRequests", totalPendingRequests);
stats.put("totalRequestMappings", totalMappings);
return stats;
}
/**
* 执行全面的内存清理操作
* 建议定期调用以防止内存泄漏
*/
public static void performFullCleanup() {
log.info("开始执行WebSocket全面清理操作...");
// 获取清理前的统计信息
java.util.Map<String, Object> statsBefore = getConnectionStats();
// 执行各种清理操作
cleanupCompletedRequests();
cleanupInvalidSessionLocks();
// 清理失效的节点session
java.util.List<Long> invalidNodeIds = new java.util.ArrayList<>();
nodeSessions.entrySet().forEach(entry -> {
WebSocketSession session = entry.getValue();
if (session == null || !session.isOpen()) {
invalidNodeIds.add(entry.getKey());
}
});
int removedNodeSessions = 0;
for (Long nodeId : invalidNodeIds) {
if (nodeSessions.remove(nodeId) != null) {
removedNodeSessions++;
}
}
// 清理失效的管理员session
int removedAdminSessions = 0;
java.util.Iterator<WebSocketSession> adminIterator = activeSessions.iterator();
while (adminIterator.hasNext()) {
WebSocketSession session = adminIterator.next();
if (session == null || !session.isOpen()) {
adminIterator.remove();
removedAdminSessions++;
}
}
// 获取清理后的统计信息
java.util.Map<String, Object> statsAfter = getConnectionStats();
log.info("WebSocket清理完成 - 清理前: {}, 清理后: {}, 移除节点session: {}, 移除管理员session: {}",
statsBefore, statsAfter, removedNodeSessions, removedAdminSessions);
}
public static GostDto send_msg(Long node_id, Object msg, String type) {
WebSocketSession nodeSession = nodeSessions.get(node_id);
if (nodeSession == null) {
log.warn("发送消息失败:节点 {} 不在线或会话不存在", node_id);
GostDto result = new GostDto();
result.setMsg("节点不在线");
return result;
}
if (!nodeSession.isOpen()) {
log.warn("发送消息失败:节点 {} 连接已断开,清理会话", node_id);
nodeSessions.remove(node_id);
sessionLocks.remove(nodeSession.getId());
GostDto result = new GostDto();
@@ -285,6 +599,9 @@ public class WebSocketServer extends TextWebSocketHandler {
// 创建CompletableFuture用于等待响应
CompletableFuture<GostDto> future = new CompletableFuture<>();
pendingRequests.put(requestId, future);
// 建立请求ID与节点ID的映射关系
requestNodeMapping.put(requestId, node_id);
try {
JSONObject data = new JSONObject();
@@ -293,17 +610,23 @@ public class WebSocketServer extends TextWebSocketHandler {
data.put("requestId", requestId);
sendToUser(nodeSession, data.toJSONString());
GostDto result = future.get(10, TimeUnit.SECONDS);
log.debug("成功发送消息到节点 {} 并收到响应: {}", node_id, result.getMsg());
return result;
} catch (Exception e) {
// 清理请求和映射关系
pendingRequests.remove(requestId);
requestNodeMapping.remove(requestId);
GostDto result = new GostDto();
if (e instanceof java.util.concurrent.TimeoutException) {
result.setMsg("等待响应超时");
log.warn("节点 {} 响应超时,可能存在连接问题", node_id);
} else {
result.setMsg("发送消息失败: " + e.getMessage());
log.error("发送消息到节点 {} 失败: {}", node_id, e.getMessage(), e);
}
log.error("发送消息到节点{}失败: {}", node_id, e.getMessage(), e);
return result;
}
}
@@ -37,11 +37,15 @@ public class WebSocketInterceptor extends HttpSessionHandshakeInterceptor {
String version = serverHttpRequest.getServletRequest().getParameter("version");
if (Objects.equals(type, "1")) {
Node node = nodeService.getOne(new QueryWrapper<Node>().eq("secret", secret));
if (node == null) return false;
if (node == null) {
log.warn("节点验证失败:未找到匹配的secret");
return false;
}
attributes.put("id", node.getId());
node.setStatus(1);
node.setVersion(version);
nodeService.updateById(node);
attributes.put("nodeSecret", secret);
attributes.put("nodeVersion", version);
log.info("节点 {} 通过验证,版本: {}", node.getId(), version);
// 不在这里更新状态,等到连接建立后再统一更新
}else {
boolean b = JwtUtil.validateToken(secret);
if (!b) return false;
@@ -26,6 +26,8 @@ import java.util.Map;
@RequestMapping("/api/v1/forward")
public class ForwardController extends BaseController {
@Autowired
private ForwardService forwardService;
@LogAnnotation
@PostMapping("/create")
@@ -73,5 +75,17 @@ public class ForwardController extends BaseController {
return forwardService.resumeForward(id);
}
/**
* 转发诊断功能
* @param params 包含forwardId的参数
* @return 诊断结果
*/
@LogAnnotation
@RequireRole
@PostMapping("/diagnose")
public R diagnoseForward(@RequestBody Map<String, Object> params) {
Long forwardId = Long.valueOf(params.get("forwardId").toString());
return forwardService.diagnoseForward(forwardId);
}
}
@@ -62,4 +62,16 @@ public class NodeController extends BaseController {
Long id = Long.valueOf(params.get("id").toString());
return nodeService.getInstallCommand(id);
}
/**
* 检查和修复节点状态
* @param params 包含节点ID的参数(可选)
* @return 检查结果
*/
@LogAnnotation
@RequireRole
@PostMapping("/check-status")
public R checkNodeStatus(@RequestBody(required = false) Map<String, Object> params) {
return nodeService.checkAndFixNodeStatus(params);
}
}
@@ -64,4 +64,11 @@ public interface ForwardService extends IService<Forward> {
* @return 结果
*/
R resumeForward(Long id);
/**
* 转发诊断功能
* @param id 转发ID
* @return 诊断结果
*/
R diagnoseForward(Long id);
}
@@ -28,4 +28,11 @@ public interface NodeService extends IService<Node> {
Node getNodeById(Long id);
R getInstallCommand(Long id);
/**
* 检查和修复节点状态
* @param params 包含节点ID的参数(可选)
* @return 检查结果
*/
R checkAndFixNodeStatus(java.util.Map<String, Object> params);
}
@@ -7,11 +7,13 @@ import com.admin.common.dto.GostDto;
import com.admin.common.lang.R;
import com.admin.common.utils.GostUtil;
import com.admin.common.utils.JwtUtil;
import com.admin.common.utils.WebSocketServer;
import com.admin.entity.*;
import com.admin.mapper.ForwardMapper;
import com.admin.service.*;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.alibaba.fastjson.JSONObject;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
@@ -19,10 +21,7 @@ import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.*;
import java.util.stream.Collectors;
/**
@@ -385,6 +384,181 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
return result ? R.ok("服务已" + operation) : R.err("更新状态失败");
}
@Override
public R diagnoseForward(Long id) {
// 1. 获取当前用户信息
UserInfo currentUser = getCurrentUserInfo();
// 2. 检查转发是否存在且用户有权限访问
Forward forward = validateForwardExists(id, currentUser);
if (forward == null) {
return R.err("转发不存在");
}
// 3. 获取隧道信息
Tunnel tunnel = validateTunnel(forward.getTunnelId());
if (tunnel == null) {
return R.err("隧道不存在");
}
// 4. 获取入口节点信息
Node inNode = nodeService.getNodeById(tunnel.getInNodeId());
if (inNode == null) {
return R.err("入口节点不存在");
}
// 5. 解析目标地址,取第一个地址作为诊断目标
String[] remoteAddresses = forward.getRemoteAddr().split(",");
String targetAddress = remoteAddresses[0].trim();
// 提取IP部分(去掉端口)
String targetIp = extractIpFromAddress(targetAddress);
if (targetIp == null) {
return R.err("无法解析目标地址: " + targetAddress);
}
List<DiagnosisResult> results = new ArrayList<>();
// 6. 根据隧道类型执行不同的诊断策略
if (tunnel.getType() == TUNNEL_TYPE_PORT_FORWARD) {
// 端口转发:入口节点直接ping目标地址
DiagnosisResult result = performPingDiagnosis(inNode, targetIp, "转发->目标");
results.add(result);
} else {
// 隧道转发:入口ping出口,出口ping目标
Node outNode = nodeService.getNodeById(tunnel.getOutNodeId());
if (outNode == null) {
return R.err("出口节点不存在");
}
// 入口ping出口
DiagnosisResult inToOutResult = performPingDiagnosis(inNode, outNode.getServerIp(), "入口->出口");
results.add(inToOutResult);
// 出口ping目标
DiagnosisResult outToTargetResult = performPingDiagnosis(outNode, targetIp, "出口->目标");
results.add(outToTargetResult);
}
// 7. 构建诊断报告
Map<String, Object> diagnosisReport = new HashMap<>();
diagnosisReport.put("forwardId", id);
diagnosisReport.put("forwardName", forward.getName());
diagnosisReport.put("tunnelType", tunnel.getType() == TUNNEL_TYPE_PORT_FORWARD ? "端口转发" : "隧道转发");
diagnosisReport.put("results", results);
diagnosisReport.put("timestamp", System.currentTimeMillis());
return R.ok(diagnosisReport);
}
/**
* 从地址字符串中提取IP地址
* 支持格式: ip:port, [ipv6]:port, domain:port
*/
private String extractIpFromAddress(String address) {
if (address == null || address.trim().isEmpty()) {
return null;
}
address = address.trim();
// IPv6格式: [ipv6]:port
if (address.startsWith("[")) {
int closeBracket = address.indexOf(']');
if (closeBracket > 1) {
return address.substring(1, closeBracket);
}
}
// IPv4或域名格式: ip:port 或 domain:port
int lastColon = address.lastIndexOf(':');
if (lastColon > 0) {
return address.substring(0, lastColon);
}
// 如果没有端口,直接返回地址
return address;
}
/**
* 执行ping诊断
*
* @param node 执行ping的节点
* @param targetIp 目标IP地址
* @param description 诊断描述
* @return 诊断结果
*/
private DiagnosisResult performPingDiagnosis(Node node, String targetIp, String description) {
try {
// 构建ping请求数据
JSONObject pingData = new JSONObject();
pingData.put("ip", targetIp);
pingData.put("count", 4);
// 发送ping命令到节点
GostDto gostResult = WebSocketServer.send_msg(node.getId(), pingData, "Ping");
DiagnosisResult result = new DiagnosisResult();
result.setNodeId(node.getId());
result.setNodeName(node.getName());
result.setTargetIp(targetIp);
result.setDescription(description);
result.setTimestamp(System.currentTimeMillis());
if (gostResult != null && "OK".equals(gostResult.getMsg())) {
// 尝试解析ping响应数据
try {
if (gostResult.getData() != null) {
JSONObject pingResponse = (JSONObject) gostResult.getData();
boolean success = pingResponse.getBooleanValue("success");
result.setSuccess(success);
if (success) {
result.setMessage("ping成功");
result.setAverageTime(pingResponse.getDoubleValue("averageTime"));
result.setPacketLoss(pingResponse.getDoubleValue("packetLoss"));
} else {
result.setMessage(pingResponse.getString("errorMessage"));
result.setAverageTime(-1.0);
result.setPacketLoss(100.0);
}
} else {
// 没有详细数据,使用默认值
result.setSuccess(true);
result.setMessage("ping成功");
result.setAverageTime(0.0);
result.setPacketLoss(0.0);
}
} catch (Exception e) {
// 解析响应数据失败,但ping命令本身成功了
result.setSuccess(true);
result.setMessage("ping成功,但无法解析详细数据");
result.setAverageTime(0.0);
result.setPacketLoss(0.0);
}
} else {
result.setSuccess(false);
result.setMessage(gostResult != null ? gostResult.getMsg() : "节点无响应");
result.setAverageTime(-1.0);
result.setPacketLoss(100.0);
}
return result;
} catch (Exception e) {
DiagnosisResult result = new DiagnosisResult();
result.setNodeId(node.getId());
result.setNodeName(node.getName());
result.setTargetIp(targetIp);
result.setDescription(description);
result.setSuccess(false);
result.setMessage("诊断执行异常: " + e.getMessage());
result.setTimestamp(System.currentTimeMillis());
result.setAverageTime(-1.0);
result.setPacketLoss(100.0);
return result;
}
}
/**
* 获取当前用户信息
*/
@@ -1142,4 +1316,20 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
return new NodeInfo(true, errorMessage, null, null);
}
}
/**
* 诊断结果数据类
*/
@Data
public static class DiagnosisResult {
private Long nodeId;
private String nodeName;
private String targetIp;
private String description;
private boolean success;
private String message;
private double averageTime;
private double packetLoss;
private long timestamp;
}
}
@@ -430,4 +430,125 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
throw new RuntimeException(ERROR_PORT_ORDER_INVALID);
}
}
/**
* 检查和修复节点状态
* 如果没有指定节点ID,则检查所有节点
* 如果指定了节点ID,则只检查该节点
*
* @param params 包含节点ID的参数(可选)
* @return 检查结果
*/
@Override
public R checkAndFixNodeStatus(java.util.Map<String, Object> params) {
try {
java.util.List<CheckResult> results = new java.util.ArrayList<>();
if (params != null && params.containsKey("nodeId")) {
// 检查指定节点
Long nodeId = Long.valueOf(params.get("nodeId").toString());
CheckResult result = checkSingleNodeStatus(nodeId);
results.add(result);
} else {
// 检查所有节点
List<Node> allNodes = this.list();
for (Node node : allNodes) {
CheckResult result = checkSingleNodeStatus(node.getId());
results.add(result);
}
}
// 统计结果
long totalNodes = results.size();
long inconsistentNodes = results.stream()
.mapToLong(r -> r.isFixed() ? 1 : 0)
.sum();
long connectedNodes = results.stream()
.mapToLong(r -> r.isConnected() ? 1 : 0)
.sum();
java.util.Map<String, Object> response = new java.util.HashMap<>();
response.put("totalNodes", totalNodes);
response.put("connectedNodes", connectedNodes);
response.put("inconsistentNodes", inconsistentNodes);
response.put("details", results);
return R.ok(response);
} catch (Exception e) {
return R.err("检查节点状态时发生错误:" + e.getMessage());
}
}
/**
* 检查单个节点的状态
*
* @param nodeId 节点ID
* @return 检查结果
*/
private CheckResult checkSingleNodeStatus(Long nodeId) {
CheckResult result = new CheckResult();
result.setNodeId(nodeId);
try {
Node node = this.getById(nodeId);
if (node == null) {
result.setNodeName("未知");
result.setConnected(false);
result.setDatabaseStatus(0);
result.setActualStatus(false);
result.setFixed(false);
result.setMessage("节点不存在");
return result;
}
result.setNodeName(node.getName());
result.setDatabaseStatus(node.getStatus());
// 调用WebSocketServer的静态方法检查实际连接状态
boolean actualConnected = com.admin.common.utils.WebSocketServer.checkAndFixNodeStatus(this, nodeId);
result.setConnected(actualConnected);
result.setActualStatus(actualConnected);
// 重新查询节点状态,看是否被修复了
Node updatedNode = this.getById(nodeId);
boolean wasFixed = (updatedNode.getStatus() != node.getStatus());
result.setFixed(wasFixed);
result.setFinalStatus(updatedNode.getStatus());
if (wasFixed) {
result.setMessage(String.format("状态已修复:%d -> %d",
node.getStatus(), updatedNode.getStatus()));
} else if (actualConnected && updatedNode.getStatus() == 1) {
result.setMessage("状态正常");
} else if (!actualConnected && updatedNode.getStatus() == 0) {
result.setMessage("状态正常");
} else {
result.setMessage("状态可能存在异常");
}
} catch (Exception e) {
result.setConnected(false);
result.setActualStatus(false);
result.setFixed(false);
result.setMessage("检查失败:" + e.getMessage());
}
return result;
}
/**
* 节点状态检查结果
*/
@lombok.Data
public static class CheckResult {
private Long nodeId;
private String nodeName;
private boolean connected;
private int databaseStatus;
private boolean actualStatus;
private boolean fixed;
private int finalStatus;
private String message;
}
}
+1 -1
View File
@@ -1 +1 @@
VITE_API_BASE=http://127.0.0.1:6365
VITE_API_BASE=http://192.168.100.6:6365
+36 -31
View File
@@ -1,5 +1,5 @@
import { Route, Routes, Navigate, useNavigate } from "react-router-dom";
import { useEffect, useState } from "react";
import { Route, Routes } from "react-router-dom";
import { useEffect } from "react";
import IndexPage from "@/pages/index";
import ChangePasswordPage from "@/pages/change-password";
@@ -12,49 +12,54 @@ import LimitPage from "@/pages/limit";
import { isLoggedIn } from "@/utils/auth";
// 路由保护组件 - 使用useNavigate避免循环渲染
// 简化的路由保护组件 - 使用浏览器重定向避免React循环
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const [checking, setChecking] = useState(true);
const [authenticated, setAuthenticated] = useState(false);
const navigate = useNavigate();
const authenticated = isLoggedIn();
useEffect(() => {
const checkAuth = () => {
const loggedIn = isLoggedIn();
if (!loggedIn) {
// 如果未登录,直接导航到登录页
navigate('/', { replace: true });
return;
}
setAuthenticated(true);
setChecking(false);
};
if (!authenticated) {
// 使用浏览器原生重定向,避免React渲染循环
window.location.replace('/');
}
}, [authenticated]);
checkAuth();
}, [navigate]);
if (checking) {
if (!authenticated) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg">检查登陆状态...</div>
<div className="text-lg">重定向中...</div>
</div>
);
}
return authenticated ? <>{children}</> : null;
return <>{children}</>;
};
// 登录页面路由组件 - 已登录则重定向到dashboard
const LoginRoute = () => {
const authenticated = isLoggedIn();
useEffect(() => {
if (authenticated) {
// 使用浏览器原生重定向
window.location.replace('/dashboard');
}
}, [authenticated]);
if (authenticated) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg">重定向中...</div>
</div>
);
}
return <IndexPage />;
};
function App() {
return (
<Routes>
<Route
path="/"
element={
isLoggedIn() ? <Navigate to="/dashboard" replace /> : <IndexPage />
}
/>
<Route path="/" element={<LoginRoute />} />
<Route
path="/change-password"
element={
+7
View File
@@ -28,6 +28,10 @@ export const getNodeList = () => Network.post("/node/list");
export const updateNode = (data: any) => Network.post("/node/update", data);
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
export const getNodeInstallCommand = (id: number) => Network.post("/node/install", { id });
export const checkNodeStatus = (nodeId?: number) => {
const params = nodeId ? { nodeId } : {};
return Network.post("/node/check-status", params);
};
// 隧道CRUD操作 - 全部使用POST请求
export const createTunnel = (data: any) => Network.post("/tunnel/create", data);
@@ -56,6 +60,9 @@ export const forceDeleteForward = (id: number) => Network.post("/forward/force-d
export const pauseForwardService = (forwardId: number) => Network.post("/forward/pause", { id: forwardId });
export const resumeForwardService = (forwardId: number) => Network.post("/forward/resume", { id: forwardId });
// 转发诊断操作
export const diagnoseForward = (forwardId: number) => Network.post("/forward/diagnose", { forwardId });
// 限速规则CRUD操作 - 全部使用POST请求
export const createSpeedLimit = (data: any) => Network.post("/speed-limit/create", data);
export const getSpeedLimitList = () => Network.post("/speed-limit/list");
+11 -11
View File
@@ -86,16 +86,6 @@ export default function AdminLayout({
),
adminOnly: true
},
{
path: '/user',
label: '用户管理',
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
</svg>
),
adminOnly: true
},
{
path: '/limit',
label: '限速管理',
@@ -105,6 +95,16 @@ export default function AdminLayout({
</svg>
),
adminOnly: true
},
{
path: '/user',
label: '用户管理',
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
</svg>
),
adminOnly: true
}
];
@@ -260,7 +260,7 @@ export default function AdminLayout({
<Logo size={28} />
<div>
<h1 className="text-base font-bold text-foreground">哆啦A梦</h1>
<p className="text-xs text-default-500">v1.0.3</p>
<p className="text-xs text-default-500">v1.0.4</p>
</div>
</div>
</div>
+247 -10
View File
@@ -8,6 +8,7 @@ import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@herou
import { Chip } from "@heroui/chip";
import { Spinner } from "@heroui/spinner";
import { Switch } from "@heroui/switch";
import { Alert } from "@heroui/alert";
import toast from 'react-hot-toast';
import AdminLayout from "@/layouts/admin";
@@ -19,7 +20,8 @@ import {
forceDeleteForward,
userTunnel,
pauseForwardService,
resumeForwardService
resumeForwardService,
diagnoseForward
} from "@/api";
interface Forward {
@@ -63,6 +65,21 @@ interface AddressItem {
copying: boolean;
}
interface DiagnosisResult {
forwardName: string;
timestamp: number;
results: Array<{
success: boolean;
description: string;
nodeName: string;
nodeId: string;
targetIp: string;
message?: string;
averageTime?: number;
packetLoss?: number;
}>;
}
export default function ForwardPage() {
const [loading, setLoading] = useState(true);
const [forwards, setForwards] = useState<Forward[]>([]);
@@ -72,10 +89,14 @@ export default function ForwardPage() {
const [modalOpen, setModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [addressModalOpen, setAddressModalOpen] = useState(false);
const [diagnosisModalOpen, setDiagnosisModalOpen] = useState(false);
const [isEdit, setIsEdit] = useState(false);
const [submitLoading, setSubmitLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
const [forwardToDelete, setForwardToDelete] = useState<Forward | null>(null);
const [currentDiagnosisForward, setCurrentDiagnosisForward] = useState<Forward | null>(null);
const [diagnosisResult, setDiagnosisResult] = useState<DiagnosisResult | null>(null);
const [addressModalTitle, setAddressModalTitle] = useState('');
const [addressList, setAddressList] = useState<AddressItem[]>([]);
@@ -359,6 +380,64 @@ export default function ForwardPage() {
}
};
// 诊断转发
const handleDiagnose = async (forward: Forward) => {
setCurrentDiagnosisForward(forward);
setDiagnosisModalOpen(true);
setDiagnosisLoading(true);
setDiagnosisResult(null);
try {
const response = await diagnoseForward(forward.id);
if (response.code === 0) {
setDiagnosisResult(response.data);
} else {
toast.error(response.msg || '诊断失败');
setDiagnosisResult({
forwardName: forward.name,
timestamp: Date.now(),
results: [{
success: false,
description: '诊断失败',
nodeName: '-',
nodeId: '-',
targetIp: forward.remoteAddr.split(',')[0] || '-',
message: response.msg || '诊断过程中发生错误'
}]
});
}
} catch (error) {
console.error('诊断失败:', error);
toast.error('网络错误,请重试');
setDiagnosisResult({
forwardName: forward.name,
timestamp: Date.now(),
results: [{
success: false,
description: '网络错误',
nodeName: '-',
nodeId: '-',
targetIp: forward.remoteAddr.split(',')[0] || '-',
message: '无法连接到服务器'
}]
});
} finally {
setDiagnosisLoading(false);
}
};
// 获取连接质量
const getQualityDisplay = (averageTime?: number, packetLoss?: number) => {
if (averageTime === undefined || packetLoss === undefined) return null;
if (averageTime < 30 && packetLoss === 0) return { text: '🚀 优秀', color: 'success' };
if (averageTime < 50 && packetLoss === 0) return { text: '✨ 很好', color: 'success' };
if (averageTime < 100 && packetLoss < 1) return { text: '👍 良好', color: 'primary' };
if (averageTime < 150 && packetLoss < 2) return { text: '😐 一般', color: 'warning' };
if (averageTime < 200 && packetLoss < 5) return { text: '😟 较差', color: 'warning' };
return { text: '😵 很差', color: 'danger' };
};
// 格式化流量
const formatFlow = (value: number): string => {
if (value === 0) return '0 B';
@@ -518,6 +597,13 @@ export default function ForwardPage() {
return addresses.length;
};
// 格式化时间
const formatTime = (timestamp: string | number): string => {
if (!timestamp) return '-';
const date = new Date(timestamp);
return date.toLocaleString('zh-CN');
};
if (loading) {
return (
<AdminLayout>
@@ -596,9 +682,9 @@ export default function ForwardPage() {
title={formatInAddress(forward.inIp, forward.inPort)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-default-600">入口:</span>
<code className="text-xs font-mono text-foreground truncate">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<span className="text-xs font-medium text-default-600 flex-shrink-0">入口:</span>
<code className="text-xs font-mono text-foreground truncate min-w-0">
{formatInAddress(forward.inIp, forward.inPort)}
</code>
</div>
@@ -620,9 +706,9 @@ export default function ForwardPage() {
title={formatRemoteAddress(forward.remoteAddr)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-default-600">目标:</span>
<code className="text-xs font-mono text-foreground truncate">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<span className="text-xs font-medium text-default-600 flex-shrink-0">目标:</span>
<code className="text-xs font-mono text-foreground truncate min-w-0">
{formatRemoteAddress(forward.remoteAddr)}
</code>
</div>
@@ -666,6 +752,20 @@ export default function ForwardPage() {
>
编辑
</Button>
<Button
size="sm"
variant="flat"
color="warning"
onPress={() => handleDiagnose(forward)}
className="flex-1 min-h-8"
startContent={
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
}
>
诊断
</Button>
<Button
size="sm"
variant="flat"
@@ -711,7 +811,7 @@ export default function ForwardPage() {
isOpen={modalOpen}
onOpenChange={setModalOpen}
size="lg"
scrollBehavior="inside"
scrollBehavior="outside"
>
<ModalContent>
{(onClose) => (
@@ -725,7 +825,7 @@ export default function ForwardPage() {
</p>
</ModalHeader>
<ModalBody>
<div className="space-y-4">
<div className="space-y-4 pb-4">
<Input
label="转发名称"
placeholder="请输入转发名称"
@@ -830,6 +930,7 @@ export default function ForwardPage() {
isOpen={deleteModalOpen}
onOpenChange={setDeleteModalOpen}
size="sm"
scrollBehavior="outside"
>
<ModalContent>
{(onClose) => (
@@ -863,7 +964,7 @@ export default function ForwardPage() {
</Modal>
{/* 地址列表弹窗 */}
<Modal isOpen={addressModalOpen} onClose={() => setAddressModalOpen(false)} size="lg">
<Modal isOpen={addressModalOpen} onClose={() => setAddressModalOpen(false)} size="lg" scrollBehavior="outside">
<ModalContent>
<ModalHeader className="text-base">{addressModalTitle}</ModalHeader>
<ModalBody className="pb-6">
@@ -891,6 +992,142 @@ export default function ForwardPage() {
</ModalBody>
</ModalContent>
</Modal>
{/* 诊断结果模态框 */}
<Modal
isOpen={diagnosisModalOpen}
onOpenChange={setDiagnosisModalOpen}
size="3xl"
scrollBehavior="outside"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-xl font-bold">转发诊断结果</h2>
{currentDiagnosisForward && (
<div className="flex items-center gap-2 min-w-0">
<span className="text-small text-default-500 truncate flex-1 min-w-0">{currentDiagnosisForward.name}</span>
<Chip
color="primary"
variant="flat"
size="sm"
className="flex-shrink-0"
>
转发服务
</Chip>
</div>
)}
</ModalHeader>
<ModalBody>
{diagnosisLoading ? (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3">
<Spinner size="sm" />
<span className="text-default-600">正在诊断转发连接...</span>
</div>
</div>
) : diagnosisResult ? (
<div className="space-y-4">
{diagnosisResult.results.map((result, index) => {
const quality = getQualityDisplay(result.averageTime, result.packetLoss);
return (
<Card key={index} className={`shadow-sm border ${result.success ? 'border-success' : 'border-danger'}`}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between w-full">
<div>
<h3 className="text-lg font-semibold text-foreground">{result.description}</h3>
<div className="flex items-center gap-2 mt-1">
<span className="text-small text-default-500">节点: {result.nodeName}</span>
<Chip
color={result.success ? 'success' : 'danger'}
variant="flat"
size="sm"
>
{result.success ? '连接成功' : '连接失败'}
</Chip>
</div>
</div>
</div>
</CardHeader>
<CardBody className="pt-0">
{result.success ? (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-primary">{result.averageTime?.toFixed(0)}</div>
<div className="text-small text-default-500">平均延迟(ms)</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-warning">{result.packetLoss?.toFixed(1)}</div>
<div className="text-small text-default-500">丢包率(%)</div>
</div>
<div className="text-center">
{quality && (
<>
<Chip color={quality.color as any} variant="flat" size="lg">
{quality.text}
</Chip>
<div className="text-small text-default-500 mt-1">连接质量</div>
</>
)}
</div>
</div>
<div className="text-small text-default-500 flex items-center gap-1">
<span className="flex-shrink-0">目标地址:</span>
<code className="font-mono truncate min-w-0" title={result.targetIp}>{result.targetIp}</code>
</div>
</div>
) : (
<div className="space-y-2">
<div className="text-small text-default-500 flex items-center gap-1">
<span className="flex-shrink-0">目标地址:</span>
<code className="font-mono truncate min-w-0" title={result.targetIp}>{result.targetIp}</code>
</div>
<Alert
color="danger"
variant="flat"
title="错误详情"
description={result.message}
/>
</div>
)}
</CardBody>
</Card>
);
})}
</div>
) : (
<div className="text-center py-16">
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-default-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-foreground">暂无诊断数据</h3>
</div>
)}
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
关闭
</Button>
{currentDiagnosisForward && (
<Button
color="primary"
onPress={() => handleDiagnose(currentDiagnosisForward)}
isLoading={diagnosisLoading}
>
重新诊断
</Button>
)}
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</div>
</AdminLayout>
);
+2 -1
View File
@@ -337,7 +337,7 @@ export default function LimitPage() {
isOpen={modalOpen}
onOpenChange={setModalOpen}
size="lg"
scrollBehavior="inside"
scrollBehavior="outside"
>
<ModalContent>
{(onClose) => (
@@ -433,6 +433,7 @@ export default function LimitPage() {
isOpen={deleteModalOpen}
onOpenChange={setDeleteModalOpen}
size="sm"
scrollBehavior="outside"
>
<ModalContent>
{(onClose) => (
+235 -48
View File
@@ -15,7 +15,8 @@ import {
getNodeList,
updateNode,
deleteNode,
getNodeInstallCommand
getNodeInstallCommand,
checkNodeStatus
} from "@/api";
interface Node {
@@ -71,6 +72,11 @@ export default function NodePage() {
const [installCommand, setInstallCommand] = useState('');
const [currentNodeName, setCurrentNodeName] = useState('');
// 状态检查相关状态
const [statusCheckLoading, setStatusCheckLoading] = useState(false);
const [statusCheckResult, setStatusCheckResult] = useState<any>(null);
const [statusCheckModal, setStatusCheckModal] = useState(false);
const websocketRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
@@ -446,6 +452,55 @@ export default function NodePage() {
}
};
// 状态检查处理函数
const handleCheckAllStatus = async () => {
setStatusCheckLoading(true);
try {
const res = await checkNodeStatus();
if (res.code === 0) {
setStatusCheckResult(res.data);
setStatusCheckModal(true);
// 刷新节点列表以显示最新状态
await loadNodes();
const { totalNodes, connectedNodes, inconsistentNodes } = res.data;
if (inconsistentNodes > 0) {
toast.success(`状态检查完成!修复了 ${inconsistentNodes} 个节点的状态不一致问题`);
} else {
toast.success(`状态检查完成!${connectedNodes}/${totalNodes} 个节点在线`);
}
} else {
toast.error(res.msg || '状态检查失败');
}
} catch (error) {
toast.error('网络错误,请重试');
} finally {
setStatusCheckLoading(false);
}
};
const handleCheckSingleNodeStatus = async (nodeId: number) => {
try {
const res = await checkNodeStatus(nodeId);
if (res.code === 0) {
// 刷新节点列表
await loadNodes();
const result = res.data.details[0];
if (result.fixed) {
toast.success(`节点状态已修复:${result.message}`);
} else {
toast.success(result.message);
}
} else {
toast.error(res.msg || '状态检查失败');
}
} catch (error) {
toast.error('网络错误,请重试');
}
};
// 提交表单
const handleSubmit = async () => {
if (!validateForm()) return;
@@ -522,17 +577,32 @@ export default function NodePage() {
{/* 页面头部 */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-foreground">节点管理</h1>
<Button
color="primary"
onPress={handleAdd}
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
</svg>
}
>
新增节点
</Button>
<div className="flex gap-2">
<Button
color="secondary"
variant="flat"
onPress={handleCheckAllStatus}
isLoading={statusCheckLoading}
startContent={
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}
>
状态检查
</Button>
<Button
color="primary"
onPress={handleAdd}
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
</svg>
}
>
新增节点
</Button>
</div>
</div>
{/* 节点列表 */}
@@ -588,14 +658,18 @@ export default function NodePage() {
<CardBody className="pt-0 pb-3">
{/* 基础信息 */}
<div className="space-y-2 mb-4">
<div className="flex justify-between text-sm">
<span className="text-default-600">入口IP</span>
<div className="text-right text-xs">
<div className="flex justify-between items-center text-sm min-w-0">
<span className="text-default-600 flex-shrink-0">入口IP</span>
<div className="text-right text-xs min-w-0 flex-1 ml-2">
{node.ip ? (
node.ip.split(',').length > 1 ? (
<span className="font-mono">{node.ip.split(',')[0].trim()} +{node.ip.split(',').length - 1}个</span>
<span className="font-mono truncate block" title={node.ip.split(',')[0].trim()}>
{node.ip.split(',')[0].trim()} +{node.ip.split(',').length - 1}个
</span>
) : (
<span className="font-mono">{node.ip.trim()}</span>
<span className="font-mono truncate block" title={node.ip.trim()}>
{node.ip.trim()}
</span>
)
) : '-'}
</div>
@@ -678,35 +752,48 @@ export default function NodePage() {
</div>
{/* 操作按钮 */}
<div className="flex gap-1.5">
<Button
size="sm"
variant="flat"
color="success"
onPress={() => handleCopyInstallCommand(node)}
isLoading={node.copyLoading}
className="flex-1 min-h-8"
>
复制
</Button>
<Button
size="sm"
variant="flat"
color="primary"
onPress={() => handleEdit(node)}
className="flex-1 min-h-8"
>
编辑
</Button>
<Button
size="sm"
variant="flat"
color="danger"
onPress={() => handleDelete(node)}
className="flex-1 min-h-8"
>
删除
</Button>
<div className="space-y-1.5">
<div className="flex gap-1.5">
<Button
size="sm"
variant="flat"
color="success"
onPress={() => handleCopyInstallCommand(node)}
isLoading={node.copyLoading}
className="flex-1 min-h-8"
>
复制
</Button>
<Button
size="sm"
variant="flat"
color="primary"
onPress={() => handleEdit(node)}
className="flex-1 min-h-8"
>
编辑
</Button>
</div>
<div className="flex gap-1.5">
<Button
size="sm"
variant="flat"
color="secondary"
onPress={() => handleCheckSingleNodeStatus(node.id)}
className="flex-1 min-h-8"
>
检查
</Button>
<Button
size="sm"
variant="flat"
color="danger"
onPress={() => handleDelete(node)}
className="flex-1 min-h-8"
>
删除
</Button>
</div>
</div>
</CardBody>
</Card>
@@ -719,7 +806,7 @@ export default function NodePage() {
isOpen={dialogVisible}
onClose={() => setDialogVisible(false)}
size="2xl"
scrollBehavior="inside"
scrollBehavior="outside"
>
<ModalContent>
<ModalHeader>{dialogTitle}</ModalHeader>
@@ -810,7 +897,7 @@ export default function NodePage() {
isOpen={installCommandModal}
onClose={() => setInstallCommandModal(false)}
size="2xl"
scrollBehavior="inside"
scrollBehavior="outside"
>
<ModalContent>
<ModalHeader>安装命令 - {currentNodeName}</ModalHeader>
@@ -856,6 +943,106 @@ export default function NodePage() {
</ModalFooter>
</ModalContent>
</Modal>
{/* 状态检查结果模态框 */}
<Modal
isOpen={statusCheckModal}
onClose={() => setStatusCheckModal(false)}
size="3xl"
scrollBehavior="outside"
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
<span>节点状态检查结果</span>
{statusCheckResult && (
<div className="flex gap-4 text-sm font-normal">
<span className="text-default-600">
总节点: <span className="font-semibold">{statusCheckResult.totalNodes}</span>
</span>
<span className="text-success-600">
在线: <span className="font-semibold">{statusCheckResult.connectedNodes}</span>
</span>
<span className="text-warning-600">
修复: <span className="font-semibold">{statusCheckResult.inconsistentNodes}</span>
</span>
</div>
)}
</ModalHeader>
<ModalBody>
{statusCheckResult && statusCheckResult.details && (
<div className="space-y-3">
{statusCheckResult.details.map((detail: any) => (
<Card key={detail.nodeId} className="border border-divider">
<CardBody className="py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex flex-col">
<span className="font-semibold text-foreground">
{detail.nodeName || `节点 ${detail.nodeId}`}
</span>
<span className="text-xs text-default-500">
ID: {detail.nodeId}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Chip
size="sm"
color={detail.connected ? 'success' : 'danger'}
variant="flat"
>
{detail.connected ? '在线' : '离线'}
</Chip>
<Chip
size="sm"
color={detail.fixed ? 'warning' : 'default'}
variant={detail.fixed ? 'flat' : 'bordered'}
>
{detail.fixed ? '已修复' : '正常'}
</Chip>
</div>
</div>
<div className="mt-2 text-sm">
<div className="flex justify-between items-center text-xs">
<span className="text-default-600">数据库状态:</span>
<span className={`font-mono ${detail.databaseStatus === 1 ? 'text-success-600' : 'text-danger-600'}`}>
{detail.databaseStatus === 1 ? '在线' : '离线'}
</span>
</div>
<div className="flex justify-between items-center text-xs mt-1">
<span className="text-default-600">实际状态:</span>
<span className={`font-mono ${detail.actualStatus ? 'text-success-600' : 'text-danger-600'}`}>
{detail.actualStatus ? '连接中' : '未连接'}
</span>
</div>
{detail.fixed && (
<div className="flex justify-between items-center text-xs mt-1">
<span className="text-default-600">最终状态:</span>
<span className={`font-mono ${detail.finalStatus === 1 ? 'text-success-600' : 'text-danger-600'}`}>
{detail.finalStatus === 1 ? '在线' : '离线'}
</span>
</div>
)}
<div className="mt-2 p-2 bg-default-50 dark:bg-default-100 rounded text-xs">
{detail.message}
</div>
</div>
</CardBody>
</Card>
))}
</div>
)}
</ModalBody>
<ModalFooter>
<Button
variant="flat"
onPress={() => setStatusCheckModal(false)}
>
关闭
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
</AdminLayout>
);
+2 -2
View File
@@ -608,7 +608,7 @@ export default function TunnelPage() {
isOpen={modalOpen}
onOpenChange={setModalOpen}
size="2xl"
scrollBehavior="inside"
scrollBehavior="outside"
>
<ModalContent>
{(onClose) => (
@@ -896,7 +896,7 @@ export default function TunnelPage() {
isOpen={diagnosisModalOpen}
onOpenChange={setDiagnosisModalOpen}
size="3xl"
scrollBehavior="inside"
scrollBehavior="outside"
>
<ModalContent>
{(onClose) => (
+137 -8
View File
@@ -152,6 +152,14 @@ export default function UserPage() {
const [editTunnelForm, setEditTunnelForm] = useState<UserTunnel | null>(null);
const [editTunnelLoading, setEditTunnelLoading] = useState(false);
// 删除确认相关状态
const { isOpen: isDeleteModalOpen, onOpen: onDeleteModalOpen, onClose: onDeleteModalClose } = useDisclosure();
const [userToDelete, setUserToDelete] = useState<User | null>(null);
// 删除隧道权限确认相关状态
const { isOpen: isDeleteTunnelModalOpen, onOpen: onDeleteTunnelModalOpen, onClose: onDeleteTunnelModalClose } = useDisclosure();
const [tunnelToDelete, setTunnelToDelete] = useState<UserTunnel | null>(null);
// 其他数据
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [speedLimits, setSpeedLimits] = useState<SpeedLimit[]>([]);
@@ -266,12 +274,21 @@ export default function UserPage() {
onUserModalOpen();
};
const handleDelete = async (user: User) => {
const handleDelete = (user: User) => {
setUserToDelete(user);
onDeleteModalOpen();
};
const handleConfirmDelete = async () => {
if (!userToDelete) return;
try {
const response = await deleteUser(user.id);
const response = await deleteUser(userToDelete.id);
if (response.code === 0) {
toast.success('删除成功');
loadUsers();
onDeleteModalClose();
setUserToDelete(null);
} else {
toast.error(response.msg || '删除失败');
}
@@ -405,14 +422,23 @@ export default function UserPage() {
}
};
const handleRemoveTunnel = async (userTunnel: UserTunnel) => {
const handleRemoveTunnel = (userTunnel: UserTunnel) => {
setTunnelToDelete(userTunnel);
onDeleteTunnelModalOpen();
};
const handleConfirmRemoveTunnel = async () => {
if (!tunnelToDelete) return;
try {
const response = await removeUserTunnel({ id: userTunnel.id });
const response = await removeUserTunnel({ id: tunnelToDelete.id });
if (response.code === 0) {
toast.success('删除成功');
if (currentUser) {
loadUserTunnels(currentUser.id);
}
onDeleteTunnelModalClose();
setTunnelToDelete(null);
} else {
toast.error(response.msg || '删除失败');
}
@@ -646,7 +672,7 @@ export default function UserPage() {
isOpen={isUserModalOpen}
onClose={onUserModalClose}
size="2xl"
scrollBehavior="inside"
scrollBehavior="outside"
>
<ModalContent>
<ModalHeader>
@@ -701,7 +727,7 @@ export default function UserPage() {
value={userForm.expTime ? parseDate(userForm.expTime.toISOString().split('T')[0]) : null}
onChange={(date) => {
if (date) {
const jsDate = new Date(date.year, date.month - 1, date.day);
const jsDate = new Date(date.year, date.month - 1, date.day, 23, 59, 59);
setUserForm(prev => ({ ...prev, expTime: jsDate }));
} else {
setUserForm(prev => ({ ...prev, expTime: null }));
@@ -743,7 +769,7 @@ export default function UserPage() {
isOpen={isTunnelModalOpen}
onClose={onTunnelModalClose}
size="5xl"
scrollBehavior="inside"
scrollBehavior="outside"
>
<ModalContent>
<ModalHeader>
@@ -822,7 +848,7 @@ export default function UserPage() {
value={tunnelForm.expTime ? parseDate(tunnelForm.expTime.toISOString().split('T')[0]) : null}
onChange={(date) => {
if (date) {
const jsDate = new Date(date.year, date.month - 1, date.day);
const jsDate = new Date(date.year, date.month - 1, date.day, 23, 59, 59);
setTunnelForm(prev => ({ ...prev, expTime: jsDate }));
} else {
setTunnelForm(prev => ({ ...prev, expTime: null }));
@@ -937,6 +963,7 @@ export default function UserPage() {
isOpen={isEditTunnelModalOpen}
onClose={onEditTunnelModalClose}
size="2xl"
scrollBehavior="outside"
>
<ModalContent>
<ModalHeader>
@@ -991,6 +1018,22 @@ export default function UserPage() {
</SelectItem>
))}
</Select>
<DatePicker
label="到期时间"
value={editTunnelForm.expTime ? parseDate(new Date(editTunnelForm.expTime).toISOString().split('T')[0]) : null}
onChange={(date) => {
if (date) {
const jsDate = new Date(date.year, date.month - 1, date.day, 23, 59, 59);
setEditTunnelForm(prev => prev ? { ...prev, expTime: jsDate.getTime() } : null);
} else {
setEditTunnelForm(prev => prev ? { ...prev, expTime: Date.now() } : null);
}
}}
showMonthAndYearPickers
className="cursor-pointer"
isRequired
/>
</div>
)}
</ModalBody>
@@ -1008,6 +1051,92 @@ export default function UserPage() {
</ModalFooter>
</ModalContent>
</Modal>
{/* 删除确认对话框 */}
<Modal
isOpen={isDeleteModalOpen}
onClose={onDeleteModalClose}
size="md"
scrollBehavior="outside"
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
确认删除用户
</ModalHeader>
<ModalBody>
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-danger-100 rounded-full flex items-center justify-center">
<DeleteIcon className="w-6 h-6 text-danger" />
</div>
<div className="flex-1">
<p className="text-foreground">
确定要删除用户 <span className="font-semibold text-danger">"{userToDelete?.user}"</span> 吗?
</p>
<p className="text-small text-default-500 mt-1">
此操作不可撤销,用户的所有数据将被永久删除。
</p>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button
variant="light"
onPress={onDeleteModalClose}
>
取消
</Button>
<Button
color="danger"
onPress={handleConfirmDelete}
>
确认删除
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* 删除隧道权限确认对话框 */}
<Modal
isOpen={isDeleteTunnelModalOpen}
onClose={onDeleteTunnelModalClose}
size="md"
scrollBehavior="outside"
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
确认删除隧道权限
</ModalHeader>
<ModalBody>
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-danger-100 rounded-full flex items-center justify-center">
<DeleteIcon className="w-6 h-6 text-danger" />
</div>
<div className="flex-1">
<p className="text-foreground">
确定要删除用户 <span className="font-semibold">{currentUser?.user}</span> 对隧道 <span className="font-semibold text-danger">"{tunnelToDelete?.tunnelName}"</span> 的权限吗?
</p>
<p className="text-small text-default-500 mt-1">
删除后该用户将无法使用此隧道创建转发,此操作不可撤销。
</p>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button
variant="light"
onPress={onDeleteTunnelModalClose}
>
取消
</Button>
<Button
color="danger"
onPress={handleConfirmRemoveTunnel}
>
确认删除
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
</AdminLayout>
);
-20
View File
@@ -1,20 +0,0 @@
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
dist/
build/
coverage/
*.tmp
*.log
*.swp
*.swo
.idea/
.vscode/
.DS_Store
Thumbs.db
logs/
-1
View File
@@ -1 +0,0 @@
VUE_APP_API_BASE=http://localhost:6365
-1
View File
@@ -1 +0,0 @@
VUE_APP_API_BASE=
-21
View File
@@ -1,21 +0,0 @@
# 多阶段构建 - 构建阶段
FROM node:20.19.0 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 生产阶段
FROM nginx:stable-alpine AS production-stage
COPY nginx.conf /etc/nginx/nginx.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
-24
View File
@@ -1,24 +0,0 @@
# gost
## Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).
-5
View File
@@ -1,5 +0,0 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}
-19
View File
@@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}
-80
View File
@@ -1,80 +0,0 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 日志格式
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# gzip压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# 静态文件缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Vue Router的history模式支持
location / {
try_files $uri $uri/ /index.html;
}
# API代理到后端
location ^~ /api/v1/ {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://backend:6365/api/v1/;
}
# WebSocket代理 - system-info
location /system-info {
proxy_pass http://backend:6365/system-info;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
-17755
View File
File diff suppressed because it is too large Load Diff
-39
View File
@@ -1,39 +0,0 @@
{
"name": "gost",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build"
},
"dependencies": {
"axios": "^1.8.4",
"core-js": "^3.8.3",
"echarts": "^5.6.0",
"element-ui": "^2.15.14",
"vue": "^2.6.14",
"vue-echarts": "^7.0.3",
"vue-router": "^3.5.3"
},
"devDependencies": {
"@babel/core": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"vue-template-compiler": "^2.6.14"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential"
],
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

-17
View File
@@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>哆啦A梦</title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>
-97
View File
@@ -1,97 +0,0 @@
<template>
<div id="app">
<router-view />
</div>
</template>
<script>
export default {
name: 'App',
created() {
// 设置移动端适配
this.setRem();
window.addEventListener('resize', this.setRem);
},
beforeDestroy() {
window.removeEventListener('resize', this.setRem);
},
methods: {
setRem() {
const width = document.documentElement.clientWidth || document.body.clientWidth;
// 移动端适配,基准值为375px宽度设备
if (width < 768) {
const ratio = width / 375;
document.documentElement.style.fontSize = 14 * ratio + 'px';
} else {
document.documentElement.style.fontSize = '16px';
}
}
}
}
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow-x: hidden;
overflow-y: hidden;
-webkit-text-size-adjust: 100%;
}
#app {
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
height: 100%;
color: #333;
}
/* 移动端样式适配 */
@media screen and (max-width: 768px) {
.el-table {
width: 100% !important;
font-size: 0.8rem;
}
.el-button {
padding: 8px 15px;
font-size: 0.9rem;
}
.el-form-item__label {
font-size: 0.9rem;
}
.el-dialog {
width: 95% !important;
margin-top: 15vh !important;
}
.el-card {
padding: 5px !important;
}
.el-card__body {
padding: 10px !important;
}
/* 确保文本可以换行,避免溢出 */
.el-table .cell,
.el-form-item__content,
.el-input__inner {
word-break: break-word !important;
white-space: normal !important;
}
.el-message-box {
width: 90% !important;
}
}
</style>
-54
View File
@@ -1,54 +0,0 @@
import Network from './network';
export const login = (data) => Network.post("/user/login", data)
// 用户CRUD操作 - 全部使用POST请求
export const createUser = (data) => Network.post("/user/create", data)
export const getAllUsers = (pageData = {}) => Network.post("/user/list", pageData)
export const updateUser = (data) => Network.post("/user/update", data)
export const deleteUser = (id) => Network.post("/user/delete", { id })
export const getUserPackageInfo = () => Network.post("/user/package")
// 节点CRUD操作 - 全部使用POST请求
export const createNode = (data) => Network.post("/node/create", data)
export const getNodeList = () => Network.post("/node/list")
export const updateNode = (data) => Network.post("/node/update", data)
export const deleteNode = (id) => Network.post("/node/delete", { id })
export const getNodeInstallCommand = (id) => Network.post("/node/install", { id })
// 隧道CRUD操作 - 全部使用POST请求
export const createTunnel = (data) => Network.post("/tunnel/create", data)
export const getTunnelList = () => Network.post("/tunnel/list")
export const getTunnelById = (id) => Network.post("/tunnel/get", { id })
export const updateTunnel = (data) => Network.post("/tunnel/update", data)
export const deleteTunnel = (id) => Network.post("/tunnel/delete", { id })
export const diagnoseTunnel = (tunnelId) => Network.post("/tunnel/diagnose", { tunnelId })
// 用户隧道权限管理操作 - 全部使用POST请求
export const assignUserTunnel = (data) => Network.post("/tunnel/user/assign", data)
export const getUserTunnelList = (queryData = {}) => Network.post("/tunnel/user/list", queryData)
export const removeUserTunnel = (params) => Network.post("/tunnel/user/remove", params)
export const updateUserTunnelFlow = (params) => Network.post("/tunnel/user/updateFlow", params)
export const updateUserTunnel = (data) => Network.post("/tunnel/user/update", data)
export const userTunnel = () => Network.post("/tunnel/user/tunnel")
// 转发CRUD操作 - 全部使用POST请求
export const createForward = (data) => Network.post("/forward/create", data)
export const getForwardList = () => Network.post("/forward/list")
export const updateForward = (data) => Network.post("/forward/update", data)
export const deleteForward = (id) => Network.post("/forward/delete", { id })
export const forceDeleteForward = (id) => Network.post("/forward/force-delete", { id })
// 转发服务控制操作 - 通过Java后端接口
export const pauseForwardService = (forwardId) => Network.post("/forward/pause", { id: forwardId })
export const resumeForwardService = (forwardId) => Network.post("/forward/resume", { id: forwardId })
// 限速规则CRUD操作 - 全部使用POST请求
export const createSpeedLimit = (data) => Network.post("/speed-limit/create", data)
export const getSpeedLimitList = () => Network.post("/speed-limit/list")
export const updateSpeedLimit = (data) => Network.post("/speed-limit/update", data)
export const deleteSpeedLimit = (id) => Network.post("/speed-limit/delete", { id })
// 修改密码接口
export const updatePassword = (data) => Network.post("/user/updatePassword", data)
-83
View File
@@ -1,83 +0,0 @@
import axios from 'axios';
// 处理token失效的逻辑
function handleTokenExpired() {
// 清除localStorage中的token
window.localStorage.removeItem('token');
// 跳转到登录页面
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
// 检查响应是否为token失效
function isTokenExpired(response) {
return response && response.code === 401 &&
(response.msg === '未登录或token已过期' ||
response.msg === '无效的token或token已过期' ||
response.msg === '无法获取用户权限信息');
}
export default {
get: function(path = '', data = {}) {
return new Promise(function(resolve, reject) {
axios.get(path, {
params: data,
timeout: 30000,
headers: {
"Authorization": window.localStorage.getItem('token')
}
})
.then(function(response) {
// 检查是否token失效
if (isTokenExpired(response.data)) {
handleTokenExpired();
return;
}
resolve(response.data);
})
.catch(function(error) {
console.error('GET请求错误:', error);
// 检查是否是401错误(token失效)
if (error.response && error.response.status === 401) {
handleTokenExpired();
return;
}
resolve({"code": -1, "msg": error.message || "网络请求失败"});
});
});
},
post: function(path = '', data = {}) {
return new Promise(function(resolve, reject) {
axios.post(path, data, {
timeout: 30000,
headers:{
"Authorization": window.localStorage.getItem('token'),
"Content-Type": "application/json"
}
})
.then(function(response) {
// 检查是否token失效
if (isTokenExpired(response.data)) {
handleTokenExpired();
return;
}
resolve(response.data);
})
.catch(function(error) {
console.error('POST请求错误:', error);
// 检查是否是401错误(token失效)
if (error.response && error.response.status === 401) {
handleTokenExpired();
return;
}
resolve({"code": -1, "msg": error.message || "网络请求失败"});
});
});
}
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

-129
View File
@@ -1,129 +0,0 @@
<template>
<div class="unified-empty-state" :class="{ 'small': size === 'small', 'large': size === 'large' }">
<el-empty
:description="description"
:image-size="computedImageSize"
:image="image"
>
<template #description>
<p class="empty-description">{{ description }}</p>
</template>
<slot></slot>
</el-empty>
</div>
</template>
<script>
export default {
name: 'EmptyState',
props: {
description: {
type: String,
default: '暂无数据'
},
size: {
type: String,
default: 'default', // 'small', 'default', 'large'
validator: value => ['small', 'default', 'large'].includes(value)
},
image: {
type: String,
default: undefined
},
imageSize: {
type: Number,
default: undefined
}
},
computed: {
computedImageSize() {
if (this.imageSize) return this.imageSize;
switch (this.size) {
case 'small':
return 80;
case 'large':
return 120;
default:
return 100;
}
}
}
}
</script>
<style scoped>
.unified-empty-state {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
padding: 20px;
width: 100%;
}
.unified-empty-state.small {
min-height: 150px;
padding: 15px;
}
.unified-empty-state.large {
min-height: 250px;
padding: 30px;
}
.empty-description {
color: #909399;
font-size: 14px;
margin: 0;
line-height: 1.4;
}
/* 针对网格布局的支持 */
.unified-empty-state {
grid-column: 1 / -1;
}
/* 响应式设计 */
@media (max-width: 768px) {
.unified-empty-state {
min-height: 150px;
padding: 15px 10px;
}
.unified-empty-state.small {
min-height: 120px;
padding: 10px;
}
.unified-empty-state.large {
min-height: 180px;
padding: 20px 15px;
}
.empty-description {
font-size: 13px;
}
}
@media (max-width: 480px) {
.unified-empty-state {
min-height: 120px;
padding: 10px 8px;
}
.unified-empty-state.small {
min-height: 100px;
padding: 8px;
}
.unified-empty-state.large {
min-height: 150px;
padding: 15px 10px;
}
.empty-description {
font-size: 12px;
}
}
</style>
-71
View File
@@ -1,71 +0,0 @@
import { isAdmin, hasRole, isLoggedIn } from '@/utils/auth'
/**
* 权限指令
* 使用方法:
* v-admin - 只有管理员可见
* v-role="0" - 只有指定角色可见
* v-auth - 只有已登录用户可见
*/
// 管理员权限指令
export const adminDirective = {
inserted(el, binding) {
if (!isAdmin()) {
// 移除元素
el.parentNode && el.parentNode.removeChild(el)
}
},
update(el, binding) {
// 动态更新权限状态
if (!isAdmin()) {
el.style.display = 'none'
} else {
el.style.display = ''
}
}
}
// 角色权限指令
export const roleDirective = {
inserted(el, binding) {
const targetRole = binding.value
if (!hasRole(targetRole)) {
el.parentNode && el.parentNode.removeChild(el)
}
},
update(el, binding) {
const targetRole = binding.value
if (!hasRole(targetRole)) {
el.style.display = 'none'
} else {
el.style.display = ''
}
}
}
// 登录权限指令
export const authDirective = {
inserted(el, binding) {
if (!isLoggedIn()) {
el.parentNode && el.parentNode.removeChild(el)
}
},
update(el, binding) {
if (!isLoggedIn()) {
el.style.display = 'none'
} else {
el.style.display = ''
}
}
}
// 批量注册指令的函数
export function installPermissionDirectives(Vue) {
Vue.directive('admin', adminDirective)
Vue.directive('role', roleDirective)
Vue.directive('auth', authDirective)
}
-46
View File
@@ -1,46 +0,0 @@
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import axios from 'axios'
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
import { installPermissionDirectives } from '@/directives/permission'
import { isAdmin, hasRole, isLoggedIn, getCurrentUserRoleId } from '@/utils/auth'
// 注册Element UI
Vue.use(ElementUI)
// 注册权限指令
installPermissionDirectives(Vue)
// 添加全局权限方法
Vue.prototype.$isAdmin = isAdmin
Vue.prototype.$hasRole = hasRole
Vue.prototype.$isLoggedIn = isLoggedIn
Vue.prototype.$getCurrentUserRoleId = getCurrentUserRoleId
// 注册axios
const base_url = process.env.VUE_APP_API_BASE
Vue.prototype.baseUrl = base_url
Vue.prototype.$axios = axios
axios.defaults.baseURL = base_url + "/api/v1/"
Vue.config.productionTip = false
Vue.filter('dateFormat',function(originVal){
const dt = new Date(originVal)
const y = dt.getFullYear()
const m = (dt.getMonth() + 1 + '').padStart(2,'0')
const d = (dt.getDate() + '').padStart(2,'0')
const hh = (dt.getHours()+'').padStart(2,'0')
const mm = (dt.getMinutes()+'').padStart(2,'0')
const ss = (dt.getSeconds()+'').padStart(2,'0')
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`
})
new Vue({
router,
render: h => h(App),
}).$mount('#app')
-73
View File
@@ -1,73 +0,0 @@
import Vue from 'vue';
import VueRouter from 'vue-router';
import { isLoggedIn } from '@/utils/auth';
// 路由懒加载
import Home from "@/views/Home.vue";
import Login from "@/views/Login.vue";
import ChangeAccountPassword from "@/views/ChangePassword.vue";
import Index from "@/views/Index";
import User from "@/views/User";
import Node from "@/views/node";
import Tunnel from "@/views/Tunnel";
import Forward from "@/views/Forward";
import Limit from "@/views/Limit";
Vue.use(VueRouter);
// 路由配置
const routes = [
{
path: '/',
redirect: () => {
// 如果用户已登录,重定向到首页,否则重定向到登录页
return isLoggedIn() ? '/index' : '/login';
}
},
{path: '/login', component: Login},
{path: '/change-password', component: ChangeAccountPassword},
{
path: '/home', component: Home, redirect: '/index', children: [
{path: '/index', component: Index},
{path: '/user', component: User},
{path: '/node', component: Node},
{path: '/tunnel', component: Tunnel},
{path: '/forward', component: Forward},
{path: '/limit', component: Limit}
]
},
]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
})
router.beforeEach((to, from, next) => {
// 检查用户是否已登录
const userIsLoggedIn = isLoggedIn();
// 如果用户已登录且访问登录页面,直接跳转到首页
if (userIsLoggedIn && to.path === '/login') {
return next('/index');
}
// 如果访问登录或修改密码页面,直接放行
if (to.path === '/login' || to.path === '/change-password') {
return next();
}
// 如果用户未登录且访问需要登录的页面,跳转到登录页面
if (!userIsLoggedIn) {
return next('/login');
}
// 其他情况正常放行
next();
})
export default router
-70
View File
@@ -1,70 +0,0 @@
import { getRoleIdFromToken, isTokenValid } from './jwt'
/**
* 权限工具类
*/
/**
* 获取当前用户的token
* @returns {string|null} token
*/
export function getToken() {
return localStorage.getItem('token')
}
/**
* 获取当前用户的角色ID
* @returns {number|null} 角色ID
*/
export function getCurrentUserRoleId() {
const token = getToken()
if (!token || !isTokenValid(token)) {
return null
}
return getRoleIdFromToken(token)
}
/**
* 判断当前用户是否是管理员
* @returns {boolean} 是否是管理员
*/
export function isAdmin() {
const roleId = getCurrentUserRoleId()
return roleId === 0
}
/**
* 判断当前用户是否有指定角色
* @param {number} targetRoleId 目标角色ID
* @returns {boolean} 是否有指定角色
*/
export function hasRole(targetRoleId) {
const roleId = getCurrentUserRoleId()
return roleId === targetRoleId
}
/**
* 判断当前用户是否已登录且token有效
* @returns {boolean} 是否已登录
*/
export function isLoggedIn() {
const token = getToken()
return token && isTokenValid(token)
}
/**
* 权限检查装饰器函数
* @param {Function} fn 要执行的函数
* @param {string} errorMsg 权限不足时的错误提示
* @returns {Function} 包装后的函数
*/
export function requireAdmin(fn, errorMsg = '权限不足,仅管理员可操作') {
return function(...args) {
if (!isAdmin()) {
// 可以在这里集成消息提示组件
console.warn(errorMsg)
return false
}
return fn.apply(this, args)
}
}
-263
View File
@@ -1,263 +0,0 @@
/**
* 剪贴板工具类
* 兼容现代浏览器和旧版浏览器的复制功能
*/
/**
* 检测是否为Safari浏览器
* @returns {boolean}
*/
const isSafari = () => {
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
};
/**
* Safari专用的复制方法
* @param {string} text - 要复制的文本
* @returns {Promise<boolean>}
*/
const safariCopy = (text) => {
return new Promise((resolve, reject) => {
const textArea = document.createElement('textarea');
textArea.value = text;
// Safari需要元素可见且可聚焦
textArea.style.position = 'absolute';
textArea.style.left = '-9999px';
textArea.style.top = '-9999px';
textArea.style.fontSize = '12pt';
textArea.style.border = '0';
textArea.style.padding = '0';
textArea.style.margin = '0';
textArea.style.outline = 'none';
textArea.style.boxShadow = 'none';
textArea.style.background = 'transparent';
document.body.appendChild(textArea);
// Safari需要先聚焦
textArea.focus();
textArea.setSelectionRange(0, text.length);
try {
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
resolve(true);
} else {
reject(new Error('Safari execCommand 复制失败'));
}
} catch (error) {
document.body.removeChild(textArea);
reject(error);
}
});
};
/**
* 复制文本到剪贴板
* @param {string} text - 要复制的文本
* @param {string} label - 复制内容的标签,用于提示消息(可选)
* @returns {Promise<boolean>} - 返回复制是否成功
*/
export const copyToClipboard = async (text, label = '内容') => {
if (!text) {
throw new Error('复制内容不能为空');
}
try {
// Safari浏览器特殊处理
if (isSafari()) {
// 优先尝试现代API,但Safari对安全上下文要求严格
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (clipboardError) {
console.warn('Safari Clipboard API失败,降级到execCommand:', clipboardError);
// 降级到Safari专用方法
await safariCopy(text);
return true;
}
} else {
// 直接使用Safari专用方法
await safariCopy(text);
return true;
}
}
// 方法1: 使用现代浏览器的 Clipboard API(非Safari浏览器首选)
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
// 方法2: 使用 document.execCommand(通用兼容性方案)
const textArea = document.createElement('textarea');
textArea.value = text;
// 设置样式,使其不可见且不影响页面布局
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
textArea.style.opacity = '0';
textArea.style.pointerEvents = 'none';
textArea.style.zIndex = '-1';
// 添加到 DOM
document.body.appendChild(textArea);
// 选择文本
textArea.focus();
textArea.select();
// 兼容iOS设备的选择
if (navigator.userAgent.match(/ipad|iphone/i)) {
textArea.setSelectionRange(0, text.length);
}
// 尝试复制
const successful = document.execCommand('copy');
// 清理 DOM
document.body.removeChild(textArea);
if (successful) {
return true;
} else {
throw new Error('document.execCommand 复制失败');
}
} catch (error) {
console.error('复制失败:', error);
throw new Error(`复制${label}失败`);
}
};
/**
* 复制文本到剪贴板(带成功提示)
* 适用于 Vue 组件中使用,会显示 Element UI 的消息提示
* @param {string} text - 要复制的文本
* @param {string} label - 复制内容的标签
* @param {object} vm - Vue 组件实例(用于调用 $message)
* @returns {Promise<void>}
*/
export const copyWithMessage = async (text, label = '内容', vm) => {
try {
await copyToClipboard(text, label);
if (vm && vm.$message) {
vm.$message.success(`${label}已复制到剪贴板`);
}
} catch (error) {
console.error('复制失败详情:', error);
// 为Safari提供更详细的错误信息
let errorMessage = error.message || `复制${label}失败,请手动复制`;
if (isSafari() && !window.isSecureContext) {
errorMessage = `复制失败:Safari需要HTTPS环境。请手动复制${label}`;
} else if (isSafari() && error.message.includes('Safari')) {
errorMessage = `Safari复制失败,请手动选择并复制${label}`;
}
if (vm && vm.$message) {
vm.$message.error(errorMessage);
} else {
console.error(errorMessage);
}
}
};
/**
* 检查浏览器是否支持剪贴板操作
* @returns {object} - 返回支持的功能信息
*/
export const getClipboardSupport = () => {
const hasClipboardAPI = !!(navigator.clipboard && window.isSecureContext);
const hasExecCommand = !!document.execCommand;
const browserInfo = {
isSafari: isSafari(),
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent),
isSecureContext: window.isSecureContext
};
return {
modern: hasClipboardAPI, // 是否支持现代 Clipboard API
legacy: hasExecCommand, // 是否支持 execCommand
supported: hasClipboardAPI || hasExecCommand, // 是否支持任何复制方法
browser: browserInfo, // 浏览器信息
recommendedMethod: browserInfo.isSafari ? 'safari-optimized' :
hasClipboardAPI ? 'clipboard-api' : 'exec-command'
};
};
/**
* 显示手动复制提示(当自动复制失败时使用)
* @param {string} text - 要复制的文本
* @param {string} label - 复制内容的标签
* @param {object} vm - Vue 组件实例
*/
export const showManualCopyDialog = (text, label, vm) => {
if (vm && vm.$alert) {
const isSafariBrowser = isSafari();
const message = isSafariBrowser
? `Safari浏览器限制,无法自动复制。请手动选择并复制以下${label}:\n\n${text}`
: `请手动复制以下${label}:\n\n${text}`;
vm.$alert(
message,
isSafariBrowser ? 'Safari复制提示' : '复制提示',
{
confirmButtonText: '已复制',
type: 'info',
dangerouslyUseHTMLString: false,
customClass: 'copy-dialog'
}
);
}
};
/**
* 带降级方案的复制方法
* 如果自动复制失败,会显示手动复制对话框
* @param {string} text - 要复制的文本
* @param {string} label - 复制内容的标签
* @param {object} vm - Vue 组件实例
* @returns {Promise<void>}
*/
export const copyWithFallback = async (text, label = '内容', vm) => {
// 如果是Safari并且不在安全上下文中,直接显示手动复制对话框
if (isSafari() && !window.isSecureContext) {
console.warn('Safari非HTTPS环境,直接使用手动复制');
showManualCopyDialog(text, label, vm);
return;
}
try {
await copyToClipboard(text, label);
if (vm && vm.$message) {
vm.$message.success(`${label}已复制到剪贴板`);
}
} catch (error) {
console.error('自动复制失败,显示手动复制对话框:', error);
// 如果是Safari或自动复制失败,显示手动复制对话框
if (isSafari() || error.message.includes('失败')) {
showManualCopyDialog(text, label, vm);
} else {
// 其他错误正常提示
if (vm && vm.$message) {
vm.$message.error(error.message || `复制${label}失败`);
}
}
}
};
export default {
copyToClipboard,
copyWithMessage,
copyWithFallback,
showManualCopyDialog,
getClipboardSupport
};
-105
View File
@@ -1,105 +0,0 @@
/**
* JWT工具类 - 前端版本
*/
/**
* 从JWT Token中获取payload
* @param {string} token JWT Token
* @returns {object|null} payload数据
*/
function getPayloadFromToken(token) {
try {
if (!token) return null;
const parts = token.split('.');
if (parts.length !== 3) return null;
const encodedPayload = parts[1];
const decodedPayload = atob(encodedPayload);
return JSON.parse(decodedPayload);
} catch (error) {
return null;
}
}
/**
* 从JWT Token中获取用户ID
* @param {string} token JWT Token
* @returns {number|null} 用户ID
*/
export function getUserIdFromToken(token) {
const payload = getPayloadFromToken(token);
return payload ? parseInt(payload.sub) : null;
}
/**
* 从JWT Token中获取用户角色ID
* @param {string} token JWT Token
* @returns {number|null} 角色ID
*/
export function getRoleIdFromToken(token) {
const payload = getPayloadFromToken(token);
return payload ? payload.role_id : null;
}
/**
* 从JWT Token中获取用户名
* @param {string} token JWT Token
* @returns {string|null} 用户名
*/
export function getUsernameFromToken(token) {
const payload = getPayloadFromToken(token);
return payload ? payload.user : null;
}
/**
* 验证token是否过期
* @param {string} token JWT Token
* @returns {boolean} 是否有效
*/
export function isTokenValid(token) {
const payload = getPayloadFromToken(token);
if (!payload) return false;
const now = Math.floor(Date.now() / 1000);
return payload.exp > now;
}
// JwtUtil对象,提供便捷的静态方法调用
export const JwtUtil = {
/**
* 从localStorage获取token并解析用户ID
* @returns {number|null} 用户ID
*/
getUserIdFromToken() {
const token = localStorage.getItem('token');
return getUserIdFromToken(token);
},
/**
* 从localStorage获取token并解析角色ID
* @returns {number|null} 角色ID
*/
getRoleIdFromToken() {
const token = localStorage.getItem('token');
return getRoleIdFromToken(token);
},
/**
* 从localStorage获取token并解析用户名
* @returns {string|null} 用户名
*/
getUsernameFromToken() {
const token = localStorage.getItem('token');
return getUsernameFromToken(token);
},
/**
* 验证localStorage中的token是否有效
* @returns {boolean} 是否有效
*/
isTokenValid() {
const token = localStorage.getItem('token');
return isTokenValid(token);
}
};
-413
View File
@@ -1,413 +0,0 @@
<template>
<div class="change-password-container">
<!-- 背景装饰 -->
<div class="bg-decoration">
<div class="circle circle-1"></div>
<div class="circle circle-2"></div>
<div class="circle circle-3"></div>
</div>
<!-- 主要内容区域 -->
<div class="change-password-wrapper">
<!-- 修改密码表单 -->
<div class="change-password-form-container">
<div class="change-password-form">
<div class="form-header">
<div class="warning-icon">
<i class="el-icon-warning" style="font-size: 50px; color: #E6A23C;"></i>
</div>
<h2 class="form-title">安全提醒</h2>
<p class="form-subtitle">检测到您使用的是默认账号密码,为了您的账户安全,请立即修改账号和密码</p>
</div>
<el-form
:model="passwordForm"
:rules="rules"
ref="passwordForm"
@keyup.enter.native="handleSubmit"
class="change-password-form-content"
label-width="100px"
>
<el-form-item label="新用户名" prop="newUsername">
<div class="input-wrapper">
<el-input
v-model="passwordForm.newUsername"
placeholder="请输入新用户名(至少3位)"
clearable
size="large"
class="custom-input"
></el-input>
</div>
</el-form-item>
<el-form-item label="当前密码" prop="currentPassword">
<div class="input-wrapper">
<el-input
v-model="passwordForm.currentPassword"
type="password"
placeholder="请输入当前密码"
show-password
size="large"
class="custom-input"
></el-input>
</div>
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<div class="input-wrapper">
<el-input
v-model="passwordForm.newPassword"
type="password"
placeholder="请输入新密码(至少6位)"
show-password
size="large"
class="custom-input"
></el-input>
</div>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<div class="input-wrapper">
<el-input
v-model="passwordForm.confirmPassword"
type="password"
placeholder="请再次输入新密码"
show-password
size="large"
class="custom-input"
></el-input>
</div>
</el-form-item>
<el-form-item class="change-password-btn-item">
<el-button
type="primary"
:loading="loading"
@click="handleSubmit"
class="change-password-btn"
size="large"
>
<span v-if="!loading">立即修改账号密码</span>
<span v-else>修改中...</span>
</el-button>
</el-form-item>
</el-form>
<div class="form-footer">
<p class="warning-text">注意:修改账号密码后需要重新登录</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { updatePassword } from "@/api";
export default {
name: 'ChangeAccountPassword',
data() {
return {
passwordForm: {
newUsername: '',
currentPassword: '',
newPassword: '',
confirmPassword: ''
},
rules: {
newUsername: [
{ required: true, message: '请输入新用户名', trigger: 'blur' },
{ min: 3, message: '用户名长度至少3位', trigger: 'blur' },
{ max: 20, message: '用户名长度不能超过20位', trigger: 'blur' }
],
currentPassword: [
{ required: true, message: '请输入当前密码', trigger: 'blur' },
{ min: 1, message: '密码不能为空', trigger: 'blur' }
],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '新密码长度不能少于6位', trigger: 'blur' },
{ max: 20, message: '新密码长度不能超过20位', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{ validator: this.validateConfirmPassword, trigger: 'blur' }
]
},
loading: false
};
},
methods: {
// 确认密码验证器
validateConfirmPassword(rule, value, callback) {
if (value === '') {
callback(new Error('请再次输入密码'));
} else if (value !== this.passwordForm.newPassword) {
callback(new Error('两次输入密码不一致'));
} else {
callback();
}
},
// 提交修改密码
handleSubmit() {
this.$refs.passwordForm.validate(async (valid) => {
if (valid) {
try {
this.loading = true;
// 调用修改密码接口
const response = await updatePassword(this.passwordForm);
if (response.code === 0) {
this.$message.success(response.msg || '账号密码修改成功');
// 提示用户重新登录
this.$confirm('账号密码修改成功,请重新登录', '提示', {
confirmButtonText: '确定',
type: 'success',
showCancelButton: false,
closeOnClickModal: false,
closeOnPressEscape: false
}).then(() => {
this.logout();
}).catch(() => {
this.logout();
});
} else {
this.$message.error(response.msg || '账号密码修改失败');
}
} catch (error) {
this.$message.error('修改账号密码时发生错误');
console.error('修改账号密码错误:', error);
} finally {
this.loading = false;
}
}
});
},
// 退出登录
logout() {
localStorage.clear();
this.$router.push("/login");
}
}
};
</script>
<style scoped>
.change-password-container {
height: 100vh;
width: 100vw;
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #E6A23C 0%, #F56C6C 100%);
display: flex;
align-items: center;
justify-content: center;
}
/* 背景装饰 */
.bg-decoration {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 1;
}
.circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
animation: float 6s ease-in-out infinite;
}
.circle-1 {
width: 200px;
height: 200px;
top: 10%;
left: 10%;
animation-delay: 0s;
}
.circle-2 {
width: 150px;
height: 150px;
top: 60%;
right: 15%;
animation-delay: 2s;
}
.circle-3 {
width: 100px;
height: 100px;
bottom: 20%;
left: 20%;
animation-delay: 4s;
}
@keyframes float {
0%, 100% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-20px) rotate(180deg); }
}
/* 主要内容区域 */
.change-password-wrapper {
position: relative;
z-index: 2;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
max-width: 600px;
}
/* 修改密码表单 */
.change-password-form-container {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
}
.change-password-form {
width: 100%;
max-width: 500px;
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
padding: 40px;
backdrop-filter: blur(10px);
}
.form-header {
text-align: center;
margin-bottom: 40px;
}
.warning-icon {
margin-bottom: 15px;
}
.form-title {
font-size: 2rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 0.5rem;
}
.form-subtitle {
color: #6b7280;
font-size: 1rem;
margin: 0;
line-height: 1.5;
}
.change-password-form-content {
margin-bottom: 30px;
}
.input-wrapper {
position: relative;
margin-bottom: 20px;
}
/* 自定义输入框样式 */
.custom-input >>> .el-input__inner {
height: 50px;
border-radius: 12px;
border: 2px solid #e5e7eb;
font-size: 16px;
padding-left: 15px;
transition: all 0.3s ease;
background: #f9fafb;
}
.custom-input >>> .el-input__inner:focus {
border-color: #E6A23C;
background: white;
box-shadow: 0 0 0 3px rgba(230, 162, 60, 0.1);
}
.custom-input >>> .el-input__suffix {
right: 15px;
}
/* 修改密码按钮 */
.change-password-btn-item {
margin-bottom: 0;
}
.change-password-btn {
width: 100%;
height: 50px;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
background: linear-gradient(135deg, #E6A23C 0%, #F56C6C 100%);
border: none;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(230, 162, 60, 0.4);
}
.change-password-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(230, 162, 60, 0.6);
}
.change-password-btn:active {
transform: translateY(0);
}
/* 表单底部 */
.form-footer {
text-align: center;
margin-top: 30px;
}
.warning-text {
color: #F56C6C;
font-size: 0.875rem;
margin: 0 0 10px 0;
font-weight: 600;
}
.copyright {
color: #9ca3af;
font-size: 0.875rem;
margin: 0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.change-password-form-container {
padding: 20px;
}
.change-password-form {
padding: 30px 20px;
}
.form-title {
font-size: 1.5rem;
}
}
/* 表单验证错误样式 */
.change-password-form-content >>> .el-form-item__error {
font-size: 14px;
color: #ef4444;
margin-top: 5px;
}
/* 加载状态 */
.change-password-btn.is-loading {
pointer-events: none;
}
</style>
File diff suppressed because it is too large Load Diff
-436
View File
@@ -1,436 +0,0 @@
<template>
<el-container>
<el-header style="height: 50px;">
<div class="header-left">
<!-- 移动端菜单按钮 -->
<el-button
icon="el-icon-menu"
class="mobile-menu-btn"
@click="toggleMobileMenu"
type="text">
</el-button>
<span style="line-height: 50px">管理后台<span style="margin-left: 10px; font-size: 12px">1.0.2</span></span>
</div>
<div class="header-avatar">
<el-avatar style="margin-right: 10px" size="medium" src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png"></el-avatar>
<el-dropdown>
<span class="el-dropdown-link" style="color: white">{{name}}</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item @click.native="showPasswordDialog">
<i class="el-icon-key"></i>
修改密码
</el-dropdown-item>
<el-dropdown-item @click.native="logout">
<i class="el-icon-switch-button"></i>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</el-header>
<el-container>
<!-- 移动端遮罩层 -->
<div v-if="mobileMenuVisible" class="mobile-overlay" @click="hideMobileMenu"></div>
<el-aside
:width="isMobile ? '250px' : '200px'"
:class="['main-aside', { 'mobile-aside': isMobile, 'mobile-hidden': isMobile && !mobileMenuVisible }]">
<el-menu
:default-active="path"
class="el-menu-vertical-demo"
:collapse="false">
<router-link to="/index">
<el-menu-item index="/index" @click="handleMenuClick('/index')">
<template slot="title">
<i class="el-icon-box"></i>
<span slot="title">首页</span>
</template>
</el-menu-item>
</router-link>
<router-link to="/limit" v-admin>
<el-menu-item index="/limit" @click="handleMenuClick('/limit')">
<template slot="title">
<i class="el-icon-box"></i>
<span slot="title">限速管理</span>
</template>
</el-menu-item>
</router-link>
<router-link to="/forward">
<el-menu-item index="/forward" @click="handleMenuClick('/forward')">
<template slot="title">
<i class="el-icon-postcard"></i>
<span slot="title">转发管理</span>
</template>
</el-menu-item>
</router-link>
<router-link to="/tunnel" v-admin>
<el-menu-item index="/tunnel" @click="handleMenuClick('/tunnel')">
<template slot="title">
<i class="el-icon-postcard"></i>
<span slot="title">隧道管理</span>
</template>
</el-menu-item>
</router-link>
<router-link to="/node" v-admin>
<el-menu-item index="/node" @click="handleMenuClick('/node')">
<template slot="title">
<i class="el-icon-postcard"></i>
<span slot="title">节点监控</span>
</template>
</el-menu-item>
</router-link>
<router-link to="/user" v-admin>
<el-menu-item index="/user" @click="handleMenuClick('/user')">
<template slot="title">
<i class="el-icon-user"></i>
<span slot="title">用户管理</span>
</template>
</el-menu-item>
</router-link>
</el-menu>
</el-aside>
<el-main :class="['main-content', { 'mobile-main': isMobile }]">
<div class="scroll-container">
<router-view/>
</div>
</el-main>
</el-container>
<!-- 修改密码弹窗 -->
<el-dialog
title="修改密码"
:visible.sync="passwordDialogVisible"
:width="isMobile ? '90%' : '400px'"
:before-close="handlePasswordDialogClose">
<el-form :model="passwordForm" :rules="passwordRules" ref="passwordForm" label-width="100px">
<el-form-item label="新用户名" prop="newUsername">
<el-input v-model="passwordForm.newUsername" placeholder="请输入新用户名(至少3位)" clearable></el-input>
</el-form-item>
<el-form-item label="当前密码" prop="currentPassword">
<el-input v-model="passwordForm.currentPassword" type="password" placeholder="请输入当前密码" show-password clearable></el-input>
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="passwordForm.newPassword" type="password" placeholder="请输入新密码" show-password clearable></el-input>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="passwordForm.confirmPassword" type="password" placeholder="请再次输入新密码" show-password clearable></el-input>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="handlePasswordDialogClose">取 消</el-button>
<el-button type="primary" @click="submitPasswordChange" :loading="passwordLoading">确 定</el-button>
</div>
</el-dialog>
</el-container>
</template>
<script>
import { updatePassword } from '@/api'
export default {
name: "home",
data(){
return{
path:'',
name: '',
isMobile: false,
mobileMenuVisible: false,
// 修改密码相关数据
passwordDialogVisible: false,
passwordLoading: false,
passwordForm: {
newUsername: '',
currentPassword: '',
newPassword: '',
confirmPassword: ''
},
passwordRules: {
newUsername: [
{ required: true, message: '请输入新用户名', trigger: 'blur' },
{ min: 3, message: '用户名长度至少3位', trigger: 'blur' },
{ max: 20, message: '用户名长度不能超过20位', trigger: 'blur' }
],
currentPassword: [
{ required: true, message: '请输入当前密码', trigger: 'blur' },
{ min: 1, message: '密码不能为空', trigger: 'blur' }
],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '新密码长度不能少于6位', trigger: 'blur' },
{ max: 20, message: '新密码长度不能超过20位', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{ validator: this.validateConfirmPassword, trigger: 'blur' }
]
}
}
},
computed: {
mainContentStyle() {
return {
margin: this.isMobile ? '10px' : '15px 15px'
}
}
},
created() {
this.path = localStorage.getItem('e')
this.name = localStorage.getItem("name")
this.checkMobile()
window.addEventListener('resize', this.checkMobile)
},
beforeDestroy() {
window.removeEventListener('resize', this.checkMobile)
},
methods:{
checkMobile() {
this.isMobile = window.innerWidth <= 768
if (!this.isMobile) {
this.mobileMenuVisible = false
}
},
toggleMobileMenu() {
this.mobileMenuVisible = !this.mobileMenuVisible
},
hideMobileMenu() {
this.mobileMenuVisible = false
},
handleMenuClick(path) {
this.save(path)
// 移动端点击菜单后隐藏菜单
if (this.isMobile) {
this.hideMobileMenu()
}
},
save(e){
localStorage.setItem('e',e)
this.path = e
},
logout(){
localStorage.clear()
this.$router.push("/login")
},
// 显示修改密码弹窗
showPasswordDialog() {
this.passwordDialogVisible = true
},
// 关闭修改密码弹窗
handlePasswordDialogClose() {
this.passwordDialogVisible = false
this.resetPasswordForm()
},
// 重置修改密码表单
resetPasswordForm() {
this.passwordForm = {
newUsername: '',
currentPassword: '',
newPassword: '',
confirmPassword: ''
}
if (this.$refs.passwordForm) {
this.$refs.passwordForm.clearValidate()
}
},
// 确认密码验证器
validateConfirmPassword(rule, value, callback) {
if (value === '') {
callback(new Error('请再次输入密码'))
} else if (value !== this.passwordForm.newPassword) {
callback(new Error('两次输入密码不一致'))
} else {
callback()
}
},
// 提交密码修改
async submitPasswordChange() {
try {
// 表单验证
await this.$refs.passwordForm.validate()
this.passwordLoading = true
// 调用修改密码接口
const response = await updatePassword(this.passwordForm)
if (response.code === 0) {
this.$message.success(response.msg || '密码修改成功')
this.handlePasswordDialogClose()
// 提示用户重新登录
this.$confirm('密码修改成功,请重新登录', '提示', {
confirmButtonText: '确定',
type: 'success',
showCancelButton: false
}).then(() => {
this.logout()
}).catch(() => {
this.logout()
})
} else {
this.$message.error(response.msg || '密码修改失败')
}
} catch (error) {
if (error !== 'validate') {
this.$message.error('修改密码时发生错误')
console.error('修改密码错误:', error)
}
} finally {
this.passwordLoading = false
}
}
}
}
</script>
<style scoped>
.el-container {
padding: 0;
margin: 0;
height: 100%;
}
.el-header {
background-color: #2F54EB;
color: white;
line-height: 50px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
display: flex;
align-items: center;
}
.mobile-menu-btn {
display: none;
color: white;
font-size: 18px;
margin-right: 10px;
padding: 0;
}
.header-avatar {
display: flex;
justify-content: space-around;
align-items: center;
}
.el-dropdown-link {
cursor: pointer;
}
.main-aside {
color: white;
transition: transform 0.3s ease;
position: relative;
z-index: 1000;
}
.mobile-aside {
position: fixed;
top: 50px;
left: 0;
height: calc(100vh - 50px);
z-index: 1001;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
}
.mobile-hidden {
transform: translateX(-100%);
}
.mobile-overlay {
position: fixed;
top: 50px;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
}
.main-content {
padding: 0;
transition: margin-left 0.3s ease;
overflow: hidden;
height: calc(100vh - 50px);
}
.scroll-container {
background-color: #f5f5f5;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
}
.mobile-main {
margin-left: 0 !important;
}
.footer {
background-color: white;
height: 50px;
position: absolute;
bottom: 0;
left: 199px;
right: 0;
line-height: 50px;
padding-left: 30px;
font-size: 12px;
color: #58666e;
}
.mobile-footer {
left: 0;
padding-left: 15px;
}
a {
text-decoration: none;
}
.el-menu-vertical-demo {
height: 100%;
}
/* 修改密码弹窗样式 */
.dialog-footer {
text-align: right;
}
.el-dialog__body {
padding: 20px;
}
.el-form-item {
margin-bottom: 22px;
}
/* 移动端适配 */
@media (max-width: 768px) {
.mobile-menu-btn {
display: inline-block;
}
.header-avatar .el-avatar {
margin-right: 5px;
}
.el-dropdown-link {
font-size: 14px;
}
}
</style>
File diff suppressed because it is too large Load Diff
-726
View File
@@ -1,726 +0,0 @@
<template>
<div class="speed-limit-container">
<!-- 页面头部 -->
<div class="header-bar">
<h2 class="page-title">
<i class="el-icon-odometer"></i>
限速管理
</h2>
<el-button
type="primary"
size="small"
icon="el-icon-plus"
@click="handleAdd"
>
新增限速
</el-button>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<div class="loading-spinner"></div>
<p>加载中...</p>
</div>
<!-- 限速规则列表 - 桌面端表格 -->
<div v-else-if="!isMobile && !loading && filteredSpeedLimitList.length !== 0" class="table-container">
<div class="custom-table">
<div class="table-header">
<div class="th">ID</div>
<div class="th">规则名称</div>
<div class="th">速度限制</div>
<div class="th">状态</div>
<div class="th">绑定隧道</div>
<div class="th">创建时间</div>
<div class="th">更新时间</div>
<div class="th">操作</div>
</div>
<div v-for="rule in filteredSpeedLimitList" :key="rule.id" class="table-row">
<div class="td">{{ rule.id }}</div>
<div class="td">{{ rule.name }}</div>
<div class="td">
<el-tag type="info">{{ rule.speed }} Mbps</el-tag>
</div>
<div class="td">
<el-tag :type="rule.status === 1 ? 'success' : 'danger'">
{{ rule.status === 1 ? '运行' : '异常' }}
</el-tag>
</div>
<div class="td">
<el-tag v-if="rule.tunnelName" type="success">{{ rule.tunnelName }}</el-tag>
<span v-else class="text-muted">未绑定</span>
</div>
<div class="td">{{ rule.createdTime | dateFormat }}</div>
<div class="td">{{ rule.updatedTime | dateFormat }}</div>
<div class="td">
<div class="action-buttons-inline">
<el-button
size="mini"
type="primary"
icon="el-icon-edit"
@click="handleEdit(rule)"
circle
></el-button>
<el-button
size="mini"
type="danger"
icon="el-icon-delete"
@click="handleDelete(rule)"
circle
></el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 限速规则列表 - 移动端卡片 -->
<div v-else class="cards-container" v-loading="loading">
<div
v-for="rule in filteredSpeedLimitList"
:key="rule.id"
class="rule-card"
>
<div class="card-header">
<div class="card-title">
<i class="el-icon-odometer"></i>
{{ rule.name }}
</div>
<div class="card-status">
<el-tag :type="rule.status === 1 ? 'success' : 'danger'" size="mini">
{{ rule.status === 1 ? '运行' : '异常' }}
</el-tag>
</div>
</div>
<div class="card-content">
<div class="info-row">
<span class="info-label">ID:</span>
<span class="info-value">{{ rule.id }}</span>
</div>
<div class="info-row">
<span class="info-label">速度限制:</span>
<span class="info-value speed-value">{{ rule.speed }} Mbps</span>
</div>
<div class="info-row">
<span class="info-label">绑定隧道:</span>
<span class="info-value tunnel-value">
{{ rule.tunnelName || '未绑定' }}
</span>
</div>
<div class="info-row">
<span class="info-label">创建时间:</span>
<span class="info-value">{{ rule.createdTime | dateFormat }}</span>
</div>
<div class="info-row">
<span class="info-label">更新时间:</span>
<span class="info-value">{{ rule.updatedTime | dateFormat }}</span>
</div>
</div>
<div class="card-actions">
<el-button
@click="handleEdit(rule)"
type="primary"
size="small"
icon="el-icon-edit"
plain
>
编辑
</el-button>
<el-button
@click="handleDelete(rule)"
type="danger"
size="small"
icon="el-icon-delete"
plain
>
删除
</el-button>
</div>
</div>
</div>
<!-- 空状态 -->
<EmptyState
v-if="!loading && filteredSpeedLimitList.length === 0"
description="暂无限速规则"
/>
<!-- 新增/编辑对话框 -->
<el-dialog
:title="dialogTitle"
:visible.sync="dialogVisible"
:width="isMobile ? '90%' : '500px'"
@close="resetForm"
>
<el-form
:model="speedLimitForm"
:rules="rules"
ref="speedLimitForm"
label-width="100px"
>
<el-form-item label="规则名称" prop="name">
<el-input
v-model="speedLimitForm.name"
placeholder="请输入限速规则名称"
clearable
></el-input>
</el-form-item>
<el-form-item label="速度限制" prop="speed">
<el-input-number
v-model="speedLimitForm.speed"
:min="1"
:max="1000000"
placeholder="请输入速度限制"
style="width: 100%;"
></el-input-number>
<div class="form-hint">
单位:Mbps,范围:1-1000000
</div>
</el-form-item>
<el-form-item label="绑定隧道" prop="tunnelId">
<el-select
v-model="speedLimitForm.tunnelId"
placeholder="请选择要绑定的隧道"
style="width: 100%;"
filterable
@change="handleTunnelChange"
>
<el-option
v-for="tunnel in tunnelList"
:key="tunnel.id"
:label="tunnel.name"
:value="tunnel.id"
></el-option>
</el-select>
<div class="form-hint">
选择要应用限速规则的隧道
</div>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitLoading"
>
{{ submitLoading ? '提交中...' : '确 定' }}
</el-button>
</span>
</el-dialog>
</div>
</template>
<script>
import { createSpeedLimit, getSpeedLimitList, updateSpeedLimit, deleteSpeedLimit, getTunnelList } from "@/api";
import EmptyState from "@/components/EmptyState.vue";
export default {
name: "Limit",
components: {
EmptyState
},
data() {
return {
speedLimitList: [],
tunnelList: [],
loading: false,
dialogVisible: false,
dialogTitle: '',
isEdit: false,
submitLoading: false,
searchKeyword: '',
isMobile: false,
// 表单数据
speedLimitForm: {
id: null,
name: '',
speed: 100,
tunnelId: null,
tunnelName: '',
status: 1
},
// 表单验证规则
rules: {
name: [
{ required: true, message: '请输入规则名称', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
],
speed: [
{ required: true, message: '请输入速度限制', trigger: 'blur' },
{ type: 'number', min: 1, message: '速度限制必须大于0', trigger: 'blur' }
],
tunnelId: [
{ required: true, message: '请选择要绑定的隧道', trigger: 'change' }
]
}
};
},
computed: {
filteredSpeedLimitList() {
if (!this.searchKeyword.trim()) {
return this.speedLimitList;
}
return this.speedLimitList.filter(item =>
item.name.toLowerCase().includes(this.searchKeyword.toLowerCase())
);
}
},
mounted() {
this.checkMobile();
this.loadSpeedLimits();
this.loadTunnels();
window.addEventListener('resize', this.checkMobile);
},
beforeDestroy() {
window.removeEventListener('resize', this.checkMobile);
},
methods: {
checkMobile() {
this.isMobile = window.innerWidth <= 768;
},
// 加载限速规则列表
async loadSpeedLimits() {
try {
this.loading = true;
const res = await getSpeedLimitList();
if (res.code === 0) {
this.speedLimitList = res.data || [];
} else {
this.$message.error(res.msg || '获取限速规则列表失败');
}
} catch (error) {
console.error('加载限速规则列表失败:', error);
this.$message.error('加载限速规则列表失败');
} finally {
this.loading = false;
}
},
// 加载隧道列表
async loadTunnels() {
try {
const res = await getTunnelList();
if (res.code === 0) {
this.tunnelList = res.data || [];
} else {
console.error('获取隧道列表失败:', res.msg);
}
} catch (error) {
console.error('加载隧道列表失败:', error);
}
},
// 搜索功能
handleSearch() {
// 通过 computed 属性实现实时搜索
},
// 处理隧道选择变化
handleTunnelChange(tunnelId) {
const selectedTunnel = this.tunnelList.find(tunnel => tunnel.id === tunnelId);
if (selectedTunnel) {
this.speedLimitForm.tunnelName = selectedTunnel.name;
} else {
this.speedLimitForm.tunnelName = '';
}
},
// 新增限速规则
handleAdd() {
this.isEdit = false;
this.dialogTitle = '新增限速规则';
this.dialogVisible = true;
},
// 编辑限速规则
handleEdit(row) {
this.isEdit = true;
this.dialogTitle = '编辑限速规则';
this.speedLimitForm = {
id: row.id,
name: row.name,
speed: row.speed,
tunnelId: row.tunnelId,
tunnelName: row.tunnelName,
status: row.status || 1
};
this.dialogVisible = true;
},
// 删除限速规则
async handleDelete(row) {
try {
await this.$confirm(`确定要删除限速规则 "${row.name}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
});
const res = await deleteSpeedLimit(row.id);
if (res.code === 0) {
this.$message.success('删除成功');
this.loadSpeedLimits();
} else {
this.$message.error(res.msg || '删除失败');
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除限速规则失败:', error);
this.$message.error('删除失败');
}
}
},
// 提交表单
async handleSubmit() {
try {
await this.$refs.speedLimitForm.validate();
this.submitLoading = true;
let res;
if (this.isEdit) {
res = await updateSpeedLimit(this.speedLimitForm);
} else {
const { id, ...createData } = this.speedLimitForm;
res = await createSpeedLimit(createData);
}
if (res.code === 0) {
this.$message.success(this.isEdit ? '修改成功' : '创建成功');
this.dialogVisible = false;
this.loadSpeedLimits();
} else {
this.$message.error(res.msg || '操作失败');
}
} catch (error) {
if (error !== false) { // 表单验证失败时不显示错误消息
console.error('提交失败:', error);
this.$message.error('操作失败');
}
} finally {
this.submitLoading = false;
}
},
// 重置表单
resetForm() {
this.speedLimitForm = {
id: null,
name: '',
speed: 100,
tunnelId: null,
tunnelName: '',
status: 1
};
this.$nextTick(() => {
if (this.$refs.speedLimitForm) {
this.$refs.speedLimitForm.clearValidate();
}
});
}
},
filters: {
dateFormat(value) {
if (!value) return '';
const date = new Date(value);
return date.toLocaleString();
}
}
};
</script>
<style scoped>
.speed-limit-container {
padding: 15px;
}
/* 页面头部 */
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
background: white;
padding: 12px 20px;
border-radius: 6px;
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.1);
}
.page-title {
font-size: 18px;
font-weight: 600;
color: #303133;
margin: 0;
display: flex;
align-items: center;
}
.page-title i {
margin-right: 6px;
color: #409eff;
}
.table-container {
background-color: white;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
padding: 20px;
overflow-x: auto;
}
.custom-table {
width: 100%;
border-collapse: collapse;
display: table;
}
.table-header {
display: table-row;
background: #f8f9fa;
font-weight: 600;
}
.table-row {
display: table-row;
border-bottom: 1px solid #ebeef5;
transition: background-color 0.3s;
}
.table-row:hover {
background-color: #f5f7fa;
}
.th, .td {
display: table-cell;
padding: 12px 15px;
text-align: left;
vertical-align: middle;
border-bottom: 1px solid #ebeef5;
}
.th {
background: #f8f9fa;
color: #909399;
font-weight: 600;
font-size: 13px;
}
.td {
font-size: 14px;
color: #606266;
}
.action-buttons-inline {
display: flex;
gap: 8px;
justify-content: center;
}
.cards-container {
display: grid;
gap: 15px;
}
.rule-card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.rule-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 20px 15px;
border-bottom: 1px solid #f0f0f0;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #303133;
display: flex;
align-items: center;
}
.card-title i {
margin-right: 8px;
color: #409EFF;
}
.card-content {
padding: 15px 20px;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #f5f5f5;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
font-size: 14px;
color: #909399;
font-weight: 500;
}
.info-value {
font-size: 14px;
color: #303133;
text-align: right;
max-width: 60%;
word-break: break-all;
}
.speed-value {
color: #409EFF;
font-weight: 600;
}
.tunnel-value {
color: #67C23A;
}
.card-actions {
display: flex;
padding: 15px 20px 20px;
gap: 10px;
}
.card-actions .el-button {
flex: 1;
}
/* 空状态使用统一组件,样式已内置 */
.form-hint {
font-size: 12px;
color: #909399;
margin-top: 5px;
}
.dialog-footer {
text-align: right;
}
.text-muted {
color: #909399;
font-style: italic;
}
/* Element UI 样式调整 */
.el-dialog {
border-radius: 12px;
}
.el-dialog__title {
font-weight: 600;
}
.el-button--mini {
border-radius: 4px;
}
.el-tag {
border-radius: 4px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.speed-limit-container {
padding: 10px;
}
.header-bar {
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 10px 15px;
margin-bottom: 10px;
}
.page-title {
font-size: 16px;
text-align: center;
}
.card-actions {
padding: 15px;
}
.card-header {
padding: 15px;
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.card-content {
padding: 10px 15px;
}
.info-row {
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 10px 0;
}
.info-value {
max-width: 100%;
text-align: left;
}
.table-container {
display: none;
}
}
@media (max-width: 480px) {
.speed-limit-container {
padding: 8px;
}
.header-bar {
padding: 8px 12px;
margin-bottom: 8px;
}
.page-title {
font-size: 15px;
}
.empty-state {
padding: 40px 15px;
}
}
</style>
-337
View File
@@ -1,337 +0,0 @@
<template>
<div class="login-container">
<!-- 背景装饰 -->
<div class="bg-decoration">
<div class="circle circle-1"></div>
<div class="circle circle-2"></div>
<div class="circle circle-3"></div>
</div>
<!-- 主要内容区域 -->
<div class="login-wrapper">
<!-- 登录表单 -->
<div class="login-form-container">
<div class="login-form">
<div class="form-header">
<h2 class="form-title">账户登录</h2>
<p class="form-subtitle">请输入您的登录凭据</p>
</div>
<el-form
:model="loginForm"
:rules="rules"
ref="loginForm"
@keyup.enter.native="handleLogin"
class="login-form-content"
>
<el-form-item prop="username">
<div class="input-wrapper">
<el-input
v-model="loginForm.username"
prefix-icon="el-icon-user"
placeholder="请输入用户名"
clearable
size="large"
class="custom-input"
></el-input>
</div>
</el-form-item>
<el-form-item prop="password">
<div class="input-wrapper">
<el-input
v-model="loginForm.password"
prefix-icon="el-icon-lock"
placeholder="请输入密码"
show-password
size="large"
class="custom-input"
></el-input>
</div>
</el-form-item>
<el-form-item class="login-btn-item">
<el-button
type="primary"
:loading="loading"
@click="handleLogin"
class="login-btn"
size="large"
>
<span v-if="!loading">立即登录</span>
<span v-else>登录中...</span>
</el-button>
</el-form-item>
</el-form>
</div>
</div>
</div>
</div>
</template>
<script>
import {login} from "@/api";
export default {
name: 'Login',
data() {
return {
loginForm: {
username: '',
password: ''
},
rules: {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度至少6位', trigger: 'blur' }
]
},
loading: false
};
},
methods: {
handleLogin() {
this.$refs.loginForm.validate(valid => {
if (valid) {
this.loading = true;
login(this.loginForm).then(res=>{
this.loading = false
if (res.code !== 0) return this.$message.error(res.msg)
// 检查是否需要强制修改密码
if (res.data.requirePasswordChange) {
localStorage.setItem('token', res.data.token);
localStorage.setItem("role_id", res.data.role_id)
localStorage.setItem("name", res.data.name)
this.$message.warning('检测到您使用的是默认账号密码,为了安全请立即修改');
this.$router.push("/change-password");
return;
}
localStorage.setItem('token', res.data.token);
localStorage.setItem("e", '/index')
localStorage.setItem("role_id", res.data.role_id)
localStorage.setItem("name", res.data.name)
this.$message.success('登录成功');
this.$router.push("/index");
}).catch(() => {
this.loading = false;
})
}
});
}
}
};
</script>
<style scoped>
.login-container {
height: 100vh;
width: 100vw;
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
}
/* 背景装饰 */
.bg-decoration {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 1;
}
.circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
animation: float 6s ease-in-out infinite;
}
.circle-1 {
width: 200px;
height: 200px;
top: 10%;
left: 10%;
animation-delay: 0s;
}
.circle-2 {
width: 150px;
height: 150px;
top: 60%;
right: 15%;
animation-delay: 2s;
}
.circle-3 {
width: 100px;
height: 100px;
bottom: 20%;
left: 20%;
animation-delay: 4s;
}
@keyframes float {
0%, 100% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-20px) rotate(180deg); }
}
/* 主要内容区域 */
.login-wrapper {
position: relative;
z-index: 2;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
max-width: 500px;
}
/* 登录表单 */
.login-form-container {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
}
.login-form {
width: 100%;
max-width: 400px;
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
padding: 40px;
backdrop-filter: blur(10px);
}
.form-header {
text-align: center;
margin-bottom: 40px;
}
.form-title {
font-size: 2rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 0.5rem;
}
.form-subtitle {
color: #6b7280;
font-size: 1rem;
margin: 0;
}
.login-form-content {
margin-bottom: 30px;
}
.input-wrapper {
position: relative;
margin-bottom: 20px;
}
/* 自定义输入框样式 */
.custom-input >>> .el-input__inner {
height: 50px;
border-radius: 12px;
border: 2px solid #e5e7eb;
font-size: 16px;
padding-left: 45px;
transition: all 0.3s ease;
background: #f9fafb;
}
.custom-input >>> .el-input__inner:focus {
border-color: #667eea;
background: white;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.custom-input >>> .el-input__prefix {
left: 15px;
color: #6b7280;
}
.custom-input >>> .el-input__suffix {
right: 15px;
}
/* 登录按钮 */
.login-btn-item {
margin-bottom: 0;
}
.login-btn {
width: 100%;
height: 50px;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.6);
}
.login-btn:active {
transform: translateY(0);
}
/* 表单底部 */
.form-footer {
text-align: center;
margin-top: 30px;
}
.copyright {
color: #9ca3af;
font-size: 0.875rem;
margin: 0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.login-form-container {
padding: 20px;
}
.login-form {
padding: 30px 20px;
}
.form-title {
font-size: 1.5rem;
}
}
/* 表单验证错误样式 */
.login-form-content >>> .el-form-item__error {
font-size: 14px;
color: #ef4444;
margin-top: 5px;
}
/* 加载状态 */
.login-btn.is-loading {
pointer-events: none;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
publicPath: './',
devServer: {
historyApiFallback: true
}
}