diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index 8b5f3f1..6e423f9 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -619,3 +619,13 @@ export const assignMonitorPermission = (userId: number) => export const removeMonitorPermission = (userId: number) => Network.post("/monitor/permission/remove", { userId }); + +// Complete ordered IDs plus the last observed server order for conflict detection. +export interface OrderUpdatePayload { + ids: number[]; + expectedIds: number[]; +} +export const updateUserOrder = (data: OrderUpdatePayload) => + Network.post("/user/update-order", data); +export const updateSpeedLimitOrder = (data: OrderUpdatePayload) => + Network.post("/speed-limit/update-order", data); diff --git a/vite-frontend/src/components/server-order.tsx b/vite-frontend/src/components/server-order.tsx new file mode 100644 index 0000000..cbe96aa --- /dev/null +++ b/vite-frontend/src/components/server-order.tsx @@ -0,0 +1,151 @@ +import { createContext, useContext, type ReactNode } from "react"; +import { + DndContext, + closestCenter, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + rectSortingStrategy, + sortableKeyboardCoordinates, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { GripVertical } from "lucide-react"; + +import { TableRow } from "@/shadcn-bridge/heroui/table"; + +const HandleContext = createContext(null); + +export function OrderHandle() { + return <>{useContext(HandleContext)}; +} + +export function OrderItem({ + id, + label, + disabled, + row = false, + children, +}: { + id: number; + label: string; + disabled: boolean; + row?: boolean; + children: ReactNode; +}) { + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id, disabled }); + const Component = row ? TableRow : "div"; + + return ( + + + ); +} + +export function OrderList({ + ids, + children, + onDragStart, + onDragEnd, +}: { + ids: number[]; + children: ReactNode; + onDragStart: () => void; + onDragEnd: (event: DragEndEvent) => void; +}) { + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), + useSensor(TouchSensor, { + activationConstraint: { delay: 250, tolerance: 8 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + return ( + + + {children} + + + ); +} + +export function OrderStatus({ + reason, + error, + retry, +}: { + reason: string; + error: string; + retry: () => void; +}) { + return ( +
+

+ {reason || "拖动手柄调整全局顺序;键盘可按空格、方向键、空格完成排序。"} +

