From 71eab8e07e4e5aaf066a0cee49b7e2beb5e1c952 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 31 Jan 2026 05:00:11 +0000 Subject: [PATCH 1/7] chore: resolve latest release in install scripts Install scripts now derive the release tag dynamically (with a CN mirror fallback) instead of being pinned to a single version. --- install.sh | 93 ++++++++++++++++++++++++++++++++++++++++++------ panel_install.sh | 82 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 157 insertions(+), 18 deletions(-) diff --git a/install.sh b/install.sh index 60605b7..20f368c 100644 --- a/install.sh +++ b/install.sh @@ -1,5 +1,8 @@ #!/bin/bash +# GitHub repo used for release downloads +REPO="Sagit-chu/flux-panel" + # 获取系统架构 get_architecture() { ARCH=$(uname -m) @@ -16,20 +19,90 @@ get_architecture() { 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() { 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) -INSTALL_DIR="/etc/flux_agent" -COUNTRY=$(curl -s https://ipinfo.io/country) -if [ "$COUNTRY" = "CN" ]; then - # 拼接 URL - DOWNLOAD_URL="https://ghfast.top/${DOWNLOAD_URL}" -fi +# 解析版本并构建下载地址 +RESOLVED_VERSION=$(resolve_version) || exit 1 +DOWNLOAD_URL=$(maybe_proxy_url "$(build_download_url)") @@ -365,4 +438,4 @@ main() { } # 执行主函数 -main \ No newline at end of file +main diff --git a/panel_install.sh b/panel_install.sh index b803503..21f8ade 100755 --- a/panel_install.sh +++ b/panel_install.sh @@ -7,16 +7,82 @@ export LC_ALL=C -# 全局下载地址配置 -DOCKER_COMPOSEV4_URL="https://github.com/Sagit-chu/flux-panel/releases/download/2.0.8/docker-compose-v4.yml" -DOCKER_COMPOSEV6_URL="https://github.com/Sagit-chu/flux-panel/releases/download/2.0.8/docker-compose-v6.yml" +# GitHub repo used for release downloads +REPO="Sagit-chu/flux-panel" COUNTRY=$(curl -s https://ipinfo.io/country) -if [ "$COUNTRY" = "CN" ]; then - # 拼接 URL - DOCKER_COMPOSEV4_URL="https://ghfast.top/${DOCKER_COMPOSEV4_URL}" - DOCKER_COMPOSEV6_URL="https://ghfast.top/${DOCKER_COMPOSEV6_URL}" -fi + +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" + + 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") From 6adf90d45cc6e212f83118c42cc55c2e793b689b Mon Sep 17 00:00:00 2001 From: root Date: Sat, 31 Jan 2026 05:00:18 +0000 Subject: [PATCH 2/7] fix: make node deletion detach tunnels safely When deleting a node, detach it from affected tunnel chains and keep tunnel config consistent instead of deleting entire tunnels. --- .../admin/service/impl/NodeServiceImpl.java | 285 ++++++++++++++++-- 1 file changed, 253 insertions(+), 32 deletions(-) diff --git a/springboot-backend/src/main/java/com/admin/service/impl/NodeServiceImpl.java b/springboot-backend/src/main/java/com/admin/service/impl/NodeServiceImpl.java index c291f8e..03a3823 100644 --- a/springboot-backend/src/main/java/com/admin/service/impl/NodeServiceImpl.java +++ b/springboot-backend/src/main/java/com/admin/service/impl/NodeServiceImpl.java @@ -9,26 +9,32 @@ import com.admin.common.lang.R; import com.admin.common.utils.GostUtil; import com.admin.common.utils.WebSocketServer; import com.admin.entity.*; -import com.admin.mapper.NodeMapper; -import com.admin.mapper.TunnelMapper; -import com.admin.service.*; -import com.alibaba.fastjson.JSONObject; -import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import org.springframework.beans.BeanUtils; -import org.springframework.context.annotation.Lazy; -import org.springframework.stereotype.Service; +import com.admin.mapper.NodeMapper; +import com.admin.mapper.TunnelMapper; +import com.admin.service.*; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; -import javax.annotation.Resource; -import java.util.List; -import java.util.Objects; -import java.util.regex.Pattern; +import javax.annotation.Resource; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.regex.Pattern; import org.springframework.beans.factory.annotation.Value; -@Service -public class NodeServiceImpl extends ServiceImpl implements NodeService { +@Service +@Slf4j +public class NodeServiceImpl extends ServiceImpl implements NodeService { @Resource @@ -38,8 +44,11 @@ public class NodeServiceImpl extends ServiceImpl implements No @Resource ViteConfigService viteConfigService; - @Resource - ChainTunnelService chainTunnelService; + @Resource + ChainTunnelService chainTunnelService; + + @Resource + ForwardPortService forwardPortService; @Override @@ -101,20 +110,232 @@ public class NodeServiceImpl extends ServiceImpl implements No return R.ok(); } - @Override - public R deleteNode(Long id) { - Node node = this.getById(id); - if (node == null) { - return R.err("节点不存在"); - } - - List list = chainTunnelService.list(new QueryWrapper().eq("node_id", id).groupBy("tunnel_id")); - for (ChainTunnel tunnel : list) { - tunnelService.deleteTunnel(tunnel.getTunnelId()); - } - this.removeById(id); - return R.ok(); - } + @Override + public R deleteNode(Long id) { + Node node = this.getById(id); + if (node == null) { + return R.err("节点不存在"); + } + + List affected = chainTunnelService.list(new QueryWrapper().eq("node_id", id)); + Map> byTunnelId = affected.stream() + .filter(ct -> ct.getTunnelId() != null) + .collect(Collectors.groupingBy(ChainTunnel::getTunnelId)); + + for (Map.Entry> entry : byTunnelId.entrySet()) { + Long tunnelId = entry.getKey(); + Tunnel tunnel = tunnelService.getById(tunnelId); + + List before = chainTunnelService.list(new QueryWrapper().eq("tunnel_id", tunnelId)); + + // Remove the node from the tunnel definition (do NOT delete the tunnel). + chainTunnelService.remove(new QueryWrapper().eq("tunnel_id", tunnelId).eq("node_id", id)); + + if (tunnel == null) { + continue; + } + + List after = chainTunnelService.list(new QueryWrapper().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().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); + return R.ok(); + } + + private boolean isTunnelConfigValid(Tunnel tunnel, List 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 chainTunnels) { + if (chainTunnels == null) { + return ""; + } + List 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 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 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 chainTunnels, Tunnel tunnel) { + if (tunnel == null || chainTunnels == null) { + return; + } + + Map nodes = new HashMap<>(); + for (ChainTunnel ct : chainTunnels) { + Node n = this.getById(ct.getNodeId()); + if (n != null) { + nodes.put(n.getId(), n); + } + } + + List inNodes = chainTunnels.stream() + .filter(ct -> ct.getChainType() != null && ct.getChainType() == 1) + .collect(Collectors.toList()); + + Map> chainNodesMap = chainTunnels.stream() + .filter(ct -> ct.getChainType() != null && ct.getChainType() == 2) + .collect(Collectors.groupingBy(ct -> ct.getInx() != null ? ct.getInx() : 0)); + + List> chainNodesList = chainNodesMap.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(Map.Entry::getValue) + .collect(Collectors.toList()); + + List 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 From f4c59f64b0b453585c6b94d6f89fc0d30efbeb96 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 31 Jan 2026 05:51:20 +0000 Subject: [PATCH 3/7] feat: add inx ordering for tunnel and node --- .../com/admin/common/dto/TunnelDetailDto.java | 7 ++- .../migration/SqliteSchemaMigration.java | 54 +++++++++++++++++++ .../src/main/java/com/admin/entity/Node.java | 14 +++-- .../main/java/com/admin/entity/Tunnel.java | 16 ++++-- .../src/main/resources/schema.sql | 7 +-- 5 files changed, 85 insertions(+), 13 deletions(-) create mode 100644 springboot-backend/src/main/java/com/admin/common/migration/SqliteSchemaMigration.java diff --git a/springboot-backend/src/main/java/com/admin/common/dto/TunnelDetailDto.java b/springboot-backend/src/main/java/com/admin/common/dto/TunnelDetailDto.java index b501654..186387e 100644 --- a/springboot-backend/src/main/java/com/admin/common/dto/TunnelDetailDto.java +++ b/springboot-backend/src/main/java/com/admin/common/dto/TunnelDetailDto.java @@ -14,6 +14,12 @@ import java.util.List; public class TunnelDetailDto { private Long id; + + /** + * Display/order index for drag-sorting in admin UI. + * Lower value appears first. + */ + private Integer inx; private String name; @@ -40,4 +46,3 @@ public class TunnelDetailDto { // 出口节点列表 private List outNodeId = new ArrayList<>(); } - diff --git a/springboot-backend/src/main/java/com/admin/common/migration/SqliteSchemaMigration.java b/springboot-backend/src/main/java/com/admin/common/migration/SqliteSchemaMigration.java new file mode 100644 index 0000000..16d3a05 --- /dev/null +++ b/springboot-backend/src/main/java/com/admin/common/migration/SqliteSchemaMigration.java @@ -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 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 + ); + } +} diff --git a/springboot-backend/src/main/java/com/admin/entity/Node.java b/springboot-backend/src/main/java/com/admin/entity/Node.java index b61d6ab..e0aa420 100644 --- a/springboot-backend/src/main/java/com/admin/entity/Node.java +++ b/springboot-backend/src/main/java/com/admin/entity/Node.java @@ -14,7 +14,7 @@ import lombok.EqualsAndHashCode; */ @Data @EqualsAndHashCode(callSuper = true) -public class Node extends BaseEntity { +public class Node extends BaseEntity { private static final long serialVersionUID = 1L; @@ -38,6 +38,12 @@ public class Node extends BaseEntity { private String tcpListenAddr; - private String udpListenAddr; - -} + private String udpListenAddr; + + /** + * Display/order index for drag-sorting in admin UI. + * Lower value appears first. + */ + private Integer inx; + +} diff --git a/springboot-backend/src/main/java/com/admin/entity/Tunnel.java b/springboot-backend/src/main/java/com/admin/entity/Tunnel.java index 1763224..01ff83a 100644 --- a/springboot-backend/src/main/java/com/admin/entity/Tunnel.java +++ b/springboot-backend/src/main/java/com/admin/entity/Tunnel.java @@ -22,7 +22,7 @@ import lombok.EqualsAndHashCode; @Data @EqualsAndHashCode(callSuper = true) @TableName(autoResultMap = true) -public class Tunnel extends BaseEntity { +public class Tunnel extends BaseEntity { private static final long serialVersionUID = 1L; @@ -32,7 +32,13 @@ public class Tunnel extends BaseEntity { private int flow; - private BigDecimal trafficRatio; - - private String inIp; -} + private BigDecimal trafficRatio; + + private String inIp; + + /** + * Display/order index for drag-sorting in admin UI. + * Lower value appears first. + */ + private Integer inx; +} diff --git a/springboot-backend/src/main/resources/schema.sql b/springboot-backend/src/main/resources/schema.sql index 318af77..1e258cb 100644 --- a/springboot-backend/src/main/resources/schema.sql +++ b/springboot-backend/src/main/resources/schema.sql @@ -39,7 +39,8 @@ CREATE TABLE IF NOT EXISTS node ( updated_time INTEGER, status INTEGER NOT NULL, 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 ( @@ -72,7 +73,8 @@ CREATE TABLE IF NOT EXISTS tunnel ( created_time INTEGER NOT NULL, updated_time 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 ( @@ -123,4 +125,3 @@ CREATE TABLE IF NOT EXISTS vite_config ( value VARCHAR(200) NOT NULL, time INTEGER NOT NULL ); - From de8e487dff9422e488d7dc4799ad6c37e63e2e4a Mon Sep 17 00:00:00 2001 From: root Date: Sat, 31 Jan 2026 05:52:14 +0000 Subject: [PATCH 4/7] feat: add update-order APIs for tunnel and node --- .../com/admin/controller/NodeController.java | 30 +++- .../admin/controller/TunnelController.java | 12 ++ .../java/com/admin/service/NodeService.java | 20 ++- .../java/com/admin/service/TunnelService.java | 18 +- .../admin/service/impl/NodeServiceImpl.java | 48 +++++- .../admin/service/impl/TunnelServiceImpl.java | 31 +++- .../java/com/admin/OrderingUpdateTests.java | 162 ++++++++++++++++++ 7 files changed, 291 insertions(+), 30 deletions(-) create mode 100644 springboot-backend/src/test/java/com/admin/OrderingUpdateTests.java diff --git a/springboot-backend/src/main/java/com/admin/controller/NodeController.java b/springboot-backend/src/main/java/com/admin/controller/NodeController.java index 3a6f3de..3e7c1a8 100644 --- a/springboot-backend/src/main/java/com/admin/controller/NodeController.java +++ b/springboot-backend/src/main/java/com/admin/controller/NodeController.java @@ -54,12 +54,24 @@ public class NodeController extends BaseController { return nodeService.deleteNode(id); } - @LogAnnotation - @RequireRole - @PostMapping("/install") - public R getInstallCommand(@RequestBody Map params) { - Long id = Long.valueOf(params.get("id").toString()); - return nodeService.getInstallCommand(id); - } - -} + @LogAnnotation + @RequireRole + @PostMapping("/install") + public R getInstallCommand(@RequestBody Map params) { + Long id = Long.valueOf(params.get("id").toString()); + return nodeService.getInstallCommand(id); + } + + /** + * 更新节点排序 + * @param params 包含nodes数组的参数,每个元素包含id和inx + * @return 更新结果 + */ + @LogAnnotation + @RequireRole + @PostMapping("/update-order") + public R updateNodeOrder(@RequestBody Map params) { + return nodeService.updateNodeOrder(params); + } + +} diff --git a/springboot-backend/src/main/java/com/admin/controller/TunnelController.java b/springboot-backend/src/main/java/com/admin/controller/TunnelController.java index f0eefcb..96db499 100644 --- a/springboot-backend/src/main/java/com/admin/controller/TunnelController.java +++ b/springboot-backend/src/main/java/com/admin/controller/TunnelController.java @@ -137,4 +137,16 @@ public class TunnelController extends BaseController { return tunnelService.diagnoseTunnel(tunnelId); } + /** + * 更新隧道排序 + * @param params 包含tunnels数组的参数,每个元素包含id和inx + * @return 更新结果 + */ + @LogAnnotation + @RequireRole + @PostMapping("/update-order") + public R updateTunnelOrder(@RequestBody Map params) { + return tunnelService.updateTunnelOrder(params); + } + } diff --git a/springboot-backend/src/main/java/com/admin/service/NodeService.java b/springboot-backend/src/main/java/com/admin/service/NodeService.java index 0a70730..b3440cb 100644 --- a/springboot-backend/src/main/java/com/admin/service/NodeService.java +++ b/springboot-backend/src/main/java/com/admin/service/NodeService.java @@ -3,8 +3,10 @@ package com.admin.service; import com.admin.common.dto.NodeDto; import com.admin.common.dto.NodeUpdateDto; import com.admin.common.lang.R; -import com.admin.entity.Node; -import com.baomidou.mybatisplus.extension.service.IService; +import com.admin.entity.Node; +import com.baomidou.mybatisplus.extension.service.IService; + +import java.util.Map; /** *

