mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-08 10:46:37 +08:00
Merge pull request #2 from Sagit-chu/opencode/sunny-island
fix: fix some bugs
This commit is contained in:
+82
-9
@@ -1,5 +1,8 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
# GitHub repo used for release downloads
|
||||||
|
REPO="Sagit-chu/flux-panel"
|
||||||
|
|
||||||
# 获取系统架构
|
# 获取系统架构
|
||||||
get_architecture() {
|
get_architecture() {
|
||||||
ARCH=$(uname -m)
|
ARCH=$(uname -m)
|
||||||
@@ -16,20 +19,90 @@ get_architecture() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 安装目录
|
||||||
|
INSTALL_DIR="/etc/flux_agent"
|
||||||
|
|
||||||
|
# 识别国家(用于镜像加速)
|
||||||
|
COUNTRY=$(curl -s https://ipinfo.io/country)
|
||||||
|
|
||||||
|
maybe_proxy_url() {
|
||||||
|
local url="$1"
|
||||||
|
if [ "$COUNTRY" = "CN" ]; then
|
||||||
|
echo "https://ghfast.top/${url}"
|
||||||
|
else
|
||||||
|
echo "$url"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_latest_release_tag() {
|
||||||
|
local effective_url tag api_tag latest_url api_url
|
||||||
|
|
||||||
|
latest_url="https://github.com/${REPO}/releases/latest"
|
||||||
|
api_url="https://api.github.com/repos/${REPO}/releases/latest"
|
||||||
|
|
||||||
|
# 方式1:跟随重定向,取最终 URL 的最后一段作为 tag
|
||||||
|
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$latest_url" 2>/dev/null || true)
|
||||||
|
tag="${effective_url##*/}"
|
||||||
|
if [[ -n "$tag" && "$tag" != "latest" ]]; then
|
||||||
|
echo "$tag"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# CN 环境下可尝试通过镜像访问(不影响非 CN)
|
||||||
|
if [ "$COUNTRY" = "CN" ]; then
|
||||||
|
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$(maybe_proxy_url "$latest_url")" 2>/dev/null || true)
|
||||||
|
tag="${effective_url##*/}"
|
||||||
|
if [[ -n "$tag" && "$tag" != "latest" ]]; then
|
||||||
|
echo "$tag"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 方式2:GitHub API(无需 jq)
|
||||||
|
api_tag=$(curl -fsSL "$api_url" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||||
|
if [[ -n "$api_tag" ]]; then
|
||||||
|
echo "$api_tag"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$COUNTRY" = "CN" ]; then
|
||||||
|
api_tag=$(curl -fsSL "$(maybe_proxy_url "$api_url")" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||||
|
if [[ -n "$api_tag" ]]; then
|
||||||
|
echo "$api_tag"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_version() {
|
||||||
|
if [[ -n "${VERSION:-}" ]]; then
|
||||||
|
echo "$VERSION"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ -n "${FLUX_VERSION:-}" ]]; then
|
||||||
|
echo "$FLUX_VERSION"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if resolve_latest_release_tag; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "❌ 无法获取最新版本号。你可以手动指定版本,例如:VERSION=<版本号> ./install.sh" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
# 构建下载地址
|
# 构建下载地址
|
||||||
build_download_url() {
|
build_download_url() {
|
||||||
local ARCH=$(get_architecture)
|
local ARCH=$(get_architecture)
|
||||||
echo "https://github.com/Sagit-chu/flux-panel/releases/download/2.0.8/gost-${ARCH}"
|
echo "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/gost-${ARCH}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 下载地址
|
# 解析版本并构建下载地址
|
||||||
DOWNLOAD_URL=$(build_download_url)
|
RESOLVED_VERSION=$(resolve_version) || exit 1
|
||||||
INSTALL_DIR="/etc/flux_agent"
|
DOWNLOAD_URL=$(maybe_proxy_url "$(build_download_url)")
|
||||||
COUNTRY=$(curl -s https://ipinfo.io/country)
|
|
||||||
if [ "$COUNTRY" = "CN" ]; then
|
|
||||||
# 拼接 URL
|
|
||||||
DOWNLOAD_URL="https://ghfast.top/${DOWNLOAD_URL}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+74
-8
@@ -7,16 +7,82 @@ export LC_ALL=C
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 全局下载地址配置
|
# GitHub repo used for release downloads
|
||||||
DOCKER_COMPOSEV4_URL="https://github.com/Sagit-chu/flux-panel/releases/download/2.0.8/docker-compose-v4.yml"
|
REPO="Sagit-chu/flux-panel"
|
||||||
DOCKER_COMPOSEV6_URL="https://github.com/Sagit-chu/flux-panel/releases/download/2.0.8/docker-compose-v6.yml"
|
|
||||||
|
|
||||||
COUNTRY=$(curl -s https://ipinfo.io/country)
|
COUNTRY=$(curl -s https://ipinfo.io/country)
|
||||||
if [ "$COUNTRY" = "CN" ]; then
|
|
||||||
# 拼接 URL
|
maybe_proxy_url() {
|
||||||
DOCKER_COMPOSEV4_URL="https://ghfast.top/${DOCKER_COMPOSEV4_URL}"
|
local url="$1"
|
||||||
DOCKER_COMPOSEV6_URL="https://ghfast.top/${DOCKER_COMPOSEV6_URL}"
|
if [ "$COUNTRY" = "CN" ]; then
|
||||||
fi
|
echo "https://ghfast.top/${url}"
|
||||||
|
else
|
||||||
|
echo "$url"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_latest_release_tag() {
|
||||||
|
local effective_url tag api_tag latest_url api_url
|
||||||
|
|
||||||
|
latest_url="https://github.com/${REPO}/releases/latest"
|
||||||
|
api_url="https://api.github.com/repos/${REPO}/releases/latest"
|
||||||
|
|
||||||
|
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$latest_url" 2>/dev/null || true)
|
||||||
|
tag="${effective_url##*/}"
|
||||||
|
if [[ -n "$tag" && "$tag" != "latest" ]]; then
|
||||||
|
echo "$tag"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$COUNTRY" = "CN" ]; then
|
||||||
|
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$(maybe_proxy_url "$latest_url")" 2>/dev/null || true)
|
||||||
|
tag="${effective_url##*/}"
|
||||||
|
if [[ -n "$tag" && "$tag" != "latest" ]]; then
|
||||||
|
echo "$tag"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
api_tag=$(curl -fsSL "$api_url" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||||
|
if [[ -n "$api_tag" ]]; then
|
||||||
|
echo "$api_tag"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$COUNTRY" = "CN" ]; then
|
||||||
|
api_tag=$(curl -fsSL "$(maybe_proxy_url "$api_url")" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||||
|
if [[ -n "$api_tag" ]]; then
|
||||||
|
echo "$api_tag"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_version() {
|
||||||
|
if [[ -n "${VERSION:-}" ]]; then
|
||||||
|
echo "$VERSION"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ -n "${FLUX_VERSION:-}" ]]; then
|
||||||
|
echo "$FLUX_VERSION"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if resolve_latest_release_tag; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "❌ 无法获取最新版本号。你可以手动指定版本,例如:VERSION=<版本号> ./panel_install.sh" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 全局下载地址配置(默认获取最新版本;也可用 VERSION=... 覆盖)
|
||||||
|
RESOLVED_VERSION=$(resolve_version) || exit 1
|
||||||
|
|
||||||
|
DOCKER_COMPOSEV4_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/docker-compose-v4.yml")
|
||||||
|
DOCKER_COMPOSEV6_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/docker-compose-v6.yml")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ public class TunnelDetailDto {
|
|||||||
|
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display/order index for drag-sorting in admin UI.
|
||||||
|
* Lower value appears first.
|
||||||
|
*/
|
||||||
|
private Integer inx;
|
||||||
|
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
private Integer type; // 1: 端口转发, 2: 隧道转发
|
private Integer type; // 1: 端口转发, 2: 隧道转发
|
||||||
@@ -40,4 +46,3 @@ public class TunnelDetailDto {
|
|||||||
// 出口节点列表
|
// 出口节点列表
|
||||||
private List<ChainTunnel> outNodeId = new ArrayList<>();
|
private List<ChainTunnel> outNodeId = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
package com.admin.common.migration;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.ApplicationArguments;
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight SQLite schema migration.
|
||||||
|
*
|
||||||
|
* Spring Boot SQL init uses CREATE TABLE IF NOT EXISTS, so existing installations
|
||||||
|
* won't automatically receive new columns. This runner adds missing columns in-place.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||||
|
public class SqliteSchemaMigration implements ApplicationRunner {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public SqliteSchemaMigration(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(ApplicationArguments args) {
|
||||||
|
ensureColumn("node", "inx", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
ensureColumn("tunnel", "inx", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureColumn(String table, String column, String columnDefinition) {
|
||||||
|
Set<String> columns = new HashSet<>(
|
||||||
|
jdbcTemplate.query(
|
||||||
|
"PRAGMA table_info(" + table + ")",
|
||||||
|
(rs, rowNum) -> rs.getString("name")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columns.contains(column)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Adding missing column {}.{}", table, column);
|
||||||
|
jdbcTemplate.execute(
|
||||||
|
"ALTER TABLE " + table + " ADD COLUMN " + column + " " + columnDefinition
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,4 +62,16 @@ public class NodeController extends BaseController {
|
|||||||
return nodeService.getInstallCommand(id);
|
return nodeService.getInstallCommand(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新节点排序
|
||||||
|
* @param params 包含nodes数组的参数,每个元素包含id和inx
|
||||||
|
* @return 更新结果
|
||||||
|
*/
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/update-order")
|
||||||
|
public R updateNodeOrder(@RequestBody Map<String, Object> params) {
|
||||||
|
return nodeService.updateNodeOrder(params);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,4 +137,16 @@ public class TunnelController extends BaseController {
|
|||||||
return tunnelService.diagnoseTunnel(tunnelId);
|
return tunnelService.diagnoseTunnel(tunnelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新隧道排序
|
||||||
|
* @param params 包含tunnels数组的参数,每个元素包含id和inx
|
||||||
|
* @return 更新结果
|
||||||
|
*/
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/update-order")
|
||||||
|
public R updateTunnelOrder(@RequestBody Map<String, Object> params) {
|
||||||
|
return tunnelService.updateTunnelOrder(params);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,4 +40,10 @@ public class Node extends BaseEntity {
|
|||||||
|
|
||||||
private String udpListenAddr;
|
private String udpListenAddr;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display/order index for drag-sorting in admin UI.
|
||||||
|
* Lower value appears first.
|
||||||
|
*/
|
||||||
|
private Integer inx;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,4 +35,10 @@ public class Tunnel extends BaseEntity {
|
|||||||
private BigDecimal trafficRatio;
|
private BigDecimal trafficRatio;
|
||||||
|
|
||||||
private String inIp;
|
private String inIp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display/order index for drag-sorting in admin UI.
|
||||||
|
* Lower value appears first.
|
||||||
|
*/
|
||||||
|
private Integer inx;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import com.admin.common.lang.R;
|
|||||||
import com.admin.entity.Node;
|
import com.admin.entity.Node;
|
||||||
import com.baomidou.mybatisplus.extension.service.IService;
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* <p>
|
* <p>
|
||||||
* 服务类
|
* 服务类
|
||||||
@@ -26,4 +28,10 @@ public interface NodeService extends IService<Node> {
|
|||||||
|
|
||||||
R getInstallCommand(Long id);
|
R getInstallCommand(Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新节点排序(管理员)
|
||||||
|
* @param params 包含nodes数组的参数,每个元素包含id和inx
|
||||||
|
*/
|
||||||
|
R updateNodeOrder(Map<String, Object> params);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import com.admin.common.lang.R;
|
|||||||
import com.admin.entity.Tunnel;
|
import com.admin.entity.Tunnel;
|
||||||
import com.baomidou.mybatisplus.extension.service.IService;
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* <p>
|
* <p>
|
||||||
* 隧道服务类
|
* 隧道服务类
|
||||||
@@ -56,4 +58,10 @@ public interface TunnelService extends IService<Tunnel> {
|
|||||||
* @return 诊断结果
|
* @return 诊断结果
|
||||||
*/
|
*/
|
||||||
R diagnoseTunnel(Long tunnelId);
|
R diagnoseTunnel(Long tunnelId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新隧道排序(管理员)
|
||||||
|
* @param params 包含tunnels数组的参数,每个元素包含id和inx
|
||||||
|
*/
|
||||||
|
R updateTunnelOrder(Map<String, Object> params);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,22 +12,30 @@ import com.admin.entity.*;
|
|||||||
import com.admin.mapper.NodeMapper;
|
import com.admin.mapper.NodeMapper;
|
||||||
import com.admin.mapper.TunnelMapper;
|
import com.admin.mapper.TunnelMapper;
|
||||||
import com.admin.service.*;
|
import com.admin.service.*;
|
||||||
|
import com.alibaba.fastjson.JSONArray;
|
||||||
import com.alibaba.fastjson.JSONObject;
|
import com.alibaba.fastjson.JSONObject;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
import org.springframework.context.annotation.Lazy;
|
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.HashMap;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
|
@Slf4j
|
||||||
public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements NodeService {
|
public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements NodeService {
|
||||||
|
|
||||||
|
|
||||||
@@ -41,6 +49,9 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
|||||||
@Resource
|
@Resource
|
||||||
ChainTunnelService chainTunnelService;
|
ChainTunnelService chainTunnelService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
ForwardPortService forwardPortService;
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public R createNode(NodeDto nodeDto) {
|
public R createNode(NodeDto nodeDto) {
|
||||||
@@ -61,11 +72,39 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public R getAllNodes() {
|
public R getAllNodes() {
|
||||||
List<Node> nodeList = this.list(new QueryWrapper<Node>().orderByDesc("status"));
|
List<Node> nodeList = this.list(new QueryWrapper<Node>().orderByAsc("inx").orderByAsc("id"));
|
||||||
nodeList.forEach(node -> node.setSecret(null));
|
nodeList.forEach(node -> node.setSecret(null));
|
||||||
return R.ok(nodeList);
|
return R.ok(nodeList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public R updateNodeOrder(Map<String, Object> params) {
|
||||||
|
if (!params.containsKey("nodes")) {
|
||||||
|
return R.err("缺少nodes参数");
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> nodesList = (List<Map<String, Object>>) params.get("nodes");
|
||||||
|
if (nodesList == null || nodesList.isEmpty()) {
|
||||||
|
return R.err("nodes参数不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Node> nodesToUpdate = new ArrayList<>();
|
||||||
|
for (Map<String, Object> nodeData : nodesList) {
|
||||||
|
Long id = Long.valueOf(nodeData.get("id").toString());
|
||||||
|
Integer inx = Integer.valueOf(nodeData.get("inx").toString());
|
||||||
|
|
||||||
|
Node node = new Node();
|
||||||
|
node.setId(id);
|
||||||
|
node.setInx(inx);
|
||||||
|
nodesToUpdate.add(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateBatchById(nodesToUpdate);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public R updateNode(NodeUpdateDto nodeUpdateDto) {
|
public R updateNode(NodeUpdateDto nodeUpdateDto) {
|
||||||
Node node = this.getById(nodeUpdateDto.getId());
|
Node node = this.getById(nodeUpdateDto.getId());
|
||||||
@@ -108,14 +147,226 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
|||||||
return R.err("节点不存在");
|
return R.err("节点不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
List<ChainTunnel> list = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("node_id", id).groupBy("tunnel_id"));
|
List<ChainTunnel> affected = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("node_id", id));
|
||||||
for (ChainTunnel tunnel : list) {
|
Map<Long, List<ChainTunnel>> byTunnelId = affected.stream()
|
||||||
tunnelService.deleteTunnel(tunnel.getTunnelId());
|
.filter(ct -> ct.getTunnelId() != null)
|
||||||
|
.collect(Collectors.groupingBy(ChainTunnel::getTunnelId));
|
||||||
|
|
||||||
|
for (Map.Entry<Long, List<ChainTunnel>> entry : byTunnelId.entrySet()) {
|
||||||
|
Long tunnelId = entry.getKey();
|
||||||
|
Tunnel tunnel = tunnelService.getById(tunnelId);
|
||||||
|
|
||||||
|
List<ChainTunnel> before = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnelId));
|
||||||
|
|
||||||
|
// Remove the node from the tunnel definition (do NOT delete the tunnel).
|
||||||
|
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnelId).eq("node_id", id));
|
||||||
|
|
||||||
|
if (tunnel == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ChainTunnel> after = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnelId));
|
||||||
|
Integer removedChainType = entry.getValue().isEmpty() ? null : entry.getValue().get(0).getChainType();
|
||||||
|
|
||||||
|
// Keep tunnel.inIp consistent when it was auto-derived from entry nodes.
|
||||||
|
String oldDerivedInIp = buildDerivedInIp(before);
|
||||||
|
String newDerivedInIp = buildDerivedInIp(after);
|
||||||
|
if (shouldUpdateTunnelInIp(tunnel.getInIp(), oldDerivedInIp)) {
|
||||||
|
updateTunnelInIp(tunnelId, newDerivedInIp);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean valid = isTunnelConfigValid(tunnel, after);
|
||||||
|
if (!valid) {
|
||||||
|
disableTunnelAndCleanupGostIfNeeded(tunnel, after, "node-delete");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For tunnel-forwarding (type=2), removing a chain/out node requires rebuilding config.
|
||||||
|
// Removing an entry node (chainType=1) does not affect remaining nodes' chain targets.
|
||||||
|
if (tunnel.getType() != null && tunnel.getType() == 2 && removedChainType != null && removedChainType != 1) {
|
||||||
|
try {
|
||||||
|
cleanupGostConfig(after, tunnelId);
|
||||||
|
rebuildGostConfig(after, tunnel);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to rebuild gost config after node delete. tunnelId={}, nodeId={}, err={}", tunnelId, id, e.getMessage(), e);
|
||||||
|
disableTunnelAndCleanupGostIfNeeded(tunnel, after, "node-delete:rebuild-failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove per-forward port allocations on this node (avoid orphan ForwardPort rows).
|
||||||
|
try {
|
||||||
|
forwardPortService.remove(new QueryWrapper<ForwardPort>().eq("node_id", id));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to cleanup forward ports when deleting node. nodeId={}, err={}", id, e.getMessage(), e);
|
||||||
|
}
|
||||||
|
|
||||||
this.removeById(id);
|
this.removeById(id);
|
||||||
return R.ok();
|
return R.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isTunnelConfigValid(Tunnel tunnel, List<ChainTunnel> chainTunnels) {
|
||||||
|
if (tunnel == null || chainTunnels == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
long inCount = chainTunnels.stream()
|
||||||
|
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
|
||||||
|
.count();
|
||||||
|
if (inCount <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tunnel.getType() != null && tunnel.getType() == 2) {
|
||||||
|
long outCount = chainTunnels.stream()
|
||||||
|
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 3)
|
||||||
|
.count();
|
||||||
|
return outCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean shouldUpdateTunnelInIp(String currentInIp, String oldDerivedInIp) {
|
||||||
|
if (StrUtil.isBlank(currentInIp)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (oldDerivedInIp == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Objects.equals(currentInIp, oldDerivedInIp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateTunnelInIp(Long tunnelId, String derivedInIp) {
|
||||||
|
Tunnel update = new Tunnel();
|
||||||
|
update.setId(tunnelId);
|
||||||
|
update.setInIp(derivedInIp == null ? "" : derivedInIp);
|
||||||
|
update.setUpdatedTime(System.currentTimeMillis());
|
||||||
|
tunnelService.updateById(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildDerivedInIp(List<ChainTunnel> chainTunnels) {
|
||||||
|
if (chainTunnels == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
List<ChainTunnel> inNodes = chainTunnels.stream()
|
||||||
|
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
if (inNodes.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder inIp = new StringBuilder();
|
||||||
|
for (ChainTunnel inNode : inNodes) {
|
||||||
|
Node n = this.getById(inNode.getNodeId());
|
||||||
|
if (n == null || StrUtil.isBlank(n.getServerIp())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
inIp.append(n.getServerIp()).append(",");
|
||||||
|
}
|
||||||
|
inIp.deleteCharAt(inIp.length() - 1);
|
||||||
|
return inIp.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void disableTunnelAndCleanupGostIfNeeded(Tunnel tunnel, List<ChainTunnel> remaining, String reason) {
|
||||||
|
try {
|
||||||
|
Tunnel update = new Tunnel();
|
||||||
|
update.setId(tunnel.getId());
|
||||||
|
update.setStatus(0);
|
||||||
|
update.setUpdatedTime(System.currentTimeMillis());
|
||||||
|
tunnelService.updateById(update);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to disable tunnel. tunnelId={}, reason={}, err={}", tunnel.getId(), reason, e.getMessage(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tunnel.getType() != null && tunnel.getType() == 2) {
|
||||||
|
try {
|
||||||
|
cleanupGostConfig(remaining, tunnel.getId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to cleanup gost config when disabling tunnel. tunnelId={}, reason={}, err={}", tunnel.getId(), reason, e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanupGostConfig(List<ChainTunnel> chainTunnels, Long tunnelId) {
|
||||||
|
if (chainTunnels == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (ChainTunnel chainTunnel : chainTunnels) {
|
||||||
|
if (chainTunnel.getChainType() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (chainTunnel.getChainType() == 1) {
|
||||||
|
GostUtil.DeleteChains(chainTunnel.getNodeId(), "chains_" + tunnelId);
|
||||||
|
} else if (chainTunnel.getChainType() == 2) {
|
||||||
|
GostUtil.DeleteChains(chainTunnel.getNodeId(), "chains_" + tunnelId);
|
||||||
|
JSONArray services = new JSONArray();
|
||||||
|
services.add(tunnelId + "_tls");
|
||||||
|
GostUtil.DeleteService(chainTunnel.getNodeId(), services);
|
||||||
|
} else if (chainTunnel.getChainType() == 3) {
|
||||||
|
JSONArray services = new JSONArray();
|
||||||
|
services.add(tunnelId + "_tls");
|
||||||
|
GostUtil.DeleteService(chainTunnel.getNodeId(), services);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rebuildGostConfig(List<ChainTunnel> chainTunnels, Tunnel tunnel) {
|
||||||
|
if (tunnel == null || chainTunnels == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<Long, Node> nodes = new HashMap<>();
|
||||||
|
for (ChainTunnel ct : chainTunnels) {
|
||||||
|
Node n = this.getById(ct.getNodeId());
|
||||||
|
if (n != null) {
|
||||||
|
nodes.put(n.getId(), n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ChainTunnel> inNodes = chainTunnels.stream()
|
||||||
|
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
Map<Integer, List<ChainTunnel>> chainNodesMap = chainTunnels.stream()
|
||||||
|
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 2)
|
||||||
|
.collect(Collectors.groupingBy(ct -> ct.getInx() != null ? ct.getInx() : 0));
|
||||||
|
|
||||||
|
List<List<ChainTunnel>> chainNodesList = chainNodesMap.entrySet().stream()
|
||||||
|
.sorted(Map.Entry.comparingByKey())
|
||||||
|
.map(Map.Entry::getValue)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
List<ChainTunnel> outNodes = chainTunnels.stream()
|
||||||
|
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 3)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
if (tunnel.getType() != null && tunnel.getType() == 2) {
|
||||||
|
for (ChainTunnel inNode : inNodes) {
|
||||||
|
if (chainNodesList.isEmpty()) {
|
||||||
|
GostUtil.AddChains(inNode.getNodeId(), outNodes, nodes);
|
||||||
|
} else {
|
||||||
|
GostUtil.AddChains(inNode.getNodeId(), chainNodesList.get(0), nodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < chainNodesList.size(); i++) {
|
||||||
|
for (ChainTunnel chainNode : chainNodesList.get(i)) {
|
||||||
|
if (i + 1 >= chainNodesList.size()) {
|
||||||
|
GostUtil.AddChains(chainNode.getNodeId(), outNodes, nodes);
|
||||||
|
} else {
|
||||||
|
GostUtil.AddChains(chainNode.getNodeId(), chainNodesList.get(i + 1), nodes);
|
||||||
|
}
|
||||||
|
GostUtil.AddChainService(chainNode.getNodeId(), chainNode, nodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ChainTunnel outNode : outNodes) {
|
||||||
|
GostUtil.AddChainService(outNode.getNodeId(), outNode, nodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public R getInstallCommand(Long id) {
|
public R getInstallCommand(Long id) {
|
||||||
@@ -126,7 +377,7 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
|||||||
ViteConfig viteConfig = viteConfigService.getOne(new QueryWrapper<ViteConfig>().eq("name", "ip"));
|
ViteConfig viteConfig = viteConfigService.getOne(new QueryWrapper<ViteConfig>().eq("name", "ip"));
|
||||||
if (viteConfig == null) return R.err("请先前往网站配置中设置ip");
|
if (viteConfig == null) return R.err("请先前往网站配置中设置ip");
|
||||||
StringBuilder command = new StringBuilder();
|
StringBuilder command = new StringBuilder();
|
||||||
command.append("curl -L https://github.com/bqlpfy/flux-panel/releases/download/2.0.7-beta/install.sh")
|
command.append("curl -L https://github.com/Sagit-chu/flux-panel/releases/latest/download/install.sh")
|
||||||
.append(" -o ./install.sh && chmod +x ./install.sh && ");
|
.append(" -o ./install.sh && chmod +x ./install.sh && ");
|
||||||
String processedServerAddr = GostUtil.processServerAddress(viteConfig.getValue());
|
String processedServerAddr = GostUtil.processServerAddress(viteConfig.getValue());
|
||||||
command.append("./install.sh")
|
command.append("./install.sh")
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import lombok.Data;
|
|||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
@@ -252,7 +253,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public R getAllTunnels() {
|
public R getAllTunnels() {
|
||||||
List<Tunnel> tunnelList = this.list();
|
List<Tunnel> tunnelList = this.list(new QueryWrapper<Tunnel>().orderByAsc("inx").orderByAsc("id"));
|
||||||
|
|
||||||
// 查询所有隧道的ChainTunnel信息
|
// 查询所有隧道的ChainTunnel信息
|
||||||
List<Long> tunnelIds = tunnelList.stream()
|
List<Long> tunnelIds = tunnelList.stream()
|
||||||
@@ -317,6 +318,34 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
return R.ok(detailDtoList);
|
return R.ok(detailDtoList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public R updateTunnelOrder(Map<String, Object> params) {
|
||||||
|
if (!params.containsKey("tunnels")) {
|
||||||
|
return R.err("缺少tunnels参数");
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> tunnelsList = (List<Map<String, Object>>) params.get("tunnels");
|
||||||
|
if (tunnelsList == null || tunnelsList.isEmpty()) {
|
||||||
|
return R.err("tunnels参数不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Tunnel> tunnelsToUpdate = new ArrayList<>();
|
||||||
|
for (Map<String, Object> tunnelData : tunnelsList) {
|
||||||
|
Long id = Long.valueOf(tunnelData.get("id").toString());
|
||||||
|
Integer inx = Integer.valueOf(tunnelData.get("inx").toString());
|
||||||
|
|
||||||
|
Tunnel tunnel = new Tunnel();
|
||||||
|
tunnel.setId(id);
|
||||||
|
tunnel.setInx(inx);
|
||||||
|
tunnelsToUpdate.add(tunnel);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateBatchById(tunnelsToUpdate);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public R updateTunnel(TunnelUpdateDto tunnelUpdateDto) {
|
public R updateTunnel(TunnelUpdateDto tunnelUpdateDto) {
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ CREATE TABLE IF NOT EXISTS node (
|
|||||||
updated_time INTEGER,
|
updated_time INTEGER,
|
||||||
status INTEGER NOT NULL,
|
status INTEGER NOT NULL,
|
||||||
tcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
|
tcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
|
||||||
udp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]'
|
udp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
|
||||||
|
inx INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS speed_limit (
|
CREATE TABLE IF NOT EXISTS speed_limit (
|
||||||
@@ -72,7 +73,8 @@ CREATE TABLE IF NOT EXISTS tunnel (
|
|||||||
created_time INTEGER NOT NULL,
|
created_time INTEGER NOT NULL,
|
||||||
updated_time INTEGER NOT NULL,
|
updated_time INTEGER NOT NULL,
|
||||||
status INTEGER NOT NULL,
|
status INTEGER NOT NULL,
|
||||||
in_ip TEXT
|
in_ip TEXT,
|
||||||
|
inx INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS chain_tunnel (
|
CREATE TABLE IF NOT EXISTS chain_tunnel (
|
||||||
@@ -123,4 +125,3 @@ CREATE TABLE IF NOT EXISTS vite_config (
|
|||||||
value VARCHAR(200) NOT NULL,
|
value VARCHAR(200) NOT NULL,
|
||||||
time INTEGER NOT NULL
|
time INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package com.admin;
|
||||||
|
|
||||||
|
import com.admin.common.lang.R;
|
||||||
|
import com.admin.entity.Node;
|
||||||
|
import com.admin.entity.Tunnel;
|
||||||
|
import com.admin.service.NodeService;
|
||||||
|
import com.admin.service.TunnelService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
@SpringBootTest(properties = {
|
||||||
|
// use a local sqlite file for tests (resolved via ${DB_PATH} placeholder)
|
||||||
|
"DB_PATH=./target/test-gost-ordering.db",
|
||||||
|
})
|
||||||
|
class OrderingUpdateTests {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private NodeService nodeService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private TunnelService tunnelService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void cleanup() {
|
||||||
|
// keep it simple; other tables may have foreign references in real runs
|
||||||
|
jdbcTemplate.execute("DELETE FROM node");
|
||||||
|
jdbcTemplate.execute("DELETE FROM tunnel");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateNodeOrder_updatesInx() {
|
||||||
|
Node n1 = new Node();
|
||||||
|
n1.setName("n1");
|
||||||
|
n1.setSecret("s1");
|
||||||
|
n1.setServerIp("127.0.0.1");
|
||||||
|
n1.setPort("1000-2000");
|
||||||
|
n1.setInterfaceName("");
|
||||||
|
n1.setHttp(0);
|
||||||
|
n1.setTls(0);
|
||||||
|
n1.setSocks(0);
|
||||||
|
n1.setTcpListenAddr("[::]");
|
||||||
|
n1.setUdpListenAddr("[::]");
|
||||||
|
n1.setStatus(0);
|
||||||
|
n1.setInx(0);
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
n1.setCreatedTime(now);
|
||||||
|
n1.setUpdatedTime(now);
|
||||||
|
assertTrue(nodeService.save(n1));
|
||||||
|
|
||||||
|
Node n2 = new Node();
|
||||||
|
n2.setName("n2");
|
||||||
|
n2.setSecret("s2");
|
||||||
|
n2.setServerIp("127.0.0.2");
|
||||||
|
n2.setPort("1000-2000");
|
||||||
|
n2.setInterfaceName("");
|
||||||
|
n2.setHttp(0);
|
||||||
|
n2.setTls(0);
|
||||||
|
n2.setSocks(0);
|
||||||
|
n2.setTcpListenAddr("[::]");
|
||||||
|
n2.setUdpListenAddr("[::]");
|
||||||
|
n2.setStatus(0);
|
||||||
|
n2.setInx(0);
|
||||||
|
n2.setCreatedTime(now);
|
||||||
|
n2.setUpdatedTime(now);
|
||||||
|
assertTrue(nodeService.save(n2));
|
||||||
|
|
||||||
|
Node n3 = new Node();
|
||||||
|
n3.setName("n3");
|
||||||
|
n3.setSecret("s3");
|
||||||
|
n3.setServerIp("127.0.0.3");
|
||||||
|
n3.setPort("1000-2000");
|
||||||
|
n3.setInterfaceName("");
|
||||||
|
n3.setHttp(0);
|
||||||
|
n3.setTls(0);
|
||||||
|
n3.setSocks(0);
|
||||||
|
n3.setTcpListenAddr("[::]");
|
||||||
|
n3.setUdpListenAddr("[::]");
|
||||||
|
n3.setStatus(0);
|
||||||
|
n3.setInx(0);
|
||||||
|
n3.setCreatedTime(now);
|
||||||
|
n3.setUpdatedTime(now);
|
||||||
|
assertTrue(nodeService.save(n3));
|
||||||
|
|
||||||
|
List<Map<String, Object>> nodes = new ArrayList<>();
|
||||||
|
nodes.add(mapIdInx(n2.getId(), 0));
|
||||||
|
nodes.add(mapIdInx(n1.getId(), 1));
|
||||||
|
nodes.add(mapIdInx(n3.getId(), 2));
|
||||||
|
|
||||||
|
Map<String, Object> params = new HashMap<>();
|
||||||
|
params.put("nodes", nodes);
|
||||||
|
|
||||||
|
R res = nodeService.updateNodeOrder(params);
|
||||||
|
assertEquals(0, res.getCode());
|
||||||
|
|
||||||
|
assertEquals(1, nodeService.getById(n1.getId()).getInx());
|
||||||
|
assertEquals(0, nodeService.getById(n2.getId()).getInx());
|
||||||
|
assertEquals(2, nodeService.getById(n3.getId()).getInx());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateTunnelOrder_updatesInx() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
|
||||||
|
Tunnel t1 = new Tunnel();
|
||||||
|
t1.setName("t1");
|
||||||
|
t1.setType(1);
|
||||||
|
t1.setFlow(1);
|
||||||
|
t1.setTrafficRatio(new BigDecimal("1.0"));
|
||||||
|
t1.setInIp("");
|
||||||
|
t1.setStatus(1);
|
||||||
|
t1.setInx(0);
|
||||||
|
t1.setCreatedTime(now);
|
||||||
|
t1.setUpdatedTime(now);
|
||||||
|
assertTrue(tunnelService.save(t1));
|
||||||
|
|
||||||
|
Tunnel t2 = new Tunnel();
|
||||||
|
t2.setName("t2");
|
||||||
|
t2.setType(2);
|
||||||
|
t2.setFlow(2);
|
||||||
|
t2.setTrafficRatio(new BigDecimal("1.0"));
|
||||||
|
t2.setInIp("");
|
||||||
|
t2.setStatus(1);
|
||||||
|
t2.setInx(0);
|
||||||
|
t2.setCreatedTime(now);
|
||||||
|
t2.setUpdatedTime(now);
|
||||||
|
assertTrue(tunnelService.save(t2));
|
||||||
|
|
||||||
|
List<Map<String, Object>> tunnels = new ArrayList<>();
|
||||||
|
tunnels.add(mapIdInx(t2.getId(), 0));
|
||||||
|
tunnels.add(mapIdInx(t1.getId(), 1));
|
||||||
|
|
||||||
|
Map<String, Object> params = new HashMap<>();
|
||||||
|
params.put("tunnels", tunnels);
|
||||||
|
|
||||||
|
R res = tunnelService.updateTunnelOrder(params);
|
||||||
|
assertEquals(0, res.getCode());
|
||||||
|
|
||||||
|
assertEquals(1, tunnelService.getById(t1.getId()).getInx());
|
||||||
|
assertEquals(0, tunnelService.getById(t2.getId()).getInx());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> mapIdInx(Long id, int inx) {
|
||||||
|
Map<String, Object> m = new HashMap<>();
|
||||||
|
m.put("id", id);
|
||||||
|
m.put("inx", inx);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ 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 updateNodeOrder = (data: { nodes: Array<{ id: number; inx: number }> }) => Network.post("/node/update-order", data);
|
||||||
export const checkNodeStatus = (nodeId?: number) => {
|
export const checkNodeStatus = (nodeId?: number) => {
|
||||||
const params = nodeId ? { nodeId } : {};
|
const params = nodeId ? { nodeId } : {};
|
||||||
return Network.post("/node/check-status", params);
|
return Network.post("/node/check-status", params);
|
||||||
@@ -41,6 +42,7 @@ export const getTunnelById = (id: number) => Network.post("/tunnel/get", { id })
|
|||||||
export const updateTunnel = (data: any) => Network.post("/tunnel/update", data);
|
export const updateTunnel = (data: any) => Network.post("/tunnel/update", data);
|
||||||
export const deleteTunnel = (id: number) => Network.post("/tunnel/delete", { id });
|
export const deleteTunnel = (id: number) => Network.post("/tunnel/delete", { id });
|
||||||
export const diagnoseTunnel = (tunnelId: number) => Network.post("/tunnel/diagnose", { tunnelId });
|
export const diagnoseTunnel = (tunnelId: number) => Network.post("/tunnel/diagnose", { tunnelId });
|
||||||
|
export const updateTunnelOrder = (data: { tunnels: Array<{ id: number; inx: number }> }) => Network.post("/tunnel/update-order", data);
|
||||||
|
|
||||||
// 用户隧道权限管理操作 - 全部使用POST请求
|
// 用户隧道权限管理操作 - 全部使用POST请求
|
||||||
export const assignUserTunnel = (data: any) => Network.post("/tunnel/user/assign", data);
|
export const assignUserTunnel = (data: any) => Network.post("/tunnel/user/assign", data);
|
||||||
|
|||||||
@@ -13,17 +13,36 @@ import { Accordion, AccordionItem } from "@heroui/accordion";
|
|||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
type DragEndEvent,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
arrayMove,
|
||||||
|
rectSortingStrategy,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createNode,
|
createNode,
|
||||||
getNodeList,
|
getNodeList,
|
||||||
updateNode,
|
updateNode,
|
||||||
deleteNode,
|
deleteNode,
|
||||||
getNodeInstallCommand
|
getNodeInstallCommand,
|
||||||
|
updateNodeOrder
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
|
||||||
interface Node {
|
interface Node {
|
||||||
id: number;
|
id: number;
|
||||||
|
inx?: number;
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
serverIp: string;
|
serverIp: string;
|
||||||
@@ -63,6 +82,7 @@ interface NodeForm {
|
|||||||
|
|
||||||
export default function NodePage() {
|
export default function NodePage() {
|
||||||
const [nodeList, setNodeList] = useState<Node[]>([]);
|
const [nodeList, setNodeList] = useState<Node[]>([]);
|
||||||
|
const [nodeOrder, setNodeOrder] = useState<number[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [dialogVisible, setDialogVisible] = useState(false);
|
const [dialogVisible, setDialogVisible] = useState(false);
|
||||||
const [dialogTitle, setDialogTitle] = useState('');
|
const [dialogTitle, setDialogTitle] = useState('');
|
||||||
@@ -112,12 +132,49 @@ export default function NodePage() {
|
|||||||
try {
|
try {
|
||||||
const res = await getNodeList();
|
const res = await getNodeList();
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
setNodeList(res.data.map((node: any) => ({
|
const nodesData: Node[] = (res.data || []).map((node: any) => ({
|
||||||
...node,
|
...node,
|
||||||
|
inx: node.inx ?? 0,
|
||||||
connectionStatus: node.status === 1 ? 'online' : 'offline',
|
connectionStatus: node.status === 1 ? 'online' : 'offline',
|
||||||
systemInfo: null,
|
systemInfo: null,
|
||||||
copyLoading: false
|
copyLoading: false,
|
||||||
})));
|
}));
|
||||||
|
|
||||||
|
setNodeList(nodesData);
|
||||||
|
|
||||||
|
// 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序
|
||||||
|
const hasDbOrdering = nodesData.some((n) => n.inx !== undefined && n.inx !== 0);
|
||||||
|
if (hasDbOrdering) {
|
||||||
|
const dbOrder = [...nodesData]
|
||||||
|
.sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))
|
||||||
|
.map((n) => n.id);
|
||||||
|
setNodeOrder(dbOrder);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('node-order');
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
const existingIds = new Set(nodesData.map((n) => n.id));
|
||||||
|
const validOrder = parsed
|
||||||
|
.map((id: any) => Number(id))
|
||||||
|
.filter((id: number) => existingIds.has(id));
|
||||||
|
|
||||||
|
if (validOrder.length > 0) {
|
||||||
|
setNodeOrder(validOrder);
|
||||||
|
} else {
|
||||||
|
setNodeOrder(nodesData.map((n) => n.id));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setNodeOrder(nodesData.map((n) => n.id));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setNodeOrder(nodesData.map((n) => n.id));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setNodeOrder(nodesData.map((n) => n.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.msg || '加载节点列表失败');
|
toast.error(res.msg || '加载节点列表失败');
|
||||||
}
|
}
|
||||||
@@ -606,6 +663,120 @@ export default function NodePage() {
|
|||||||
setErrors({});
|
setErrors({});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 处理拖拽结束
|
||||||
|
const handleDragEnd = async (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (!active || !over || active.id === over.id) return;
|
||||||
|
if (!nodeOrder || nodeOrder.length === 0) return;
|
||||||
|
|
||||||
|
const activeId = Number(active.id);
|
||||||
|
const overId = Number(over.id);
|
||||||
|
if (isNaN(activeId) || isNaN(overId)) return;
|
||||||
|
|
||||||
|
const oldIndex = nodeOrder.indexOf(activeId);
|
||||||
|
const newIndex = nodeOrder.indexOf(overId);
|
||||||
|
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||||||
|
|
||||||
|
const newOrder = arrayMove(nodeOrder, oldIndex, newIndex);
|
||||||
|
setNodeOrder(newOrder);
|
||||||
|
|
||||||
|
// 保存到 localStorage
|
||||||
|
try {
|
||||||
|
localStorage.setItem('node-order', JSON.stringify(newOrder));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('无法保存节点排序到localStorage:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 持久化到数据库
|
||||||
|
try {
|
||||||
|
const nodesToUpdate = newOrder.map((id, index) => ({ id, inx: index }));
|
||||||
|
const response = await updateNodeOrder({ nodes: nodesToUpdate });
|
||||||
|
if (response.code === 0) {
|
||||||
|
setNodeList((prev) =>
|
||||||
|
prev.map((node) => {
|
||||||
|
const updated = nodesToUpdate.find((n) => n.id === node.id);
|
||||||
|
return updated ? { ...node, inx: updated.inx } : node;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error('保存排序失败:' + (response.msg || '未知错误'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存节点排序到数据库失败:', error);
|
||||||
|
toast.error('保存排序失败,请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 传感器配置
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 根据排序顺序获取节点列表
|
||||||
|
const getSortedNodes = (): Node[] => {
|
||||||
|
if (!nodeList || nodeList.length === 0) return [];
|
||||||
|
|
||||||
|
const sortedNodes = [...nodeList].sort((a, b) => {
|
||||||
|
const aInx = a.inx ?? 0;
|
||||||
|
const bInx = b.inx ?? 0;
|
||||||
|
return aInx - bInx;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果数据库中没有排序信息,则使用本地存储的顺序
|
||||||
|
if (nodeOrder && nodeOrder.length > 0 && sortedNodes.every((n) => n.inx === undefined || n.inx === 0)) {
|
||||||
|
const nodeMap = new Map(nodeList.map((n) => [n.id, n] as const));
|
||||||
|
const localSorted: Node[] = [];
|
||||||
|
|
||||||
|
nodeOrder.forEach((id) => {
|
||||||
|
const node = nodeMap.get(id);
|
||||||
|
if (node) localSorted.push(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
nodeList.forEach((node) => {
|
||||||
|
if (!nodeOrder.includes(node.id)) {
|
||||||
|
localSorted.push(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return localSorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedNodes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SortableItem = ({
|
||||||
|
id,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
children: (listeners: any) => any;
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: transform ? CSS.Transform.toString(transform) : undefined,
|
||||||
|
transition: transition || undefined,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} {...attributes}>
|
||||||
|
{children(listeners)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div className="px-3 lg:px-6 py-8">
|
<div className="px-3 lg:px-6 py-8">
|
||||||
@@ -651,18 +822,35 @@ export default function NodePage() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||||
{nodeList.map((node) => (
|
<SortableContext
|
||||||
<Card
|
items={getSortedNodes().map((n) => n.id)}
|
||||||
key={node.id}
|
strategy={rectSortingStrategy}
|
||||||
className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200"
|
>
|
||||||
>
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||||
|
{getSortedNodes().map((node) => (
|
||||||
|
<SortableItem key={node.id} id={node.id}>
|
||||||
|
{(listeners) => (
|
||||||
|
<Card
|
||||||
|
key={node.id}
|
||||||
|
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200"
|
||||||
|
>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex justify-between items-start w-full">
|
<div className="flex justify-between items-start w-full">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-foreground truncate text-sm">{node.name}</h3>
|
<h3 className="font-semibold text-foreground truncate text-sm">{node.name}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 ml-2">
|
<div className="flex items-center gap-1.5 ml-2">
|
||||||
|
<div
|
||||||
|
className="cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
|
||||||
|
{...listeners}
|
||||||
|
title="拖拽排序"
|
||||||
|
style={{ touchAction: 'none' }}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<Chip
|
<Chip
|
||||||
color={node.connectionStatus === 'online' ? 'success' : 'danger'}
|
color={node.connectionStatus === 'online' ? 'success' : 'danger'}
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@@ -825,9 +1013,13 @@ export default function NodePage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
)}
|
||||||
</div>
|
</SortableItem>
|
||||||
)}
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 新增/编辑节点对话框 */}
|
{/* 新增/编辑节点对话框 */}
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -10,6 +10,23 @@ import { Divider } from "@heroui/divider";
|
|||||||
import { Alert } from "@heroui/alert";
|
import { Alert } from "@heroui/alert";
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
type DragEndEvent,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
arrayMove,
|
||||||
|
rectSortingStrategy,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createTunnel,
|
createTunnel,
|
||||||
@@ -17,7 +34,8 @@ import {
|
|||||||
updateTunnel,
|
updateTunnel,
|
||||||
deleteTunnel,
|
deleteTunnel,
|
||||||
getNodeList,
|
getNodeList,
|
||||||
diagnoseTunnel
|
diagnoseTunnel,
|
||||||
|
updateTunnelOrder
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
|
||||||
interface ChainTunnel {
|
interface ChainTunnel {
|
||||||
@@ -30,6 +48,7 @@ interface ChainTunnel {
|
|||||||
|
|
||||||
interface Tunnel {
|
interface Tunnel {
|
||||||
id: number;
|
id: number;
|
||||||
|
inx?: number;
|
||||||
name: string;
|
name: string;
|
||||||
type: number; // 1: 端口转发, 2: 隧道转发
|
type: number; // 1: 端口转发, 2: 隧道转发
|
||||||
inNodeId: ChainTunnel[]; // 入口节点列表
|
inNodeId: ChainTunnel[]; // 入口节点列表
|
||||||
@@ -87,6 +106,7 @@ interface DiagnosisResult {
|
|||||||
export default function TunnelPage() {
|
export default function TunnelPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||||
|
const [tunnelOrder, setTunnelOrder] = useState<number[]>([]);
|
||||||
const [nodes, setNodes] = useState<Node[]>([]);
|
const [nodes, setNodes] = useState<Node[]>([]);
|
||||||
|
|
||||||
// 模态框状态
|
// 模态框状态
|
||||||
@@ -131,7 +151,45 @@ export default function TunnelPage() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (tunnelsRes.code === 0) {
|
if (tunnelsRes.code === 0) {
|
||||||
setTunnels(tunnelsRes.data || []);
|
const tunnelsData: Tunnel[] = (tunnelsRes.data || []).map((t: any) => ({
|
||||||
|
...t,
|
||||||
|
inx: t.inx ?? 0,
|
||||||
|
}));
|
||||||
|
setTunnels(tunnelsData);
|
||||||
|
|
||||||
|
// 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序
|
||||||
|
const hasDbOrdering = tunnelsData.some((t) => t.inx !== undefined && t.inx !== 0);
|
||||||
|
if (hasDbOrdering) {
|
||||||
|
const dbOrder = [...tunnelsData]
|
||||||
|
.sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))
|
||||||
|
.map((t) => t.id);
|
||||||
|
setTunnelOrder(dbOrder);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('tunnel-order');
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
const existingIds = new Set(tunnelsData.map((t) => t.id));
|
||||||
|
const validOrder = parsed
|
||||||
|
.map((id: any) => Number(id))
|
||||||
|
.filter((id: number) => existingIds.has(id));
|
||||||
|
|
||||||
|
if (validOrder.length > 0) {
|
||||||
|
setTunnelOrder(validOrder);
|
||||||
|
} else {
|
||||||
|
setTunnelOrder(tunnelsData.map((t) => t.id));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTunnelOrder(tunnelsData.map((t) => t.id));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTunnelOrder(tunnelsData.map((t) => t.id));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setTunnelOrder(tunnelsData.map((t) => t.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(tunnelsRes.msg || '获取隧道列表失败');
|
toast.error(tunnelsRes.msg || '获取隧道列表失败');
|
||||||
}
|
}
|
||||||
@@ -481,6 +539,120 @@ export default function TunnelPage() {
|
|||||||
return { text: '😵 很差', color: 'danger' };
|
return { text: '😵 很差', color: 'danger' };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 处理拖拽结束
|
||||||
|
const handleDragEnd = async (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (!active || !over || active.id === over.id) return;
|
||||||
|
if (!tunnelOrder || tunnelOrder.length === 0) return;
|
||||||
|
|
||||||
|
const activeId = Number(active.id);
|
||||||
|
const overId = Number(over.id);
|
||||||
|
if (isNaN(activeId) || isNaN(overId)) return;
|
||||||
|
|
||||||
|
const oldIndex = tunnelOrder.indexOf(activeId);
|
||||||
|
const newIndex = tunnelOrder.indexOf(overId);
|
||||||
|
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||||||
|
|
||||||
|
const newOrder = arrayMove(tunnelOrder, oldIndex, newIndex);
|
||||||
|
setTunnelOrder(newOrder);
|
||||||
|
|
||||||
|
// 保存到 localStorage
|
||||||
|
try {
|
||||||
|
localStorage.setItem('tunnel-order', JSON.stringify(newOrder));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('无法保存隧道排序到localStorage:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 持久化到数据库
|
||||||
|
try {
|
||||||
|
const tunnelsToUpdate = newOrder.map((id, index) => ({ id, inx: index }));
|
||||||
|
const response = await updateTunnelOrder({ tunnels: tunnelsToUpdate });
|
||||||
|
if (response.code === 0) {
|
||||||
|
setTunnels((prev) =>
|
||||||
|
prev.map((tunnel) => {
|
||||||
|
const updated = tunnelsToUpdate.find((t) => t.id === tunnel.id);
|
||||||
|
return updated ? { ...tunnel, inx: updated.inx } : tunnel;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error('保存排序失败:' + (response.msg || '未知错误'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存隧道排序到数据库失败:', error);
|
||||||
|
toast.error('保存排序失败,请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 传感器配置
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 根据排序顺序获取隧道列表
|
||||||
|
const getSortedTunnels = (): Tunnel[] => {
|
||||||
|
if (!tunnels || tunnels.length === 0) return [];
|
||||||
|
|
||||||
|
const sortedTunnels = [...tunnels].sort((a, b) => {
|
||||||
|
const aInx = a.inx ?? 0;
|
||||||
|
const bInx = b.inx ?? 0;
|
||||||
|
return aInx - bInx;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果数据库中没有排序信息,则使用本地存储的顺序
|
||||||
|
if (tunnelOrder && tunnelOrder.length > 0 && sortedTunnels.every((t) => t.inx === undefined || t.inx === 0)) {
|
||||||
|
const tunnelMap = new Map(tunnels.map((t) => [t.id, t] as const));
|
||||||
|
const localSorted: Tunnel[] = [];
|
||||||
|
|
||||||
|
tunnelOrder.forEach((id) => {
|
||||||
|
const tunnel = tunnelMap.get(id);
|
||||||
|
if (tunnel) localSorted.push(tunnel);
|
||||||
|
});
|
||||||
|
|
||||||
|
tunnels.forEach((tunnel) => {
|
||||||
|
if (!tunnelOrder.includes(tunnel.id)) {
|
||||||
|
localSorted.push(tunnel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return localSorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedTunnels;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SortableItem = ({
|
||||||
|
id,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
children: (listeners: any) => any;
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: transform ? CSS.Transform.toString(transform) : undefined,
|
||||||
|
transition: transition || undefined,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} {...attributes}>
|
||||||
|
{children(listeners)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
||||||
@@ -516,17 +688,24 @@ export default function TunnelPage() {
|
|||||||
|
|
||||||
{/* 隧道卡片网格 */}
|
{/* 隧道卡片网格 */}
|
||||||
{tunnels.length > 0 ? (
|
{tunnels.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||||
{tunnels.map((tunnel) => {
|
<SortableContext
|
||||||
|
items={getSortedTunnels().map((t) => t.id)}
|
||||||
|
strategy={rectSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||||
|
{getSortedTunnels().map((tunnel) => {
|
||||||
const typeDisplay = getTypeDisplay(tunnel.type);
|
const typeDisplay = getTypeDisplay(tunnel.type);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={tunnel.id} className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200">
|
<SortableItem key={tunnel.id} id={tunnel.id}>
|
||||||
<CardHeader className="pb-2">
|
{(listeners) => (
|
||||||
<div className="flex justify-between items-start w-full">
|
<Card key={tunnel.id} className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200">
|
||||||
<div className="flex-1 min-w-0">
|
<CardHeader className="pb-2">
|
||||||
<h3 className="font-semibold text-foreground truncate text-sm">{tunnel.name}</h3>
|
<div className="flex justify-between items-start w-full">
|
||||||
<div className="flex items-center gap-1.5 mt-1">
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-foreground truncate text-sm">{tunnel.name}</h3>
|
||||||
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
<Chip
|
<Chip
|
||||||
color={typeDisplay.color as any}
|
color={typeDisplay.color as any}
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@@ -536,12 +715,22 @@ export default function TunnelPage() {
|
|||||||
{typeDisplay.text}
|
{typeDisplay.text}
|
||||||
</Chip>
|
</Chip>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
|
||||||
|
{...listeners}
|
||||||
|
title="拖拽排序"
|
||||||
|
style={{ touchAction: 'none' }}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardBody className="pt-0 pb-3">
|
<CardBody className="pt-0 pb-3">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* 拓扑结构 */}
|
{/* 拓扑结构 */}
|
||||||
<div className="pt-2 border-t border-divider">
|
<div className="pt-2 border-t border-divider">
|
||||||
@@ -654,10 +843,14 @@ export default function TunnelPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
)}
|
||||||
})}
|
</SortableItem>
|
||||||
</div>
|
);
|
||||||
) : (
|
})}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
) : (
|
||||||
/* 空状态 */
|
/* 空状态 */
|
||||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
<CardBody className="text-center py-16">
|
<CardBody className="text-center py-16">
|
||||||
|
|||||||
Reference in New Issue
Block a user