mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
优化ui 添加转发诊断 移除vue前端
This commit is contained in:
@@ -11,47 +11,6 @@ on:
|
|||||||
- '.github/workflows/docker-build.yml'
|
- '.github/workflows/docker-build.yml'
|
||||||
|
|
||||||
jobs:
|
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:
|
build-vite:
|
||||||
if: contains(github.event.head_commit.message, '[skip vite]') == false
|
if: contains(github.event.head_commit.message, '[skip vite]') == false
|
||||||
name: Build & Push Vite Frontend
|
name: Build & Push Vite Frontend
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ services:
|
|||||||
start_period: 90s
|
start_period: 90s
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: bqlpfy/vue-frontend:latest
|
image: bqlpfy/vite-frontend:latest
|
||||||
container_name: vue-frontend
|
container_name: vite-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT}:80"
|
- "${FRONTEND_PORT}:80"
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ services:
|
|||||||
start_period: 90s
|
start_period: 90s
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: bqlpfy/vue-frontend:latest
|
image: bqlpfy/vite-frontend:latest
|
||||||
container_name: vue-frontend
|
container_name: vite-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT}:80"
|
- "${FRONTEND_PORT}:80"
|
||||||
|
|||||||
Binary file not shown.
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"addr": "127.0.0.1:6365",
|
|
||||||
"secret": "0fb968b83a234441b290eb17e872b928"
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"services": null
|
|
||||||
}
|
|
||||||
@@ -145,29 +145,7 @@ generate_random() {
|
|||||||
LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c16
|
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() {
|
get_config_params() {
|
||||||
@@ -209,12 +187,6 @@ install_panel() {
|
|||||||
curl -L -o gost.sql "$GOST_SQL_URL"
|
curl -L -o gost.sql "$GOST_SQL_URL"
|
||||||
echo "✅ 下载完成"
|
echo "✅ 下载完成"
|
||||||
|
|
||||||
# UI选择
|
|
||||||
select_ui
|
|
||||||
|
|
||||||
# 根据UI选择修改docker-compose.yml中的前端镜像
|
|
||||||
configure_frontend_image
|
|
||||||
|
|
||||||
# 自动检测并配置 IPv6 支持
|
# 自动检测并配置 IPv6 支持
|
||||||
if check_ipv6_support; then
|
if check_ipv6_support; then
|
||||||
echo "🚀 系统支持 IPv6,自动启用 IPv6 配置..."
|
echo "🚀 系统支持 IPv6,自动启用 IPv6 配置..."
|
||||||
@@ -258,12 +230,6 @@ update_panel() {
|
|||||||
curl -L -o docker-compose.yml "$DOCKER_COMPOSE_URL"
|
curl -L -o docker-compose.yml "$DOCKER_COMPOSE_URL"
|
||||||
echo "✅ 下载完成"
|
echo "✅ 下载完成"
|
||||||
|
|
||||||
# UI选择
|
|
||||||
select_ui
|
|
||||||
|
|
||||||
# 根据UI选择修改docker-compose.yml中的前端镜像
|
|
||||||
configure_frontend_image
|
|
||||||
|
|
||||||
# 自动检测并配置 IPv6 支持
|
# 自动检测并配置 IPv6 支持
|
||||||
if check_ipv6_support; then
|
if check_ipv6_support; then
|
||||||
echo "🚀 系统支持 IPv6,自动启用 IPv6 配置..."
|
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
|
// 存储等待响应的请求,key为requestId,value为CompletableFuture
|
||||||
private static final ConcurrentHashMap<String, CompletableFuture<GostDto>> pendingRequests = new ConcurrentHashMap<>();
|
private static final ConcurrentHashMap<String, CompletableFuture<GostDto>> pendingRequests = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// 存储请求ID与节点ID的映射关系,用于清理特定节点的请求
|
||||||
|
private static final ConcurrentHashMap<String, Long> requestNodeMapping = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
//接受客户端消息
|
//接受客户端消息
|
||||||
@Override
|
@Override
|
||||||
@@ -66,6 +69,9 @@ public class WebSocketServer extends TextWebSocketHandler {
|
|||||||
|
|
||||||
if (requestId != null) {
|
if (requestId != null) {
|
||||||
CompletableFuture<GostDto> future = pendingRequests.remove(requestId);
|
CompletableFuture<GostDto> future = pendingRequests.remove(requestId);
|
||||||
|
// 同时清理请求-节点映射关系
|
||||||
|
requestNodeMapping.remove(requestId);
|
||||||
|
|
||||||
if (future != null) {
|
if (future != null) {
|
||||||
GostDto result = new GostDto();
|
GostDto result = new GostDto();
|
||||||
|
|
||||||
@@ -123,29 +129,60 @@ public class WebSocketServer extends TextWebSocketHandler {
|
|||||||
if (!Objects.equals(type, "1")) {
|
if (!Objects.equals(type, "1")) {
|
||||||
// 网页管理员连接
|
// 网页管理员连接
|
||||||
activeSessions.add(session);
|
activeSessions.add(session);
|
||||||
|
log.info("管理员连接建立,sessionId: {}", session.getId());
|
||||||
} else {
|
} else {
|
||||||
// 客户端节点连接
|
// 客户端节点连接
|
||||||
Long nodeId = Long.valueOf(id);
|
Long nodeId = Long.valueOf(id);
|
||||||
|
String version = (String) session.getAttributes().get("nodeVersion");
|
||||||
|
|
||||||
|
log.info("节点 {} 连接建立,开始更新状态", nodeId);
|
||||||
|
|
||||||
|
// 先添加到会话映射
|
||||||
nodeSessions.put(nodeId, session);
|
nodeSessions.put(nodeId, session);
|
||||||
|
|
||||||
// 更新节点状态为在线
|
// 更新节点状态为在线
|
||||||
Node byId = nodeService.getById(nodeId);
|
Node node = nodeService.getById(nodeId);
|
||||||
if (byId != null) {
|
if (node != null) {
|
||||||
byId.setStatus(1);
|
// 更新状态和版本信息
|
||||||
nodeService.updateById(byId);
|
node.setStatus(1);
|
||||||
|
if (version != null) {
|
||||||
|
node.setVersion(version);
|
||||||
|
}
|
||||||
|
boolean updateResult = nodeService.updateById(node);
|
||||||
|
|
||||||
// 广播节点上线状态给所有管理员
|
if (updateResult) {
|
||||||
JSONObject res = new JSONObject();
|
log.info("节点 {} 状态更新为在线成功,版本: {}", nodeId, version);
|
||||||
res.put("id", id);
|
|
||||||
res.put("type", "status");
|
// 广播节点上线状态给所有管理员
|
||||||
res.put("data", 1);
|
JSONObject res = new JSONObject();
|
||||||
broadcastMessage(res.toJSONString());
|
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) {
|
} catch (Exception e) {
|
||||||
log.error("建立连接时发生异常: {}", e.getMessage(), 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 type = session.getAttributes().get("type").toString();
|
||||||
String sessionId = session.getId();
|
String sessionId = session.getId();
|
||||||
|
|
||||||
|
log.info("连接关闭,ID: {}, 类型: {}, 状态: {}", id, type, status);
|
||||||
|
|
||||||
if (!Objects.equals(type, "1")) {
|
if (!Objects.equals(type, "1")) {
|
||||||
// 连接关闭
|
// 管理员连接关闭
|
||||||
activeSessions.remove(session);
|
boolean removed = activeSessions.remove(session);
|
||||||
|
log.info("管理员连接关闭,sessionId: {}, 移除结果: {}", sessionId, removed);
|
||||||
} else {
|
} else {
|
||||||
// 客户端节点连接关闭
|
// 客户端节点连接关闭
|
||||||
Long nodeId = Long.valueOf(id);
|
Long nodeId = Long.valueOf(id);
|
||||||
nodeSessions.remove(nodeId);
|
WebSocketSession removedSession = nodeSessions.remove(nodeId);
|
||||||
|
|
||||||
|
log.info("节点 {} 连接关闭,开始更新状态为离线", nodeId);
|
||||||
|
|
||||||
// 更新节点状态为离线
|
// 更新节点状态为离线
|
||||||
Node byId = nodeService.getById(nodeId);
|
Node node = nodeService.getById(nodeId);
|
||||||
if (byId != null) {
|
if (node != null) {
|
||||||
byId.setStatus(0);
|
node.setStatus(0);
|
||||||
nodeService.updateById(byId);
|
boolean updateResult = nodeService.updateById(node);
|
||||||
|
|
||||||
JSONObject res = new JSONObject();
|
if (updateResult) {
|
||||||
res.put("id", id);
|
log.info("节点 {} 状态更新为离线成功", nodeId);
|
||||||
res.put("type", "status");
|
|
||||||
res.put("data", 0);
|
JSONObject res = new JSONObject();
|
||||||
broadcastMessage(res.toJSONString());
|
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锁对象
|
// 清理session锁对象
|
||||||
sessionLocks.remove(sessionId);
|
sessionLocks.remove(sessionId);
|
||||||
|
|
||||||
// 清理该节点的待处理请求
|
|
||||||
if (Objects.equals(type, "1")) {
|
|
||||||
clearPendingRequestsForNode(Long.valueOf(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("关闭连接时发生异常: {}", e.getMessage(), 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) {
|
public static void broadcastMessage(String message) {
|
||||||
@@ -247,31 +338,254 @@ public class WebSocketServer extends TextWebSocketHandler {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 清理指定节点的待处理请求
|
* 清理指定节点的待处理请求
|
||||||
|
* 只清理属于该节点的未完成请求,避免影响其他节点的请求
|
||||||
*/
|
*/
|
||||||
private static void clearPendingRequestsForNode(Long nodeId) {
|
private static void clearPendingRequestsForNode(Long nodeId) {
|
||||||
// 完成所有待处理的请求,设置为连接断开错误
|
if (nodeId == null) {
|
||||||
pendingRequests.entrySet().removeIf(entry -> {
|
log.warn("节点ID为空,无法清理待处理请求");
|
||||||
CompletableFuture<GostDto> future = entry.getValue();
|
return;
|
||||||
if (!future.isDone()) {
|
}
|
||||||
GostDto errorResult = new GostDto();
|
|
||||||
errorResult.setMsg("节点连接已断开");
|
java.util.concurrent.atomic.AtomicInteger clearedCount = new java.util.concurrent.atomic.AtomicInteger(0);
|
||||||
future.complete(errorResult);
|
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) {
|
public static GostDto send_msg(Long node_id, Object msg, String type) {
|
||||||
WebSocketSession nodeSession = nodeSessions.get(node_id);
|
WebSocketSession nodeSession = nodeSessions.get(node_id);
|
||||||
|
|
||||||
if (nodeSession == null) {
|
if (nodeSession == null) {
|
||||||
|
log.warn("发送消息失败:节点 {} 不在线或会话不存在", node_id);
|
||||||
GostDto result = new GostDto();
|
GostDto result = new GostDto();
|
||||||
result.setMsg("节点不在线");
|
result.setMsg("节点不在线");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!nodeSession.isOpen()) {
|
if (!nodeSession.isOpen()) {
|
||||||
|
log.warn("发送消息失败:节点 {} 连接已断开,清理会话", node_id);
|
||||||
nodeSessions.remove(node_id);
|
nodeSessions.remove(node_id);
|
||||||
sessionLocks.remove(nodeSession.getId());
|
sessionLocks.remove(nodeSession.getId());
|
||||||
GostDto result = new GostDto();
|
GostDto result = new GostDto();
|
||||||
@@ -285,6 +599,9 @@ public class WebSocketServer extends TextWebSocketHandler {
|
|||||||
// 创建CompletableFuture用于等待响应
|
// 创建CompletableFuture用于等待响应
|
||||||
CompletableFuture<GostDto> future = new CompletableFuture<>();
|
CompletableFuture<GostDto> future = new CompletableFuture<>();
|
||||||
pendingRequests.put(requestId, future);
|
pendingRequests.put(requestId, future);
|
||||||
|
|
||||||
|
// 建立请求ID与节点ID的映射关系
|
||||||
|
requestNodeMapping.put(requestId, node_id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
JSONObject data = new JSONObject();
|
JSONObject data = new JSONObject();
|
||||||
@@ -293,17 +610,23 @@ public class WebSocketServer extends TextWebSocketHandler {
|
|||||||
data.put("requestId", requestId);
|
data.put("requestId", requestId);
|
||||||
sendToUser(nodeSession, data.toJSONString());
|
sendToUser(nodeSession, data.toJSONString());
|
||||||
GostDto result = future.get(10, TimeUnit.SECONDS);
|
GostDto result = future.get(10, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
log.debug("成功发送消息到节点 {} 并收到响应: {}", node_id, result.getMsg());
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
// 清理请求和映射关系
|
||||||
pendingRequests.remove(requestId);
|
pendingRequests.remove(requestId);
|
||||||
|
requestNodeMapping.remove(requestId);
|
||||||
|
|
||||||
GostDto result = new GostDto();
|
GostDto result = new GostDto();
|
||||||
if (e instanceof java.util.concurrent.TimeoutException) {
|
if (e instanceof java.util.concurrent.TimeoutException) {
|
||||||
result.setMsg("等待响应超时");
|
result.setMsg("等待响应超时");
|
||||||
|
log.warn("节点 {} 响应超时,可能存在连接问题", node_id);
|
||||||
} else {
|
} else {
|
||||||
result.setMsg("发送消息失败: " + e.getMessage());
|
result.setMsg("发送消息失败: " + e.getMessage());
|
||||||
|
log.error("发送消息到节点 {} 失败: {}", node_id, e.getMessage(), e);
|
||||||
}
|
}
|
||||||
log.error("发送消息到节点{}失败: {}", node_id, e.getMessage(), e);
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,11 +37,15 @@ public class WebSocketInterceptor extends HttpSessionHandshakeInterceptor {
|
|||||||
String version = serverHttpRequest.getServletRequest().getParameter("version");
|
String version = serverHttpRequest.getServletRequest().getParameter("version");
|
||||||
if (Objects.equals(type, "1")) {
|
if (Objects.equals(type, "1")) {
|
||||||
Node node = nodeService.getOne(new QueryWrapper<Node>().eq("secret", secret));
|
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());
|
attributes.put("id", node.getId());
|
||||||
node.setStatus(1);
|
attributes.put("nodeSecret", secret);
|
||||||
node.setVersion(version);
|
attributes.put("nodeVersion", version);
|
||||||
nodeService.updateById(node);
|
log.info("节点 {} 通过验证,版本: {}", node.getId(), version);
|
||||||
|
// 不在这里更新状态,等到连接建立后再统一更新
|
||||||
}else {
|
}else {
|
||||||
boolean b = JwtUtil.validateToken(secret);
|
boolean b = JwtUtil.validateToken(secret);
|
||||||
if (!b) return false;
|
if (!b) return false;
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import java.util.Map;
|
|||||||
@RequestMapping("/api/v1/forward")
|
@RequestMapping("/api/v1/forward")
|
||||||
public class ForwardController extends BaseController {
|
public class ForwardController extends BaseController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ForwardService forwardService;
|
||||||
|
|
||||||
@LogAnnotation
|
@LogAnnotation
|
||||||
@PostMapping("/create")
|
@PostMapping("/create")
|
||||||
@@ -73,5 +75,17 @@ public class ForwardController extends BaseController {
|
|||||||
return forwardService.resumeForward(id);
|
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());
|
Long id = Long.valueOf(params.get("id").toString());
|
||||||
return nodeService.getInstallCommand(id);
|
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 结果
|
* @return 结果
|
||||||
*/
|
*/
|
||||||
R resumeForward(Long id);
|
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);
|
Node getNodeById(Long id);
|
||||||
|
|
||||||
R getInstallCommand(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.lang.R;
|
||||||
import com.admin.common.utils.GostUtil;
|
import com.admin.common.utils.GostUtil;
|
||||||
import com.admin.common.utils.JwtUtil;
|
import com.admin.common.utils.JwtUtil;
|
||||||
|
import com.admin.common.utils.WebSocketServer;
|
||||||
import com.admin.entity.*;
|
import com.admin.entity.*;
|
||||||
import com.admin.mapper.ForwardMapper;
|
import com.admin.mapper.ForwardMapper;
|
||||||
import com.admin.service.*;
|
import com.admin.service.*;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
|
import com.alibaba.fastjson.JSONObject;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
@@ -19,10 +21,7 @@ import org.springframework.context.annotation.Lazy;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.util.HashSet;
|
import java.util.*;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -385,6 +384,181 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
|||||||
return result ? R.ok("服务已" + operation) : R.err("更新状态失败");
|
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);
|
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);
|
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 @@
|
|||||||
VITE_API_BASE=http://127.0.0.1:6365
|
VITE_API_BASE=http://192.168.100.6:6365
|
||||||
|
|||||||
+36
-31
@@ -1,5 +1,5 @@
|
|||||||
import { Route, Routes, Navigate, useNavigate } from "react-router-dom";
|
import { Route, Routes } from "react-router-dom";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import IndexPage from "@/pages/index";
|
import IndexPage from "@/pages/index";
|
||||||
import ChangePasswordPage from "@/pages/change-password";
|
import ChangePasswordPage from "@/pages/change-password";
|
||||||
@@ -12,49 +12,54 @@ import LimitPage from "@/pages/limit";
|
|||||||
|
|
||||||
import { isLoggedIn } from "@/utils/auth";
|
import { isLoggedIn } from "@/utils/auth";
|
||||||
|
|
||||||
// 路由保护组件 - 使用useNavigate避免循环渲染
|
// 简化的路由保护组件 - 使用浏览器重定向避免React循环
|
||||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
const [checking, setChecking] = useState(true);
|
const authenticated = isLoggedIn();
|
||||||
const [authenticated, setAuthenticated] = useState(false);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAuth = () => {
|
if (!authenticated) {
|
||||||
const loggedIn = isLoggedIn();
|
// 使用浏览器原生重定向,避免React渲染循环
|
||||||
|
window.location.replace('/');
|
||||||
if (!loggedIn) {
|
}
|
||||||
// 如果未登录,直接导航到登录页
|
}, [authenticated]);
|
||||||
navigate('/', { replace: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAuthenticated(true);
|
|
||||||
setChecking(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
checkAuth();
|
if (!authenticated) {
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
if (checking) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="text-lg">检查登陆状态...</div>
|
<div className="text-lg">重定向中...</div>
|
||||||
</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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route path="/" element={<LoginRoute />} />
|
||||||
path="/"
|
|
||||||
element={
|
|
||||||
isLoggedIn() ? <Navigate to="/dashboard" replace /> : <IndexPage />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="/change-password"
|
path="/change-password"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export const getNodeList = () => Network.post("/node/list");
|
|||||||
export const updateNode = (data: any) => Network.post("/node/update", data);
|
export const updateNode = (data: any) => Network.post("/node/update", data);
|
||||||
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
|
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
|
||||||
export const getNodeInstallCommand = (id: number) => Network.post("/node/install", { 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请求
|
// 隧道CRUD操作 - 全部使用POST请求
|
||||||
export const createTunnel = (data: any) => Network.post("/tunnel/create", data);
|
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 pauseForwardService = (forwardId: number) => Network.post("/forward/pause", { id: forwardId });
|
||||||
export const resumeForwardService = (forwardId: number) => Network.post("/forward/resume", { 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请求
|
// 限速规则CRUD操作 - 全部使用POST请求
|
||||||
export const createSpeedLimit = (data: any) => Network.post("/speed-limit/create", data);
|
export const createSpeedLimit = (data: any) => Network.post("/speed-limit/create", data);
|
||||||
export const getSpeedLimitList = () => Network.post("/speed-limit/list");
|
export const getSpeedLimitList = () => Network.post("/speed-limit/list");
|
||||||
|
|||||||
@@ -86,16 +86,6 @@ export default function AdminLayout({
|
|||||||
),
|
),
|
||||||
adminOnly: true
|
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',
|
path: '/limit',
|
||||||
label: '限速管理',
|
label: '限速管理',
|
||||||
@@ -105,6 +95,16 @@ export default function AdminLayout({
|
|||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
adminOnly: true
|
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} />
|
<Logo size={28} />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-base font-bold text-foreground">哆啦A梦</h1>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@herou
|
|||||||
import { Chip } from "@heroui/chip";
|
import { Chip } from "@heroui/chip";
|
||||||
import { Spinner } from "@heroui/spinner";
|
import { Spinner } from "@heroui/spinner";
|
||||||
import { Switch } from "@heroui/switch";
|
import { Switch } from "@heroui/switch";
|
||||||
|
import { Alert } from "@heroui/alert";
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
import AdminLayout from "@/layouts/admin";
|
import AdminLayout from "@/layouts/admin";
|
||||||
@@ -19,7 +20,8 @@ import {
|
|||||||
forceDeleteForward,
|
forceDeleteForward,
|
||||||
userTunnel,
|
userTunnel,
|
||||||
pauseForwardService,
|
pauseForwardService,
|
||||||
resumeForwardService
|
resumeForwardService,
|
||||||
|
diagnoseForward
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
|
||||||
interface Forward {
|
interface Forward {
|
||||||
@@ -63,6 +65,21 @@ interface AddressItem {
|
|||||||
copying: boolean;
|
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() {
|
export default function ForwardPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [forwards, setForwards] = useState<Forward[]>([]);
|
const [forwards, setForwards] = useState<Forward[]>([]);
|
||||||
@@ -72,10 +89,14 @@ export default function ForwardPage() {
|
|||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||||
const [addressModalOpen, setAddressModalOpen] = useState(false);
|
const [addressModalOpen, setAddressModalOpen] = useState(false);
|
||||||
|
const [diagnosisModalOpen, setDiagnosisModalOpen] = useState(false);
|
||||||
const [isEdit, setIsEdit] = useState(false);
|
const [isEdit, setIsEdit] = useState(false);
|
||||||
const [submitLoading, setSubmitLoading] = useState(false);
|
const [submitLoading, setSubmitLoading] = useState(false);
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
|
||||||
const [forwardToDelete, setForwardToDelete] = useState<Forward | null>(null);
|
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 [addressModalTitle, setAddressModalTitle] = useState('');
|
||||||
const [addressList, setAddressList] = useState<AddressItem[]>([]);
|
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 => {
|
const formatFlow = (value: number): string => {
|
||||||
if (value === 0) return '0 B';
|
if (value === 0) return '0 B';
|
||||||
@@ -518,6 +597,13 @@ export default function ForwardPage() {
|
|||||||
return addresses.length;
|
return addresses.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (timestamp: string | number): string => {
|
||||||
|
if (!timestamp) return '-';
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleString('zh-CN');
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<AdminLayout>
|
<AdminLayout>
|
||||||
@@ -596,9 +682,9 @@ export default function ForwardPage() {
|
|||||||
title={formatInAddress(forward.inIp, forward.inPort)}
|
title={formatInAddress(forward.inIp, forward.inPort)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
<span className="text-xs font-medium text-default-600">入口:</span>
|
<span className="text-xs font-medium text-default-600 flex-shrink-0">入口:</span>
|
||||||
<code className="text-xs font-mono text-foreground truncate">
|
<code className="text-xs font-mono text-foreground truncate min-w-0">
|
||||||
{formatInAddress(forward.inIp, forward.inPort)}
|
{formatInAddress(forward.inIp, forward.inPort)}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
@@ -620,9 +706,9 @@ export default function ForwardPage() {
|
|||||||
title={formatRemoteAddress(forward.remoteAddr)}
|
title={formatRemoteAddress(forward.remoteAddr)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
<span className="text-xs font-medium text-default-600">目标:</span>
|
<span className="text-xs font-medium text-default-600 flex-shrink-0">目标:</span>
|
||||||
<code className="text-xs font-mono text-foreground truncate">
|
<code className="text-xs font-mono text-foreground truncate min-w-0">
|
||||||
{formatRemoteAddress(forward.remoteAddr)}
|
{formatRemoteAddress(forward.remoteAddr)}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
@@ -666,6 +752,20 @@ export default function ForwardPage() {
|
|||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@@ -711,7 +811,7 @@ export default function ForwardPage() {
|
|||||||
isOpen={modalOpen}
|
isOpen={modalOpen}
|
||||||
onOpenChange={setModalOpen}
|
onOpenChange={setModalOpen}
|
||||||
size="lg"
|
size="lg"
|
||||||
scrollBehavior="inside"
|
scrollBehavior="outside"
|
||||||
>
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
{(onClose) => (
|
{(onClose) => (
|
||||||
@@ -725,7 +825,7 @@ export default function ForwardPage() {
|
|||||||
</p>
|
</p>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 pb-4">
|
||||||
<Input
|
<Input
|
||||||
label="转发名称"
|
label="转发名称"
|
||||||
placeholder="请输入转发名称"
|
placeholder="请输入转发名称"
|
||||||
@@ -830,6 +930,7 @@ export default function ForwardPage() {
|
|||||||
isOpen={deleteModalOpen}
|
isOpen={deleteModalOpen}
|
||||||
onOpenChange={setDeleteModalOpen}
|
onOpenChange={setDeleteModalOpen}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
scrollBehavior="outside"
|
||||||
>
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
{(onClose) => (
|
{(onClose) => (
|
||||||
@@ -863,7 +964,7 @@ export default function ForwardPage() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* 地址列表弹窗 */}
|
{/* 地址列表弹窗 */}
|
||||||
<Modal isOpen={addressModalOpen} onClose={() => setAddressModalOpen(false)} size="lg">
|
<Modal isOpen={addressModalOpen} onClose={() => setAddressModalOpen(false)} size="lg" scrollBehavior="outside">
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<ModalHeader className="text-base">{addressModalTitle}</ModalHeader>
|
<ModalHeader className="text-base">{addressModalTitle}</ModalHeader>
|
||||||
<ModalBody className="pb-6">
|
<ModalBody className="pb-6">
|
||||||
@@ -891,6 +992,142 @@ export default function ForwardPage() {
|
|||||||
</ModalBody>
|
</ModalBody>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ export default function LimitPage() {
|
|||||||
isOpen={modalOpen}
|
isOpen={modalOpen}
|
||||||
onOpenChange={setModalOpen}
|
onOpenChange={setModalOpen}
|
||||||
size="lg"
|
size="lg"
|
||||||
scrollBehavior="inside"
|
scrollBehavior="outside"
|
||||||
>
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
{(onClose) => (
|
{(onClose) => (
|
||||||
@@ -433,6 +433,7 @@ export default function LimitPage() {
|
|||||||
isOpen={deleteModalOpen}
|
isOpen={deleteModalOpen}
|
||||||
onOpenChange={setDeleteModalOpen}
|
onOpenChange={setDeleteModalOpen}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
scrollBehavior="outside"
|
||||||
>
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
{(onClose) => (
|
{(onClose) => (
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
getNodeList,
|
getNodeList,
|
||||||
updateNode,
|
updateNode,
|
||||||
deleteNode,
|
deleteNode,
|
||||||
getNodeInstallCommand
|
getNodeInstallCommand,
|
||||||
|
checkNodeStatus
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
|
||||||
interface Node {
|
interface Node {
|
||||||
@@ -71,6 +72,11 @@ export default function NodePage() {
|
|||||||
const [installCommand, setInstallCommand] = useState('');
|
const [installCommand, setInstallCommand] = useState('');
|
||||||
const [currentNodeName, setCurrentNodeName] = 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 websocketRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const reconnectAttemptsRef = useRef(0);
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
if (!validateForm()) return;
|
if (!validateForm()) return;
|
||||||
@@ -522,17 +577,32 @@ export default function NodePage() {
|
|||||||
{/* 页面头部 */}
|
{/* 页面头部 */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-foreground">节点管理</h1>
|
<h1 className="text-2xl font-bold text-foreground">节点管理</h1>
|
||||||
<Button
|
<div className="flex gap-2">
|
||||||
color="primary"
|
<Button
|
||||||
onPress={handleAdd}
|
color="secondary"
|
||||||
startContent={
|
variant="flat"
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
onPress={handleCheckAllStatus}
|
||||||
<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" />
|
isLoading={statusCheckLoading}
|
||||||
</svg>
|
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>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* 节点列表 */}
|
{/* 节点列表 */}
|
||||||
@@ -588,14 +658,18 @@ export default function NodePage() {
|
|||||||
<CardBody className="pt-0 pb-3">
|
<CardBody className="pt-0 pb-3">
|
||||||
{/* 基础信息 */}
|
{/* 基础信息 */}
|
||||||
<div className="space-y-2 mb-4">
|
<div className="space-y-2 mb-4">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between items-center text-sm min-w-0">
|
||||||
<span className="text-default-600">入口IP</span>
|
<span className="text-default-600 flex-shrink-0">入口IP</span>
|
||||||
<div className="text-right text-xs">
|
<div className="text-right text-xs min-w-0 flex-1 ml-2">
|
||||||
{node.ip ? (
|
{node.ip ? (
|
||||||
node.ip.split(',').length > 1 ? (
|
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>
|
</div>
|
||||||
@@ -678,35 +752,48 @@ export default function NodePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div className="flex gap-1.5">
|
<div className="space-y-1.5">
|
||||||
<Button
|
<div className="flex gap-1.5">
|
||||||
size="sm"
|
<Button
|
||||||
variant="flat"
|
size="sm"
|
||||||
color="success"
|
variant="flat"
|
||||||
onPress={() => handleCopyInstallCommand(node)}
|
color="success"
|
||||||
isLoading={node.copyLoading}
|
onPress={() => handleCopyInstallCommand(node)}
|
||||||
className="flex-1 min-h-8"
|
isLoading={node.copyLoading}
|
||||||
>
|
className="flex-1 min-h-8"
|
||||||
复制
|
>
|
||||||
</Button>
|
复制
|
||||||
<Button
|
</Button>
|
||||||
size="sm"
|
<Button
|
||||||
variant="flat"
|
size="sm"
|
||||||
color="primary"
|
variant="flat"
|
||||||
onPress={() => handleEdit(node)}
|
color="primary"
|
||||||
className="flex-1 min-h-8"
|
onPress={() => handleEdit(node)}
|
||||||
>
|
className="flex-1 min-h-8"
|
||||||
编辑
|
>
|
||||||
</Button>
|
编辑
|
||||||
<Button
|
</Button>
|
||||||
size="sm"
|
</div>
|
||||||
variant="flat"
|
<div className="flex gap-1.5">
|
||||||
color="danger"
|
<Button
|
||||||
onPress={() => handleDelete(node)}
|
size="sm"
|
||||||
className="flex-1 min-h-8"
|
variant="flat"
|
||||||
>
|
color="secondary"
|
||||||
删除
|
onPress={() => handleCheckSingleNodeStatus(node.id)}
|
||||||
</Button>
|
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>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -719,7 +806,7 @@ export default function NodePage() {
|
|||||||
isOpen={dialogVisible}
|
isOpen={dialogVisible}
|
||||||
onClose={() => setDialogVisible(false)}
|
onClose={() => setDialogVisible(false)}
|
||||||
size="2xl"
|
size="2xl"
|
||||||
scrollBehavior="inside"
|
scrollBehavior="outside"
|
||||||
>
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<ModalHeader>{dialogTitle}</ModalHeader>
|
<ModalHeader>{dialogTitle}</ModalHeader>
|
||||||
@@ -810,7 +897,7 @@ export default function NodePage() {
|
|||||||
isOpen={installCommandModal}
|
isOpen={installCommandModal}
|
||||||
onClose={() => setInstallCommandModal(false)}
|
onClose={() => setInstallCommandModal(false)}
|
||||||
size="2xl"
|
size="2xl"
|
||||||
scrollBehavior="inside"
|
scrollBehavior="outside"
|
||||||
>
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<ModalHeader>安装命令 - {currentNodeName}</ModalHeader>
|
<ModalHeader>安装命令 - {currentNodeName}</ModalHeader>
|
||||||
@@ -856,6 +943,106 @@ export default function NodePage() {
|
|||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -608,7 +608,7 @@ export default function TunnelPage() {
|
|||||||
isOpen={modalOpen}
|
isOpen={modalOpen}
|
||||||
onOpenChange={setModalOpen}
|
onOpenChange={setModalOpen}
|
||||||
size="2xl"
|
size="2xl"
|
||||||
scrollBehavior="inside"
|
scrollBehavior="outside"
|
||||||
>
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
{(onClose) => (
|
{(onClose) => (
|
||||||
@@ -896,7 +896,7 @@ export default function TunnelPage() {
|
|||||||
isOpen={diagnosisModalOpen}
|
isOpen={diagnosisModalOpen}
|
||||||
onOpenChange={setDiagnosisModalOpen}
|
onOpenChange={setDiagnosisModalOpen}
|
||||||
size="3xl"
|
size="3xl"
|
||||||
scrollBehavior="inside"
|
scrollBehavior="outside"
|
||||||
>
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
{(onClose) => (
|
{(onClose) => (
|
||||||
|
|||||||
@@ -152,6 +152,14 @@ export default function UserPage() {
|
|||||||
const [editTunnelForm, setEditTunnelForm] = useState<UserTunnel | null>(null);
|
const [editTunnelForm, setEditTunnelForm] = useState<UserTunnel | null>(null);
|
||||||
const [editTunnelLoading, setEditTunnelLoading] = useState(false);
|
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 [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||||
const [speedLimits, setSpeedLimits] = useState<SpeedLimit[]>([]);
|
const [speedLimits, setSpeedLimits] = useState<SpeedLimit[]>([]);
|
||||||
@@ -266,12 +274,21 @@ export default function UserPage() {
|
|||||||
onUserModalOpen();
|
onUserModalOpen();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (user: User) => {
|
const handleDelete = (user: User) => {
|
||||||
|
setUserToDelete(user);
|
||||||
|
onDeleteModalOpen();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (!userToDelete) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await deleteUser(user.id);
|
const response = await deleteUser(userToDelete.id);
|
||||||
if (response.code === 0) {
|
if (response.code === 0) {
|
||||||
toast.success('删除成功');
|
toast.success('删除成功');
|
||||||
loadUsers();
|
loadUsers();
|
||||||
|
onDeleteModalClose();
|
||||||
|
setUserToDelete(null);
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.msg || '删除失败');
|
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 {
|
try {
|
||||||
const response = await removeUserTunnel({ id: userTunnel.id });
|
const response = await removeUserTunnel({ id: tunnelToDelete.id });
|
||||||
if (response.code === 0) {
|
if (response.code === 0) {
|
||||||
toast.success('删除成功');
|
toast.success('删除成功');
|
||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
loadUserTunnels(currentUser.id);
|
loadUserTunnels(currentUser.id);
|
||||||
}
|
}
|
||||||
|
onDeleteTunnelModalClose();
|
||||||
|
setTunnelToDelete(null);
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.msg || '删除失败');
|
toast.error(response.msg || '删除失败');
|
||||||
}
|
}
|
||||||
@@ -646,7 +672,7 @@ export default function UserPage() {
|
|||||||
isOpen={isUserModalOpen}
|
isOpen={isUserModalOpen}
|
||||||
onClose={onUserModalClose}
|
onClose={onUserModalClose}
|
||||||
size="2xl"
|
size="2xl"
|
||||||
scrollBehavior="inside"
|
scrollBehavior="outside"
|
||||||
>
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
@@ -701,7 +727,7 @@ export default function UserPage() {
|
|||||||
value={userForm.expTime ? parseDate(userForm.expTime.toISOString().split('T')[0]) : null}
|
value={userForm.expTime ? parseDate(userForm.expTime.toISOString().split('T')[0]) : null}
|
||||||
onChange={(date) => {
|
onChange={(date) => {
|
||||||
if (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 }));
|
setUserForm(prev => ({ ...prev, expTime: jsDate }));
|
||||||
} else {
|
} else {
|
||||||
setUserForm(prev => ({ ...prev, expTime: null }));
|
setUserForm(prev => ({ ...prev, expTime: null }));
|
||||||
@@ -743,7 +769,7 @@ export default function UserPage() {
|
|||||||
isOpen={isTunnelModalOpen}
|
isOpen={isTunnelModalOpen}
|
||||||
onClose={onTunnelModalClose}
|
onClose={onTunnelModalClose}
|
||||||
size="5xl"
|
size="5xl"
|
||||||
scrollBehavior="inside"
|
scrollBehavior="outside"
|
||||||
>
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
@@ -822,7 +848,7 @@ export default function UserPage() {
|
|||||||
value={tunnelForm.expTime ? parseDate(tunnelForm.expTime.toISOString().split('T')[0]) : null}
|
value={tunnelForm.expTime ? parseDate(tunnelForm.expTime.toISOString().split('T')[0]) : null}
|
||||||
onChange={(date) => {
|
onChange={(date) => {
|
||||||
if (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 }));
|
setTunnelForm(prev => ({ ...prev, expTime: jsDate }));
|
||||||
} else {
|
} else {
|
||||||
setTunnelForm(prev => ({ ...prev, expTime: null }));
|
setTunnelForm(prev => ({ ...prev, expTime: null }));
|
||||||
@@ -937,6 +963,7 @@ export default function UserPage() {
|
|||||||
isOpen={isEditTunnelModalOpen}
|
isOpen={isEditTunnelModalOpen}
|
||||||
onClose={onEditTunnelModalClose}
|
onClose={onEditTunnelModalClose}
|
||||||
size="2xl"
|
size="2xl"
|
||||||
|
scrollBehavior="outside"
|
||||||
>
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
@@ -991,6 +1018,22 @@ export default function UserPage() {
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
@@ -1008,6 +1051,92 @@ export default function UserPage() {
|
|||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 +0,0 @@
|
|||||||
VUE_APP_API_BASE=http://localhost:6365
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
VUE_APP_API_BASE=
|
|
||||||
@@ -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;"]
|
|
||||||
@@ -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/).
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
presets: [
|
|
||||||
'@vue/cli-plugin-babel/preset'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "es5",
|
|
||||||
"module": "esnext",
|
|
||||||
"baseUrl": "./",
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"paths": {
|
|
||||||
"@/*": [
|
|
||||||
"src/*"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"lib": [
|
|
||||||
"esnext",
|
|
||||||
"dom",
|
|
||||||
"dom.iterable",
|
|
||||||
"scripthost"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
-17755
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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)
|
|
||||||
|
|
||||||
@@ -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 |
@@ -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>
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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')
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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
@@ -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
@@ -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>
|
|
||||||
@@ -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
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
publicPath: './',
|
|
||||||
devServer: {
|
|
||||||
historyApiFallback: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user