增加拖拽排序

This commit is contained in:
qaq
2025-08-05 22:16:21 +08:00
parent a06904ee3a
commit 62f9da97f4
16 changed files with 462 additions and 56 deletions
BIN
View File
Binary file not shown.
+1
View File
@@ -42,6 +42,7 @@ CREATE TABLE `forward` (
`created_time` bigint(20) NOT NULL,
`updated_time` bigint(20) NOT NULL,
`status` int(10) NOT NULL
`inx` int(10) NOT NULL DEFAULT 0,
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- --------------------------------------------------------
+6
View File
@@ -0,0 +1,6 @@
{
"name": "forward-panel",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+1
View File
@@ -0,0 +1 @@
{}
+23
View File
@@ -756,6 +756,29 @@ UPDATE \`forward\`
SET \`strategy\` = 'fifo'
WHERE \`strategy\` IS NULL;
-- forward 表:添加 inx 字段(排序索引)
SET @sql = (
SELECT IF(
NOT EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'forward'
AND column_name = 'inx'
),
'ALTER TABLE \`forward\` ADD COLUMN \`inx\` INT(10) DEFAULT 0 COMMENT "排序索引";',
'SELECT "Column \`inx\` already exists in \`forward\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 为现有数据设置默认排序索引
UPDATE \`forward\`
SET \`inx\` = 0
WHERE \`inx\` IS NULL;
-- 创建 vite_config 表(如果不存在)
CREATE TABLE IF NOT EXISTS \`vite_config\` (
\`id\` int(10) NOT NULL AUTO_INCREMENT,
@@ -85,4 +85,6 @@ public class ForwardWithTunnelDto {
private Long outFlow;
private String strategy;
private Integer inx;
}
@@ -87,4 +87,15 @@ public class ForwardController extends BaseController {
return forwardService.diagnoseForward(forwardId);
}
/**
* 更新转发排序
* @param params 包含forwards数组的参数,每个元素包含id和inx
* @return 更新结果
*/
@LogAnnotation
@PostMapping("/update-order")
public R updateForwardOrder(@RequestBody Map<String, Object> params) {
return forwardService.updateForwardOrder(params);
}
}
@@ -38,4 +38,6 @@ public class Forward extends BaseEntity{
private Long outFlow;
private Integer inx;
}
@@ -5,6 +5,7 @@ import com.admin.common.dto.ForwardUpdateDto;
import com.admin.common.lang.R;
import com.admin.entity.Forward;
import com.baomidou.mybatisplus.extension.service.IService;
import java.util.Map;
/**
* <p>
@@ -71,4 +72,11 @@ public interface ForwardService extends IService<Forward> {
* @return 诊断结果
*/
R diagnoseForward(Long id);
/**
* 更新转发排序
* @param params 包含forwards数组的参数
* @return 更新结果
*/
R updateForwardOrder(Map<String, Object> params);
}
@@ -452,6 +452,68 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
return R.ok(diagnosisReport);
}
@Override
public R updateForwardOrder(Map<String, Object> params) {
try {
// 1. 获取当前用户信息
UserInfo currentUser = getCurrentUserInfo();
// 2. 验证参数
if (!params.containsKey("forwards")) {
return R.err("缺少forwards参数");
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> forwardsList = (List<Map<String, Object>>) params.get("forwards");
if (forwardsList == null || forwardsList.isEmpty()) {
return R.err("forwards参数不能为空");
}
// 3. 验证用户权限(只能更新自己的转发)
if (currentUser.getRoleId() != ADMIN_ROLE_ID) {
// 普通用户只能更新自己的转发
List<Long> forwardIds = forwardsList.stream()
.map(item -> Long.valueOf(item.get("id").toString()))
.collect(Collectors.toList());
// 检查所有转发是否属于当前用户
QueryWrapper<Forward> queryWrapper = new QueryWrapper<>();
queryWrapper.in("id", forwardIds);
queryWrapper.eq("user_id", currentUser.getUserId());
long count = this.count(queryWrapper);
if (count != forwardIds.size()) {
return R.err("只能更新自己的转发排序");
}
}
// 4. 批量更新排序
List<Forward> forwardsToUpdate = new ArrayList<>();
for (Map<String, Object> forwardData : forwardsList) {
Long id = Long.valueOf(forwardData.get("id").toString());
Integer inx = Integer.valueOf(forwardData.get("inx").toString());
Forward forward = new Forward();
forward.setId(id);
forward.setInx(inx);
forwardsToUpdate.add(forward);
}
// 5. 执行批量更新
boolean success = this.updateBatchById(forwardsToUpdate);
if (success) {
log.info("用户 {} 更新了 {} 个转发的排序", currentUser.getUserName(), forwardsToUpdate.size());
return R.ok("排序更新成功");
} else {
return R.err("排序更新失败");
}
} catch (Exception e) {
log.error("更新转发排序失败", e);
return R.err("更新排序时发生错误: " + e.getMessage());
}
}
/**
* 从地址字符串中提取IP地址
* 支持格式: ip:port, [ipv6]:port, domain:port
@@ -19,6 +19,7 @@
f.in_flow AS inFlow,
f.strategy AS strategy,
f.out_flow AS outFlow,
f.inx AS inx,
t.name AS tunnelName,
t.in_ip AS inIp,
t.out_ip AS outIp,
@@ -49,6 +50,7 @@
f.in_flow AS inFlow,
f.out_flow AS outFlow,
f.strategy AS strategy,
f.inx AS inx,
t.name AS tunnelName,
t.in_ip AS inIp,
t.out_ip AS outIp,
+5
View File
@@ -10,6 +10,9 @@
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@heroui/accordion": "^2.2.21",
"@heroui/alert": "^2.2.24",
"@heroui/autocomplete": "^2.3.25",
@@ -44,10 +47,12 @@
"@react-types/shared": "3.30.0",
"@tailwindcss/postcss": "4.1.11",
"@tailwindcss/vite": "4.1.11",
"@types/react-beautiful-dnd": "^13.1.8",
"axios": "^1.11.0",
"clsx": "2.1.1",
"framer-motion": "11.18.2",
"react": "18.3.1",
"react-beautiful-dnd": "^13.1.1",
"react-dom": "18.3.1",
"react-hot-toast": "^2.5.2",
"react-router-dom": "6.23.0",
+3
View File
@@ -63,6 +63,9 @@ export const resumeForwardService = (forwardId: number) => Network.post("/forwar
// 转发诊断操作
export const diagnoseForward = (forwardId: number) => Network.post("/forward/diagnose", { forwardId });
// 转发排序操作
export const updateForwardOrder = (data: { forwards: Array<{ id: number; inx: number }> }) => Network.post("/forward/update-order", data);
// 限速规则CRUD操作 - 全部使用POST请求
export const createSpeedLimit = (data: any) => Network.post("/speed-limit/create", data);
export const getSpeedLimitList = () => Network.post("/speed-limit/list");
+1 -1
View File
@@ -4,7 +4,7 @@ export type SiteConfig = typeof siteConfig;
// 缓存相关常量
const CACHE_PREFIX = 'vite_config_';
const VERSION = "1.2.2";
const VERSION = "1.2.3";
// 缓存策略:
// - 长期有效,不设置过期时间
+283 -10
View File
@@ -11,6 +11,25 @@ import { Switch } from "@heroui/switch";
import { Alert } from "@heroui/alert";
import { Accordion, AccordionItem } from "@heroui/accordion";
import toast from 'react-hot-toast';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
rectSortingStrategy,
} from '@dnd-kit/sortable';
import {
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import AdminLayout from "@/layouts/admin";
import {
@@ -22,8 +41,10 @@ import {
userTunnel,
pauseForwardService,
resumeForwardService,
diagnoseForward
diagnoseForward,
updateForwardOrder
} from "@/api";
import { JwtUtil } from "@/utils/jwt";
interface Forward {
id: number;
@@ -41,6 +62,7 @@ interface Forward {
createdTime: string;
userName?: string;
userId?: number;
inx?: number;
}
interface Tunnel {
@@ -110,6 +132,9 @@ export default function ForwardPage() {
}
});
// 拖拽排序相关状态
const [forwardOrder, setForwardOrder] = useState<number[]>([]);
// 模态框状态
const [modalOpen, setModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
@@ -166,6 +191,55 @@ export default function ForwardPage() {
setViewMode(newMode);
try {
localStorage.setItem('forward-view-mode', newMode);
// 切换到直接显示模式时,初始化拖拽排序顺序
if (newMode === 'direct') {
// 在平铺模式下,只对当前用户的转发进行排序
const currentUserId = JwtUtil.getUserIdFromToken();
let userForwards = forwards;
if (currentUserId !== null) {
userForwards = forwards.filter((f: Forward) => f.userId === currentUserId);
}
// 检查数据库中是否有排序信息
const hasDbOrdering = userForwards.some((f: Forward) => f.inx !== undefined && f.inx !== 0);
if (hasDbOrdering) {
// 使用数据库中的排序信息
const dbOrder = userForwards
.sort((a: Forward, b: Forward) => (a.inx ?? 0) - (b.inx ?? 0))
.map((f: Forward) => f.id);
setForwardOrder(dbOrder);
// 同步到localStorage
try {
localStorage.setItem('forward-order', JSON.stringify(dbOrder));
} catch (error) {
console.warn('无法保存排序到localStorage:', error);
}
} else {
// 使用本地存储的顺序
const savedOrder = localStorage.getItem('forward-order');
if (savedOrder) {
try {
const orderIds = JSON.parse(savedOrder);
const validOrder = orderIds.filter((id: number) =>
userForwards.some((f: Forward) => f.id === id)
);
userForwards.forEach((forward: Forward) => {
if (!validOrder.includes(forward.id)) {
validOrder.push(forward.id);
}
});
setForwardOrder(validOrder);
} catch {
setForwardOrder(userForwards.map((f: Forward) => f.id));
}
} else {
setForwardOrder(userForwards.map((f: Forward) => f.id));
}
}
}
} catch (error) {
console.warn('无法保存显示模式到localStorage:', error);
}
@@ -181,10 +255,62 @@ export default function ForwardPage() {
]);
if (forwardsRes.code === 0) {
setForwards(forwardsRes.data?.map((forward: any) => ({
const forwardsData = forwardsRes.data?.map((forward: any) => ({
...forward,
serviceRunning: forward.status === 1
})) || []);
})) || [];
setForwards(forwardsData);
// 初始化拖拽排序顺序
if (viewMode === 'direct') {
// 在平铺模式下,只对当前用户的转发进行排序
const currentUserId = JwtUtil.getUserIdFromToken();
let userForwards = forwardsData;
if (currentUserId !== null) {
userForwards = forwardsData.filter((f: Forward) => f.userId === currentUserId);
}
// 检查数据库中是否有排序信息
const hasDbOrdering = userForwards.some((f: Forward) => f.inx !== undefined && f.inx !== 0);
if (hasDbOrdering) {
// 使用数据库中的排序信息
const dbOrder = userForwards
.sort((a: Forward, b: Forward) => (a.inx ?? 0) - (b.inx ?? 0))
.map((f: Forward) => f.id);
setForwardOrder(dbOrder);
// 同步到localStorage
try {
localStorage.setItem('forward-order', JSON.stringify(dbOrder));
} catch (error) {
console.warn('无法保存排序到localStorage:', error);
}
} else {
// 使用本地存储的顺序
const savedOrder = localStorage.getItem('forward-order');
if (savedOrder) {
try {
const orderIds = JSON.parse(savedOrder);
// 验证保存的顺序是否仍然有效(只包含当前用户的转发)
const validOrder = orderIds.filter((id: number) =>
userForwards.some((f: Forward) => f.id === id)
);
// 添加新的转发ID(如果存在)
userForwards.forEach((forward: Forward) => {
if (!validOrder.includes(forward.id)) {
validOrder.push(forward.id);
}
});
setForwardOrder(validOrder);
} catch {
setForwardOrder(userForwards.map((f: Forward) => f.id));
}
} else {
setForwardOrder(userForwards.map((f: Forward) => f.id));
}
}
}
} else {
toast.error(forwardsRes.msg || '获取转发列表失败');
}
@@ -206,7 +332,10 @@ export default function ForwardPage() {
const groupForwardsByUserAndTunnel = (): UserGroup[] => {
const userMap = new Map<string, UserGroup>();
forwards.forEach(forward => {
// 获取排序后的转发列表
const sortedForwards = getSortedForwards();
sortedForwards.forEach(forward => {
const userKey = forward.userId ? forward.userId.toString() : 'unknown';
const userName = forward.userName || '未知用户';
@@ -687,7 +816,7 @@ export default function ForwardPage() {
);
} else {
// 直接显示模式下,过滤指定隧道的转发
forwardsToExport = forwards.filter(forward => forward.tunnelId === selectedTunnelForExport);
forwardsToExport = getSortedForwards().filter(forward => forward.tunnelId === selectedTunnelForExport);
}
if (forwardsToExport.length === 0) {
@@ -876,13 +1005,135 @@ export default function ForwardPage() {
return addresses.length;
};
// 处理拖拽结束
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = forwardOrder.indexOf(Number(active.id));
const newIndex = forwardOrder.indexOf(Number(over.id));
if (oldIndex !== -1 && newIndex !== -1) {
const newOrder = arrayMove(forwardOrder, oldIndex, newIndex);
setForwardOrder(newOrder);
// 保存到localStorage
try {
localStorage.setItem('forward-order', JSON.stringify(newOrder));
} catch (error) {
console.warn('无法保存排序到localStorage:', error);
}
// 持久化到数据库
try {
const forwardsToUpdate = newOrder.map((id, index) => ({
id,
inx: index
}));
const response = await updateForwardOrder({ forwards: forwardsToUpdate });
if (response.code === 0) {
// 更新本地数据中的 inx 字段
setForwards(prev => prev.map(forward => {
const updatedForward = forwardsToUpdate.find(f => f.id === forward.id);
if (updatedForward) {
return { ...forward, inx: updatedForward.inx };
}
return forward;
}));
} else {
toast.error('保存排序失败:' + (response.msg || '未知错误'));
}
} catch (error) {
console.error('保存排序到数据库失败:', error);
toast.error('保存排序失败,请重试');
}
}
};
// 传感器配置
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// 根据排序顺序获取转发列表
const getSortedForwards = (): Forward[] => {
// 在平铺模式下,只显示当前用户的转发
let filteredForwards = forwards;
if (viewMode === 'direct') {
const currentUserId = JwtUtil.getUserIdFromToken();
if (currentUserId !== null) {
filteredForwards = forwards.filter(forward => forward.userId === currentUserId);
}
}
// 优先使用数据库中的 inx 字段进行排序
const sortedForwards = [...filteredForwards].sort((a, b) => {
const aInx = a.inx ?? 0;
const bInx = b.inx ?? 0;
return aInx - bInx;
});
// 如果数据库中没有排序信息,则使用本地存储的顺序
if (forwardOrder.length > 0 && sortedForwards.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;
};
// 可拖拽的转发卡片组件
const SortableForwardCard = ({ forward }: { forward: Forward }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: forward.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
{renderForwardCard(forward)}
</div>
);
};
// 渲染转发卡片
const renderForwardCard = (forward: Forward) => {
const statusDisplay = getStatusDisplay(forward.status);
const strategyDisplay = getStrategyDisplay(forward.strategy);
return (
<Card key={forward.id} className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200">
<Card key={forward.id} className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200">
<CardHeader className="pb-2">
<div className="flex justify-between items-start w-full">
<div className="flex-1 min-w-0">
@@ -890,6 +1141,13 @@ export default function ForwardPage() {
<p className="text-xs text-default-500 truncate">{forward.tunnelName}</p>
</div>
<div className="flex items-center gap-1.5 ml-2">
{viewMode === 'direct' && (
<div className="cursor-grab active:cursor-grabbing p-1 text-default-400 hover:text-default-600 transition-colors opacity-0 group-hover:opacity-100">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
</svg>
</div>
)}
<Switch
size="sm"
isSelected={forward.serviceRunning}
@@ -1044,7 +1302,9 @@ export default function ForwardPage() {
<div className="px-3 lg:px-6 py-8">
{/* 页面头部 */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-foreground">转发管理</h1>
<div className="flex-1">
<h1 className="text-2xl font-bold text-foreground">转发管理</h1>
</div>
<div className="flex items-center gap-3">
{/* 显示模式切换按钮 */}
<Button
@@ -1190,9 +1450,22 @@ export default function ForwardPage() {
) : (
/* 直接显示模式 */
forwards.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{forwards.map((forward) => renderForwardCard(forward))}
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={getSortedForwards().map(f => f.id)}
strategy={rectSortingStrategy}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{getSortedForwards().map((forward) => (
<SortableForwardCard key={forward.id} forward={forward} />
))}
</div>
</SortableContext>
</DndContext>
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
+52 -45
View File
@@ -688,51 +688,58 @@ export default function UserPage() {
</div>
</div>
<div className="flex gap-1.5 mt-3">
<Button
size="sm"
variant="flat"
color="primary"
onPress={() => handleEdit(user)}
className="flex-1 min-h-8"
startContent={<EditIcon className="w-3 h-3" />}
>
编辑
</Button>
<Button
size="sm"
variant="flat"
color="warning"
onPress={() => handleResetFlow(user)}
className="flex-1 min-h-8"
startContent={
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
</svg>
}
>
重置
</Button>
<Button
size="sm"
variant="flat"
color="success"
onPress={() => handleManageTunnels(user)}
className="flex-1 min-h-8"
startContent={<SettingsIcon className="w-3 h-3" />}
>
权限
</Button>
<Button
size="sm"
variant="flat"
color="danger"
onPress={() => handleDelete(user)}
className="flex-1 min-h-8"
startContent={<DeleteIcon className="w-3 h-3" />}
>
删除
</Button>
<div className="space-y-1.5 mt-3">
{/* 第一行:编辑和重置 */}
<div className="flex gap-1.5">
<Button
size="sm"
variant="flat"
color="primary"
onPress={() => handleEdit(user)}
className="flex-1 min-h-8"
startContent={<EditIcon className="w-3 h-3" />}
>
编辑
</Button>
<Button
size="sm"
variant="flat"
color="warning"
onPress={() => handleResetFlow(user)}
className="flex-1 min-h-8"
startContent={
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
</svg>
}
>
重置
</Button>
</div>
{/* 第二行:权限和删除 */}
<div className="flex gap-1.5">
<Button
size="sm"
variant="flat"
color="success"
onPress={() => handleManageTunnels(user)}
className="flex-1 min-h-8"
startContent={<SettingsIcon className="w-3 h-3" />}
>
权限
</Button>
<Button
size="sm"
variant="flat"
color="danger"
onPress={() => handleDelete(user)}
className="flex-1 min-h-8"
startContent={<DeleteIcon className="w-3 h-3" />}
>
删除
</Button>
</div>
</div>
</CardBody>
</Card>