diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx index 8966ea8..b9c94fd 100644 --- a/vite-frontend/src/pages/forward.tsx +++ b/vite-frontend/src/pages/forward.tsx @@ -64,6 +64,7 @@ import { diagnoseForward, updateForwardOrder, getConfigByName, + updateConfig, } from "@/api"; import { type ForwardAddressItem, @@ -144,10 +145,19 @@ interface ForwardTunnelGroup { items: Forward[]; } +type ForwardGroupOrderMap = Record; +type ForwardGroupCollapsedMap = Record; + const UNKNOWN_FORWARD_USER_NAME = "未知用户"; const UNCATEGORIZED_FORWARD_TUNNEL_NAME = "未分类"; const FORWARD_COMPACT_MODE_CONFIG_KEY = "forward_compact_mode"; const FORWARD_COMPACT_MODE_EVENT = "forwardCompactModeChanged"; +const FORWARD_GROUP_ORDER_CONFIG_KEY = "forward_group_order_map"; +const FORWARD_GROUP_COLLAPSED_CONFIG_KEY = "forward_group_collapsed_map"; +const FORWARD_GROUP_ORDER_LOCAL_STORAGE_PREFIX = "forward-group-order"; +const FORWARD_GROUP_COLLAPSED_LOCAL_STORAGE_PREFIX = + "forward-group-collapsed"; +const FORWARD_TUNNEL_GROUP_SORTABLE_PREFIX = "forward-tunnel-group"; const FORWARD_GROUPED_TABLE_MIN_WIDTH_CLASS = "min-w-[1320px]"; const FORWARD_GROUPED_TABLE_COLUMN_CLASS = { select: "w-14", @@ -197,6 +207,257 @@ const compareForwardTunnelNameAsc = (a: string, b: string): number => { }); }; +const compareForwardTunnelGroupKeyAsc = (a: string, b: string): number => { + const aIsUncategorized = a === "__uncategorized__"; + const bIsUncategorized = b === "__uncategorized__"; + + if (aIsUncategorized !== bIsUncategorized) { + return aIsUncategorized ? 1 : -1; + } + + return compareForwardTunnelNameAsc(a, b); +}; + +const buildForwardGroupOrderLocalKey = (tokenUserId: number): string => { + return `${FORWARD_GROUP_ORDER_LOCAL_STORAGE_PREFIX}:u:${tokenUserId}`; +}; + +const buildForwardGroupCollapsedLocalKey = (tokenUserId: number): string => { + return `${FORWARD_GROUP_COLLAPSED_LOCAL_STORAGE_PREFIX}:u:${tokenUserId}`; +}; + +const parsePreferenceMap = (raw: string | null): Record | null => { + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw); + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + + return parsed as Record; + } catch { + return null; + } +}; + +const parseGroupOrderMap = (raw: string | null): ForwardGroupOrderMap => { + const parsed = parsePreferenceMap(raw); + + if (!parsed) { + return {}; + } + + const result: ForwardGroupOrderMap = {}; + + Object.entries(parsed).forEach(([userId, value]) => { + if (!Array.isArray(value)) { + return; + } + + const keys = value + .map((item) => (typeof item === "string" ? item.trim() : "")) + .filter((item) => item !== ""); + + if (keys.length > 0) { + result[userId] = Array.from(new Set(keys)); + } + }); + + return result; +}; + +const parseGroupCollapsedMap = (raw: string | null): ForwardGroupCollapsedMap => { + const parsed = parsePreferenceMap(raw); + + if (!parsed) { + return {}; + } + + const result: ForwardGroupCollapsedMap = {}; + + Object.entries(parsed).forEach(([key, value]) => { + if (typeof value === "boolean") { + result[key] = value; + } + }); + + return result; +}; + +const sanitizeGroupOrderMap = ( + source: ForwardGroupOrderMap, + availableTunnelKeysByUser: Map>, +): ForwardGroupOrderMap => { + const sanitized: ForwardGroupOrderMap = {}; + + availableTunnelKeysByUser.forEach((availableKeys, userId) => { + if (availableKeys.size === 0) { + return; + } + + const orderFromSource = source[userId.toString()] || []; + const used = new Set(); + const merged: string[] = []; + + orderFromSource.forEach((key) => { + if (!availableKeys.has(key) || used.has(key)) { + return; + } + + used.add(key); + merged.push(key); + }); + + Array.from(availableKeys) + .sort(compareForwardTunnelGroupKeyAsc) + .forEach((key) => { + if (!used.has(key)) { + used.add(key); + merged.push(key); + } + }); + + if (merged.length > 0) { + sanitized[userId.toString()] = merged; + } + }); + + return sanitized; +}; + +const sanitizeGroupCollapsedMap = ( + source: ForwardGroupCollapsedMap, + availableCollapseKeys: Set, +): ForwardGroupCollapsedMap => { + const sanitized: ForwardGroupCollapsedMap = {}; + + availableCollapseKeys.forEach((key) => { + if (source[key] === true) { + sanitized[key] = true; + } + }); + + return sanitized; +}; + +const buildTunnelGroupCollapseKey = (userId: number, tunnelKey: string): string => { + return `${userId}:${tunnelKey}`; +}; + +const buildTunnelGroupSortableId = (userId: number, tunnelKey: string): string => { + return `${FORWARD_TUNNEL_GROUP_SORTABLE_PREFIX}:${userId}:${tunnelKey}`; +}; + +const parseTunnelGroupSortableId = ( + value: unknown, +): { userId: number; tunnelKey: string } | null => { + if (typeof value !== "string") { + return null; + } + + if (!value.startsWith(`${FORWARD_TUNNEL_GROUP_SORTABLE_PREFIX}:`)) { + return null; + } + + const parts = value.split(":"); + + if (parts.length < 3) { + return null; + } + + const userId = Number(parts[1]); + const tunnelKey = parts.slice(2).join(":").trim(); + + if (!Number.isFinite(userId) || tunnelKey === "") { + return null; + } + + return { userId, tunnelKey }; +}; + +const buildAvailableGroupData = (forwards: Forward[]): { + availableTunnelKeysByUser: Map>; + availableCollapseKeys: Set; +} => { + const availableTunnelKeysByUser = new Map>(); + const availableCollapseKeys = new Set(); + + forwards.forEach((forward) => { + const userId = forward.userId ?? 0; + const tunnelKey = buildForwardTunnelGroupKey(forward.tunnelName); + + let set = availableTunnelKeysByUser.get(userId); + + if (!set) { + set = new Set(); + availableTunnelKeysByUser.set(userId, set); + } + + set.add(tunnelKey); + availableCollapseKeys.add(buildTunnelGroupCollapseKey(userId, tunnelKey)); + }); + + return { availableTunnelKeysByUser, availableCollapseKeys }; +}; + +const isSameStringArray = (a: string[], b: string[]): boolean => { + if (a.length !== b.length) { + return false; + } + + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) { + return false; + } + } + + return true; +}; + +const isSameGroupOrderMap = ( + a: ForwardGroupOrderMap, + b: ForwardGroupOrderMap, +): boolean => { + const aKeys = Object.keys(a).sort(compareForwardTunnelNameAsc); + const bKeys = Object.keys(b).sort(compareForwardTunnelNameAsc); + + if (!isSameStringArray(aKeys, bKeys)) { + return false; + } + + for (const key of aKeys) { + if (!isSameStringArray(a[key] || [], b[key] || [])) { + return false; + } + } + + return true; +}; + +const isSameGroupCollapsedMap = ( + a: ForwardGroupCollapsedMap, + b: ForwardGroupCollapsedMap, +): boolean => { + const aKeys = Object.keys(a).sort(compareForwardTunnelNameAsc); + const bKeys = Object.keys(b).sort(compareForwardTunnelNameAsc); + + if (!isSameStringArray(aKeys, bKeys)) { + return false; + } + + for (const key of aKeys) { + if (a[key] !== b[key]) { + return false; + } + } + + return true; +}; + export default function ForwardPage() { const [loading, setLoading] = useState(true); const [forwards, setForwards] = useState([]); @@ -304,6 +565,13 @@ export default function ForwardPage() { null, ); const [batchLoading, setBatchLoading] = useState(false); + const [groupOrderMap, setGroupOrderMap] = useState({}); + const [collapsedTunnelGroups, setCollapsedTunnelGroups] = + useState({}); + const [groupPreferenceHydrated, setGroupPreferenceHydrated] = useState(false); + const tokenUserId = JwtUtil.getUserIdFromToken(); + const tokenRoleId = JwtUtil.getRoleIdFromToken(); + const isAdmin = tokenRoleId === 0; useEffect(() => { return () => { @@ -312,6 +580,192 @@ export default function ForwardPage() { }; }, []); + const persistGroupOrderToLocal = (nextOrderMap: ForwardGroupOrderMap) => { + if (tokenUserId === null) { + return; + } + + try { + localStorage.setItem( + buildForwardGroupOrderLocalKey(tokenUserId), + JSON.stringify(nextOrderMap), + ); + } catch {} + }; + + const persistGroupCollapsedToLocal = ( + nextCollapsedMap: ForwardGroupCollapsedMap, + ) => { + if (tokenUserId === null) { + return; + } + + try { + localStorage.setItem( + buildForwardGroupCollapsedLocalKey(tokenUserId), + JSON.stringify(nextCollapsedMap), + ); + } catch {} + }; + + const persistGroupOrderToGlobal = async ( + nextOrderMap: ForwardGroupOrderMap, + ): Promise => { + if (!isAdmin || tokenUserId === null) { + return; + } + + try { + const currentRes = await getConfigByName(FORWARD_GROUP_ORDER_CONFIG_KEY); + const globalMap = + parsePreferenceMap( + currentRes.code === 0 && typeof currentRes.data?.value === "string" + ? currentRes.data.value + : null, + ) || {}; + + globalMap[tokenUserId.toString()] = nextOrderMap; + + const saveRes = await updateConfig( + FORWARD_GROUP_ORDER_CONFIG_KEY, + JSON.stringify(globalMap), + ); + + if (saveRes.code !== 0) { + toast.error(saveRes.msg || "保存分组排序失败"); + } + } catch { + toast.error("保存分组排序失败"); + } + }; + + const persistGroupCollapsedToGlobal = async ( + nextCollapsedMap: ForwardGroupCollapsedMap, + ): Promise => { + if (!isAdmin || tokenUserId === null) { + return; + } + + try { + const currentRes = await getConfigByName(FORWARD_GROUP_COLLAPSED_CONFIG_KEY); + const globalMap = + parsePreferenceMap( + currentRes.code === 0 && typeof currentRes.data?.value === "string" + ? currentRes.data.value + : null, + ) || {}; + + globalMap[tokenUserId.toString()] = nextCollapsedMap; + + const saveRes = await updateConfig( + FORWARD_GROUP_COLLAPSED_CONFIG_KEY, + JSON.stringify(globalMap), + ); + + if (saveRes.code !== 0) { + toast.error(saveRes.msg || "保存分组折叠状态失败"); + } + } catch { + toast.error("保存分组折叠状态失败"); + } + }; + + useEffect(() => { + let cancelled = false; + + const loadGroupPreferences = async () => { + if (tokenUserId === null) { + if (!cancelled) { + setGroupOrderMap({}); + setCollapsedTunnelGroups({}); + setGroupPreferenceHydrated(true); + } + + return; + } + + let localOrderMap: ForwardGroupOrderMap = {}; + let localCollapsedMap: ForwardGroupCollapsedMap = {}; + + try { + localOrderMap = parseGroupOrderMap( + localStorage.getItem(buildForwardGroupOrderLocalKey(tokenUserId)), + ); + } catch { + localOrderMap = {}; + } + + try { + localCollapsedMap = parseGroupCollapsedMap( + localStorage.getItem(buildForwardGroupCollapsedLocalKey(tokenUserId)), + ); + } catch { + localCollapsedMap = {}; + } + + if (isAdmin) { + try { + const [globalOrderRes, globalCollapsedRes] = await Promise.all([ + getConfigByName(FORWARD_GROUP_ORDER_CONFIG_KEY), + getConfigByName(FORWARD_GROUP_COLLAPSED_CONFIG_KEY), + ]); + + const globalOrderMap = parsePreferenceMap( + globalOrderRes.code === 0 && + typeof globalOrderRes.data?.value === "string" + ? globalOrderRes.data.value + : null, + ); + const globalCollapsedMap = parsePreferenceMap( + globalCollapsedRes.code === 0 && + typeof globalCollapsedRes.data?.value === "string" + ? globalCollapsedRes.data.value + : null, + ); + + const globalOrderBucket = globalOrderMap?.[tokenUserId.toString()]; + const globalCollapsedBucket = + globalCollapsedMap?.[tokenUserId.toString()]; + + if ( + globalOrderBucket && + typeof globalOrderBucket === "object" && + !Array.isArray(globalOrderBucket) + ) { + localOrderMap = parseGroupOrderMap(JSON.stringify(globalOrderBucket)); + } + + if ( + globalCollapsedBucket && + typeof globalCollapsedBucket === "object" && + !Array.isArray(globalCollapsedBucket) + ) { + localCollapsedMap = parseGroupCollapsedMap( + JSON.stringify(globalCollapsedBucket), + ); + } + } catch {} + } + + if (cancelled) { + return; + } + + setGroupOrderMap(localOrderMap); + setCollapsedTunnelGroups(localCollapsedMap); + persistGroupOrderToLocal(localOrderMap); + persistGroupCollapsedToLocal(localCollapsedMap); + setGroupPreferenceHydrated(true); + }; + + setGroupPreferenceHydrated(false); + loadGroupPreferences(); + + return () => { + cancelled = true; + }; + }, [tokenUserId, isAdmin]); + useEffect(() => { const loadForwardCompactMode = async () => { try { @@ -1395,6 +1849,40 @@ export default function ForwardPage() { if (!active || !over || active.id === over.id) return; + const activeGroup = parseTunnelGroupSortableId(active.id); + const overGroup = parseTunnelGroupSortableId(over.id); + + if (activeGroup && overGroup) { + if (compactMode || !groupPreferenceHydrated) { + return; + } + + if (activeGroup.userId !== overGroup.userId) { + return; + } + + const userIdKey = activeGroup.userId.toString(); + const currentOrder = groupOrderMap[userIdKey] || []; + const oldIndex = currentOrder.indexOf(activeGroup.tunnelKey); + const newIndex = currentOrder.indexOf(overGroup.tunnelKey); + + if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) { + return; + } + + const moved = arrayMove(currentOrder, oldIndex, newIndex); + const nextOrderMap: ForwardGroupOrderMap = { + ...groupOrderMap, + [userIdKey]: moved, + }; + + setGroupOrderMap(nextOrderMap); + persistGroupOrderToLocal(nextOrderMap); + void persistGroupOrderToGlobal(nextOrderMap); + + return; + } + // 确保 forwardOrder 存在且有效 if (!forwardOrder || forwardOrder.length === 0) return; @@ -1619,10 +2107,6 @@ export default function ForwardPage() { }), ); - const tokenUserId = JwtUtil.getUserIdFromToken(); - const tokenRoleId = JwtUtil.getRoleIdFromToken(); - const isAdmin = tokenRoleId === 0; - // 根据排序顺序获取转发列表 const orderedForwards = useMemo((): Forward[] => { // 确保 forwards 数组存在且有效 @@ -1711,6 +2195,59 @@ export default function ForwardPage() { searchKeyword, ]); + const availableGroupData = useMemo( + () => buildAvailableGroupData(forwards), + [forwards], + ); + + const sanitizedGroupOrderMap = useMemo( + () => + sanitizeGroupOrderMap( + groupOrderMap, + availableGroupData.availableTunnelKeysByUser, + ), + [groupOrderMap, availableGroupData], + ); + + const sanitizedCollapsedTunnelGroups = useMemo( + () => + sanitizeGroupCollapsedMap( + collapsedTunnelGroups, + availableGroupData.availableCollapseKeys, + ), + [collapsedTunnelGroups, availableGroupData], + ); + + useEffect(() => { + if (!groupPreferenceHydrated || tokenUserId === null) { + return; + } + + if (!isSameGroupOrderMap(groupOrderMap, sanitizedGroupOrderMap)) { + setGroupOrderMap(sanitizedGroupOrderMap); + persistGroupOrderToLocal(sanitizedGroupOrderMap); + void persistGroupOrderToGlobal(sanitizedGroupOrderMap); + } + + if ( + !isSameGroupCollapsedMap( + collapsedTunnelGroups, + sanitizedCollapsedTunnelGroups, + ) + ) { + setCollapsedTunnelGroups(sanitizedCollapsedTunnelGroups); + persistGroupCollapsedToLocal(sanitizedCollapsedTunnelGroups); + void persistGroupCollapsedToGlobal(sanitizedCollapsedTunnelGroups); + } + }, [ + groupPreferenceHydrated, + tokenUserId, + groupOrderMap, + sanitizedGroupOrderMap, + collapsedTunnelGroups, + sanitizedCollapsedTunnelGroups, + ]); + const groupedForwards = useMemo((): ForwardUserGroup[] => { if (orderedForwards.length === 0) { return []; @@ -1770,15 +2307,27 @@ export default function ForwardPage() { const groups = Array.from(userGroupMap.values()).map((group) => { const tunnels = Array.from(group.tunnelMap.values()); + const tunnelOrder = sanitizedGroupOrderMap[group.userId.toString()] || []; + const tunnelOrderIndex = new Map(); + + tunnelOrder.forEach((key, index) => { + tunnelOrderIndex.set(key, index); + }); tunnels.sort((a, b) => { - const aIsUncategorized = - a.tunnelName === UNCATEGORIZED_FORWARD_TUNNEL_NAME; - const bIsUncategorized = - b.tunnelName === UNCATEGORIZED_FORWARD_TUNNEL_NAME; + const aIndex = tunnelOrderIndex.get(a.tunnelKey); + const bIndex = tunnelOrderIndex.get(b.tunnelKey); - if (aIsUncategorized !== bIsUncategorized) { - return aIsUncategorized ? 1 : -1; + if (aIndex !== undefined || bIndex !== undefined) { + if (aIndex === undefined) { + return 1; + } + + if (bIndex === undefined) { + return -1; + } + + return aIndex - bIndex; } const nameCompare = compareForwardTunnelNameAsc(a.tunnelName, b.tunnelName); @@ -1817,7 +2366,7 @@ export default function ForwardPage() { }); return groups; - }, [orderedForwards, isAdmin, tokenUserId]); + }, [orderedForwards, isAdmin, tokenUserId, sanitizedGroupOrderMap]); const sortedForwards = useMemo(() => { if (compactMode) { @@ -1834,6 +2383,121 @@ export default function ForwardPage() { [sortedForwards], ); + const toggleTunnelGroupCollapsed = (userId: number, tunnelKey: string) => { + const collapseKey = buildTunnelGroupCollapseKey(userId, tunnelKey); + const nextCollapsedMap: ForwardGroupCollapsedMap = { + ...sanitizedCollapsedTunnelGroups, + }; + + if (nextCollapsedMap[collapseKey] === true) { + delete nextCollapsedMap[collapseKey]; + } else { + nextCollapsedMap[collapseKey] = true; + } + + setCollapsedTunnelGroups(nextCollapsedMap); + persistGroupCollapsedToLocal(nextCollapsedMap); + void persistGroupCollapsedToGlobal(nextCollapsedMap); + }; + + const SortableTunnelGroupContainer = ({ + groupUserId, + tunnel, + collapsed, + onToggleCollapsed, + wrapperClassName, + headerClassName, + titleClassName, + countClassName, + bodyClassName, + children, + }: { + groupUserId: number; + tunnel: ForwardTunnelGroup; + collapsed: boolean; + onToggleCollapsed: () => void; + wrapperClassName: string; + headerClassName: string; + titleClassName: string; + countClassName: string; + bodyClassName: string; + children: React.ReactNode; + }) => { + const sortableId = buildTunnelGroupSortableId(groupUserId, tunnel.tunnelKey); + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: sortableId }); + + const style: React.CSSProperties = { + transform: transform + ? CSS.Transform.toString({ + ...transform, + x: Math.round(transform.x), + y: Math.round(transform.y), + }) + : undefined, + transition: isDragging ? undefined : transition || undefined, + opacity: isDragging ? 0.55 : 1, + willChange: isDragging ? "transform" : undefined, + zIndex: isDragging ? 1 : undefined, + }; + + return ( +
+
+
+ + {tunnel.tunnelName} +
+
+ {tunnel.items.length} 条转发 +
+ +
+
+
+ {!collapsed &&
{children}
} +
+ ); + }; + // 可拖拽的转发卡片组件 const SortableForwardCard = ({ forward }: { forward: Forward }) => { const { @@ -2616,7 +3280,7 @@ export default function ForwardPage() { ); }; - if (loading) { + if (loading || !groupPreferenceHydrated) { return ; } @@ -2972,130 +3636,154 @@ export default function ForwardPage() {
- {group.tunnels.map((tunnel) => { - const tunnelSortableForwardIds = tunnel.items - .map((item) => item.id) - .filter((id) => id > 0); + + + buildTunnelGroupSortableId(group.userId, tunnel.tunnelKey), + )} + strategy={verticalListSortingStrategy} + > + {group.tunnels.map((tunnel) => { + const tunnelSortableForwardIds = tunnel.items + .map((item) => item.id) + .filter((id) => id > 0); + const collapsed = + sanitizedCollapsedTunnelGroups[ + buildTunnelGroupCollapseKey( + group.userId, + tunnel.tunnelKey, + ) + ] === true; - return ( -
-
- - {tunnel.tunnelName} - - - {tunnel.items.length} 条转发 - -
- - - + toggleTunnelGroupCollapsed( + group.userId, + tunnel.tunnelKey, + ) + } > - - {selectMode && ( - - 选择 - - )} - - - 名称 - - - 入口 - - - 目标 - - - 策略 - - - 总流量 - - - 状态 - - - 操作 - - - - {(forward) => ( - - + + {selectMode && ( + + 选择 + + )} + - - )} - -
-
-
- ); - })} + + 名称 + + + 入口 + + + 目标 + + + 策略 + + + 总流量 + + + 状态 + + + 操作 + + + + {(forward) => ( + + + + )} + + +
+ + ); + })} + +
); @@ -3145,50 +3833,68 @@ export default function ForwardPage() {
- {group.tunnels.map((tunnel) => { - const tunnelSortableForwardIds = tunnel.items - .map((item) => item.id) - .filter((id) => id > 0); + + + buildTunnelGroupSortableId(group.userId, tunnel.tunnelKey), + )} + strategy={verticalListSortingStrategy} + > + {group.tunnels.map((tunnel) => { + const tunnelSortableForwardIds = tunnel.items + .map((item) => item.id) + .filter((id) => id > 0); + const collapsed = + sanitizedCollapsedTunnelGroups[ + buildTunnelGroupCollapseKey(group.userId, tunnel.tunnelKey) + ] === true; - return ( -
-
- - {tunnel.tunnelName} - - - {tunnel.items.length} 条转发 - -
- - {}} - > - + toggleTunnelGroupCollapsed(group.userId, tunnel.tunnelKey) + } > -
- {tunnel.items.map((forward) => - forward && forward.id ? ( - - ) : null, - )} -
-
-
-
- ); - })} + {}} + > + +
+ {tunnel.items.map((forward) => + forward && forward.id ? ( + + ) : null, + )} +
+
+
+ + ); + })} +
+
);