feat: improve forward page grouped view

This commit is contained in:
sagitchu
2026-02-20 19:27:40 +08:00
parent d216567c02
commit abc9f21ab9
2 changed files with 293 additions and 187 deletions
+265 -170
View File
@@ -15,6 +15,7 @@ import {
SortableContext,
sortableKeyboardCoordinates,
rectSortingStrategy,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
@@ -143,11 +144,15 @@ export default function ForwardPage() {
const savedMode = localStorage.getItem("forward-view-mode");
return (savedMode as "grouped" | "direct") || "direct";
} catch {
} catch (e) {
return "direct";
}
});
// 筛选状态
const [filterUserId, setFilterUserId] = useState<string>("all");
const [filterTunnelId, setFilterTunnelId] = useState<string>("all");
// 拖拽排序相关状态
const [forwardOrder, setForwardOrder] = useState<number[]>([]);
@@ -1184,6 +1189,15 @@ export default function ForwardPage() {
(forward) => forward.userId === tokenUserId,
);
}
} else if (viewMode === "grouped") {
if (filterUserId !== "all") {
const targetUserId = parseInt(filterUserId);
filteredForwards = filteredForwards.filter(f => f.userId === targetUserId || (targetUserId === 0 && !f.userId));
}
if (filterTunnelId !== "all") {
const targetTunnelId = parseInt(filterTunnelId);
filteredForwards = filteredForwards.filter(f => f.tunnelId === targetTunnelId);
}
}
// 确保过滤后的转发列表有效
@@ -1227,7 +1241,7 @@ export default function ForwardPage() {
}
return sortedByDb;
}, [forwards, forwardOrder, viewMode, tokenUserId]);
}, [forwards, forwardOrder, viewMode, tokenUserId, filterUserId, filterTunnelId]);
const sortableForwardIds = useMemo(
() => sortedForwards.map((f) => f.id).filter((id) => id > 0),
@@ -1259,6 +1273,170 @@ export default function ForwardPage() {
);
};
// 生成用作筛选项的用户和隧道列表
const uniqueUsers = useMemo(() => {
const userMap = new Map<number, { id: number; name: string }>();
forwards.forEach((f) => {
const uId = f.userId || 0;
if (!userMap.has(uId)) {
userMap.set(uId, { id: uId, name: f.userName || "未知用户" });
}
});
return Array.from(userMap.values());
}, [forwards]);
// 可拖拽的表格行组件
const SortableTableRow = ({ forward, selectMode, selectedIds, toggleSelect, getStrategyDisplay, formatInAddress, formatRemoteAddress, handleServiceToggle, handleEdit, handleDelete, handleDiagnose, showAddressModal, hasMultipleAddresses }: any) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: forward.id });
const style = {
transform: transform ? CSS.Transform.toString(transform) : undefined,
transition: isDragging ? undefined : transition || undefined,
opacity: isDragging ? 0.5 : 1,
backgroundColor: isDragging ? "var(--nextui-default-100)" : undefined,
};
const strategyDisplay = getStrategyDisplay(forward.strategy);
return (
<TableRow ref={setNodeRef} style={style} key={forward.id}>
{selectMode && (
<TableCell>
<Checkbox
isSelected={selectedIds.has(forward.id)}
onValueChange={() => toggleSelect(forward.id)}
/>
</TableCell>
)}
<TableCell>
<div
className="cursor-grab active:cursor-grabbing p-1 text-default-400 hover:text-default-600 transition-colors"
{...attributes}
{...listeners}
title="拖拽排序"
>
<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>
</TableCell>
<TableCell className="whitespace-nowrap">
<span className="text-sm font-medium text-default-700">
{forward.userName || "未知用户"}
</span>
</TableCell>
<TableCell className="whitespace-nowrap">
<Chip
size="sm"
color="secondary"
className="border-none bg-secondary/10 px-2"
>
<span className="font-medium text-secondary-700">{forward.tunnelName}</span>
</Chip>
</TableCell>
<TableCell className="whitespace-nowrap font-semibold text-foreground">
{forward.name}
</TableCell>
<TableCell className="max-w-[220px]">
<button
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${hasMultipleAddresses(forward.inIp)
? "hover:bg-default-200 hover:shadow-sm"
: ""
}`}
title={formatInAddress(forward.inIp, forward.inPort)}
type="button"
onClick={() =>
showAddressModal(forward.inIp, forward.inPort, "入口端口")
}
>
{formatInAddress(forward.inIp, forward.inPort)}
</button>
</TableCell>
<TableCell className="max-w-[240px]">
<button
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${hasMultipleAddresses(forward.remoteAddr)
? "hover:bg-default-200 hover:shadow-sm"
: ""
}`}
title={formatRemoteAddress(forward.remoteAddr)}
type="button"
onClick={() =>
showAddressModal(forward.remoteAddr, null, "目标地址")
}
>
{formatRemoteAddress(forward.remoteAddr)}
</button>
</TableCell>
<TableCell>
<Chip
className="text-xs font-medium"
color={strategyDisplay.color as any}
size="sm"
variant="flat"
>
{strategyDisplay.text}
</Chip>
</TableCell>
<TableCell>
<div className="flex items-center gap-2.5 whitespace-nowrap">
<Switch
isDisabled={forward.status !== 1 && forward.status !== 0}
isSelected={forward.serviceRunning}
size="sm"
color="success"
onValueChange={() => handleServiceToggle(forward)}
/>
</div>
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Button
isIconOnly
className="bg-primary/10 text-primary hover:bg-primary/20"
size="sm"
title="编辑"
onPress={() => handleEdit(forward)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</Button>
<Button
isIconOnly
className="bg-warning/10 text-warning hover:bg-warning/20"
size="sm"
title="诊断"
onPress={() => handleDiagnose(forward)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
</Button>
<Button
isIconOnly
className="bg-danger/10 text-danger hover:bg-danger/20"
size="sm"
title="删除"
onPress={() => handleDelete(forward)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</Button>
</div>
</TableCell>
</TableRow>
);
};
// 渲染转发卡片
const renderForwardCard = (forward: Forward, listeners?: any) => {
const statusDisplay = getStatusDisplay(forward.status);
@@ -1524,7 +1702,45 @@ export default function ForwardPage() {
<div className="px-3 lg:px-6 py-8">
{/* 页面头部 */}
<div className="flex items-center justify-between mb-6 gap-2">
<div className="flex-1" />
<div className="flex-1 flex gap-2">
{viewMode === "grouped" && (
<>
<Select
aria-label="筛选用户"
className="max-w-[150px]"
size="sm"
variant="bordered"
selectedKeys={[filterUserId]}
onSelectionChange={(keys) => {
const key = Array.from(keys)[0] as string;
setFilterUserId(key || "all");
}}
>
<SelectItem key="all">全部用户</SelectItem>
{uniqueUsers.map(user => (
<SelectItem key={user.id.toString()}>{user.name}</SelectItem>
))}
</Select>
<Select
aria-label="筛选隧道"
className="max-w-[150px]"
size="sm"
variant="bordered"
selectedKeys={[filterTunnelId]}
onSelectionChange={(keys) => {
const key = Array.from(keys)[0] as string;
setFilterTunnelId(key || "all");
}}
>
<SelectItem key="all">全部隧道</SelectItem>
{tunnels.map(tunnel => (
<SelectItem key={tunnel.id.toString()}>{tunnel.name}</SelectItem>
))}
</Select>
</>
)}
</div>
<div className="flex flex-wrap items-center justify-end gap-2 sm:gap-3">
{/* 显示模式切换按钮 */}
<Button
@@ -1673,175 +1889,54 @@ export default function ForwardPage() {
{viewMode === "grouped" ? (
sortedForwards.length > 0 ? (
<div className="overflow-hidden rounded-xl border border-divider bg-content1 shadow-md">
<Table
aria-label="全部转发列表"
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",
}}
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
>
<TableHeader>
{selectMode && <TableColumn className="w-14">选择</TableColumn>}
<TableColumn>用户</TableColumn>
<TableColumn>隧道</TableColumn>
<TableColumn>名称</TableColumn>
<TableColumn>入口</TableColumn>
<TableColumn>目标</TableColumn>
<TableColumn>策略</TableColumn>
<TableColumn>流量</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn className="text-right">操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无转发配置" items={sortedForwards}>
{(forward) => {
const statusDisplay = getStatusDisplay(forward.status);
const strategyDisplay = getStrategyDisplay(forward.strategy);
return (
<TableRow key={forward.id}>
{selectMode && (
<TableCell>
<Checkbox
isSelected={selectedIds.has(forward.id)}
onValueChange={() => toggleSelect(forward.id)}
/>
</TableCell>
)}
<TableCell className="whitespace-nowrap">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
{(forward.userName || "未")[0].toUpperCase()}
</div>
<span className="text-sm font-medium text-default-700">
{forward.userName || "未知用户"}
</span>
</div>
</TableCell>
<TableCell className="whitespace-nowrap">
<Chip
size="sm"
color="secondary"
className="border-none bg-secondary/10 px-2"
>
<span className="font-medium text-secondary-700">{forward.tunnelName}</span>
</Chip>
</TableCell>
<TableCell className="whitespace-nowrap font-semibold text-foreground">
{forward.name}
</TableCell>
<TableCell className="max-w-[220px]">
<button
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${hasMultipleAddresses(forward.inIp)
? "hover:bg-default-200 hover:shadow-sm"
: ""
}`}
title={formatInAddress(forward.inIp, forward.inPort)}
type="button"
onClick={() =>
showAddressModal(forward.inIp, forward.inPort, "入口端口")
}
>
{formatInAddress(forward.inIp, forward.inPort)}
</button>
</TableCell>
<TableCell className="max-w-[240px]">
<button
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${hasMultipleAddresses(forward.remoteAddr)
? "hover:bg-default-200 hover:shadow-sm"
: ""
}`}
title={formatRemoteAddress(forward.remoteAddr)}
type="button"
onClick={() =>
showAddressModal(forward.remoteAddr, null, "目标地址")
}
>
{formatRemoteAddress(forward.remoteAddr)}
</button>
</TableCell>
<TableCell>
<Chip
className="text-xs font-medium"
color={strategyDisplay.color as any}
size="sm"
variant="flat"
>
{strategyDisplay.text}
</Chip>
</TableCell>
<TableCell className="whitespace-nowrap">
<div className="flex flex-col gap-1.5 text-xs font-medium text-default-600">
<span className="flex items-center gap-1 text-success-600">
<span className="text-[10px]">↑</span>
{formatFlow(forward.inFlow || 0)}
</span>
<span className="flex items-center gap-1 text-primary-600">
<span className="text-[10px]">↓</span>
{formatFlow(forward.outFlow || 0)}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2.5 whitespace-nowrap">
<Switch
isDisabled={forward.status !== 1 && forward.status !== 0}
isSelected={forward.serviceRunning}
size="sm"
color="success"
onValueChange={() => handleServiceToggle(forward)}
/>
<Chip
className="border-none text-xs font-medium"
color={statusDisplay.color as any}
size="sm"
>
{statusDisplay.text}
</Chip>
</div>
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Button
isIconOnly
className="bg-primary/10 text-primary hover:bg-primary/20"
size="sm"
title="编辑"
onPress={() => handleEdit(forward)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</Button>
<Button
isIconOnly
className="bg-warning/10 text-warning hover:bg-warning/20"
size="sm"
title="诊断"
onPress={() => handleDiagnose(forward)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
</Button>
<Button
isIconOnly
className="bg-danger/10 text-danger hover:bg-danger/20"
size="sm"
title="删除"
onPress={() => handleDelete(forward)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</Button>
</div>
</TableCell>
</TableRow>
);
<Table
aria-label="全部转发列表"
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",
}}
</TableBody>
</Table>
>
<TableHeader>
{selectMode && <TableColumn className="w-14">选择</TableColumn>}
<TableColumn className="w-10 pl-4"></TableColumn>
<TableColumn>用户</TableColumn>
<TableColumn>隧道</TableColumn>
<TableColumn>名称</TableColumn>
<TableColumn>入口</TableColumn>
<TableColumn>目标</TableColumn>
<TableColumn>策略</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn className="text-right">操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无转发配置" items={sortedForwards}>
{(forward) => (
<SortableContext items={sortableForwardIds} strategy={verticalListSortingStrategy} key={forward.id}>
<SortableTableRow
forward={forward}
selectMode={selectMode}
selectedIds={selectedIds}
toggleSelect={toggleSelect}
getStrategyDisplay={getStrategyDisplay}
formatInAddress={formatInAddress}
formatRemoteAddress={formatRemoteAddress}
handleServiceToggle={handleServiceToggle}
handleEdit={handleEdit}
handleDelete={handleDelete}
handleDiagnose={handleDiagnose}
showAddressModal={showAddressModal}
hasMultipleAddresses={hasMultipleAddresses}
/>
</SortableContext>
)}
</TableBody>
</Table>
</DndContext>
</div>
) : (
/* 空状态 */
@@ -72,18 +72,24 @@ interface TableBodyProps<T>
loadingContent?: React.ReactNode;
}
export function TableBody<T>({
children,
className,
emptyContent,
isLoading,
items,
loadingContent,
...props
}: TableBodyProps<T>) {
export const TableBody = React.forwardRef<
HTMLTableSectionElement,
TableBodyProps<any>
>(function TableBody<T>(
{
children,
className,
emptyContent,
isLoading,
items,
loadingContent,
...props
}: TableBodyProps<T>,
ref: React.Ref<HTMLTableSectionElement>,
) {
if (isLoading) {
return (
<tbody className={className} {...props}>
<tbody ref={ref} className={className} {...props}>
<tr>
<td className="p-4 text-center text-default-500" colSpan={999}>
{loadingContent ?? "加载中..."}
@@ -96,7 +102,7 @@ export function TableBody<T>({
if (items && typeof children === "function") {
if (items.length === 0) {
return (
<tbody className={className} {...props}>
<tbody ref={ref} className={className} {...props}>
<tr>
<td className="p-4 text-center text-default-500" colSpan={999}>
{emptyContent ?? "暂无数据"}
@@ -107,7 +113,7 @@ export function TableBody<T>({
}
return (
<tbody className={className} {...props}>
<tbody ref={ref} className={className} {...props}>
{items.map((item) => {
const key =
typeof item === "object" && item !== null && "id" in item
@@ -125,7 +131,7 @@ export function TableBody<T>({
if (staticChildren.length === 0) {
return (
<tbody className={className} {...props}>
<tbody ref={ref} className={className} {...props}>
<tr>
<td className="p-4 text-center text-default-500" colSpan={999}>
{emptyContent ?? "暂无数据"}
@@ -136,11 +142,11 @@ export function TableBody<T>({
}
return (
<tbody className={className} {...props}>
<tbody ref={ref} className={className} {...props}>
{typeof children === "function" ? null : children}
</tbody>
);
}
});
export function TableColumn({
className,
@@ -160,16 +166,21 @@ export function TableColumn({
);
}
export function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
export const TableRow = React.forwardRef<
HTMLTableRowElement,
React.ComponentProps<"tr">
>(({ className, ...props }, ref) => {
const { trClassName } = React.useContext(TableStyleContext);
return (
<tr
ref={ref}
className={cn("border-b last:border-b-0", trClassName, className)}
{...props}
/>
);
}
});
TableRow.displayName = "TableRow";
export function TableCell({ className, ...props }: React.ComponentProps<"td">) {
const { tdClassName } = React.useContext(TableStyleContext);