feat(forward): add tunnel group collapse and drag sorting in full mode

This commit is contained in:
sagitchu
2026-03-02 22:24:39 +08:00
parent 98a9e5c666
commit 634c6cd620
+742 -36
View File
@@ -64,6 +64,7 @@ import {
diagnoseForward, diagnoseForward,
updateForwardOrder, updateForwardOrder,
getConfigByName, getConfigByName,
updateConfig,
} from "@/api"; } from "@/api";
import { import {
type ForwardAddressItem, type ForwardAddressItem,
@@ -144,10 +145,19 @@ interface ForwardTunnelGroup {
items: Forward[]; items: Forward[];
} }
type ForwardGroupOrderMap = Record<string, string[]>;
type ForwardGroupCollapsedMap = Record<string, boolean>;
const UNKNOWN_FORWARD_USER_NAME = "未知用户"; const UNKNOWN_FORWARD_USER_NAME = "未知用户";
const UNCATEGORIZED_FORWARD_TUNNEL_NAME = "未分类"; const UNCATEGORIZED_FORWARD_TUNNEL_NAME = "未分类";
const FORWARD_COMPACT_MODE_CONFIG_KEY = "forward_compact_mode"; const FORWARD_COMPACT_MODE_CONFIG_KEY = "forward_compact_mode";
const FORWARD_COMPACT_MODE_EVENT = "forwardCompactModeChanged"; 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_MIN_WIDTH_CLASS = "min-w-[1320px]";
const FORWARD_GROUPED_TABLE_COLUMN_CLASS = { const FORWARD_GROUPED_TABLE_COLUMN_CLASS = {
select: "w-14", 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 = <T,>(raw: string | null): Record<string, T> | 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<string, T>;
} catch {
return null;
}
};
const parseGroupOrderMap = (raw: string | null): ForwardGroupOrderMap => {
const parsed = parsePreferenceMap<unknown>(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<unknown>(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<number, Set<string>>,
): ForwardGroupOrderMap => {
const sanitized: ForwardGroupOrderMap = {};
availableTunnelKeysByUser.forEach((availableKeys, userId) => {
if (availableKeys.size === 0) {
return;
}
const orderFromSource = source[userId.toString()] || [];
const used = new Set<string>();
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<string>,
): 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<number, Set<string>>;
availableCollapseKeys: Set<string>;
} => {
const availableTunnelKeysByUser = new Map<number, Set<string>>();
const availableCollapseKeys = new Set<string>();
forwards.forEach((forward) => {
const userId = forward.userId ?? 0;
const tunnelKey = buildForwardTunnelGroupKey(forward.tunnelName);
let set = availableTunnelKeysByUser.get(userId);
if (!set) {
set = new Set<string>();
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() { export default function ForwardPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [forwards, setForwards] = useState<Forward[]>([]); const [forwards, setForwards] = useState<Forward[]>([]);
@@ -304,6 +565,13 @@ export default function ForwardPage() {
null, null,
); );
const [batchLoading, setBatchLoading] = useState(false); const [batchLoading, setBatchLoading] = useState(false);
const [groupOrderMap, setGroupOrderMap] = useState<ForwardGroupOrderMap>({});
const [collapsedTunnelGroups, setCollapsedTunnelGroups] =
useState<ForwardGroupCollapsedMap>({});
const [groupPreferenceHydrated, setGroupPreferenceHydrated] = useState(false);
const tokenUserId = JwtUtil.getUserIdFromToken();
const tokenRoleId = JwtUtil.getRoleIdFromToken();
const isAdmin = tokenRoleId === 0;
useEffect(() => { useEffect(() => {
return () => { 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<void> => {
if (!isAdmin || tokenUserId === null) {
return;
}
try {
const currentRes = await getConfigByName(FORWARD_GROUP_ORDER_CONFIG_KEY);
const globalMap =
parsePreferenceMap<ForwardGroupOrderMap>(
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<void> => {
if (!isAdmin || tokenUserId === null) {
return;
}
try {
const currentRes = await getConfigByName(FORWARD_GROUP_COLLAPSED_CONFIG_KEY);
const globalMap =
parsePreferenceMap<ForwardGroupCollapsedMap>(
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<ForwardGroupOrderMap>(
globalOrderRes.code === 0 &&
typeof globalOrderRes.data?.value === "string"
? globalOrderRes.data.value
: null,
);
const globalCollapsedMap = parsePreferenceMap<ForwardGroupCollapsedMap>(
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(() => { useEffect(() => {
const loadForwardCompactMode = async () => { const loadForwardCompactMode = async () => {
try { try {
@@ -1395,6 +1849,40 @@ export default function ForwardPage() {
if (!active || !over || active.id === over.id) return; 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 存在且有效 // 确保 forwardOrder 存在且有效
if (!forwardOrder || forwardOrder.length === 0) return; 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[] => { const orderedForwards = useMemo((): Forward[] => {
// 确保 forwards 数组存在且有效 // 确保 forwards 数组存在且有效
@@ -1711,6 +2195,59 @@ export default function ForwardPage() {
searchKeyword, 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[] => { const groupedForwards = useMemo((): ForwardUserGroup[] => {
if (orderedForwards.length === 0) { if (orderedForwards.length === 0) {
return []; return [];
@@ -1770,15 +2307,27 @@ export default function ForwardPage() {
const groups = Array.from(userGroupMap.values()).map((group) => { const groups = Array.from(userGroupMap.values()).map((group) => {
const tunnels = Array.from(group.tunnelMap.values()); const tunnels = Array.from(group.tunnelMap.values());
const tunnelOrder = sanitizedGroupOrderMap[group.userId.toString()] || [];
const tunnelOrderIndex = new Map<string, number>();
tunnelOrder.forEach((key, index) => {
tunnelOrderIndex.set(key, index);
});
tunnels.sort((a, b) => { tunnels.sort((a, b) => {
const aIsUncategorized = const aIndex = tunnelOrderIndex.get(a.tunnelKey);
a.tunnelName === UNCATEGORIZED_FORWARD_TUNNEL_NAME; const bIndex = tunnelOrderIndex.get(b.tunnelKey);
const bIsUncategorized =
b.tunnelName === UNCATEGORIZED_FORWARD_TUNNEL_NAME;
if (aIsUncategorized !== bIsUncategorized) { if (aIndex !== undefined || bIndex !== undefined) {
return aIsUncategorized ? 1 : -1; if (aIndex === undefined) {
return 1;
}
if (bIndex === undefined) {
return -1;
}
return aIndex - bIndex;
} }
const nameCompare = compareForwardTunnelNameAsc(a.tunnelName, b.tunnelName); const nameCompare = compareForwardTunnelNameAsc(a.tunnelName, b.tunnelName);
@@ -1817,7 +2366,7 @@ export default function ForwardPage() {
}); });
return groups; return groups;
}, [orderedForwards, isAdmin, tokenUserId]); }, [orderedForwards, isAdmin, tokenUserId, sanitizedGroupOrderMap]);
const sortedForwards = useMemo(() => { const sortedForwards = useMemo(() => {
if (compactMode) { if (compactMode) {
@@ -1834,6 +2383,121 @@ export default function ForwardPage() {
[sortedForwards], [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 (
<div ref={setNodeRef} className={wrapperClassName} style={style}>
<div className={headerClassName}>
<div className="flex items-center gap-2 min-w-0">
<Button
isIconOnly
aria-label={collapsed ? "展开分组" : "折叠分组"}
className="h-7 w-7 min-w-7"
size="sm"
variant="light"
onPress={onToggleCollapsed}
>
<svg
aria-hidden="true"
className={`h-4 w-4 transition-transform ${collapsed ? "-rotate-90" : "rotate-0"}`}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="m6 9 6 6 6-6" />
</svg>
</Button>
<span className={titleClassName}>{tunnel.tunnelName}</span>
</div>
<div className="flex items-center gap-2">
<span className={countClassName}>{tunnel.items.length} 条转发</span>
<div
className="cursor-grab active:cursor-grabbing p-1 text-default-400 hover:text-default-600 transition-colors"
title="拖拽分组排序"
{...attributes}
{...listeners}
>
<svg
aria-hidden="true"
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>
</div>
</div>
{!collapsed && <div className={bodyClassName}>{children}</div>}
</div>
);
};
// 可拖拽的转发卡片组件 // 可拖拽的转发卡片组件
const SortableForwardCard = ({ forward }: { forward: Forward }) => { const SortableForwardCard = ({ forward }: { forward: Forward }) => {
const { const {
@@ -2616,7 +3280,7 @@ export default function ForwardPage() {
); );
}; };
if (loading) { if (loading || !groupPreferenceHydrated) {
return <PageLoadingState message="正在加载..." />; return <PageLoadingState message="正在加载..." />;
} }
@@ -2972,25 +3636,47 @@ export default function ForwardPage() {
</div> </div>
<div className="space-y-4 p-4"> <div className="space-y-4 p-4">
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
>
<SortableContext
items={group.tunnels.map((tunnel) =>
buildTunnelGroupSortableId(group.userId, tunnel.tunnelKey),
)}
strategy={verticalListSortingStrategy}
>
{group.tunnels.map((tunnel) => { {group.tunnels.map((tunnel) => {
const tunnelSortableForwardIds = tunnel.items const tunnelSortableForwardIds = tunnel.items
.map((item) => item.id) .map((item) => item.id)
.filter((id) => id > 0); .filter((id) => id > 0);
const collapsed =
sanitizedCollapsedTunnelGroups[
buildTunnelGroupCollapseKey(
group.userId,
tunnel.tunnelKey,
)
] === true;
return ( return (
<div <SortableTunnelGroupContainer
key={`grouped-table-${group.userId}-${tunnel.tunnelKey}`} key={`grouped-table-${group.userId}-${tunnel.tunnelKey}`}
className="overflow-hidden rounded-lg border border-secondary/20 bg-secondary/5" bodyClassName=""
collapsed={collapsed}
countClassName="text-xs text-secondary-700"
groupUserId={group.userId}
headerClassName="flex items-center justify-between border-b border-secondary/20 bg-secondary/10 px-4 py-2.5"
titleClassName="truncate text-sm font-semibold text-secondary-700"
tunnel={tunnel}
wrapperClassName="overflow-hidden rounded-lg border border-secondary/20 bg-secondary/5"
onToggleCollapsed={() =>
toggleTunnelGroupCollapsed(
group.userId,
tunnel.tunnelKey,
)
}
> >
<div className="flex items-center justify-between border-b border-secondary/20 bg-secondary/10 px-4 py-2.5">
<span className="text-sm font-semibold text-secondary-700">
{tunnel.tunnelName}
</span>
<span className="text-xs text-secondary-700">
{tunnel.items.length} 条转发
</span>
</div>
<DndContext <DndContext
collisionDetection={closestCenter} collisionDetection={closestCenter}
sensors={sensors} sensors={sensors}
@@ -3093,9 +3779,11 @@ export default function ForwardPage() {
</TableBody> </TableBody>
</Table> </Table>
</DndContext> </DndContext>
</div> </SortableTunnelGroupContainer>
); );
})} })}
</SortableContext>
</DndContext>
</div> </div>
</div> </div>
); );
@@ -3145,25 +3833,41 @@ export default function ForwardPage() {
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
>
<SortableContext
items={group.tunnels.map((tunnel) =>
buildTunnelGroupSortableId(group.userId, tunnel.tunnelKey),
)}
strategy={verticalListSortingStrategy}
>
{group.tunnels.map((tunnel) => { {group.tunnels.map((tunnel) => {
const tunnelSortableForwardIds = tunnel.items const tunnelSortableForwardIds = tunnel.items
.map((item) => item.id) .map((item) => item.id)
.filter((id) => id > 0); .filter((id) => id > 0);
const collapsed =
sanitizedCollapsedTunnelGroups[
buildTunnelGroupCollapseKey(group.userId, tunnel.tunnelKey)
] === true;
return ( return (
<div <SortableTunnelGroupContainer
key={`direct-group-${group.userId}-${tunnel.tunnelKey}`} key={`direct-group-${group.userId}-${tunnel.tunnelKey}`}
className="rounded-xl border border-secondary/20 bg-secondary/5 p-3 space-y-3" bodyClassName="p-3"
collapsed={collapsed}
countClassName="text-xs text-secondary-700"
groupUserId={group.userId}
headerClassName="flex items-center justify-between rounded-lg bg-secondary/10 px-3 py-2"
titleClassName="truncate text-sm font-semibold text-secondary-700"
tunnel={tunnel}
wrapperClassName="rounded-xl border border-secondary/20 bg-secondary/5 space-y-3"
onToggleCollapsed={() =>
toggleTunnelGroupCollapsed(group.userId, tunnel.tunnelKey)
}
> >
<div className="flex items-center justify-between rounded-lg bg-secondary/10 px-3 py-2">
<span className="text-sm font-semibold text-secondary-700">
{tunnel.tunnelName}
</span>
<span className="text-xs text-secondary-700">
{tunnel.items.length} 条转发
</span>
</div>
<DndContext <DndContext
collisionDetection={closestCenter} collisionDetection={closestCenter}
sensors={sensors} sensors={sensors}
@@ -3186,9 +3890,11 @@ export default function ForwardPage() {
</div> </div>
</SortableContext> </SortableContext>
</DndContext> </DndContext>
</div> </SortableTunnelGroupContainer>
); );
})} })}
</SortableContext>
</DndContext>
</div> </div>
</div> </div>
); );