+ {error && ( +

+ {error}{" "} + +

+ )} +
+ ); +} diff --git a/vite-frontend/src/hooks/use-server-order.ts b/vite-frontend/src/hooks/use-server-order.ts new file mode 100644 index 0000000..0ec6a9a --- /dev/null +++ b/vite-frontend/src/hooks/use-server-order.ts @@ -0,0 +1,120 @@ +import type { DragEndEvent } from "@dnd-kit/core"; +import type { OrderUpdatePayload } from "@/api"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { arrayMove } from "@dnd-kit/sortable"; + +interface Result { + code: number; + msg?: string; + transportError?: boolean; +} + +// Keep one complete, authoritative snapshot. Filtering is presentation-only. +export function useServerOrder( + fetchItems: () => Promise, + saveOrder: (payload: OrderUpdatePayload) => Promise, +) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const mounted = useRef(false); + const request = useRef(0); + const savingRef = useRef(false); + const snapshot = useRef(items); + + snapshot.current = items; + const dragSnapshot = useRef(null); + + const reload = useCallback(async () => { + const version = ++request.current; + + setLoading(true); + try { + const rows = await fetchItems(); + + if (!mounted.current || version !== request.current) return; + setItems(rows); + } catch { + if (!mounted.current || version !== request.current) return; + // Never expose a partial or unverified set as reorderable after failure. + setItems([]); + setError("加载列表失败,请重试后再排序。"); + } finally { + if (mounted.current && version === request.current) setLoading(false); + } + }, [fetchItems]); + + useEffect(() => { + mounted.current = true; + void reload(); + + return () => { + mounted.current = false; + ++request.current; + }; + }, [reload]); + + const onDragStart = () => { + dragSnapshot.current = snapshot.current; + }; + const reorder = async ({ active, over }: DragEndEvent, disabled: boolean) => { + const before = dragSnapshot.current; + + dragSnapshot.current = null; + if ( + disabled || + loading || + savingRef.current || + !over || + active.id === over.id || + before !== snapshot.current + ) + return; + const from = items.findIndex((item) => item.id === active.id); + const to = items.findIndex((item) => item.id === over.id); + + if (from < 0 || to < 0) return; + savingRef.current = true; + setSaving(true); + setError(""); + try { + const result = await saveOrder({ + ids: arrayMove(items, from, to).map((item) => item.id), + expectedIds: items.map((item) => item.id), + }); + + if (!mounted.current) return; + if (result.code !== 0) { + setError( + result.transportError + ? "排序保存未确认,请检查重新加载后的服务器顺序再重试。" + : result.code === 409 + ? "列表已被其他操作更改,排序未保存,请按重新加载后的顺序重试。" + : `排序保存失败:${result.msg || "请重试"}。请检查重新加载后的服务器顺序。`, + ); + } + } catch { + if (mounted.current) + setError("排序保存未确认,请检查重新加载后的服务器顺序再重试。"); + } finally { + if (mounted.current) { + await reload(); + if (mounted.current) setSaving(false); + } + savingRef.current = false; + } + }; + + return { + items, + setItems, + loading, + saving, + error, + reload, + onDragStart, + reorder, + }; +} diff --git a/vite-frontend/src/pages/limit.tsx b/vite-frontend/src/pages/limit.tsx index b4be918..c49d797 100644 --- a/vite-frontend/src/pages/limit.tsx +++ b/vite-frontend/src/pages/limit.tsx @@ -1,12 +1,16 @@ -import { useState, useEffect, useMemo } from "react"; +import { useState, useMemo } from "react"; import toast from "react-hot-toast"; import { LayoutGrid, List } from "lucide-react"; import { - AnimatedPage, - StaggerList, - StaggerItem, -} from "@/components/animated-page"; + OrderHandle, + OrderItem, + OrderList, + OrderStatus, +} from "@/components/server-order"; +import { useServerOrder } from "@/hooks/use-server-order"; +import { isAdmin } from "@/utils/auth"; +import { AnimatedPage, StaggerList } from "@/components/animated-page"; import { SearchBar } from "@/components/search-bar"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Button } from "@/shadcn-bridge/heroui/button"; @@ -24,11 +28,11 @@ import { TableHeader, TableColumn, TableBody, - TableRow, TableCell, } from "@/shadcn-bridge/heroui/table"; import { createSpeedLimit, + updateSpeedLimitOrder, getSpeedLimitList, updateSpeedLimit, deleteSpeedLimit, @@ -52,9 +56,17 @@ interface SpeedLimitForm { status: number; } +const fetchOrderedRules = async (): Promise => { + const response = await getSpeedLimitList(); + + if (response.code !== 0) throw new Error(response.msg); + + return (response.data || []) as SpeedLimitRule[]; +}; + export default function LimitPage() { - const [loading, setLoading] = useState(true); - const [rules, setRules] = useState([]); + const order = useServerOrder(fetchOrderedRules, updateSpeedLimitOrder); + const { items: rules, loading, reload: loadData } = order; const [searchKeyword, setSearchKeyword] = useLocalStorageState( "limit-search-keyword", "", @@ -92,28 +104,6 @@ export default function LimitPage() { // 表单验证错误 const [errors, setErrors] = useState<{ [key: string]: string }>({}); - useEffect(() => { - loadData(); - }, []); - - // 加载所有数据 - const loadData = async () => { - setLoading(true); - try { - const rulesRes = await getSpeedLimitList(); - - if (rulesRes.code === 0) { - setRules(rulesRes.data || []); - } else { - toast.error(rulesRes.msg || "获取限速规则失败"); - } - } catch { - toast.error("加载数据失败"); - } finally { - setLoading(false); - } - }; - // 表单验证 const validateForm = (): boolean => { const newErrors: { [key: string]: string } = {}; @@ -166,7 +156,7 @@ export default function LimitPage() { // 确认删除规则 const confirmDelete = async () => { - if (!ruleToDelete) return; + if (!ruleToDelete || order.saving || deleteLoading) return; setDeleteLoading(true); try { @@ -175,7 +165,7 @@ export default function LimitPage() { if (res.code === 0) { toast.success("删除成功"); setDeleteModalOpen(false); - loadData(); + await loadData(); } else { toast.error(res.msg || "删除失败"); } @@ -188,7 +178,7 @@ export default function LimitPage() { // 提交表单 const handleSubmit = async () => { - if (!validateForm()) return; + if (order.saving || !validateForm()) return; setSubmitLoading(true); try { @@ -215,7 +205,7 @@ export default function LimitPage() { if (res.code === 0) { toast.success(isEdit ? "修改成功" : "创建成功"); setModalOpen(false); - loadData(); + await loadData(); } else { toast.error(res.msg || "操作失败"); } @@ -226,9 +216,16 @@ export default function LimitPage() { } }; - if (loading) { - return ; - } + const orderReason = !isAdmin() + ? "仅管理员可调整全局顺序。" + : searchKeyword.length > 0 + ? "搜索期间无法排序,请清空搜索后重试。" + : order.saving + ? "正在保存顺序,请稍候。" + : loading || submitLoading || deleteLoading + ? "正在加载或更新列表,请稍候。" + : ""; + const orderDisabled = Boolean(orderReason); return ( @@ -263,172 +260,203 @@ export default function LimitPage() { - {/* 统一卡片网格 */} - {filteredRules.length > 0 ? ( - viewMode === "list" ? ( - - - - 规则名称 - 速度限制 - 操作 - - - {(rule) => ( - - -
-
- - {rule.name} + { + if (!order.saving) void loadData(); + }} + /> + rule.id)} + onDragEnd={(event) => { + void order.reorder(event, orderDisabled); + }} + onDragStart={order.onDragStart} + > + {/* 统一卡片网格 */} + {loading ? ( + + ) : filteredRules.length > 0 ? ( + viewMode === "list" ? ( + +
+ + 规则名称 + 速度限制 + 操作 + + + {(rule) => ( + + +
+ +
+ + {rule.name} + +
+ + + + {rule.speed} Mbps -
-
- - - {rule.speed} Mbps - - - -
- + +
+
+
+ )} +
+
+
+ ) : ( + + {filteredRules.map((rule) => ( + + + + +
+
+

+ {rule.name} +

+
+ + {rule.status === 1 ? "运行" : "异常"} + +
+
+ +
+
+ + 速度限制 + + + {rule.speed} Mbps + +
+
+ +
+
- - - )} - - -
+ + +
+ ))} +
+ ) ) : ( - - {filteredRules.map((rule) => ( - - - -
-
-

- {rule.name} -

-
- - {rule.status === 1 ? "运行" : "异常"} - -
-
- -
-
- - 速度限制 - - - {rule.speed} Mbps - -
-
- -
- - -
-
-
-
- ))} -
- ) - ) : ( - /* 空状态 */ - - -

- 暂无限速规则 -

-

- 还没有创建任何限速规则,点击上方按钮开始创建 -

-
-
- )} + /* 空状态 */ + + +

