Merge pull request #10 from Sagit-chu/opencode/silent-falcon

优化拖拽排序顺滑度
This commit is contained in:
sagit
2026-02-02 12:55:09 +08:00
committed by GitHub
6 changed files with 156 additions and 74 deletions
@@ -117,6 +117,19 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
long currentTime = System.currentTimeMillis(); long currentTime = System.currentTimeMillis();
tunnel.setCreatedTime(currentTime); tunnel.setCreatedTime(currentTime);
tunnel.setUpdatedTime(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())){ if (StringUtils.isEmpty(tunnel.getInIp())){
java.util.LinkedHashSet<String> inIps = new java.util.LinkedHashSet<>(); java.util.LinkedHashSet<String> inIps = new java.util.LinkedHashSet<>();
for (ChainTunnel chainTunnel : tunnelDto.getInNodeId()) { for (ChainTunnel chainTunnel : tunnelDto.getInNodeId()) {
+39 -25
View File
@@ -741,31 +741,45 @@ export default function DashboardPage() {
tickLine={false} tickLine={false}
axisLine={{ stroke: '#e5e7eb', strokeWidth: 1 }} axisLine={{ stroke: '#e5e7eb', strokeWidth: 1 }}
/> />
<YAxis <YAxis
tick={{ fontSize: 12 }} tick={{ fontSize: 12 }}
tickLine={false} tickLine={false}
axisLine={{ stroke: '#e5e7eb', strokeWidth: 1 }} axisLine={{ stroke: '#e5e7eb', strokeWidth: 1 }}
tickFormatter={(value) => { tickFormatter={(value: number | string) => {
if (value === 0) return '0'; const v = typeof value === 'number' ? value : Number(value);
if (value < 1024) return `${value}B`; if (!Number.isFinite(v)) return String(value);
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}K`; if (v === 0) return '0';
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)}M`; if (v < 1024) return `${v}B`;
return `${(value / (1024 * 1024 * 1024)).toFixed(1)}G`; 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 }) => { />
if (active && payload && payload.length) { <Tooltip
return ( content={({
<div className="bg-white dark:bg-default-100 border border-default-200 rounded-lg shadow-lg p-3"> active,
<p className="font-medium text-foreground">{`时间: ${label}`}</p> payload,
<p className="text-primary"> label,
{`流量: ${formatFlow(payload[0]?.value as number || 0)}`} }: {
</p> active?: boolean;
</div> payload?: Array<{ value?: number | string }>;
); label?: string | number;
} }) => {
return null; 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 <Line
+33 -17
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 { Card, CardBody, CardHeader } from "@heroui/card";
import { Button } from "@heroui/button"; import { Button } from "@heroui/button";
import { Input } from "@heroui/input"; import { Input } from "@heroui/input";
@@ -15,7 +15,8 @@ import {
DndContext, DndContext,
closestCenter, closestCenter,
KeyboardSensor, KeyboardSensor,
PointerSensor, MouseSensor,
TouchSensor,
useSensor, useSensor,
useSensors, useSensors,
DragEndEvent, DragEndEvent,
@@ -353,8 +354,6 @@ export default function ForwardPage() {
const userMap = new Map<string, UserGroup>(); const userMap = new Map<string, UserGroup>();
// 获取排序后的转发列表 // 获取排序后的转发列表
const sortedForwards = getSortedForwards();
sortedForwards.forEach(forward => { sortedForwards.forEach(forward => {
const userKey = forward.userId ? forward.userId.toString() : 'unknown'; const userKey = forward.userId ? forward.userId.toString() : 'unknown';
const userName = forward.userName || '未知用户'; const userName = forward.userName || '未知用户';
@@ -855,7 +854,7 @@ export default function ForwardPage() {
); );
} else { } else {
// 直接显示模式下,过滤指定隧道的转发 // 直接显示模式下,过滤指定隧道的转发
forwardsToExport = getSortedForwards().filter(forward => forward.tunnelId === selectedTunnelForExport); forwardsToExport = sortedForwards.filter(forward => forward.tunnelId === selectedTunnelForExport);
} }
if (forwardsToExport.length === 0) { if (forwardsToExport.length === 0) {
@@ -1102,14 +1101,26 @@ export default function ForwardPage() {
// 传感器配置 - 使用默认配置避免错误 // 传感器配置 - 使用默认配置避免错误
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor), useSensor(MouseSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 8,
},
}),
useSensor(KeyboardSensor, { useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates, coordinateGetter: sortableKeyboardCoordinates,
}) })
); );
const tokenUserId = JwtUtil.getUserIdFromToken();
// 根据排序顺序获取转发列表 // 根据排序顺序获取转发列表
const getSortedForwards = (): Forward[] => { const sortedForwards = useMemo((): Forward[] => {
// 确保 forwards 数组存在且有效 // 确保 forwards 数组存在且有效
if (!forwards || forwards.length === 0) { if (!forwards || forwards.length === 0) {
return []; return [];
@@ -1118,9 +1129,8 @@ export default function ForwardPage() {
// 在平铺模式下,只显示当前用户的转发 // 在平铺模式下,只显示当前用户的转发
let filteredForwards = forwards; let filteredForwards = forwards;
if (viewMode === 'direct') { if (viewMode === 'direct') {
const currentUserId = JwtUtil.getUserIdFromToken(); if (tokenUserId !== null) {
if (currentUserId !== null) { filteredForwards = forwards.filter(forward => forward.userId === tokenUserId);
filteredForwards = forwards.filter(forward => forward.userId === currentUserId);
} }
} }
@@ -1130,14 +1140,14 @@ export default function ForwardPage() {
} }
// 优先使用数据库中的 inx 字段进行排序 // 优先使用数据库中的 inx 字段进行排序
const sortedForwards = [...filteredForwards].sort((a, b) => { const sortedByDb = [...filteredForwards].sort((a, b) => {
const aInx = a.inx ?? 0; const aInx = a.inx ?? 0;
const bInx = b.inx ?? 0; const bInx = b.inx ?? 0;
return aInx - bInx; 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 forwardMap = new Map(filteredForwards.map(f => [f.id, f]));
const localSortedForwards: Forward[] = []; const localSortedForwards: Forward[] = [];
@@ -1158,8 +1168,13 @@ export default function ForwardPage() {
return localSortedForwards; 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 }) => { const SortableForwardCard = ({ forward }: { forward: Forward }) => {
@@ -1179,8 +1194,9 @@ export default function ForwardPage() {
const style = { const style = {
transform: transform ? CSS.Transform.toString(transform) : undefined, transform: transform ? CSS.Transform.toString(transform) : undefined,
transition: transition || undefined, transition: isDragging ? undefined : transition || undefined,
opacity: isDragging ? 0.5 : 1, opacity: isDragging ? 0.5 : 1,
willChange: 'transform',
}; };
return ( return (
@@ -1529,11 +1545,11 @@ export default function ForwardPage() {
onDragStart={() => {}} // 添加空的 onDragStart 处理器 onDragStart={() => {}} // 添加空的 onDragStart 处理器
> >
<SortableContext <SortableContext
items={getSortedForwards().map(f => f.id || 0).filter(id => id > 0)} items={sortableForwardIds}
strategy={rectSortingStrategy} 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"> <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 ? ( forward && forward.id ? (
<SortableForwardCard key={forward.id} forward={forward} /> <SortableForwardCard key={forward.id} forward={forward} />
) : null ) : null
+25 -11
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 { Card, CardBody, CardHeader } from "@heroui/card";
import { Button } from "@heroui/button"; import { Button } from "@heroui/button";
import { Input } from "@heroui/input"; import { Input } from "@heroui/input";
@@ -16,7 +16,8 @@ import axios from 'axios';
import { import {
DndContext, DndContext,
KeyboardSensor, KeyboardSensor,
PointerSensor, MouseSensor,
TouchSensor,
type DragEndEvent, type DragEndEvent,
useSensor, useSensor,
useSensors, useSensors,
@@ -102,8 +103,9 @@ const SortableItem = ({
const style = { const style = {
transform: transform ? CSS.Transform.toString(transform) : undefined, transform: transform ? CSS.Transform.toString(transform) : undefined,
transition: transition || undefined, transition: isDragging ? undefined : transition || undefined,
opacity: isDragging ? 0.5 : 1, opacity: isDragging ? 0.5 : 1,
willChange: 'transform',
}; };
return ( return (
@@ -806,24 +808,34 @@ export default function NodePage() {
// 传感器配置 // 传感器配置
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor), useSensor(MouseSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 8,
},
}),
useSensor(KeyboardSensor, { useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates, coordinateGetter: sortableKeyboardCoordinates,
}) })
); );
// 根据排序顺序获取节点列表 // 根据排序顺序获取节点列表
const getSortedNodes = (): Node[] => { const sortedNodes = useMemo((): Node[] => {
if (!nodeList || nodeList.length === 0) return []; if (!nodeList || nodeList.length === 0) return [];
const sortedNodes = [...nodeList].sort((a, b) => { const sortedByDb = [...nodeList].sort((a, b) => {
const aInx = a.inx ?? 0; const aInx = a.inx ?? 0;
const bInx = b.inx ?? 0; const bInx = b.inx ?? 0;
return aInx - bInx; 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 nodeMap = new Map(nodeList.map((n) => [n.id, n] as const));
const localSorted: Node[] = []; const localSorted: Node[] = [];
@@ -841,8 +853,10 @@ export default function NodePage() {
return localSorted; return localSorted;
} }
return sortedNodes; return sortedByDb;
}; }, [nodeList, nodeOrder]);
const sortableNodeIds = useMemo(() => sortedNodes.map((n) => n.id), [sortedNodes]);
return ( return (
@@ -900,11 +914,11 @@ export default function NodePage() {
) : ( ) : (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext <SortableContext
items={getSortedNodes().map((n) => n.id)} items={sortableNodeIds}
strategy={rectSortingStrategy} 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"> <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}> <SortableItem key={node.id} id={node.id}>
{(listeners) => ( {(listeners) => (
<Card <Card
+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 { Card, CardBody, CardHeader } from "@heroui/card";
import { Button } from "@heroui/button"; import { Button } from "@heroui/button";
import { Input, Textarea } from "@heroui/input"; import { Input, Textarea } from "@heroui/input";
@@ -13,7 +13,8 @@ import toast from 'react-hot-toast';
import { import {
DndContext, DndContext,
KeyboardSensor, KeyboardSensor,
PointerSensor, MouseSensor,
TouchSensor,
type DragEndEvent, type DragEndEvent,
useSensor, useSensor,
useSensors, useSensors,
@@ -586,24 +587,34 @@ export default function TunnelPage() {
// 传感器配置 // 传感器配置
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor), useSensor(MouseSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 8,
},
}),
useSensor(KeyboardSensor, { useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates, coordinateGetter: sortableKeyboardCoordinates,
}) })
); );
// 根据排序顺序获取隧道列表 // 根据排序顺序获取隧道列表
const getSortedTunnels = (): Tunnel[] => { const sortedTunnels = useMemo((): Tunnel[] => {
if (!tunnels || tunnels.length === 0) return []; if (!tunnels || tunnels.length === 0) return [];
const sortedTunnels = [...tunnels].sort((a, b) => { const sortedByDb = [...tunnels].sort((a, b) => {
const aInx = a.inx ?? 0; const aInx = a.inx ?? 0;
const bInx = b.inx ?? 0; const bInx = b.inx ?? 0;
return aInx - bInx; 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 tunnelMap = new Map(tunnels.map((t) => [t.id, t] as const));
const localSorted: Tunnel[] = []; const localSorted: Tunnel[] = [];
@@ -621,8 +632,10 @@ export default function TunnelPage() {
return localSorted; return localSorted;
} }
return sortedTunnels; return sortedByDb;
}; }, [tunnels, tunnelOrder]);
const sortableTunnelIds = useMemo(() => sortedTunnels.map((t) => t.id), [sortedTunnels]);
const SortableItem = ({ const SortableItem = ({
id, id,
@@ -642,8 +655,9 @@ export default function TunnelPage() {
const style = { const style = {
transform: transform ? CSS.Transform.toString(transform) : undefined, transform: transform ? CSS.Transform.toString(transform) : undefined,
transition: transition || undefined, transition: isDragging ? undefined : transition || undefined,
opacity: isDragging ? 0.5 : 1, opacity: isDragging ? 0.5 : 1,
willChange: 'transform',
}; };
return ( return (
@@ -690,11 +704,11 @@ export default function TunnelPage() {
{tunnels.length > 0 ? ( {tunnels.length > 0 ? (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext <SortableContext
items={getSortedTunnels().map((t) => t.id)} items={sortableTunnelIds}
strategy={rectSortingStrategy} 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"> <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); const typeDisplay = getTypeDisplay(tunnel.type);
return ( 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>>;
}