feat(frontend): group forwards by user with admin priority sorting

- Add ForwardUserGroup interface and helper utilities
- Group forwards by user in both grouped table and card views
- Sort user groups with admin's own group first, then alphabetically
- Restrict drag-and-drop to same user group only
- Remove redundant user column from grouped table view
- Add user group headers with forward count badges
This commit is contained in:
sagitchu
2026-02-28 17:20:10 +08:00
parent a645cc699b
commit b01dbdb6e5
+268 -92
View File
@@ -130,6 +130,27 @@ interface ForwardForm {
speedId: number | null; speedId: number | null;
} }
interface ForwardUserGroup {
userId: number;
userName: string;
items: Forward[];
}
const UNKNOWN_FORWARD_USER_NAME = "未知用户";
const normalizeForwardUserName = (userName?: string): string => {
const normalized = (userName || UNKNOWN_FORWARD_USER_NAME).trim();
return normalized || UNKNOWN_FORWARD_USER_NAME;
};
const compareForwardUserNameAsc = (a: string, b: string): number => {
return a.localeCompare(b, "en", {
sensitivity: "base",
numeric: 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[]>([]);
@@ -1145,6 +1166,16 @@ export default function ForwardPage() {
// 检查 ID 是否有效 // 检查 ID 是否有效
if (isNaN(activeId) || isNaN(overId)) return; if (isNaN(activeId) || isNaN(overId)) return;
const activeForward = forwards.find((forward) => forward.id === activeId);
const overForward = forwards.find((forward) => forward.id === overId);
const activeUserId = activeForward?.userId ?? 0;
const overUserId = overForward?.userId ?? 0;
// 仅允许在同一用户分组内拖拽,避免不同用户数据混排
if (activeUserId !== overUserId) {
return;
}
const oldIndex = forwardOrder.indexOf(activeId); const oldIndex = forwardOrder.indexOf(activeId);
const newIndex = forwardOrder.indexOf(overId); const newIndex = forwardOrder.indexOf(overId);
@@ -1342,9 +1373,11 @@ export default function ForwardPage() {
); );
const tokenUserId = JwtUtil.getUserIdFromToken(); const tokenUserId = JwtUtil.getUserIdFromToken();
const tokenRoleId = JwtUtil.getRoleIdFromToken();
const isAdmin = tokenRoleId === 0;
// 根据排序顺序获取转发列表 // 根据排序顺序获取转发列表
const sortedForwards = useMemo((): Forward[] => { const orderedForwards = useMemo((): Forward[] => {
// 确保 forwards 数组存在且有效 // 确保 forwards 数组存在且有效
if (!forwards || forwards.length === 0) { if (!forwards || forwards.length === 0) {
return []; return [];
@@ -1426,16 +1459,70 @@ export default function ForwardPage() {
}, [ }, [
forwards, forwards,
forwardOrder, forwardOrder,
viewMode,
tokenUserId,
filterUserId, filterUserId,
filterTunnelId, filterTunnelId,
searchKeyword, searchKeyword,
]); ]);
const sortableForwardIds = useMemo( const groupedForwards = useMemo((): ForwardUserGroup[] => {
() => sortedForwards.map((f) => f.id).filter((id) => id > 0), if (orderedForwards.length === 0) {
[sortedForwards], return [];
}
const userGroupMap = new Map<number, ForwardUserGroup>();
orderedForwards.forEach((forward) => {
const userId = forward.userId ?? 0;
const userName = normalizeForwardUserName(forward.userName);
const existingGroup = userGroupMap.get(userId);
if (!existingGroup) {
userGroupMap.set(userId, {
userId,
userName,
items: [forward],
});
return;
}
existingGroup.items.push(forward);
if (
existingGroup.userName === UNKNOWN_FORWARD_USER_NAME &&
userName !== UNKNOWN_FORWARD_USER_NAME
) {
existingGroup.userName = userName;
}
});
const groups = Array.from(userGroupMap.values());
groups.sort((a, b) => {
if (isAdmin && tokenUserId !== null) {
const aIsSelf = a.userId === tokenUserId;
const bIsSelf = b.userId === tokenUserId;
if (aIsSelf !== bIsSelf) {
return aIsSelf ? -1 : 1;
}
}
const nameCompare = compareForwardUserNameAsc(a.userName, b.userName);
if (nameCompare !== 0) {
return nameCompare;
}
return a.userId - b.userId;
});
return groups;
}, [orderedForwards, isAdmin, tokenUserId]);
const sortedForwards = useMemo(
() => groupedForwards.flatMap((group) => group.items),
[groupedForwards],
); );
// 可拖拽的转发卡片组件 // 可拖拽的转发卡片组件
@@ -1474,15 +1561,46 @@ export default function ForwardPage() {
const userMap = new Map<number, { id: number; name: string }>(); const userMap = new Map<number, { id: number; name: string }>();
forwards.forEach((f) => { forwards.forEach((f) => {
const uId = f.userId || 0; const uId = f.userId ?? 0;
const userName = normalizeForwardUserName(f.userName);
const existingUser = userMap.get(uId);
if (!userMap.has(uId)) { if (!existingUser) {
userMap.set(uId, { id: uId, name: f.userName || "未知用户" }); userMap.set(uId, { id: uId, name: userName });
return;
}
if (
existingUser.name === UNKNOWN_FORWARD_USER_NAME &&
userName !== UNKNOWN_FORWARD_USER_NAME
) {
existingUser.name = userName;
} }
}); });
return Array.from(userMap.values()); const users = Array.from(userMap.values());
}, [forwards]);
users.sort((a, b) => {
if (isAdmin && tokenUserId !== null) {
const aIsSelf = a.id === tokenUserId;
const bIsSelf = b.id === tokenUserId;
if (aIsSelf !== bIsSelf) {
return aIsSelf ? -1 : 1;
}
}
const nameCompare = compareForwardUserNameAsc(a.name, b.name);
if (nameCompare !== 0) {
return nameCompare;
}
return a.id - b.id;
});
return users;
}, [forwards, isAdmin, tokenUserId]);
// 可拖拽的表格行组件 // 可拖拽的表格行组件
const SortableTableRow = ({ const SortableTableRow = ({
@@ -1552,11 +1670,6 @@ export default function ForwardPage() {
</svg> </svg>
</div> </div>
</TableCell> </TableCell>
<TableCell className="whitespace-nowrap">
<span className="text-sm font-medium text-default-700">
{forward.userName || "未知用户"}
</span>
</TableCell>
<TableCell className="whitespace-nowrap font-semibold text-foreground"> <TableCell className="whitespace-nowrap font-semibold text-foreground">
{forward.name} {forward.name}
</TableCell> </TableCell>
@@ -2207,63 +2320,93 @@ export default function ForwardPage() {
{/* 根据显示模式渲染不同内容 */} {/* 根据显示模式渲染不同内容 */}
{viewMode === "grouped" ? ( {viewMode === "grouped" ? (
sortedForwards.length > 0 ? ( sortedForwards.length > 0 ? (
<div className="overflow-hidden rounded-xl border border-divider bg-content1 shadow-md"> <div className="space-y-4">
<DndContext {groupedForwards.map((group) => {
collisionDetection={closestCenter} const groupSortableForwardIds = group.items
sensors={sensors} .map((item) => item.id)
onDragEnd={handleDragEnd} .filter((id) => id > 0);
> const isSelfGroup =
<Table isAdmin && tokenUserId !== null && group.userId === tokenUserId;
aria-label="全部转发列表"
classNames={{ return (
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider", <div
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0", key={`grouped-table-${group.userId}-${group.userName}`}
tr: "hover:bg-default-50/50 transition-colors", className="overflow-hidden rounded-xl border border-divider bg-content1 shadow-md"
}} >
> <div className="flex items-center justify-between border-b border-divider bg-default-100/40 px-4 py-3">
<TableHeader> <div className="flex items-center gap-2">
{selectMode && ( <span className="text-sm font-semibold text-foreground">
<TableColumn className="w-14">选择</TableColumn> {group.userName}
)} </span>
<TableColumn className="w-10 pl-4" /> {isSelfGroup && (
<TableColumn>用户</TableColumn> <Chip color="primary" size="sm" variant="flat">
<TableColumn>名称</TableColumn> 管理员本人
<TableColumn>隧道</TableColumn> </Chip>
<TableColumn>入口</TableColumn> )}
<TableColumn>目标</TableColumn> </div>
<TableColumn>策略</TableColumn> <span className="text-xs text-default-600">
<TableColumn>总流量</TableColumn> {group.items.length} 条转发
<TableColumn>状态</TableColumn> </span>
<TableColumn className="text-right">操作</TableColumn> </div>
</TableHeader>
<TableBody emptyContent="暂无转发配置" items={sortedForwards}> <DndContext
{(forward) => ( collisionDetection={closestCenter}
<SortableContext sensors={sensors}
key={forward.id} onDragEnd={handleDragEnd}
items={sortableForwardIds} >
strategy={verticalListSortingStrategy} <Table
aria-label={`${group.userName}转发列表`}
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
> >
<SortableTableRow <TableHeader>
formatFlow={formatFlow} {selectMode && (
formatInAddress={formatInAddress} <TableColumn className="w-14">选择</TableColumn>
formatRemoteAddress={formatRemoteAddress} )}
forward={forward} <TableColumn className="w-10 pl-4" />
getStrategyDisplay={getStrategyDisplay} <TableColumn>名称</TableColumn>
handleDelete={handleDelete} <TableColumn>隧道</TableColumn>
handleDiagnose={handleDiagnose} <TableColumn>入口</TableColumn>
handleEdit={handleEdit} <TableColumn>目标</TableColumn>
handleServiceToggle={handleServiceToggle} <TableColumn>策略</TableColumn>
hasMultipleAddresses={hasMultipleAddresses} <TableColumn>总流量</TableColumn>
selectMode={selectMode} <TableColumn>状态</TableColumn>
selectedIds={selectedIds} <TableColumn className="text-right">操作</TableColumn>
showAddressModal={showAddressModal} </TableHeader>
toggleSelect={toggleSelect} <TableBody emptyContent="暂无转发配置" items={group.items}>
/> {(forward) => (
</SortableContext> <SortableContext
)} key={forward.id}
</TableBody> items={groupSortableForwardIds}
</Table> strategy={verticalListSortingStrategy}
</DndContext> >
<SortableTableRow
formatFlow={formatFlow}
formatInAddress={formatInAddress}
formatRemoteAddress={formatRemoteAddress}
forward={forward}
getStrategyDisplay={getStrategyDisplay}
handleDelete={handleDelete}
handleDiagnose={handleDiagnose}
handleEdit={handleEdit}
handleServiceToggle={handleServiceToggle}
hasMultipleAddresses={hasMultipleAddresses}
selectMode={selectMode}
selectedIds={selectedIds}
showAddressModal={showAddressModal}
toggleSelect={toggleSelect}
/>
</SortableContext>
)}
</TableBody>
</Table>
</DndContext>
</div>
);
})}
</div> </div>
) : ( ) : (
/* 空状态 */ /* 空状态 */
@@ -2279,26 +2422,59 @@ export default function ForwardPage() {
</Card> </Card>
) )
) : /* 直接显示模式 */ ) : /* 直接显示模式 */
forwards.length > 0 ? ( sortedForwards.length > 0 ? (
<DndContext <div className="space-y-5">
collisionDetection={closestCenter} {groupedForwards.map((group) => {
sensors={sensors} const groupSortableForwardIds = group.items
onDragEnd={handleDragEnd} .map((item) => item.id)
onDragStart={() => {}} // 添加空的 onDragStart 处理器 .filter((id) => id > 0);
> const isSelfGroup =
<SortableContext isAdmin && tokenUserId !== null && group.userId === tokenUserId;
items={sortableForwardIds}
strategy={rectSortingStrategy} return (
> <div
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4"> key={`direct-group-${group.userId}-${group.userName}`}
{sortedForwards.map((forward) => className="space-y-3"
forward && forward.id ? ( >
<SortableForwardCard key={forward.id} forward={forward} /> <div className="flex items-center justify-between px-1">
) : null, <div className="flex items-center gap-2">
)} <span className="text-sm font-semibold text-foreground">
</div> {group.userName}
</SortableContext> </span>
</DndContext> {isSelfGroup && (
<Chip color="primary" size="sm" variant="flat">
管理员本人
</Chip>
)}
</div>
<span className="text-xs text-default-600">
{group.items.length} 条转发
</span>
</div>
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
onDragStart={() => {}} // 添加空的 onDragStart 处理器
>
<SortableContext
items={groupSortableForwardIds}
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">
{group.items.map((forward) =>
forward && forward.id ? (
<SortableForwardCard key={forward.id} forward={forward} />
) : null,
)}
</div>
</SortableContext>
</DndContext>
</div>
);
})}
</div>
) : ( ) : (
/* 空状态 */ /* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50"> <Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">