+ 暂无限速规则 +

+

+ 还没有创建任何限速规则,点击上方按钮开始创建 +

+
+
+ )} + {/* 新增/编辑模态框 */} + + + + + + + ); + }} + + + + ) : ( + + {users.map((user) => { + const userStatus = getUserStatus(user); + const expStatus = user.expTime + ? getExpireStatus(user.expTime) + : null; + const usedFlow = calculateUserTotalUsedFlow(user); + const flowPercent = + user.flow > 0 && !(user.flow === 99999 && !user.flowMiB) + ? Math.min( + (usedFlow / flowLimitBytes(user.flow, user.flowMiB)) * + 100, + 100, + ) + : 0; + + return ( + + + + +
+
+
+ {(user.name || user.user).slice(0, 2).toUpperCase()} +
- )} -
-
- -
- {/* 第一行:编辑和重置 */} -
- -
+
+
+ + {userStatus.text} + + {user.disabledByQuota ? ( + - - - } - variant="flat" - onPress={() => handleResetFlow(user)} - > - 重置 - + 配额超额 + + ) : null} +
+ +
+ + +
+ {/* 流量信息 */} +
+
+ 流量限制 + + {formatFlowLimit(user.flow, user.flowMiB)} + +
+
+ 已使用 + + {formatFlow(usedFlow)} + +
+ 90 + ? "danger" + : flowPercent > 70 + ? "warning" + : "success" + } + size="sm" + value={flowPercent} + /> +
+ + {/* 其他信息 */} +
+ {(user.dailyQuotaGB ?? 0) > 0 || + (user.monthlyQuotaGB ?? 0) > 0 || + (user.disabledByQuota ?? 0) > 0 ? ( + <> +
+ + 每日配额 + + + {formatFlow(Number(user.dailyUsedBytes ?? 0))}{" "} + / {formatQuotaLimit(user.dailyQuotaGB)} + +
+
+ + 每月配额 + + + {formatFlow( + Number(user.monthlyUsedBytes ?? 0), + )}{" "} + / {formatQuotaLimit(user.monthlyQuotaGB)} + +
+ + ) : null} +
+ 规则数量 + + {user.num} + +
+
+ 重置日期 + + {user.flowResetTime === 0 + ? "不重置" + : `每月${user.flowResetTime}号`} + +
+ {user.expTime && ( +
+ 过期时间 +
+ {expStatus && expStatus.color === "success" ? ( +
+ {formatDate(user.expTime)} +
+ ) : ( + + {expStatus?.text || "未知状态"} + + )} +
+
+ )} +
- {/* 第二行:权限和删除 */} -
- - +
+ {/* 第一行:编辑和重置 */} +
+ + +
+ + {/* 第二行:权限和删除 */} +
+ + +
-
-
-
- - ); - })} -
- )} + + + + ); + })} + + )} + {/* 用户表单模态框 */} 取消 -