Compare commits

..

9 Commits

Author SHA1 Message Date
sagit 0f57ec58b3 Merge pull request #10 from Sagit-chu/opencode/silent-falcon
优化拖拽排序顺滑度
2026-02-02 12:55:09 +08:00
root ac30f0172f 修复平铺模式用户切换与图表TS构建 2026-02-02 04:50:22 +00:00
root 531ba0bfed 新建隧道默认追加到末尾 2026-02-02 03:29:47 +00:00
root d787e4b07a 优化拖拽排序顺滑度 2026-02-02 03:14:18 +00:00
root 68e5d0ac0b 修复节点监控进度条频繁重置 2026-02-01 15:39:23 +00:00
root 38b70821c5 节点监控离线延迟去抖 2026-02-01 14:41:23 +00:00
root 936158dd32 节点地址输入支持域名 2026-02-01 12:34:31 +00:00
root e0d0553fd3 修复v6-only入口优先选择出口IPv6 2026-02-01 12:34:31 +00:00
root 78aa86b23c 前端版本号从tag注入并提交本地配置 2026-02-01 12:10:17 +00:00
12 changed files with 352 additions and 146 deletions
+5 -1
View File
@@ -150,7 +150,11 @@ jobs:
- name: Prepare build args
run: |
echo "VITE_GITHUB_REPO=https://github.com/${{ github.repository }}" > ./vite-frontend/.env.production
VERSION="${{ needs.check-version.outputs.version }}"
{
echo "VITE_GITHUB_REPO=https://github.com/${{ github.repository }}"
echo "VITE_APP_VERSION=$VERSION"
} > ./vite-frontend/.env.production
- name: Build and push Vite Docker images
run: |
+9
View File
@@ -0,0 +1,9 @@
---
active: true
iteration: 1
max_iterations: 100
completion_promise: "DONE"
started_at: "2026-01-24T05:20:50.887Z"
session_id: "ses_41192b7d7ffewFHxf1dDS7ESHO"
---
Complete the task as instructed
+3
View File
@@ -0,0 +1,3 @@
<factorypath>
<factorypathentry kind="VARJAR" id="M2_REPO/org/projectlombok/lombok/1.18.30/lombok-1.18.30.jar" enabled="true" runInBatchMode="false"/>
</factorypath>
@@ -333,10 +333,16 @@ public class GostUtil {
}
private static boolean supportsV4(Node node) {
// New dual-stack fields take precedence over legacy serverIp.
// If user explicitly provided only v6, treat as v6-only.
if (StrUtil.isNotBlank(node.getServerIpV4())) {
return true;
}
if (StrUtil.isNotBlank(node.getServerIpV6())) {
return false;
}
String legacy = node.getServerIp();
if (StrUtil.isBlank(legacy)) {
return false;
@@ -355,10 +361,16 @@ public class GostUtil {
}
private static boolean supportsV6(Node node) {
// New dual-stack fields take precedence over legacy serverIp.
// If user explicitly provided only v4, treat as v4-only.
if (StrUtil.isNotBlank(node.getServerIpV6())) {
return true;
}
if (StrUtil.isNotBlank(node.getServerIpV4())) {
return false;
}
String legacy = node.getServerIp();
if (StrUtil.isBlank(legacy)) {
return false;
@@ -117,6 +117,19 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
long currentTime = System.currentTimeMillis();
tunnel.setCreatedTime(currentTime);
tunnel.setUpdatedTime(currentTime);
// When tunnels are ordered via `inx`, new tunnels should be appended.
// Only apply this when an order already exists (max `inx` > 0) to avoid
// changing behavior for deployments still relying on local ordering.
Tunnel lastByInx = this.getOne(new QueryWrapper<Tunnel>()
.select("inx")
.orderByDesc("inx")
.orderByDesc("id")
.last("LIMIT 1"));
Integer maxInx = lastByInx == null ? null : lastByInx.getInx();
if (maxInx != null && maxInx > 0) {
tunnel.setInx(maxInx + 1);
}
if (StringUtils.isEmpty(tunnel.getInIp())){
java.util.LinkedHashSet<String> inIps = new java.util.LinkedHashSet<>();
for (ChainTunnel chainTunnel : tunnelDto.getInNodeId()) {
+1 -1
View File
@@ -4,7 +4,7 @@ export type SiteConfig = typeof siteConfig;
// 缓存相关常量
const CACHE_PREFIX = 'vite_config_';
const VERSION = "2.0.9";
const VERSION = import.meta.env.VITE_APP_VERSION || "dev";
const APP_VERSION = "1.0.3";
const GITHUB_REPO = import.meta.env.VITE_GITHUB_REPO || "https://github.com/Sagit-chu/flux-panel";
+40 -26
View File
@@ -741,31 +741,45 @@ export default function DashboardPage() {
tickLine={false}
axisLine={{ stroke: '#e5e7eb', strokeWidth: 1 }}
/>
<YAxis
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb', strokeWidth: 1 }}
tickFormatter={(value) => {
if (value === 0) return '0';
if (value < 1024) return `${value}B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}K`;
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)}M`;
return `${(value / (1024 * 1024 * 1024)).toFixed(1)}G`;
}}
/>
<Tooltip
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
return (
<div className="bg-white dark:bg-default-100 border border-default-200 rounded-lg shadow-lg p-3">
<p className="font-medium text-foreground">{`时间: ${label}`}</p>
<p className="text-primary">
{`流量: ${formatFlow(payload[0]?.value as number || 0)}`}
</p>
</div>
);
}
return null;
<YAxis
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb', strokeWidth: 1 }}
tickFormatter={(value: number | string) => {
const v = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(v)) return String(value);
if (v === 0) return '0';
if (v < 1024) return `${v}B`;
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)}K`;
if (v < 1024 * 1024 * 1024) return `${(v / (1024 * 1024)).toFixed(1)}M`;
return `${(v / (1024 * 1024 * 1024)).toFixed(1)}G`;
}}
/>
<Tooltip
content={({
active,
payload,
label,
}: {
active?: boolean;
payload?: Array<{ value?: number | string }>;
label?: string | number;
}) => {
if (active && payload && payload.length) {
const firstValue = payload[0]?.value;
const numericValue = typeof firstValue === 'number' ? firstValue : Number(firstValue);
const flowValue = Number.isFinite(numericValue) ? numericValue : 0;
return (
<div className="bg-white dark:bg-default-100 border border-default-200 rounded-lg shadow-lg p-3">
<p className="font-medium text-foreground">{`时间: ${label ?? ''}`}</p>
<p className="text-primary">
{`流量: ${formatFlow(flowValue)}`}
</p>
</div>
);
}
return null;
}}
/>
<Line
@@ -983,4 +997,4 @@ export default function DashboardPage() {
</div>
);
}
}
+42 -26
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
@@ -15,7 +15,8 @@ import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
DragEndEvent,
@@ -353,8 +354,6 @@ export default function ForwardPage() {
const userMap = new Map<string, UserGroup>();
// 获取排序后的转发列表
const sortedForwards = getSortedForwards();
sortedForwards.forEach(forward => {
const userKey = forward.userId ? forward.userId.toString() : 'unknown';
const userName = forward.userName || '未知用户';
@@ -855,7 +854,7 @@ export default function ForwardPage() {
);
} else {
// 直接显示模式下,过滤指定隧道的转发
forwardsToExport = getSortedForwards().filter(forward => forward.tunnelId === selectedTunnelForExport);
forwardsToExport = sortedForwards.filter(forward => forward.tunnelId === selectedTunnelForExport);
}
if (forwardsToExport.length === 0) {
@@ -1102,64 +1101,80 @@ export default function ForwardPage() {
// 传感器配置 - 使用默认配置避免错误
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(MouseSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const tokenUserId = JwtUtil.getUserIdFromToken();
// 根据排序顺序获取转发列表
const getSortedForwards = (): Forward[] => {
const sortedForwards = useMemo((): Forward[] => {
// 确保 forwards 数组存在且有效
if (!forwards || forwards.length === 0) {
return [];
}
// 在平铺模式下,只显示当前用户的转发
let filteredForwards = forwards;
if (viewMode === 'direct') {
const currentUserId = JwtUtil.getUserIdFromToken();
if (currentUserId !== null) {
filteredForwards = forwards.filter(forward => forward.userId === currentUserId);
if (tokenUserId !== null) {
filteredForwards = forwards.filter(forward => forward.userId === tokenUserId);
}
}
// 确保过滤后的转发列表有效
if (!filteredForwards || filteredForwards.length === 0) {
return [];
}
// 优先使用数据库中的 inx 字段进行排序
const sortedForwards = [...filteredForwards].sort((a, b) => {
const sortedByDb = [...filteredForwards].sort((a, b) => {
const aInx = a.inx ?? 0;
const bInx = b.inx ?? 0;
return aInx - bInx;
});
// 如果数据库中没有排序信息,则使用本地存储的顺序
if (forwardOrder && forwardOrder.length > 0 && sortedForwards.every(f => f.inx === undefined || f.inx === 0)) {
if (forwardOrder && forwardOrder.length > 0 && sortedByDb.every(f => f.inx === undefined || f.inx === 0)) {
const forwardMap = new Map(filteredForwards.map(f => [f.id, f]));
const localSortedForwards: Forward[] = [];
forwardOrder.forEach(id => {
const forward = forwardMap.get(id);
if (forward) {
localSortedForwards.push(forward);
}
});
// 添加不在排序列表中的转发(新添加的)
filteredForwards.forEach(forward => {
if (!forwardOrder.includes(forward.id)) {
localSortedForwards.push(forward);
}
});
return localSortedForwards;
}
return sortedForwards;
};
return sortedByDb;
}, [forwards, forwardOrder, viewMode, tokenUserId]);
const sortableForwardIds = useMemo(
() => sortedForwards.map(f => f.id).filter(id => id > 0),
[sortedForwards]
);
// 可拖拽的转发卡片组件
const SortableForwardCard = ({ forward }: { forward: Forward }) => {
@@ -1179,8 +1194,9 @@ export default function ForwardPage() {
const style = {
transform: transform ? CSS.Transform.toString(transform) : undefined,
transition: transition || undefined,
transition: isDragging ? undefined : transition || undefined,
opacity: isDragging ? 0.5 : 1,
willChange: 'transform',
};
return (
@@ -1529,11 +1545,11 @@ export default function ForwardPage() {
onDragStart={() => {}} // 添加空的 onDragStart 处理器
>
<SortableContext
items={getSortedForwards().map(f => f.id || 0).filter(id => id > 0)}
items={sortableForwardIds}
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">
{getSortedForwards().map((forward) => (
{sortedForwards.map((forward) => (
forward && forward.id ? (
<SortableForwardCard key={forward.id} forward={forward} />
) : null
@@ -2394,4 +2410,4 @@ export default function ForwardPage() {
</div>
);
}
}
+182 -81
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useMemo, useRef } from "react";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
@@ -16,7 +16,8 @@ import axios from 'axios';
import {
DndContext,
KeyboardSensor,
PointerSensor,
MouseSensor,
TouchSensor,
type DragEndEvent,
useSensor,
useSensors,
@@ -72,6 +73,7 @@ interface Node {
interface NodeForm {
id: number | null;
name: string;
serverHost: string;
serverIpV4: string;
serverIpV6: string;
port: string;
@@ -83,10 +85,42 @@ interface NodeForm {
socks: number; // 0 关 1 开
}
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: isDragging ? undefined : transition || undefined,
opacity: isDragging ? 0.5 : 1,
willChange: 'transform',
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
{children(listeners)}
</div>
);
};
export default function NodePage() {
const [nodeList, setNodeList] = useState<Node[]>([]);
const [nodeOrder, setNodeOrder] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [wsConnected, setWsConnected] = useState(false);
const [wsConnecting, setWsConnecting] = useState(false);
const [dialogVisible, setDialogVisible] = useState(false);
const [dialogTitle, setDialogTitle] = useState('');
const [isEdit, setIsEdit] = useState(false);
@@ -99,6 +133,7 @@ export default function NodePage() {
const [form, setForm] = useState<NodeForm>({
id: null,
name: '',
serverHost: '',
serverIpV4: '',
serverIpV6: '',
port: '1000-65535',
@@ -120,6 +155,31 @@ export default function NodePage() {
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
const maxReconnectAttempts = 5;
const offlineTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const offlineDelayMs = 3000;
const clearOfflineTimer = (nodeId: number) => {
const timer = offlineTimersRef.current.get(nodeId);
if (timer) {
clearTimeout(timer);
offlineTimersRef.current.delete(nodeId);
}
};
const scheduleNodeOffline = (nodeId: number) => {
if (offlineTimersRef.current.has(nodeId)) return;
const timer = setTimeout(() => {
offlineTimersRef.current.delete(nodeId);
setNodeList((prev) =>
prev.map((node) => {
if (node.id !== nodeId) return node;
if (node.connectionStatus === 'offline' && node.systemInfo === null) return node;
return { ...node, connectionStatus: 'offline', systemInfo: null };
})
);
}, offlineDelayMs);
offlineTimersRef.current.set(nodeId, timer);
};
useEffect(() => {
loadNodes();
@@ -204,12 +264,15 @@ export default function NodePage() {
// 构建WebSocket URL,使用axios的baseURL
const baseUrl = axios.defaults.baseURL || (import.meta.env.VITE_API_BASE ? `${import.meta.env.VITE_API_BASE}/api/v1/` : '/api/v1/');
const wsUrl = baseUrl.replace(/^http/, 'ws').replace(/\/api\/v1\/$/, '') + `/system-info?type=0&secret=${localStorage.getItem('token')}`;
try {
setWsConnecting(true);
websocketRef.current = new WebSocket(wsUrl);
websocketRef.current.onopen = () => {
reconnectAttemptsRef.current = 0;
setWsConnected(true);
setWsConnecting(false);
};
websocketRef.current.onmessage = (event) => {
@@ -227,9 +290,13 @@ export default function NodePage() {
websocketRef.current.onclose = () => {
websocketRef.current = null;
setWsConnected(false);
setWsConnecting(false);
attemptReconnect();
};
} catch (error) {
setWsConnected(false);
setWsConnecting(false);
attemptReconnect();
}
};
@@ -237,21 +304,27 @@ export default function NodePage() {
// 处理WebSocket消息
const handleWebSocketMessage = (data: any) => {
const { id, type, data: messageData } = data;
const nodeId = Number(id);
if (Number.isNaN(nodeId)) return;
if (type === 'status') {
setNodeList(prev => prev.map(node => {
if (node.id == id) {
return {
...node,
connectionStatus: messageData === 1 ? 'online' : 'offline',
systemInfo: messageData === 0 ? null : node.systemInfo
};
}
return node;
}));
if (messageData === 1) {
clearOfflineTimer(nodeId);
setNodeList((prev) =>
prev.map((node) => {
if (node.id !== nodeId) return node;
if (node.connectionStatus === 'online') return node;
return { ...node, connectionStatus: 'online' };
})
);
} else {
// 离线事件做延迟处理,避免短抖动导致频繁闪烁
scheduleNodeOffline(nodeId);
}
} else if (type === 'info') {
clearOfflineTimer(nodeId);
setNodeList(prev => prev.map(node => {
if (node.id == id) {
if (node.id === nodeId) {
try {
let systemInfo;
if (typeof messageData === 'string') {
@@ -314,10 +387,12 @@ export default function NodePage() {
// 尝试重新连接
const attemptReconnect = () => {
if (reconnectTimerRef.current) return;
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
reconnectAttemptsRef.current++;
reconnectTimerRef.current = setTimeout(() => {
reconnectTimerRef.current = null;
initWebSocket();
}, 3000 * reconnectAttemptsRef.current);
}
@@ -329,8 +404,13 @@ export default function NodePage() {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
offlineTimersRef.current.forEach((timer) => clearTimeout(timer));
offlineTimersRef.current.clear();
reconnectAttemptsRef.current = 0;
setWsConnected(false);
setWsConnecting(false);
if (websocketRef.current) {
websocketRef.current.onopen = null;
@@ -345,12 +425,6 @@ export default function NodePage() {
websocketRef.current = null;
}
setNodeList(prev => prev.map(node => ({
...node,
connectionStatus: 'offline',
systemInfo: null
})));
};
@@ -409,6 +483,15 @@ export default function NodePage() {
const validateIpv4Literal = (ip: string): boolean => ipv4Regex.test(ip.trim());
const validateIpv6Literal = (ip: string): boolean => ipv6Regex.test(ip.trim());
// Hostname/domain validation (no scheme/port)
const hostnameRegex = /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*$/;
const validateHostname = (host: string): boolean => {
const v = host.trim();
if (!v) return false;
if (v === 'localhost') return true;
return hostnameRegex.test(v);
};
// 验证端口格式:支持 80,443,100-600
const validatePort = (portStr: string): { valid: boolean; error?: string } => {
if (!portStr || !portStr.trim()) {
@@ -474,10 +557,13 @@ export default function NodePage() {
const v4 = form.serverIpV4.trim();
const v6 = form.serverIpV6.trim();
const host = form.serverHost.trim();
if (!v4 && !v6) {
newErrors.serverIpV4 = '请至少填写一个IPv4或IPv6地址';
newErrors.serverIpV6 = '请至少填写一个IPv4或IPv6地址';
if (!v4 && !v6 && !host) {
const msg = '请至少填写一个 IPv4/IPv6 地址或域名';
newErrors.serverIpV4 = msg;
newErrors.serverIpV6 = msg;
newErrors.serverHost = msg;
} else {
if (v4 && !validateIpv4Literal(v4)) {
newErrors.serverIpV4 = '请输入有效的IPv4地址';
@@ -485,6 +571,9 @@ export default function NodePage() {
if (v6 && !validateIpv6Literal(v6)) {
newErrors.serverIpV6 = '请输入有效的IPv6地址';
}
if (host && !validateHostname(host)) {
newErrors.serverHost = '请输入有效的域名/主机名';
}
}
const portValidation = validatePort(form.port);
@@ -510,11 +599,18 @@ export default function NodePage() {
const handleEdit = (node: Node) => {
setDialogTitle('编辑节点');
setIsEdit(true);
const legacy = (node.serverIp || '').trim();
const normalizedV4 = node.serverIpV4?.trim() || (validateIpv4Literal(legacy) ? legacy : '');
const normalizedV6 = node.serverIpV6?.trim() || (validateIpv6Literal(legacy) ? legacy : '');
const normalizedHost = (!normalizedV4 && !normalizedV6 && legacy) ? legacy : '';
setForm({
id: node.id,
name: node.name,
serverIpV4: node.serverIpV4?.trim() || (validateIpv4Literal((node.serverIp || '').trim()) ? (node.serverIp || '').trim() : ''),
serverIpV6: node.serverIpV6?.trim() || (validateIpv6Literal((node.serverIp || '').trim()) ? (node.serverIp || '').trim() : ''),
serverHost: normalizedHost,
serverIpV4: normalizedV4,
serverIpV6: normalizedV6,
port: node.port || '1000-65535',
tcpListenAddr: node.tcpListenAddr || '[::]',
udpListenAddr: node.udpListenAddr || '[::]',
@@ -605,9 +701,10 @@ export default function NodePage() {
try {
const apiCall = isEdit ? updateNode : createNode;
const data = {
...form,
serverIp: form.serverIpV4?.trim() || form.serverIpV6?.trim() || ''
const { serverHost: _serverHost, ...rest } = form;
const data = {
...rest,
serverIp: form.serverIpV4?.trim() || form.serverIpV6?.trim() || form.serverHost?.trim() || ''
};
const res = await apiCall(data);
@@ -616,22 +713,22 @@ export default function NodePage() {
setDialogVisible(false);
if (isEdit) {
setNodeList(prev => prev.map(n =>
n.id === form.id ? {
...n,
name: form.name,
serverIp: form.serverIpV4?.trim() || form.serverIpV6?.trim() || '',
serverIpV4: form.serverIpV4,
serverIpV6: form.serverIpV6,
port: form.port,
tcpListenAddr: form.tcpListenAddr,
udpListenAddr: form.udpListenAddr,
interfaceName: form.interfaceName,
http: form.http,
tls: form.tls,
socks: form.socks
} : n
));
setNodeList(prev => prev.map(n =>
n.id === form.id ? {
...n,
name: form.name,
serverIp: form.serverIpV4?.trim() || form.serverIpV6?.trim() || form.serverHost?.trim() || '',
serverIpV4: form.serverIpV4,
serverIpV6: form.serverIpV6,
port: form.port,
tcpListenAddr: form.tcpListenAddr,
udpListenAddr: form.udpListenAddr,
interfaceName: form.interfaceName,
http: form.http,
tls: form.tls,
socks: form.socks
} : n
));
} else {
loadNodes();
}
@@ -650,6 +747,7 @@ export default function NodePage() {
setForm({
id: null,
name: '',
serverHost: '',
serverIpV4: '',
serverIpV6: '',
port: '1000-65535',
@@ -710,24 +808,34 @@ export default function NodePage() {
// 传感器配置
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(MouseSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// 根据排序顺序获取节点列表
const getSortedNodes = (): Node[] => {
const sortedNodes = useMemo((): Node[] => {
if (!nodeList || nodeList.length === 0) return [];
const sortedNodes = [...nodeList].sort((a, b) => {
const sortedByDb = [...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)) {
if (nodeOrder && nodeOrder.length > 0 && sortedByDb.every((n) => n.inx === undefined || n.inx === 0)) {
const nodeMap = new Map(nodeList.map((n) => [n.id, n] as const));
const localSorted: Node[] = [];
@@ -745,37 +853,10 @@ export default function NodePage() {
return localSorted;
}
return sortedNodes;
};
return sortedByDb;
}, [nodeList, nodeOrder]);
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>
);
};
const sortableNodeIds = useMemo(() => sortedNodes.map((n) => n.id), [sortedNodes]);
return (
@@ -797,6 +878,15 @@ export default function NodePage() {
</div>
{!wsConnected && (
<Alert
color="warning"
variant="flat"
description={wsConnecting ? '监控连接中...' : '监控连接已断开,正在重连...'}
className="mb-4"
/>
)}
{/* 节点列表 */}
{loading ? (
<div className="flex items-center justify-center h-64">
@@ -824,11 +914,11 @@ export default function NodePage() {
) : (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext
items={getSortedNodes().map((n) => n.id)}
items={sortableNodeIds}
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">
{getSortedNodes().map((node) => (
{sortedNodes.map((node) => (
<SortableItem key={node.id} id={node.id}>
{(listeners) => (
<Card
@@ -1059,6 +1149,17 @@ export default function NodePage() {
variant="bordered"
/>
<Input
label="服务器域名/主机名"
placeholder="例如: node.example.com"
value={form.serverHost}
onChange={(e) => setForm(prev => ({ ...prev, serverHost: e.target.value }))}
isInvalid={!!errors.serverHost}
errorMessage={errors.serverHost}
variant="bordered"
description="可选:不带协议、不带端口。至少填写一个 IPv4/IPv6/域名"
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="服务器IPv4"
@@ -1079,7 +1180,7 @@ export default function NodePage() {
isInvalid={!!errors.serverIpV6}
errorMessage={errors.serverIpV6}
variant="bordered"
description="至少填写一个 IPv4/IPv6 地址"
description="至少填写一个 IPv4/IPv6/域名"
/>
</div>
+25 -11
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { Button } from "@heroui/button";
import { Input, Textarea } from "@heroui/input";
@@ -13,7 +13,8 @@ import toast from 'react-hot-toast';
import {
DndContext,
KeyboardSensor,
PointerSensor,
MouseSensor,
TouchSensor,
type DragEndEvent,
useSensor,
useSensors,
@@ -586,24 +587,34 @@ export default function TunnelPage() {
// 传感器配置
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(MouseSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// 根据排序顺序获取隧道列表
const getSortedTunnels = (): Tunnel[] => {
const sortedTunnels = useMemo((): Tunnel[] => {
if (!tunnels || tunnels.length === 0) return [];
const sortedTunnels = [...tunnels].sort((a, b) => {
const sortedByDb = [...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)) {
if (tunnelOrder && tunnelOrder.length > 0 && sortedByDb.every((t) => t.inx === undefined || t.inx === 0)) {
const tunnelMap = new Map(tunnels.map((t) => [t.id, t] as const));
const localSorted: Tunnel[] = [];
@@ -621,8 +632,10 @@ export default function TunnelPage() {
return localSorted;
}
return sortedTunnels;
};
return sortedByDb;
}, [tunnels, tunnelOrder]);
const sortableTunnelIds = useMemo(() => sortedTunnels.map((t) => t.id), [sortedTunnels]);
const SortableItem = ({
id,
@@ -642,8 +655,9 @@ export default function TunnelPage() {
const style = {
transform: transform ? CSS.Transform.toString(transform) : undefined,
transition: transition || undefined,
transition: isDragging ? undefined : transition || undefined,
opacity: isDragging ? 0.5 : 1,
willChange: 'transform',
};
return (
@@ -690,11 +704,11 @@ export default function TunnelPage() {
{tunnels.length > 0 ? (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext
items={getSortedTunnels().map((t) => t.id)}
items={sortableTunnelIds}
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) => {
{sortedTunnels.map((tunnel) => {
const typeDisplay = getTypeDisplay(tunnel.type);
return (
+11
View File
@@ -0,0 +1,11 @@
declare module 'recharts' {
import * as React from 'react';
export const LineChart: React.ComponentType<Record<string, unknown>>;
export const Line: React.ComponentType<Record<string, unknown>>;
export const XAxis: React.ComponentType<Record<string, unknown>>;
export const YAxis: React.ComponentType<Record<string, unknown>>;
export const CartesianGrid: React.ComponentType<Record<string, unknown>>;
export const Tooltip: React.ComponentType<Record<string, unknown>>;
export const ResponsiveContainer: React.ComponentType<Record<string, unknown>>;
}
+9
View File
@@ -1 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_VERSION?: string;
readonly VITE_GITHUB_REPO?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}