mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-05 01:26:37 +08:00
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:
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user