@@ -14,7 +16,7 @@ import com.baomidou.mybatisplus.extension.service.IService; * @author QAQ * @since 2025-06-03 */ -public interface NodeService extends IService { +public interface NodeService extends IService { R createNode(NodeDto nodeDto); @@ -24,6 +26,12 @@ public interface NodeService extends IService { R deleteNode(Long id); - R getInstallCommand(Long id); - -} + R getInstallCommand(Long id); + + /** + * 更新节点排序(管理员) + * @param params 包含nodes数组的参数,每个元素包含id和inx + */ + R updateNodeOrder(Map params); + +} diff --git a/springboot-backend/src/main/java/com/admin/service/TunnelService.java b/springboot-backend/src/main/java/com/admin/service/TunnelService.java index f949f57..6bd4b97 100644 --- a/springboot-backend/src/main/java/com/admin/service/TunnelService.java +++ b/springboot-backend/src/main/java/com/admin/service/TunnelService.java @@ -4,8 +4,10 @@ import com.admin.common.dto.TunnelDto; import com.admin.common.dto.TunnelUpdateDto; import com.admin.common.lang.R; -import com.admin.entity.Tunnel; -import com.baomidou.mybatisplus.extension.service.IService; +import com.admin.entity.Tunnel; +import com.baomidou.mybatisplus.extension.service.IService; + +import java.util.Map; /** *

@@ -15,7 +17,7 @@ import com.baomidou.mybatisplus.extension.service.IService; * @author QAQ * @since 2025-06-03 */ -public interface TunnelService extends IService { +public interface TunnelService extends IService { /** * 创建隧道 @@ -55,5 +57,11 @@ public interface TunnelService extends IService { * @param tunnelId 隧道ID * @return 诊断结果 */ - R diagnoseTunnel(Long tunnelId); -} + R diagnoseTunnel(Long tunnelId); + + /** + * 更新隧道排序(管理员) + * @param params 包含tunnels数组的参数,每个元素包含id和inx + */ + R updateTunnelOrder(Map params); +} diff --git a/springboot-backend/src/main/java/com/admin/service/impl/NodeServiceImpl.java b/springboot-backend/src/main/java/com/admin/service/impl/NodeServiceImpl.java index 03a3823..b8f75e4 100644 --- a/springboot-backend/src/main/java/com/admin/service/impl/NodeServiceImpl.java +++ b/springboot-backend/src/main/java/com/admin/service/impl/NodeServiceImpl.java @@ -24,13 +24,15 @@ import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.util.HashMap; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; 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 @Slf4j @@ -68,12 +70,40 @@ public class NodeServiceImpl extends ServiceImpl implements No return R.ok(); } - @Override - public R getAllNodes() { - List nodeList = this.list(new QueryWrapper().orderByDesc("status")); - nodeList.forEach(node -> node.setSecret(null)); - return R.ok(nodeList); - } + @Override + public R getAllNodes() { + List nodeList = this.list(new QueryWrapper().orderByAsc("inx").orderByAsc("id")); + nodeList.forEach(node -> node.setSecret(null)); + return R.ok(nodeList); + } + + @Override + @Transactional + public R updateNodeOrder(Map params) { + if (!params.containsKey("nodes")) { + return R.err("缺少nodes参数"); + } + + @SuppressWarnings("unchecked") + List> nodesList = (List>) params.get("nodes"); + if (nodesList == null || nodesList.isEmpty()) { + return R.err("nodes参数不能为空"); + } + + List nodesToUpdate = new ArrayList<>(); + for (Map 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 public R updateNode(NodeUpdateDto nodeUpdateDto) { @@ -347,8 +377,8 @@ public class NodeServiceImpl extends ServiceImpl implements No ViteConfig viteConfig = viteConfigService.getOne(new QueryWrapper().eq("name", "ip")); if (viteConfig == null) return R.err("请先前往网站配置中设置ip"); StringBuilder command = new StringBuilder(); - command.append("curl -L https://github.com/bqlpfy/flux-panel/releases/download/2.0.7-beta/install.sh") - .append(" -o ./install.sh && chmod +x ./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 && "); String processedServerAddr = GostUtil.processServerAddress(viteConfig.getValue()); command.append("./install.sh") .append(" -a ").append(processedServerAddr) // 服务器地址 diff --git a/springboot-backend/src/main/java/com/admin/service/impl/TunnelServiceImpl.java b/springboot-backend/src/main/java/com/admin/service/impl/TunnelServiceImpl.java index a5eafaa..7620b26 100644 --- a/springboot-backend/src/main/java/com/admin/service/impl/TunnelServiceImpl.java +++ b/springboot-backend/src/main/java/com/admin/service/impl/TunnelServiceImpl.java @@ -18,6 +18,7 @@ import lombok.Data; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.BeanUtils; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import javax.annotation.Resource; import java.util.*; @@ -252,7 +253,7 @@ public class TunnelServiceImpl extends ServiceImpl impleme @Override public R getAllTunnels() { - List tunnelList = this.list(); + List tunnelList = this.list(new QueryWrapper().orderByAsc("inx").orderByAsc("id")); // 查询所有隧道的ChainTunnel信息 List tunnelIds = tunnelList.stream() @@ -317,6 +318,34 @@ public class TunnelServiceImpl extends ServiceImpl impleme return R.ok(detailDtoList); } + @Override + @Transactional + public R updateTunnelOrder(Map params) { + if (!params.containsKey("tunnels")) { + return R.err("缺少tunnels参数"); + } + + @SuppressWarnings("unchecked") + List> tunnelsList = (List>) params.get("tunnels"); + if (tunnelsList == null || tunnelsList.isEmpty()) { + return R.err("tunnels参数不能为空"); + } + + List tunnelsToUpdate = new ArrayList<>(); + for (Map 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 public R updateTunnel(TunnelUpdateDto tunnelUpdateDto) { diff --git a/springboot-backend/src/test/java/com/admin/OrderingUpdateTests.java b/springboot-backend/src/test/java/com/admin/OrderingUpdateTests.java new file mode 100644 index 0000000..d412d34 --- /dev/null +++ b/springboot-backend/src/test/java/com/admin/OrderingUpdateTests.java @@ -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> nodes = new ArrayList<>(); + nodes.add(mapIdInx(n2.getId(), 0)); + nodes.add(mapIdInx(n1.getId(), 1)); + nodes.add(mapIdInx(n3.getId(), 2)); + + Map 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> tunnels = new ArrayList<>(); + tunnels.add(mapIdInx(t2.getId(), 0)); + tunnels.add(mapIdInx(t1.getId(), 1)); + + Map 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 mapIdInx(Long id, int inx) { + Map m = new HashMap<>(); + m.put("id", id); + m.put("inx", inx); + return m; + } +} From 81c9c117a2a48f8d98faca238d96c7e47122199f Mon Sep 17 00:00:00 2001 From: root Date: Sat, 31 Jan 2026 05:52:26 +0000 Subject: [PATCH 5/7] feat: add tunnel/node update-order API clients --- vite-frontend/src/api/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index 76b0145..56c31ce 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -29,6 +29,7 @@ export const getNodeList = () => Network.post("/node/list"); export const updateNode = (data: any) => Network.post("/node/update", data); export const deleteNode = (id: number) => Network.post("/node/delete", { id }); export const getNodeInstallCommand = (id: number) => Network.post("/node/install", { id }); +export const updateNodeOrder = (data: { nodes: Array<{ id: number; inx: number }> }) => Network.post("/node/update-order", data); export const checkNodeStatus = (nodeId?: number) => { const params = nodeId ? { nodeId } : {}; 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 deleteTunnel = (id: number) => Network.post("/tunnel/delete", { id }); 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请求 export const assignUserTunnel = (data: any) => Network.post("/tunnel/user/assign", data); @@ -88,4 +90,4 @@ export const updateConfig = (name: string, value: string) => Network.post("/conf // 验证码相关接口 export const checkCaptcha = () => Network.post("/captcha/check"); export const generateCaptcha = () => Network.post(`/captcha/generate`); -export const verifyCaptcha = (data: { captchaId: string; trackData: string }) => Network.post("/captcha/verify", data); \ No newline at end of file +export const verifyCaptcha = (data: { captchaId: string; trackData: string }) => Network.post("/captcha/verify", data); From 8222b319179d914a934eb90a57460cfc07774452 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 31 Jan 2026 05:52:39 +0000 Subject: [PATCH 6/7] feat: enable drag-sort in tunnel management --- vite-frontend/src/pages/tunnel.tsx | 233 ++++++++++++++++++++++++++--- 1 file changed, 213 insertions(+), 20 deletions(-) diff --git a/vite-frontend/src/pages/tunnel.tsx b/vite-frontend/src/pages/tunnel.tsx index 12dac79..ced8129 100644 --- a/vite-frontend/src/pages/tunnel.tsx +++ b/vite-frontend/src/pages/tunnel.tsx @@ -10,6 +10,23 @@ import { Divider } from "@heroui/divider"; import { Alert } from "@heroui/alert"; 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 { createTunnel, @@ -17,7 +34,8 @@ import { updateTunnel, deleteTunnel, getNodeList, - diagnoseTunnel + diagnoseTunnel, + updateTunnelOrder } from "@/api"; interface ChainTunnel { @@ -30,6 +48,7 @@ interface ChainTunnel { interface Tunnel { id: number; + inx?: number; name: string; type: number; // 1: 端口转发, 2: 隧道转发 inNodeId: ChainTunnel[]; // 入口节点列表 @@ -87,6 +106,7 @@ interface DiagnosisResult { export default function TunnelPage() { const [loading, setLoading] = useState(true); const [tunnels, setTunnels] = useState([]); + const [tunnelOrder, setTunnelOrder] = useState([]); const [nodes, setNodes] = useState([]); // 模态框状态 @@ -131,7 +151,45 @@ export default function TunnelPage() { ]); 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 { toast.error(tunnelsRes.msg || '获取隧道列表失败'); } @@ -481,6 +539,120 @@ export default function TunnelPage() { 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 ( +

+ {children(listeners)} +
+ ); + }; + if (loading) { return ( @@ -516,17 +688,24 @@ export default function TunnelPage() { {/* 隧道卡片网格 */} {tunnels.length > 0 ? ( -
- {tunnels.map((tunnel) => { + + t.id)} + strategy={rectSortingStrategy} + > +
+ {getSortedTunnels().map((tunnel) => { const typeDisplay = getTypeDisplay(tunnel.type); return ( - - -
-
-

{tunnel.name}

-
+ + {(listeners) => ( + + +
+
+

{tunnel.name}

+
{typeDisplay.text} - + +
+
+
+ + + +
-
-
- + - +
{/* 拓扑结构 */}
@@ -654,10 +843,14 @@ export default function TunnelPage() {
- ); - })} -
- ) : ( + )} + + ); + })} +
+ + +) : ( /* 空状态 */ @@ -1629,4 +1822,4 @@ export default function TunnelPage() {
); -} \ No newline at end of file +} From aac1d63ac0b7ff36082227c67ce8180851c881d9 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 31 Jan 2026 05:52:52 +0000 Subject: [PATCH 7/7] feat: enable drag-sort in node monitoring --- vite-frontend/src/pages/node.tsx | 220 +++++++++++++++++++++++++++++-- 1 file changed, 206 insertions(+), 14 deletions(-) diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index afb8e0a..dc5166a 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -13,17 +13,36 @@ import { Accordion, AccordionItem } from "@heroui/accordion"; import toast from 'react-hot-toast'; 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 { createNode, getNodeList, updateNode, deleteNode, - getNodeInstallCommand + getNodeInstallCommand, + updateNodeOrder } from "@/api"; interface Node { id: number; + inx?: number; name: string; ip: string; serverIp: string; @@ -63,6 +82,7 @@ interface NodeForm { export default function NodePage() { const [nodeList, setNodeList] = useState([]); + const [nodeOrder, setNodeOrder] = useState([]); const [loading, setLoading] = useState(false); const [dialogVisible, setDialogVisible] = useState(false); const [dialogTitle, setDialogTitle] = useState(''); @@ -112,12 +132,49 @@ export default function NodePage() { try { const res = await getNodeList(); if (res.code === 0) { - setNodeList(res.data.map((node: any) => ({ + const nodesData: Node[] = (res.data || []).map((node: any) => ({ ...node, + inx: node.inx ?? 0, connectionStatus: node.status === 1 ? 'online' : 'offline', 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 { toast.error(res.msg || '加载节点列表失败'); } @@ -606,6 +663,120 @@ export default function NodePage() { 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 ( +
+ {children(listeners)} +
+ ); + }; + return (
@@ -651,18 +822,35 @@ export default function NodePage() { ) : ( -
- {nodeList.map((node) => ( - + + n.id)} + strategy={rectSortingStrategy} + > +
+ {getSortedNodes().map((node) => ( + + {(listeners) => ( +

{node.name}

+
+ + + +
- ))} -
- )} + )} + + ))} +
+ + +)} {/* 新增/编辑节点对话框 */} ); -} \ No newline at end of file +}