mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
4199 lines
170 KiB
TypeScript
4199 lines
170 KiB
TypeScript
import type {
|
|
BatchOperationFailure,
|
|
TunnelBatchDeletePreviewApiData,
|
|
TunnelDeletePreviewApiData,
|
|
} from "@/api/types";
|
|
|
|
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
|
import toast from "react-hot-toast";
|
|
import {
|
|
DndContext,
|
|
KeyboardSensor,
|
|
MouseSensor,
|
|
TouchSensor,
|
|
type DragEndEvent,
|
|
useSensor,
|
|
useSensors,
|
|
} from "@dnd-kit/core";
|
|
import {
|
|
SortableContext,
|
|
arrayMove,
|
|
rectSortingStrategy,
|
|
sortableKeyboardCoordinates,
|
|
useSortable,
|
|
} from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import { LayoutGrid, List } from "lucide-react";
|
|
|
|
import { SearchBar } from "@/components/search-bar";
|
|
import { AnimatedPage } from "@/components/animated-page";
|
|
import { BatchActionResultModal } from "@/components/batch-action-result-modal";
|
|
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
|
import { Button } from "@/shadcn-bridge/heroui/button";
|
|
import { Input, Textarea } from "@/shadcn-bridge/heroui/input";
|
|
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
|
import {
|
|
Modal,
|
|
ModalContent,
|
|
ModalHeader,
|
|
ModalBody,
|
|
ModalFooter,
|
|
} from "@/shadcn-bridge/heroui/modal";
|
|
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
|
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
|
import { Divider } from "@/shadcn-bridge/heroui/divider";
|
|
import { Alert } from "@/shadcn-bridge/heroui/alert";
|
|
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
|
import { Progress } from "@/shadcn-bridge/heroui/progress";
|
|
import { Radio, RadioGroup } from "@/shadcn-bridge/heroui/radio";
|
|
import {
|
|
Table,
|
|
TableHeader,
|
|
TableColumn,
|
|
TableBody,
|
|
TableRow,
|
|
TableCell,
|
|
} from "@/shadcn-bridge/heroui/table";
|
|
import {
|
|
createTunnel,
|
|
batchDeleteTunnelsWithForwards,
|
|
getTunnelList,
|
|
updateTunnel,
|
|
deleteTunnelWithForwards,
|
|
getNodeList,
|
|
diagnoseTunnel,
|
|
updateTunnelOrder,
|
|
batchRedeployTunnels,
|
|
previewBatchTunnelDelete,
|
|
previewTunnelDelete,
|
|
} from "@/api";
|
|
import { PageLoadingState } from "@/components/page-state";
|
|
import {
|
|
buildDiagnosisFallbackResult,
|
|
getDiagnosisQualityDisplay,
|
|
type DiagnosisResult,
|
|
} from "@/pages/tunnel/diagnosis";
|
|
import { diagnoseTunnelStream } from "@/api/diagnosis-stream";
|
|
import {
|
|
createTunnelFormDefaults,
|
|
getTunnelFlowDisplay,
|
|
getTunnelTypeDisplay,
|
|
validateTunnelForm,
|
|
} from "@/pages/tunnel/form";
|
|
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
|
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
|
|
import {
|
|
buildBatchFailureMessage,
|
|
extractBatchFailures,
|
|
extractApiErrorMessage,
|
|
} from "@/api/error-message";
|
|
|
|
interface ChainTunnel {
|
|
nodeId: number;
|
|
protocol?: string; // 'tls' | 'wss' | 'tcp' | 'mtls' | 'mwss' | 'mtcp' | 'kcp' - 转发链协议
|
|
strategy?: string; // 'fifo' | 'round' | 'rand' | 'best' - 仅转发链/多出口需要
|
|
chainType?: number; // 1: 入口, 2: 转发链, 3: 出口
|
|
inx?: number; // 转发链序号
|
|
connectIp?: string; // 连接IP(多IP节点指定连接地址)
|
|
}
|
|
|
|
interface BestExitStateItem {
|
|
ownerNodeId: number;
|
|
ownerNodeName: string;
|
|
ownerRole: "entry" | "chain" | string;
|
|
exitNodeId?: number;
|
|
exitNodeName: string;
|
|
updatedAt?: number;
|
|
reason?: string;
|
|
}
|
|
|
|
interface BestExitState {
|
|
enabled: boolean;
|
|
summary: string;
|
|
status: "applied" | "waiting" | string;
|
|
updatedAt?: number;
|
|
reason?: string;
|
|
items: BestExitStateItem[];
|
|
}
|
|
|
|
interface Tunnel {
|
|
id: number;
|
|
inx?: number;
|
|
name: string;
|
|
type: number; // 1: 端口转发, 2: 隧道转发
|
|
inNodeId: ChainTunnel[]; // 入口节点列表
|
|
outNodeId?: ChainTunnel[]; // 出口节点列表
|
|
chainNodes?: ChainTunnel[][]; // 转发链节点列表,二维数组
|
|
inIp: string;
|
|
outIp?: string;
|
|
protocol?: string;
|
|
flow: number; // 1: 单向, 2: 双向
|
|
trafficRatio: number;
|
|
ipPreference?: string;
|
|
probeTargetHost?: string;
|
|
probeTargetPort?: number;
|
|
bestExitState?: BestExitState;
|
|
status: number;
|
|
createdTime: string;
|
|
}
|
|
|
|
interface Node {
|
|
id: number;
|
|
name: string;
|
|
status: number; // 1: 在线, 0: 离线
|
|
serverIp?: string;
|
|
serverIpV4?: string;
|
|
serverIpV6?: string;
|
|
extraIPs?: string;
|
|
}
|
|
|
|
interface TunnelForm {
|
|
id?: number;
|
|
name: string;
|
|
type: number;
|
|
inNodeId: ChainTunnel[];
|
|
outNodeId?: ChainTunnel[];
|
|
chainNodes?: ChainTunnel[][]; // 转发链节点列表,二维数组,外层是跳数,内层是该跳的节点
|
|
flow: number;
|
|
trafficRatio: number;
|
|
inIp: string; // 入口IP
|
|
ipPreference: string;
|
|
probeTargetHost?: string;
|
|
probeTargetPort?: number;
|
|
status: number;
|
|
}
|
|
|
|
interface BatchProgressState {
|
|
active: boolean;
|
|
label: string;
|
|
percent: number;
|
|
}
|
|
|
|
interface BatchResultModalState {
|
|
failures: BatchOperationFailure[];
|
|
open: boolean;
|
|
summary: string;
|
|
title: string;
|
|
}
|
|
|
|
type TunnelDeleteAction = "replace" | "delete_forwards";
|
|
|
|
const EMPTY_BATCH_RESULT_MODAL_STATE: BatchResultModalState = {
|
|
failures: [],
|
|
open: false,
|
|
summary: "",
|
|
title: "",
|
|
};
|
|
|
|
const DEFAULT_TUNNEL_DELETE_ACTION: TunnelDeleteAction = "replace";
|
|
|
|
const TUNNEL_ORDER_KEY = "tunnel-order";
|
|
|
|
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
|
!!value && typeof value === "object" && !Array.isArray(value);
|
|
|
|
const toSafeString = (value: unknown): string => {
|
|
if (typeof value === "string") return value;
|
|
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
return "";
|
|
};
|
|
|
|
const toSafeNumber = (value: unknown): number | undefined => {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value !== "string" || !value.trim()) return undefined;
|
|
|
|
const parsed = Number(value);
|
|
|
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
};
|
|
|
|
const normalizeBestExitStateItem = (
|
|
value: unknown,
|
|
): BestExitStateItem | undefined => {
|
|
if (!isObjectRecord(value)) return undefined;
|
|
|
|
const ownerNodeId = toSafeNumber(value.ownerNodeId);
|
|
|
|
if (ownerNodeId === undefined) return undefined;
|
|
|
|
const exitNodeId = toSafeNumber(value.exitNodeId);
|
|
const updatedAt = toSafeNumber(value.updatedAt);
|
|
const reason = toSafeString(value.reason);
|
|
|
|
return {
|
|
ownerNodeId,
|
|
ownerNodeName: toSafeString(value.ownerNodeName),
|
|
ownerRole: toSafeString(value.ownerRole),
|
|
...(exitNodeId !== undefined ? { exitNodeId } : {}),
|
|
exitNodeName: toSafeString(value.exitNodeName),
|
|
...(updatedAt !== undefined ? { updatedAt } : {}),
|
|
...(reason ? { reason } : {}),
|
|
};
|
|
};
|
|
|
|
const normalizeBestExitState = (value: unknown): BestExitState | undefined => {
|
|
if (!isObjectRecord(value) || value.enabled !== true) return undefined;
|
|
|
|
const updatedAt = toSafeNumber(value.updatedAt);
|
|
const reason = toSafeString(value.reason);
|
|
const items = Array.isArray(value.items)
|
|
? value.items.flatMap((item) => {
|
|
const normalized = normalizeBestExitStateItem(item);
|
|
|
|
return normalized ? [normalized] : [];
|
|
})
|
|
: [];
|
|
|
|
return {
|
|
enabled: true,
|
|
summary: toSafeString(value.summary),
|
|
status: toSafeString(value.status),
|
|
...(updatedAt !== undefined ? { updatedAt } : {}),
|
|
...(reason ? { reason } : {}),
|
|
items,
|
|
};
|
|
};
|
|
|
|
const bestExitOwnerRoleText = (role?: string) => {
|
|
if (role === "chain") return "中转";
|
|
return "入口";
|
|
};
|
|
|
|
const bestExitDetailTitle = (state?: BestExitState) => {
|
|
if (!state?.items?.length) return undefined;
|
|
return state.items
|
|
.map(
|
|
(item) =>
|
|
`${bestExitOwnerRoleText(item.ownerRole)} ${item.ownerNodeName || item.ownerNodeId} -> ${item.exitNodeName || "等待探测"}`,
|
|
)
|
|
.join("\n");
|
|
};
|
|
|
|
const renderBestExitState = (state?: BestExitState) => {
|
|
if (!state?.enabled) return null;
|
|
|
|
const isWaiting = state.status === "waiting";
|
|
const summaryText = state.summary || "等待探测";
|
|
const displaySummary =
|
|
isWaiting && !summaryText.includes("等待")
|
|
? `等待探测 · ${summaryText}`
|
|
: summaryText;
|
|
const className = isWaiting
|
|
? "border-warning-200/70 bg-warning-50/50 text-warning-700 dark:border-warning-300/20 dark:bg-warning-900/20 dark:text-warning-300"
|
|
: "border-success-200/60 bg-success-50/40 text-success-700 dark:border-success-300/20 dark:bg-success-900/20 dark:text-success-300";
|
|
|
|
return (
|
|
<span
|
|
className={`inline-flex max-w-full items-center rounded border px-1.5 py-0.5 text-[11px] leading-4 ${className}`}
|
|
title={bestExitDetailTitle(state)}
|
|
>
|
|
<span className="min-w-0 truncate">最优出口:{displaySummary}</span>
|
|
</span>
|
|
);
|
|
};
|
|
|
|
const mapTunnelApiItems = (items: any[]): Tunnel[] => {
|
|
return (items || []).map((tunnel) => {
|
|
const { bestExitState: rawBestExitState, ...tunnelFields } = tunnel;
|
|
const bestExitState = normalizeBestExitState(rawBestExitState);
|
|
|
|
return {
|
|
...tunnelFields,
|
|
...(bestExitState ? { bestExitState } : {}),
|
|
inx: tunnel.inx ?? 0,
|
|
inNodeId: Array.isArray(tunnel.inNodeId) ? tunnel.inNodeId : [],
|
|
outNodeId: Array.isArray(tunnel.outNodeId) ? tunnel.outNodeId : [],
|
|
chainNodes: Array.isArray(tunnel.chainNodes) ? tunnel.chainNodes : [],
|
|
inIp: tunnel.inIp || "",
|
|
flow: tunnel.flow ?? 1,
|
|
trafficRatio: tunnel.trafficRatio ?? 1,
|
|
status: typeof tunnel.status === "number" ? tunnel.status : 0,
|
|
createdTime: tunnel.createdTime || "",
|
|
};
|
|
});
|
|
};
|
|
|
|
export default function TunnelPage() {
|
|
const [loading, setLoading] = useState(true);
|
|
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
|
const [tunnelOrder, setTunnelOrder] = useState<number[]>([]);
|
|
const [nodes, setNodes] = useState<Node[]>([]);
|
|
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
|
|
"tunnel-search-keyword",
|
|
"",
|
|
);
|
|
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
|
const [viewMode, setViewMode] = useLocalStorageState<"list" | "grid">(
|
|
"tunnel-view-mode",
|
|
"grid",
|
|
);
|
|
|
|
// 模态框状态
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
|
const [diagnosisModalOpen, setDiagnosisModalOpen] = useState(false);
|
|
const [isEdit, setIsEdit] = useState(false);
|
|
const [submitLoading, setSubmitLoading] = useState(false);
|
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
|
const [deletePreviewLoading, setDeletePreviewLoading] = useState(false);
|
|
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
|
|
const [tunnelToDelete, setTunnelToDelete] = useState<Tunnel | null>(null);
|
|
const [tunnelDeletePreview, setTunnelDeletePreview] =
|
|
useState<TunnelDeletePreviewApiData | null>(null);
|
|
const [deleteAction, setDeleteAction] = useState<TunnelDeleteAction>(
|
|
DEFAULT_TUNNEL_DELETE_ACTION,
|
|
);
|
|
const [deleteTargetTunnelId, setDeleteTargetTunnelId] = useState<
|
|
number | null
|
|
>(null);
|
|
const [currentDiagnosisTunnel, setCurrentDiagnosisTunnel] =
|
|
useState<Tunnel | null>(null);
|
|
const [diagnosisResult, setDiagnosisResult] =
|
|
useState<DiagnosisResult | null>(null);
|
|
const [diagnosisProgress, setDiagnosisProgress] = useState({
|
|
total: 0,
|
|
completed: 0,
|
|
success: 0,
|
|
failed: 0,
|
|
timedOut: false,
|
|
});
|
|
const diagnosisAbortRef = useRef<AbortController | null>(null);
|
|
|
|
const getNodeIpOptions = (nodeId: number): string[] => {
|
|
const node = nodes.find((item) => item.id === nodeId);
|
|
|
|
if (!node) {
|
|
return [];
|
|
}
|
|
|
|
const values: string[] = [];
|
|
const push = (value?: string) => {
|
|
const trimmed = (value || "").trim();
|
|
|
|
if (trimmed) {
|
|
values.push(trimmed);
|
|
}
|
|
};
|
|
|
|
push(node.serverIpV4);
|
|
push(node.serverIpV6);
|
|
push(node.serverIp);
|
|
|
|
(node.extraIPs || "")
|
|
.split(",")
|
|
.map((v) => v.trim())
|
|
.filter((v) => v)
|
|
.forEach((v) => values.push(v));
|
|
|
|
return Array.from(new Set(values));
|
|
};
|
|
|
|
const getCommonIpOptions = (nodeIds: number[]): string[] => {
|
|
if (nodeIds.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const optionSets = nodeIds.map(
|
|
(nodeId) => new Set(getNodeIpOptions(nodeId)),
|
|
);
|
|
const base = optionSets[0];
|
|
|
|
return Array.from(base).filter((ip) =>
|
|
optionSets.every((set) => set.has(ip)),
|
|
);
|
|
};
|
|
|
|
// 表单状态
|
|
const [form, setForm] = useState<TunnelForm>(createTunnelFormDefaults());
|
|
|
|
// 表单验证错误
|
|
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
|
|
|
// 批量操作相关状态
|
|
const [selectMode, setSelectMode] = useState(false);
|
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
|
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
|
|
const [batchDeletePreviewLoading, setBatchDeletePreviewLoading] =
|
|
useState(false);
|
|
const [batchDeletePreview, setBatchDeletePreview] =
|
|
useState<TunnelBatchDeletePreviewApiData | null>(null);
|
|
const [batchDeleteAction, setBatchDeleteAction] =
|
|
useState<TunnelDeleteAction>(DEFAULT_TUNNEL_DELETE_ACTION);
|
|
const [batchDeleteTargetTunnelId, setBatchDeleteTargetTunnelId] = useState<
|
|
number | null
|
|
>(null);
|
|
const [batchLoading, setBatchLoading] = useState(false);
|
|
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
|
|
active: false,
|
|
label: "",
|
|
percent: 0,
|
|
});
|
|
const [batchResultModal, setBatchResultModal] =
|
|
useState<BatchResultModalState>(EMPTY_BATCH_RESULT_MODAL_STATE);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
diagnosisAbortRef.current?.abort();
|
|
diagnosisAbortRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
const applyTunnelList = useCallback((items: Tunnel[]) => {
|
|
setTunnels(items);
|
|
|
|
const hasDbOrdering = items.some(
|
|
(tunnel) => tunnel.inx !== undefined && tunnel.inx !== 0,
|
|
);
|
|
|
|
if (hasDbOrdering) {
|
|
const dbOrder = [...items]
|
|
.sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))
|
|
.map((tunnel) => tunnel.id);
|
|
|
|
setTunnelOrder(dbOrder);
|
|
|
|
return;
|
|
}
|
|
|
|
setTunnelOrder(
|
|
loadStoredOrder(
|
|
TUNNEL_ORDER_KEY,
|
|
items.map((tunnel) => tunnel.id),
|
|
),
|
|
);
|
|
}, []);
|
|
|
|
const refreshTunnelList = useCallback(
|
|
async (withLoading = true) => {
|
|
if (withLoading) {
|
|
setLoading(true);
|
|
}
|
|
|
|
try {
|
|
const tunnelsRes = await getTunnelList();
|
|
|
|
if (tunnelsRes.code === 0) {
|
|
applyTunnelList(mapTunnelApiItems(tunnelsRes.data || []));
|
|
} else {
|
|
toast.error(tunnelsRes.msg || "获取隧道列表失败");
|
|
}
|
|
} catch {
|
|
toast.error("获取隧道列表失败");
|
|
} finally {
|
|
if (withLoading) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
},
|
|
[applyTunnelList],
|
|
);
|
|
|
|
const refreshNodes = useCallback(async () => {
|
|
try {
|
|
const nodesRes = await getNodeList();
|
|
|
|
if (nodesRes.code === 0) {
|
|
setNodes(nodesRes.data || []);
|
|
}
|
|
} catch {}
|
|
}, []);
|
|
|
|
// 加载所有数据
|
|
const loadData = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
await Promise.all([refreshTunnelList(false), refreshNodes()]);
|
|
} catch {
|
|
toast.error("加载数据失败");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [refreshNodes, refreshTunnelList]);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [loadData]);
|
|
|
|
const resetDeleteState = useCallback(() => {
|
|
setDeleteLoading(false);
|
|
setDeletePreviewLoading(false);
|
|
setTunnelToDelete(null);
|
|
setTunnelDeletePreview(null);
|
|
setDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION);
|
|
setDeleteTargetTunnelId(null);
|
|
}, []);
|
|
|
|
const handleDeleteModalOpenChange = useCallback(
|
|
(open: boolean) => {
|
|
setDeleteModalOpen(open);
|
|
if (!open) {
|
|
resetDeleteState();
|
|
}
|
|
},
|
|
[resetDeleteState],
|
|
);
|
|
|
|
const resetBatchDeleteState = useCallback(() => {
|
|
setBatchDeletePreviewLoading(false);
|
|
setBatchDeletePreview(null);
|
|
setBatchDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION);
|
|
setBatchDeleteTargetTunnelId(null);
|
|
}, []);
|
|
|
|
const handleBatchDeleteModalOpenChange = useCallback(
|
|
(open: boolean) => {
|
|
setBatchDeleteModalOpen(open);
|
|
if (!open) {
|
|
resetBatchDeleteState();
|
|
}
|
|
},
|
|
[resetBatchDeleteState],
|
|
);
|
|
|
|
// 表单验证
|
|
const validateForm = (): boolean => {
|
|
const newErrors = validateTunnelForm(form, nodes, isEdit);
|
|
|
|
setErrors(newErrors);
|
|
|
|
return Object.keys(newErrors).length === 0;
|
|
};
|
|
|
|
// 新增隧道
|
|
const handleAdd = () => {
|
|
setIsEdit(false);
|
|
setForm(createTunnelFormDefaults());
|
|
setErrors({});
|
|
setModalOpen(true);
|
|
};
|
|
|
|
// 编辑隧道 - 只能修改部分字段
|
|
const handleEdit = (tunnel: Tunnel) => {
|
|
setIsEdit(true);
|
|
|
|
// 直接使用列表数据,getAllTunnels 已经包含完整的节点信息
|
|
setForm({
|
|
id: tunnel.id,
|
|
name: tunnel.name,
|
|
type: tunnel.type,
|
|
inNodeId: tunnel.inNodeId || [],
|
|
outNodeId: tunnel.outNodeId || [],
|
|
chainNodes: tunnel.chainNodes || [],
|
|
flow: tunnel.flow,
|
|
trafficRatio: tunnel.trafficRatio,
|
|
inIp: tunnel.inIp
|
|
? tunnel.inIp
|
|
.split(",")
|
|
.map((ip: string) => ip.trim())
|
|
.join("\n")
|
|
: "",
|
|
ipPreference: tunnel.ipPreference || "",
|
|
probeTargetHost: tunnel.probeTargetHost || "",
|
|
probeTargetPort: tunnel.probeTargetPort || 0,
|
|
status: tunnel.status,
|
|
});
|
|
setErrors({});
|
|
setModalOpen(true);
|
|
};
|
|
|
|
// 删除隧道
|
|
const handleDelete = async (tunnel: Tunnel) => {
|
|
setTunnelToDelete(tunnel);
|
|
setDeleteModalOpen(true);
|
|
|
|
setDeletePreviewLoading(true);
|
|
setTunnelDeletePreview(null);
|
|
setDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION);
|
|
setDeleteTargetTunnelId(null);
|
|
|
|
try {
|
|
const response = await previewTunnelDelete(tunnel.id);
|
|
|
|
if (response.code !== 0 || !response.data) {
|
|
toast.error(response.msg || "获取删除依赖失败");
|
|
setDeleteModalOpen(false);
|
|
resetDeleteState();
|
|
|
|
return;
|
|
}
|
|
|
|
setTunnelDeletePreview(response.data);
|
|
} catch (error) {
|
|
toast.error(extractApiErrorMessage(error, "获取删除依赖失败"));
|
|
setDeleteModalOpen(false);
|
|
resetDeleteState();
|
|
} finally {
|
|
setDeletePreviewLoading(false);
|
|
}
|
|
};
|
|
|
|
const confirmDelete = async () => {
|
|
if (!tunnelToDelete) return;
|
|
|
|
const forwardCount = tunnelDeletePreview?.forwardCount ?? 0;
|
|
const action: TunnelDeleteAction =
|
|
forwardCount > 0 ? deleteAction : "delete_forwards";
|
|
|
|
if (
|
|
action === "replace" &&
|
|
forwardCount > 0 &&
|
|
(!deleteTargetTunnelId ||
|
|
!deleteReplacementTunnels.some(
|
|
(tunnel) => tunnel.id === deleteTargetTunnelId,
|
|
))
|
|
) {
|
|
toast.error("请选择替换规则的目标隧道");
|
|
|
|
return;
|
|
}
|
|
|
|
setDeleteLoading(true);
|
|
try {
|
|
const response = await deleteTunnelWithForwards({
|
|
id: tunnelToDelete.id,
|
|
action,
|
|
targetTunnelId:
|
|
action === "replace"
|
|
? (deleteTargetTunnelId ?? undefined)
|
|
: undefined,
|
|
});
|
|
|
|
if (response.code === 0) {
|
|
const deleteResult = (response.data || null) as {
|
|
warnings?: string[];
|
|
} | null;
|
|
|
|
if ((deleteResult?.warnings?.length ?? 0) > 0) {
|
|
toast.success(
|
|
`删除成功,另有 ${deleteResult?.warnings?.length ?? 0} 条节点清理提示`,
|
|
);
|
|
} else {
|
|
toast.success("删除成功");
|
|
}
|
|
setDeleteModalOpen(false);
|
|
setTunnels((prev) =>
|
|
prev.filter((tunnel) => tunnel.id !== tunnelToDelete.id),
|
|
);
|
|
setTunnelOrder((prev) => {
|
|
const next = prev.filter((id) => id !== tunnelToDelete.id);
|
|
|
|
saveOrder(TUNNEL_ORDER_KEY, next);
|
|
|
|
return next;
|
|
});
|
|
setSelectedIds((prev) => {
|
|
const next = new Set(prev);
|
|
|
|
next.delete(tunnelToDelete.id);
|
|
|
|
return next;
|
|
});
|
|
resetDeleteState();
|
|
} else if (
|
|
response.data &&
|
|
typeof response.data === "object" &&
|
|
Number((response.data as { failCount?: number }).failCount ?? 0) > 0
|
|
) {
|
|
const result = response.data as {
|
|
failCount?: number;
|
|
successCount?: number;
|
|
};
|
|
const failures = extractBatchFailures(response.data);
|
|
|
|
if (failures.length > 0) {
|
|
setBatchResultModal({
|
|
failures,
|
|
open: true,
|
|
summary: `成功 ${Number(result.successCount ?? 0)} 项,失败 ${Number(result.failCount ?? failures.length)} 项`,
|
|
title: "规则处理失败",
|
|
});
|
|
}
|
|
toast.error(response.msg || "删除失败");
|
|
} else {
|
|
toast.error(response.msg || "删除失败");
|
|
}
|
|
} catch (error) {
|
|
toast.error(extractApiErrorMessage(error, "删除失败"));
|
|
} finally {
|
|
setDeleteLoading(false);
|
|
}
|
|
};
|
|
|
|
// 隧道类型改变时的处理
|
|
const handleTypeChange = (type: number) => {
|
|
setForm((prev) => ({
|
|
...prev,
|
|
type,
|
|
outNodeId: type === 1 ? [] : prev.outNodeId,
|
|
chainNodes: type === 1 ? [] : prev.chainNodes,
|
|
}));
|
|
};
|
|
|
|
// 删除转发链中的某一跳(删除整个分组)
|
|
const removeChainNode = (groupIndex: number) => {
|
|
setForm((prev) => ({
|
|
...prev,
|
|
chainNodes: (prev.chainNodes || []).filter(
|
|
(_, index) => index !== groupIndex,
|
|
),
|
|
}));
|
|
};
|
|
|
|
const toSelectedNodeIds = (keys: Iterable<unknown>): number[] => {
|
|
return Array.from(keys)
|
|
.map((key) => Number.parseInt(String(key), 10))
|
|
.filter((nodeId) => Number.isFinite(nodeId));
|
|
};
|
|
|
|
// 更新某一跳的所有节点的协议
|
|
const updateChainProtocol = (groupIndex: number, protocol: string) => {
|
|
setForm((prev) => {
|
|
const chainNodes = [...(prev.chainNodes || [])];
|
|
|
|
chainNodes[groupIndex] = (chainNodes[groupIndex] || []).map((node) => ({
|
|
...node,
|
|
protocol,
|
|
}));
|
|
|
|
return { ...prev, chainNodes };
|
|
});
|
|
};
|
|
|
|
// 更新某一跳的所有节点的策略
|
|
const updateChainStrategy = (groupIndex: number, strategy: string) => {
|
|
setForm((prev) => {
|
|
const chainNodes = [...(prev.chainNodes || [])];
|
|
|
|
chainNodes[groupIndex] = (chainNodes[groupIndex] || []).map((node) => ({
|
|
...node,
|
|
strategy,
|
|
}));
|
|
|
|
return { ...prev, chainNodes };
|
|
});
|
|
};
|
|
|
|
// 更新某一跳的所有节点的连接IP
|
|
const updateChainConnectIp = (groupIndex: number, connectIp: string) => {
|
|
setForm((prev) => {
|
|
const chainNodes = [...(prev.chainNodes || [])];
|
|
|
|
chainNodes[groupIndex] = (chainNodes[groupIndex] || []).map((node) => ({
|
|
...node,
|
|
connectIp,
|
|
}));
|
|
|
|
return { ...prev, chainNodes };
|
|
});
|
|
};
|
|
|
|
// 获取所有转发链中已选择的节点ID列表
|
|
const getSelectedChainNodeIds = (): number[] => {
|
|
return (form.chainNodes || []).flatMap((group) =>
|
|
group.map((node) => node.nodeId),
|
|
);
|
|
};
|
|
|
|
// 获取转发链分组(已经是二维数组)
|
|
const getChainGroups = (): ChainTunnel[][] => {
|
|
return form.chainNodes || [];
|
|
};
|
|
|
|
const mergeOrderedNodes = (
|
|
currentNodes: ChainTunnel[],
|
|
selectedNodeIds: number[],
|
|
buildDefault: (nodeId: number) => ChainTunnel,
|
|
): ChainTunnel[] => {
|
|
const selectedSet = new Set(selectedNodeIds);
|
|
const kept = currentNodes.filter((node) => selectedSet.has(node.nodeId));
|
|
const keptIds = new Set(kept.map((node) => node.nodeId));
|
|
const added = selectedNodeIds
|
|
.filter((nodeId) => !keptIds.has(nodeId))
|
|
.map((nodeId) => buildDefault(nodeId));
|
|
|
|
return [...kept, ...added];
|
|
};
|
|
|
|
const syncChainGroupNodes = (
|
|
groupIndex: number,
|
|
selectedNodeIds: number[],
|
|
) => {
|
|
setForm((prev) => {
|
|
const chainNodes = [...(prev.chainNodes || [])];
|
|
const currentGroup = chainNodes[groupIndex] || [];
|
|
const protocol = currentGroup[0]?.protocol || "tls";
|
|
const strategy = currentGroup[0]?.strategy || "round";
|
|
const realNodes = currentGroup.filter((node) => node.nodeId !== -1);
|
|
const mergedNodes = mergeOrderedNodes(
|
|
realNodes,
|
|
selectedNodeIds,
|
|
(nodeId) => ({
|
|
nodeId,
|
|
chainType: 2,
|
|
protocol,
|
|
strategy,
|
|
}),
|
|
);
|
|
|
|
chainNodes[groupIndex] =
|
|
mergedNodes.length > 0
|
|
? mergedNodes
|
|
: [{ nodeId: -1, chainType: 2, protocol, strategy }];
|
|
|
|
return { ...prev, chainNodes };
|
|
});
|
|
};
|
|
|
|
// 提交表单
|
|
const handleSubmit = async () => {
|
|
if (!validateForm()) return;
|
|
|
|
setSubmitLoading(true);
|
|
try {
|
|
// 过滤掉占位节点(nodeId === -1 的节点)
|
|
const cleanedChainNodes = (form.chainNodes || [])
|
|
.map((group) => group.filter((node) => node.nodeId !== -1))
|
|
.filter((group) => group.length > 0); // 移除空组
|
|
|
|
// 过滤掉出口节点中的占位节点
|
|
const cleanedOutNodeId = (form.outNodeId || []).filter(
|
|
(node) => node.nodeId !== -1,
|
|
);
|
|
|
|
// 将换行符分隔的IP转换为逗号分隔
|
|
const inIpString = form.inIp
|
|
.split("\n")
|
|
.map((ip) => ip.trim())
|
|
.filter((ip) => ip)
|
|
.join(",");
|
|
const probeTargetHost = (form.probeTargetHost || "").trim();
|
|
const probeTargetPort = probeTargetHost
|
|
? Number(form.probeTargetPort || 0)
|
|
: 0;
|
|
|
|
const data = {
|
|
...form,
|
|
inIp: inIpString,
|
|
outNodeId: cleanedOutNodeId,
|
|
chainNodes: cleanedChainNodes,
|
|
probeTargetHost,
|
|
probeTargetPort,
|
|
};
|
|
|
|
const response = isEdit
|
|
? await updateTunnel(data)
|
|
: await createTunnel(data);
|
|
|
|
if (response.code === 0) {
|
|
toast.success(isEdit ? "更新成功" : "创建成功");
|
|
setModalOpen(false);
|
|
await refreshTunnelList(false);
|
|
} else {
|
|
toast.error(response.msg || (isEdit ? "更新失败" : "创建失败"));
|
|
}
|
|
} catch {
|
|
toast.error("网络错误,请重试");
|
|
} finally {
|
|
setSubmitLoading(false);
|
|
}
|
|
};
|
|
|
|
// 诊断隧道
|
|
const handleDiagnose = async (tunnel: Tunnel) => {
|
|
diagnosisAbortRef.current?.abort();
|
|
const abortController = new AbortController();
|
|
|
|
diagnosisAbortRef.current = abortController;
|
|
|
|
setCurrentDiagnosisTunnel(tunnel);
|
|
setDiagnosisModalOpen(true);
|
|
setDiagnosisLoading(true);
|
|
setDiagnosisProgress({
|
|
total: 0,
|
|
completed: 0,
|
|
success: 0,
|
|
failed: 0,
|
|
timedOut: false,
|
|
});
|
|
setDiagnosisResult({
|
|
tunnelName: tunnel.name,
|
|
tunnelType: tunnel.type === 1 ? "端口转发" : "隧道转发",
|
|
timestamp: Date.now(),
|
|
results: [],
|
|
});
|
|
|
|
try {
|
|
let streamErrorMessage = "";
|
|
const streamResult = await diagnoseTunnelStream(
|
|
tunnel.id,
|
|
{
|
|
onStart: (payload) => {
|
|
const startTunnelName =
|
|
typeof payload.tunnelName === "string" &&
|
|
payload.tunnelName.trim() !== ""
|
|
? payload.tunnelName
|
|
: tunnel.name;
|
|
const startTunnelType =
|
|
typeof payload.tunnelType === "string" &&
|
|
payload.tunnelType.trim() !== ""
|
|
? payload.tunnelType
|
|
: tunnel.type === 1
|
|
? "端口转发"
|
|
: "隧道转发";
|
|
const startTotal = Number(payload.total);
|
|
const startItems = Array.isArray(payload.items)
|
|
? (payload.items as DiagnosisResult["results"])
|
|
: [];
|
|
|
|
setDiagnosisResult((prev) => ({
|
|
tunnelName: startTunnelName,
|
|
tunnelType: startTunnelType,
|
|
timestamp: Date.now(),
|
|
results: startItems.length > 0 ? startItems : prev?.results || [],
|
|
}));
|
|
if (Number.isFinite(startTotal) && startTotal >= 0) {
|
|
setDiagnosisProgress((prev) => ({
|
|
...prev,
|
|
total: startTotal,
|
|
}));
|
|
}
|
|
},
|
|
onItem: ({ result, progress }) => {
|
|
setDiagnosisResult((prev) => {
|
|
const base: DiagnosisResult = prev || {
|
|
tunnelName: tunnel.name,
|
|
tunnelType: tunnel.type === 1 ? "端口转发" : "隧道转发",
|
|
timestamp: Date.now(),
|
|
results: [],
|
|
};
|
|
const nextResults = [...base.results];
|
|
const existingIndex = nextResults.findIndex(
|
|
(item) =>
|
|
item.description === result.description &&
|
|
item.nodeId === result.nodeId &&
|
|
item.targetIp === result.targetIp &&
|
|
item.targetPort === result.targetPort,
|
|
);
|
|
|
|
if (existingIndex >= 0) {
|
|
nextResults[existingIndex] = {
|
|
...result,
|
|
diagnosing: false,
|
|
};
|
|
} else {
|
|
nextResults.push({
|
|
...result,
|
|
diagnosing: false,
|
|
});
|
|
}
|
|
|
|
return {
|
|
...base,
|
|
timestamp: Date.now(),
|
|
results: nextResults,
|
|
};
|
|
});
|
|
setDiagnosisProgress({
|
|
total: progress.total,
|
|
completed: progress.completed,
|
|
success: progress.success,
|
|
failed: progress.failed,
|
|
timedOut: Boolean(progress.timedOut),
|
|
});
|
|
},
|
|
onDone: (progress) => {
|
|
setDiagnosisProgress({
|
|
total: progress.total,
|
|
completed: progress.completed,
|
|
success: progress.success,
|
|
failed: progress.failed,
|
|
timedOut: Boolean(progress.timedOut),
|
|
});
|
|
},
|
|
onError: (message) => {
|
|
streamErrorMessage = message;
|
|
},
|
|
},
|
|
abortController.signal,
|
|
);
|
|
|
|
if (streamResult.fallback) {
|
|
const response = await diagnoseTunnel(tunnel.id);
|
|
|
|
if (response.code === 0) {
|
|
const resultData = response.data as DiagnosisResult;
|
|
const successCount = resultData.results.filter(
|
|
(r) => r.success,
|
|
).length;
|
|
const failedCount = resultData.results.length - successCount;
|
|
|
|
setDiagnosisResult(resultData);
|
|
setDiagnosisProgress({
|
|
total: resultData.results.length,
|
|
completed: resultData.results.length,
|
|
success: successCount,
|
|
failed: failedCount,
|
|
timedOut: false,
|
|
});
|
|
} else {
|
|
toast.error(response.msg || "诊断失败");
|
|
setDiagnosisResult(
|
|
buildDiagnosisFallbackResult({
|
|
tunnelName: tunnel.name,
|
|
tunnelType: tunnel.type,
|
|
description: "诊断失败",
|
|
message: response.msg || "诊断过程中发生错误",
|
|
}),
|
|
);
|
|
setDiagnosisProgress({
|
|
total: 1,
|
|
completed: 1,
|
|
success: 0,
|
|
failed: 1,
|
|
timedOut: false,
|
|
});
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (streamErrorMessage) {
|
|
toast.error(streamErrorMessage);
|
|
}
|
|
if (streamResult.timedOut) {
|
|
toast.error("诊断超时(单条30秒 / 整体2分钟),已返回当前结果");
|
|
}
|
|
} catch {
|
|
if (abortController.signal.aborted) {
|
|
return;
|
|
}
|
|
toast.error("网络错误,请重试");
|
|
setDiagnosisResult(
|
|
buildDiagnosisFallbackResult({
|
|
tunnelName: tunnel.name,
|
|
tunnelType: tunnel.type,
|
|
description: "网络错误",
|
|
message: "无法连接到服务器",
|
|
}),
|
|
);
|
|
setDiagnosisProgress({
|
|
total: 1,
|
|
completed: 1,
|
|
success: 0,
|
|
failed: 1,
|
|
timedOut: false,
|
|
});
|
|
} finally {
|
|
if (diagnosisAbortRef.current === abortController) {
|
|
diagnosisAbortRef.current = null;
|
|
}
|
|
setDiagnosisLoading(false);
|
|
}
|
|
};
|
|
|
|
// 处理拖拽结束
|
|
const handleDragEnd = async (event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
|
|
if (!active || !over || active.id === over.id) return;
|
|
if (!tunnelOrder || tunnelOrder.length === 0) return;
|
|
|
|
const activeId = Number(active.id);
|
|
const overId = Number(over.id);
|
|
|
|
if (isNaN(activeId) || isNaN(overId)) return;
|
|
|
|
const oldIndex = tunnelOrder.indexOf(activeId);
|
|
const newIndex = tunnelOrder.indexOf(overId);
|
|
|
|
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
|
|
|
const newOrder = arrayMove(tunnelOrder, oldIndex, newIndex);
|
|
|
|
setTunnelOrder(newOrder);
|
|
|
|
saveOrder(TUNNEL_ORDER_KEY, newOrder);
|
|
|
|
// 持久化到数据库
|
|
try {
|
|
const tunnelsToUpdate = newOrder.map((id, index) => ({ id, inx: index }));
|
|
const response = await updateTunnelOrder({ tunnels: tunnelsToUpdate });
|
|
|
|
if (response.code === 0) {
|
|
setTunnels((prev) =>
|
|
prev.map((tunnel) => {
|
|
const updated = tunnelsToUpdate.find((t) => t.id === tunnel.id);
|
|
|
|
return updated ? { ...tunnel, inx: updated.inx } : tunnel;
|
|
}),
|
|
);
|
|
} else {
|
|
toast.error("保存排序失败:" + (response.msg || "未知错误"));
|
|
}
|
|
} catch {
|
|
toast.error("保存排序失败,请重试");
|
|
}
|
|
};
|
|
|
|
const toggleSelectMode = () => {
|
|
setSelectMode(!selectMode);
|
|
if (selectMode) {
|
|
setSelectedIds(new Set());
|
|
}
|
|
};
|
|
|
|
const toggleSelect = (id: number) => {
|
|
const newSet = new Set(selectedIds);
|
|
|
|
if (newSet.has(id)) {
|
|
newSet.delete(id);
|
|
} else {
|
|
newSet.add(id);
|
|
}
|
|
setSelectedIds(newSet);
|
|
};
|
|
|
|
const selectAll = () => {
|
|
const allIds = sortedTunnels.map((t) => t.id);
|
|
|
|
setSelectedIds(new Set(allIds));
|
|
};
|
|
|
|
const deselectAll = () => {
|
|
setSelectedIds(new Set());
|
|
};
|
|
|
|
const openBatchResultModal = useCallback(
|
|
(title: string, summary: string, failures: BatchOperationFailure[]) => {
|
|
setBatchResultModal({
|
|
failures,
|
|
open: true,
|
|
summary,
|
|
title,
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleOpenBatchDeleteModal = async () => {
|
|
if (selectedIds.size === 0) return;
|
|
|
|
setBatchDeleteModalOpen(true);
|
|
setBatchDeletePreviewLoading(true);
|
|
setBatchDeletePreview(null);
|
|
setBatchDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION);
|
|
setBatchDeleteTargetTunnelId(null);
|
|
|
|
try {
|
|
const response = await previewBatchTunnelDelete(selectedTunnelIdList);
|
|
|
|
if (response.code !== 0 || !response.data) {
|
|
toast.error(response.msg || "获取批量删除依赖失败");
|
|
setBatchDeleteModalOpen(false);
|
|
resetBatchDeleteState();
|
|
|
|
return;
|
|
}
|
|
|
|
setBatchDeletePreview(response.data);
|
|
} catch (error) {
|
|
toast.error(extractApiErrorMessage(error, "获取批量删除依赖失败"));
|
|
setBatchDeleteModalOpen(false);
|
|
resetBatchDeleteState();
|
|
} finally {
|
|
setBatchDeletePreviewLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleBatchDelete = async () => {
|
|
if (selectedIds.size === 0) return;
|
|
if (
|
|
batchDeleteHasForwardDependencies &&
|
|
batchDeleteAction === "replace" &&
|
|
(!batchDeleteTargetTunnelId || batchDeleteReplaceUnavailable)
|
|
) {
|
|
toast.error("请选择替换规则的目标隧道");
|
|
|
|
return;
|
|
}
|
|
|
|
setBatchLoading(true);
|
|
setBatchProgress({
|
|
active: true,
|
|
label: `正在删除 ${selectedIds.size} 条隧道...`,
|
|
percent: 30,
|
|
});
|
|
try {
|
|
const res = await batchDeleteTunnelsWithForwards({
|
|
ids: selectedTunnelIdList,
|
|
action: batchDeleteHasForwardDependencies
|
|
? batchDeleteAction
|
|
: "delete_forwards",
|
|
targetTunnelId:
|
|
batchDeleteHasForwardDependencies && batchDeleteAction === "replace"
|
|
? (batchDeleteTargetTunnelId ?? undefined)
|
|
: undefined,
|
|
});
|
|
|
|
if (res.code === 0) {
|
|
const result = (res.data || {
|
|
successCount: 0,
|
|
failCount: 0,
|
|
warnings: [],
|
|
}) as {
|
|
successCount: number;
|
|
failCount: number;
|
|
warnings?: string[];
|
|
};
|
|
const warningCount = result?.warnings?.length ?? 0;
|
|
|
|
if (result.failCount === 0) {
|
|
toast.success(
|
|
warningCount > 0
|
|
? `成功删除 ${result.successCount} 项,另有 ${warningCount} 条节点清理提示`
|
|
: `成功删除 ${result.successCount} 项`,
|
|
);
|
|
setBatchProgress({
|
|
active: true,
|
|
label: `删除完成:成功 ${result.successCount} 项`,
|
|
percent: 100,
|
|
});
|
|
} else {
|
|
const failures = extractBatchFailures(result);
|
|
|
|
if (failures.length > 0) {
|
|
openBatchResultModal(
|
|
"批量删除结果",
|
|
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
|
failures,
|
|
);
|
|
} else {
|
|
toast.error(
|
|
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
|
);
|
|
}
|
|
setBatchProgress({
|
|
active: true,
|
|
label: `部分完成:成功 ${result.successCount} 项,正在刷新列表...`,
|
|
percent: 75,
|
|
});
|
|
}
|
|
await refreshTunnelList(false);
|
|
setSelectedIds(new Set());
|
|
setSelectMode(false);
|
|
setBatchDeleteModalOpen(false);
|
|
resetBatchDeleteState();
|
|
} else {
|
|
toast.error(res.msg || "删除失败");
|
|
}
|
|
} catch (error) {
|
|
toast.error(extractApiErrorMessage(error, "删除失败"));
|
|
} finally {
|
|
setBatchProgress({ active: false, label: "", percent: 0 });
|
|
setBatchLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleBatchRedeploy = async () => {
|
|
if (selectedIds.size === 0) return;
|
|
setBatchLoading(true);
|
|
setBatchProgress({
|
|
active: true,
|
|
label: `正在重新下发 ${selectedIds.size} 条隧道...`,
|
|
percent: 30,
|
|
});
|
|
try {
|
|
const res = await batchRedeployTunnels(Array.from(selectedIds));
|
|
|
|
if (res.code === 0) {
|
|
const result = res.data;
|
|
|
|
if (result.failCount === 0) {
|
|
toast.success(`成功重新下发 ${result.successCount} 项`);
|
|
} else {
|
|
const failures = extractBatchFailures(result);
|
|
|
|
if (failures.length > 0) {
|
|
openBatchResultModal(
|
|
"批量下发结果",
|
|
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
|
failures,
|
|
);
|
|
} else {
|
|
toast.error(
|
|
buildBatchFailureMessage(
|
|
result,
|
|
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
setSelectedIds(new Set());
|
|
setSelectMode(false);
|
|
setBatchProgress({
|
|
active: true,
|
|
label: `重新下发完成:成功 ${result.successCount} 项,正在刷新列表...`,
|
|
percent: 100,
|
|
});
|
|
await refreshTunnelList(false);
|
|
} else {
|
|
toast.error(res.msg || "下发失败");
|
|
}
|
|
} catch (error) {
|
|
toast.error(extractApiErrorMessage(error, "下发失败"));
|
|
} finally {
|
|
setBatchProgress({ active: false, label: "", percent: 0 });
|
|
setBatchLoading(false);
|
|
}
|
|
};
|
|
|
|
// 传感器配置
|
|
const sensors = useSensors(
|
|
useSensor(MouseSensor, {
|
|
activationConstraint: {
|
|
distance: 8,
|
|
},
|
|
}),
|
|
useSensor(TouchSensor, {
|
|
activationConstraint: {
|
|
delay: 250,
|
|
tolerance: 8,
|
|
},
|
|
}),
|
|
useSensor(KeyboardSensor, {
|
|
coordinateGetter: sortableKeyboardCoordinates,
|
|
}),
|
|
);
|
|
|
|
// 根据排序顺序获取隧道列表
|
|
const sortedTunnels = useMemo((): Tunnel[] => {
|
|
if (!tunnels || tunnels.length === 0) return [];
|
|
|
|
let filteredTunnels = tunnels;
|
|
|
|
if (searchKeyword.trim()) {
|
|
const lowerKeyword = searchKeyword.toLowerCase();
|
|
|
|
filteredTunnels = filteredTunnels.filter(
|
|
(t) =>
|
|
(t.name && t.name.toLowerCase().includes(lowerKeyword)) ||
|
|
(t.inIp && t.inIp.toLowerCase().includes(lowerKeyword)),
|
|
);
|
|
}
|
|
|
|
const sortedByDb = [...filteredTunnels].sort((a, b) => {
|
|
const aInx = a.inx ?? 0;
|
|
const bInx = b.inx ?? 0;
|
|
|
|
return aInx - bInx;
|
|
});
|
|
|
|
// 如果数据库中没有排序信息,则使用本地存储的顺序
|
|
if (
|
|
tunnelOrder &&
|
|
tunnelOrder.length > 0 &&
|
|
sortedByDb.every((t) => t.inx === undefined || t.inx === 0)
|
|
) {
|
|
const tunnelMap = new Map(filteredTunnels.map((t) => [t.id, t] as const));
|
|
const localSorted: Tunnel[] = [];
|
|
|
|
tunnelOrder.forEach((id) => {
|
|
const tunnel = tunnelMap.get(id);
|
|
|
|
if (tunnel) localSorted.push(tunnel);
|
|
});
|
|
|
|
filteredTunnels.forEach((tunnel) => {
|
|
if (!tunnelOrder.includes(tunnel.id)) {
|
|
localSorted.push(tunnel);
|
|
}
|
|
});
|
|
|
|
return localSorted;
|
|
}
|
|
|
|
return sortedByDb;
|
|
}, [tunnels, tunnelOrder, searchKeyword]);
|
|
|
|
const sortableTunnelIds = useMemo(
|
|
() => sortedTunnels.map((t) => t.id),
|
|
[sortedTunnels],
|
|
);
|
|
|
|
const deleteReplacementTunnels = useMemo(() => {
|
|
if (!tunnelToDelete) {
|
|
return [] as Tunnel[];
|
|
}
|
|
|
|
return tunnels
|
|
.filter(
|
|
(tunnel) => tunnel.id !== tunnelToDelete.id && tunnel.status === 1,
|
|
)
|
|
.sort((a, b) => {
|
|
const aInx = a.inx ?? 0;
|
|
const bInx = b.inx ?? 0;
|
|
|
|
return aInx - bInx;
|
|
});
|
|
}, [tunnelToDelete, tunnels]);
|
|
|
|
useEffect(() => {
|
|
if (!deleteModalOpen) {
|
|
return;
|
|
}
|
|
|
|
if ((tunnelDeletePreview?.forwardCount ?? 0) <= 0) {
|
|
return;
|
|
}
|
|
|
|
if (deleteReplacementTunnels.length === 0) {
|
|
setDeleteAction("delete_forwards");
|
|
setDeleteTargetTunnelId(null);
|
|
|
|
return;
|
|
}
|
|
|
|
if (deleteAction !== "replace") {
|
|
return;
|
|
}
|
|
|
|
setDeleteTargetTunnelId((prev) => {
|
|
if (
|
|
prev &&
|
|
deleteReplacementTunnels.some((tunnel) => tunnel.id === prev)
|
|
) {
|
|
return prev;
|
|
}
|
|
|
|
return deleteReplacementTunnels[0]?.id ?? null;
|
|
});
|
|
}, [
|
|
deleteAction,
|
|
deleteModalOpen,
|
|
deleteReplacementTunnels,
|
|
tunnelDeletePreview?.forwardCount,
|
|
]);
|
|
|
|
const deletePreviewForwardCount = tunnelDeletePreview?.forwardCount ?? 0;
|
|
const deleteHasForwardDependencies = deletePreviewForwardCount > 0;
|
|
const deleteReplaceUnavailable =
|
|
deleteHasForwardDependencies && deleteReplacementTunnels.length === 0;
|
|
const deleteConfirmLabel = deleteHasForwardDependencies
|
|
? deleteAction === "replace"
|
|
? "迁移规则后删除该隧道"
|
|
: "删除规则并删除该隧道"
|
|
: "删除该隧道";
|
|
|
|
const selectedTunnelIdList = useMemo(
|
|
() => Array.from(selectedIds),
|
|
[selectedIds],
|
|
);
|
|
const batchDeleteReplacementTunnels = useMemo(() => {
|
|
if (selectedIds.size === 0) {
|
|
return [] as Tunnel[];
|
|
}
|
|
|
|
return tunnels
|
|
.filter((tunnel) => !selectedIds.has(tunnel.id) && tunnel.status === 1)
|
|
.sort((a, b) => {
|
|
const aInx = a.inx ?? 0;
|
|
const bInx = b.inx ?? 0;
|
|
|
|
return aInx - bInx;
|
|
});
|
|
}, [selectedIds, tunnels]);
|
|
|
|
useEffect(() => {
|
|
if (!batchDeleteModalOpen) {
|
|
return;
|
|
}
|
|
|
|
if ((batchDeletePreview?.totalForwardCount ?? 0) <= 0) {
|
|
return;
|
|
}
|
|
|
|
if (batchDeleteReplacementTunnels.length === 0) {
|
|
setBatchDeleteAction("delete_forwards");
|
|
setBatchDeleteTargetTunnelId(null);
|
|
|
|
return;
|
|
}
|
|
|
|
if (batchDeleteAction !== "replace") {
|
|
return;
|
|
}
|
|
|
|
setBatchDeleteTargetTunnelId((prev) => {
|
|
if (
|
|
prev &&
|
|
batchDeleteReplacementTunnels.some((tunnel) => tunnel.id === prev)
|
|
) {
|
|
return prev;
|
|
}
|
|
|
|
return batchDeleteReplacementTunnels[0]?.id ?? null;
|
|
});
|
|
}, [
|
|
batchDeleteAction,
|
|
batchDeleteModalOpen,
|
|
batchDeletePreview?.totalForwardCount,
|
|
batchDeleteReplacementTunnels,
|
|
]);
|
|
|
|
const batchDeleteTotalForwardCount =
|
|
batchDeletePreview?.totalForwardCount ?? 0;
|
|
const batchDeleteHasForwardDependencies = batchDeleteTotalForwardCount > 0;
|
|
const batchDeleteDependentTunnelCount =
|
|
batchDeletePreview?.items?.filter((item) => item.forwardCount > 0).length ??
|
|
0;
|
|
const batchDeleteDirectDeleteTunnelCount = Math.max(
|
|
selectedTunnelIdList.length - batchDeleteDependentTunnelCount,
|
|
0,
|
|
);
|
|
const batchDeletePreviewItems = useMemo(() => {
|
|
return [...(batchDeletePreview?.items ?? [])].sort((a, b) => {
|
|
if (a.forwardCount > 0 === b.forwardCount > 0) {
|
|
return a.tunnelName.localeCompare(b.tunnelName, "zh-CN");
|
|
}
|
|
|
|
return a.forwardCount > 0 ? -1 : 1;
|
|
});
|
|
}, [batchDeletePreview?.items]);
|
|
const batchDeleteDependentItems = useMemo(
|
|
() => batchDeletePreviewItems.filter((item) => item.forwardCount > 0),
|
|
[batchDeletePreviewItems],
|
|
);
|
|
const batchDeleteReplaceUnavailable =
|
|
batchDeleteHasForwardDependencies &&
|
|
batchDeleteReplacementTunnels.length === 0;
|
|
const batchDeleteConfirmLabel = batchDeleteHasForwardDependencies
|
|
? batchDeleteAction === "replace"
|
|
? `迁移规则后删除这 ${selectedTunnelIdList.length} 条隧道`
|
|
: `删除规则并删除 ${selectedTunnelIdList.length} 条隧道`
|
|
: `删除这 ${selectedTunnelIdList.length} 条隧道`;
|
|
|
|
const SortableItem = ({
|
|
id,
|
|
children,
|
|
}: {
|
|
id: number;
|
|
children: (listeners: any) => any;
|
|
}) => {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id });
|
|
|
|
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.5 : 1,
|
|
willChange: isDragging ? "transform" : undefined,
|
|
};
|
|
|
|
return (
|
|
<div ref={setNodeRef} style={style} {...attributes}>
|
|
{children(listeners)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
if (loading) {
|
|
return <PageLoadingState message="正在加载..." />;
|
|
}
|
|
|
|
return (
|
|
<AnimatedPage className="px-3 lg:px-6 py-8">
|
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
|
|
<div className="flex-1 max-w-sm flex items-center gap-2">
|
|
<SearchBar
|
|
isVisible={isSearchVisible}
|
|
placeholder="搜索隧道名称或IP"
|
|
value={searchKeyword}
|
|
onChange={setSearchKeyword}
|
|
onClose={() => setIsSearchVisible(false)}
|
|
onOpen={() => setIsSearchVisible(true)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
|
|
<div className="flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap [&>*]:shrink-0">
|
|
{selectMode ? (
|
|
<>
|
|
<span className="text-sm text-default-600 shrink-0">
|
|
已选择 {selectedIds.size} 项
|
|
</span>
|
|
<Button
|
|
color="primary"
|
|
size="sm"
|
|
variant="flat"
|
|
onPress={selectAll}
|
|
>
|
|
全选
|
|
</Button>
|
|
<Button
|
|
color="secondary"
|
|
size="sm"
|
|
variant="flat"
|
|
onPress={deselectAll}
|
|
>
|
|
清空
|
|
</Button>
|
|
<Button
|
|
color="danger"
|
|
isDisabled={selectedIds.size === 0}
|
|
size="sm"
|
|
variant="flat"
|
|
onPress={handleOpenBatchDeleteModal}
|
|
>
|
|
删除
|
|
</Button>
|
|
<Button
|
|
color="primary"
|
|
isDisabled={selectedIds.size === 0}
|
|
isLoading={batchLoading}
|
|
size="sm"
|
|
variant="flat"
|
|
onPress={handleBatchRedeploy}
|
|
>
|
|
下发
|
|
</Button>
|
|
<Button
|
|
color="secondary"
|
|
size="sm"
|
|
variant="solid"
|
|
onPress={toggleSelectMode}
|
|
>
|
|
退出
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button
|
|
className="bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45"
|
|
color="default"
|
|
size="sm"
|
|
variant="flat"
|
|
onPress={toggleSelectMode}
|
|
>
|
|
批量
|
|
</Button>
|
|
<Button
|
|
isIconOnly
|
|
size="sm"
|
|
variant="flat"
|
|
onPress={() =>
|
|
setViewMode(viewMode === "list" ? "grid" : "list")
|
|
}
|
|
>
|
|
{viewMode === "list" ? (
|
|
<LayoutGrid className="w-4 h-4" />
|
|
) : (
|
|
<List className="w-4 h-4" />
|
|
)}
|
|
</Button>
|
|
<Button
|
|
color="primary"
|
|
size="sm"
|
|
variant="flat"
|
|
onPress={handleAdd}
|
|
>
|
|
新增
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{batchProgress.active && (
|
|
<div className="mb-4">
|
|
<Alert
|
|
color="primary"
|
|
description={batchProgress.label}
|
|
variant="flat"
|
|
/>
|
|
<Progress
|
|
aria-label={batchProgress.label}
|
|
className="mt-3"
|
|
color="primary"
|
|
size="sm"
|
|
value={batchProgress.percent}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 隧道卡片网格 */}
|
|
{tunnels.length > 0 ? (
|
|
viewMode === "list" ? (
|
|
<Card className="bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl border border-white/80 dark:border-white/10 shadow-[0_15px_35px_rgba(0,0,0,0.1)]">
|
|
<Table
|
|
aria-label="隧道列表"
|
|
className="overflow-x-auto min-w-full"
|
|
classNames={{
|
|
wrapper:
|
|
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-[24px]",
|
|
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
|
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
|
tr: "hover:bg-white/10 dark:hover:bg-white/5 transition-colors",
|
|
}}
|
|
>
|
|
<TableHeader>
|
|
{selectMode ? (
|
|
<TableColumn className="w-12 px-4 whitespace-nowrap overflow-hidden">
|
|
<Checkbox
|
|
isSelected={
|
|
selectedIds.size === sortedTunnels.length &&
|
|
sortedTunnels.length > 0
|
|
}
|
|
onValueChange={(checked) =>
|
|
checked ? selectAll() : deselectAll()
|
|
}
|
|
/>
|
|
</TableColumn>
|
|
) : (
|
|
<TableColumn className="w-0 p-0 overflow-hidden text-[0px]" />
|
|
)}
|
|
<TableColumn>隧道名称</TableColumn>
|
|
<TableColumn>类型</TableColumn>
|
|
<TableColumn>拓扑</TableColumn>
|
|
<TableColumn>流量统计</TableColumn>
|
|
<TableColumn>操作</TableColumn>
|
|
</TableHeader>
|
|
<TableBody items={sortedTunnels}>
|
|
{(tunnel) => {
|
|
const typeDisplay = getTunnelTypeDisplay(tunnel.type);
|
|
const tunnelTypeChipClassName =
|
|
tunnel.type === 1
|
|
? "text-[10px] h-5 bg-primary-100 text-primary-800 border-primary-300 dark:bg-primary-900/45 dark:text-primary-200 dark:border-primary-700"
|
|
: "text-[10px] h-5 bg-success-100 text-success-800 border-success-300 dark:bg-success-900/35 dark:text-success-200 dark:border-success-700";
|
|
const bestExitStateContent = renderBestExitState(
|
|
tunnel.bestExitState,
|
|
);
|
|
|
|
return (
|
|
<TableRow key={tunnel.id}>
|
|
{selectMode ? (
|
|
<TableCell className="px-4">
|
|
<Checkbox
|
|
isSelected={selectedIds.has(tunnel.id)}
|
|
onValueChange={() => toggleSelect(tunnel.id)}
|
|
/>
|
|
</TableCell>
|
|
) : (
|
|
<TableCell className="w-0 p-0 overflow-hidden text-[0px]" />
|
|
)}
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className={`shrink-0 w-2 h-2 rounded-full ${
|
|
tunnel.status === 1 ? "bg-success" : "bg-danger"
|
|
}`}
|
|
/>
|
|
<span className="font-medium text-foreground text-sm">
|
|
{tunnel.name}
|
|
</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Chip
|
|
className={tunnelTypeChipClassName}
|
|
color={typeDisplay.color as any}
|
|
size="sm"
|
|
variant="flat"
|
|
>
|
|
{typeDisplay.text}
|
|
</Chip>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex min-w-0 flex-col gap-1.5">
|
|
<div className="flex items-center gap-1.5 text-xs">
|
|
<span className="font-semibold text-primary-700 dark:text-primary-400">
|
|
{tunnel.inNodeId?.length || 0}入口
|
|
</span>
|
|
<span className="text-default-400">→</span>
|
|
<span className="font-semibold text-secondary-700 dark:text-secondary-400">
|
|
{tunnel.type === 2
|
|
? tunnel.chainNodes?.length || 0
|
|
: 0}
|
|
跳
|
|
</span>
|
|
<span className="text-default-400">→</span>
|
|
<span className="font-semibold text-success-700 dark:text-success-400">
|
|
{tunnel.type === 2
|
|
? tunnel.outNodeId?.length || 0
|
|
: tunnel.inNodeId?.length || 0}
|
|
出口
|
|
</span>
|
|
</div>
|
|
{bestExitStateContent}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<span className="text-default-500">
|
|
{getTunnelFlowDisplay(tunnel.flow)}
|
|
</span>
|
|
<span className="text-default-300">|</span>
|
|
<span className="text-default-500">
|
|
{tunnel.trafficRatio}x
|
|
</span>
|
|
{tunnel.type === 2 && tunnel.ipPreference && (
|
|
<>
|
|
<span className="text-default-300">|</span>
|
|
<span className="text-default-500">
|
|
{tunnel.ipPreference === "v4" ? "IPv4" : "IPv6"}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
|
|
<Button
|
|
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
|
|
size="sm"
|
|
variant="flat"
|
|
onPress={() => handleEdit(tunnel)}
|
|
>
|
|
编辑
|
|
</Button>
|
|
<Button
|
|
className="h-6 px-2 min-w-0 text-xs bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-950/30 dark:text-amber-400"
|
|
size="sm"
|
|
variant="flat"
|
|
onPress={() => handleDiagnose(tunnel)}
|
|
>
|
|
诊断
|
|
</Button>
|
|
<Button
|
|
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
|
|
size="sm"
|
|
variant="flat"
|
|
onPress={() => handleDelete(tunnel)}
|
|
>
|
|
删除
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
}}
|
|
</TableBody>
|
|
</Table>
|
|
</Card>
|
|
) : (
|
|
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
|
<SortableContext
|
|
items={sortableTunnelIds}
|
|
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">
|
|
{sortedTunnels.map((tunnel) => {
|
|
const typeDisplay = getTunnelTypeDisplay(tunnel.type);
|
|
const tunnelTypeChipClassName =
|
|
tunnel.type === 1
|
|
? "text-xs bg-primary-100 text-primary-800 border-primary-300 dark:bg-primary-900/45 dark:text-primary-200 dark:border-primary-700"
|
|
: "text-xs bg-success-100 text-success-800 border-success-300 dark:bg-success-900/35 dark:text-success-200 dark:border-success-700";
|
|
const bestExitStateContent = renderBestExitState(
|
|
tunnel.bestExitState,
|
|
);
|
|
|
|
return (
|
|
<SortableItem key={tunnel.id} id={tunnel.id}>
|
|
{(listeners) => (
|
|
<Card
|
|
key={tunnel.id}
|
|
className="group overflow-hidden bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl border border-white/80 dark:border-white/10 shadow-[0_15px_35px_rgba(0,0,0,0.1)]"
|
|
>
|
|
<CardHeader className="pb-2 md:pb-2">
|
|
<div className="flex justify-between items-start w-full">
|
|
{selectMode && (
|
|
<Checkbox
|
|
className="mr-2"
|
|
isSelected={selectedIds.has(tunnel.id)}
|
|
onValueChange={() => toggleSelect(tunnel.id)}
|
|
/>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-semibold text-foreground truncate text-sm">
|
|
{tunnel.name}
|
|
</h3>
|
|
<div className="flex items-center gap-1.5 mt-1">
|
|
<Chip
|
|
className={tunnelTypeChipClassName}
|
|
color={typeDisplay.color as any}
|
|
size="sm"
|
|
variant="flat"
|
|
>
|
|
{typeDisplay.text}
|
|
</Chip>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className="cursor-grab active:cursor-grabbing p-1 -mr-1 text-default-400 hover:text-default-600 transition-colors touch-manipulation flex-shrink-0"
|
|
{...listeners}
|
|
style={{ touchAction: "none" }}
|
|
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>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
|
<div className="space-y-3">
|
|
{/* 拓扑结构 */}
|
|
<div className="pt-2 border-t border-divider">
|
|
<div className="flex items-center justify-center gap-2 text-xs">
|
|
{/* 入口节点 */}
|
|
<div className="flex items-center gap-1 px-2 py-1 bg-primary-50/30 dark:bg-primary-100/20 backdrop-blur-md rounded border border-primary-200/50 dark:border-primary-300/20">
|
|
<svg
|
|
aria-hidden="true"
|
|
className="w-3 h-3 text-primary-600"
|
|
fill="currentColor"
|
|
viewBox="0 0 20 20"
|
|
>
|
|
<path
|
|
clipRule="evenodd"
|
|
d="M3 4a1 1 0 011-1h12a1 1 0 011 1v12a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm2 2v8h10V6H5z"
|
|
fillRule="evenodd"
|
|
/>
|
|
</svg>
|
|
<span className="font-semibold text-primary-700 dark:text-primary-400">
|
|
{tunnel.inNodeId?.length || 0}入口
|
|
</span>
|
|
</div>
|
|
|
|
{/* 箭头 */}
|
|
<svg
|
|
aria-hidden="true"
|
|
className="w-4 h-4 text-default-400"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
d="M9 5l7 7-7 7"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
/>
|
|
</svg>
|
|
|
|
{/* 转发链 */}
|
|
<div className="flex items-center gap-1 px-2 py-1 bg-secondary-50/30 dark:bg-secondary-100/20 backdrop-blur-md rounded border border-secondary-200/50 dark:border-secondary-300/20">
|
|
<svg
|
|
aria-hidden="true"
|
|
className="w-3 h-3 text-secondary-600"
|
|
fill="currentColor"
|
|
viewBox="0 0 20 20"
|
|
>
|
|
<path
|
|
clipRule="evenodd"
|
|
d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z"
|
|
fillRule="evenodd"
|
|
/>
|
|
</svg>
|
|
<span className="font-semibold text-secondary-700 dark:text-secondary-400">
|
|
{tunnel.type === 2
|
|
? tunnel.chainNodes?.length || 0
|
|
: 0}
|
|
跳
|
|
</span>
|
|
</div>
|
|
|
|
{/* 箭头 */}
|
|
<svg
|
|
aria-hidden="true"
|
|
className="w-4 h-4 text-default-400"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
d="M9 5l7 7-7 7"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
/>
|
|
</svg>
|
|
|
|
{/* 出口节点 */}
|
|
<div className="flex items-center gap-1 px-2 py-1 bg-success-50/30 dark:bg-success-100/20 backdrop-blur-md rounded border border-success-200/50 dark:border-success-300/20">
|
|
<svg
|
|
aria-hidden="true"
|
|
className="w-3 h-3 text-success-600"
|
|
fill="currentColor"
|
|
viewBox="0 0 20 20"
|
|
>
|
|
<path
|
|
clipRule="evenodd"
|
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-8.707l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L9 9.414V13a1 1 0 102 0V9.414l1.293 1.293a1 1 0 001.414-1.414z"
|
|
fillRule="evenodd"
|
|
/>
|
|
</svg>
|
|
<span className="font-semibold text-success-700 dark:text-success-400">
|
|
{tunnel.type === 2
|
|
? tunnel.outNodeId?.length || 0
|
|
: tunnel.inNodeId?.length || 0}
|
|
出口
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{bestExitStateContent && (
|
|
<div className="mt-2 flex min-w-0 justify-center">
|
|
{bestExitStateContent}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 流量配置 */}
|
|
<div
|
|
className={`grid gap-2 ${tunnel.type === 2 && tunnel.ipPreference ? "grid-cols-3" : "grid-cols-2"}`}
|
|
>
|
|
<div className="text-center p-1.5 bg-white/5 dark:bg-black/5 backdrop-blur-3xl rounded-lg border border-divider">
|
|
<div className="text-xs text-default-500">
|
|
流量计算
|
|
</div>
|
|
<div className="text-sm font-semibold text-foreground mt-0.5">
|
|
{getTunnelFlowDisplay(tunnel.flow)}
|
|
</div>
|
|
</div>
|
|
<div className="text-center p-1.5 bg-white/5 dark:bg-black/5 backdrop-blur-3xl rounded-lg border border-divider">
|
|
<div className="text-xs text-default-500">
|
|
流量倍率
|
|
</div>
|
|
<div className="text-sm font-semibold text-foreground mt-0.5">
|
|
{tunnel.trafficRatio}x
|
|
</div>
|
|
</div>
|
|
{tunnel.type === 2 && tunnel.ipPreference && (
|
|
<div className="text-center p-1.5 bg-white/5 dark:bg-black/5 backdrop-blur-3xl rounded-lg border border-divider">
|
|
<div className="text-xs text-default-500">
|
|
连接偏好
|
|
</div>
|
|
<div className="text-sm font-semibold text-foreground mt-0.5">
|
|
{tunnel.ipPreference === "v4"
|
|
? "IPv4"
|
|
: "IPv6"}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-1.5 mt-3">
|
|
<Button
|
|
className="flex-1 min-h-8"
|
|
color="primary"
|
|
size="sm"
|
|
startContent={
|
|
<svg
|
|
aria-hidden="true"
|
|
className="w-3 h-3"
|
|
fill="currentColor"
|
|
viewBox="0 0 20 20"
|
|
>
|
|
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
|
</svg>
|
|
}
|
|
variant="flat"
|
|
onPress={() => handleEdit(tunnel)}
|
|
>
|
|
编辑
|
|
</Button>
|
|
<Button
|
|
className="flex-1 min-h-8"
|
|
color="warning"
|
|
size="sm"
|
|
startContent={
|
|
<svg
|
|
aria-hidden="true"
|
|
className="w-3 h-3"
|
|
fill="currentColor"
|
|
viewBox="0 0 20 20"
|
|
>
|
|
<path
|
|
clipRule="evenodd"
|
|
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
|
fillRule="evenodd"
|
|
/>
|
|
</svg>
|
|
}
|
|
variant="flat"
|
|
onPress={() => handleDiagnose(tunnel)}
|
|
>
|
|
诊断
|
|
</Button>
|
|
<Button
|
|
className="flex-1 min-h-8"
|
|
color="danger"
|
|
size="sm"
|
|
startContent={
|
|
<svg
|
|
aria-hidden="true"
|
|
className="w-3 h-3"
|
|
fill="currentColor"
|
|
viewBox="0 0 20 20"
|
|
>
|
|
<path
|
|
clipRule="evenodd"
|
|
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
|
|
fillRule="evenodd"
|
|
/>
|
|
<path
|
|
clipRule="evenodd"
|
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z"
|
|
fillRule="evenodd"
|
|
/>
|
|
</svg>
|
|
}
|
|
variant="flat"
|
|
onPress={() => handleDelete(tunnel)}
|
|
>
|
|
删除
|
|
</Button>
|
|
</div>
|
|
</CardBody>
|
|
</Card>
|
|
)}
|
|
</SortableItem>
|
|
);
|
|
})}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
)
|
|
) : (
|
|
/* 空状态 */
|
|
<Card className="bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl border border-white/80 dark:border-white/10 shadow-[0_15px_35px_rgba(0,0,0,0.1)]">
|
|
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
|
|
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
|
|
暂无隧道配置
|
|
</h3>
|
|
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
|
|
还没有创建任何隧道配置,点击上方按钮开始创建
|
|
</p>
|
|
</CardBody>
|
|
</Card>
|
|
)}
|
|
|
|
{/* 新增/编辑模态框 */}
|
|
<Modal
|
|
backdrop="blur"
|
|
classNames={{
|
|
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
|
|
}}
|
|
isOpen={modalOpen}
|
|
placement="center"
|
|
scrollBehavior="inside"
|
|
size="2xl"
|
|
onOpenChange={setModalOpen}
|
|
>
|
|
<ModalContent>
|
|
{(onClose) => (
|
|
<>
|
|
<ModalHeader className="flex flex-col gap-1">
|
|
<h2 className="text-xl font-bold">
|
|
{isEdit ? "编辑隧道" : "新增隧道"}
|
|
</h2>
|
|
<p className="text-small text-default-500">
|
|
{isEdit
|
|
? "修改节点配置会中断现有连接,隧道类型不可修改"
|
|
: "创建新的隧道配置"}
|
|
</p>
|
|
</ModalHeader>
|
|
<ModalBody>
|
|
<div className="space-y-4">
|
|
<Input
|
|
errorMessage={errors.name}
|
|
isInvalid={!!errors.name}
|
|
label="隧道名称"
|
|
placeholder="请输入隧道名称"
|
|
value={form.name}
|
|
variant="bordered"
|
|
onChange={(e) =>
|
|
setForm((prev) => ({ ...prev, name: e.target.value }))
|
|
}
|
|
/>
|
|
|
|
<Select
|
|
description={isEdit ? "编辑时无法修改隧道类型" : undefined}
|
|
errorMessage={errors.type}
|
|
isDisabled={isEdit}
|
|
isInvalid={!!errors.type}
|
|
label="隧道类型"
|
|
placeholder="请选择隧道类型"
|
|
selectedKeys={[form.type.toString()]}
|
|
variant="bordered"
|
|
onSelectionChange={(keys) => {
|
|
const selectedKey = Array.from(keys)[0] as string;
|
|
|
|
if (selectedKey) {
|
|
handleTypeChange(parseInt(selectedKey));
|
|
}
|
|
}}
|
|
>
|
|
<SelectItem key="1">端口转发</SelectItem>
|
|
<SelectItem key="2">隧道转发</SelectItem>
|
|
</Select>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<Select
|
|
errorMessage={errors.flow}
|
|
isInvalid={!!errors.flow}
|
|
label="流量计算"
|
|
placeholder="请选择流量计算方式"
|
|
selectedKeys={[form.flow.toString()]}
|
|
variant="bordered"
|
|
onSelectionChange={(keys) => {
|
|
const selectedKey = Array.from(keys)[0] as string;
|
|
|
|
if (selectedKey) {
|
|
setForm((prev) => ({
|
|
...prev,
|
|
flow: parseInt(selectedKey),
|
|
}));
|
|
}
|
|
}}
|
|
>
|
|
<SelectItem key="1">单向计算(仅上传)</SelectItem>
|
|
<SelectItem key="2">双向计算(上传+下载)</SelectItem>
|
|
</Select>
|
|
|
|
<Input
|
|
errorMessage={errors.trafficRatio}
|
|
isInvalid={!!errors.trafficRatio}
|
|
label="流量倍率"
|
|
max={100}
|
|
min={0.01}
|
|
placeholder="例如:0.5 或 1 或 2"
|
|
step="any"
|
|
type="number"
|
|
value={form.trafficRatio.toString()}
|
|
variant="bordered"
|
|
onChange={(e) =>
|
|
setForm((prev) => ({
|
|
...prev,
|
|
trafficRatio: parseFloat(e.target.value) || 0,
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<Textarea
|
|
description="入口IP由系统自动从入口节点采集,无需手动填写。支持多个IP,每行一个地址,留空则使用入口节点IP"
|
|
errorMessage={errors.inIp}
|
|
isInvalid={!!errors.inIp}
|
|
label="入口IP"
|
|
maxRows={5}
|
|
minRows={3}
|
|
placeholder="一行一个IP地址或域名,例如: 192.168.1.100 example.com"
|
|
value={form.inIp}
|
|
variant="bordered"
|
|
onChange={(e) =>
|
|
setForm((prev) => ({ ...prev, inIp: e.target.value }))
|
|
}
|
|
/>
|
|
|
|
{form.type === 2 && (
|
|
<Select
|
|
description="当节点同时拥有IPv4和IPv6地址时,选择隧道连接使用的地址类型"
|
|
label="隧道连接地址偏好"
|
|
placeholder="自动选择"
|
|
selectedKeys={[form.ipPreference || ""]}
|
|
variant="bordered"
|
|
onSelectionChange={(keys) => {
|
|
const selectedKey = Array.from(keys)[0] as string;
|
|
|
|
setForm((prev) => ({
|
|
...prev,
|
|
ipPreference: selectedKey || "",
|
|
}));
|
|
}}
|
|
>
|
|
<SelectItem key="v4">优先IPv4</SelectItem>
|
|
<SelectItem key="v6">优先IPv6</SelectItem>
|
|
</Select>
|
|
)}
|
|
|
|
<div className="rounded-xl border border-divider/60 bg-default-50/40 p-3 space-y-3">
|
|
<div>
|
|
<div className="text-sm font-medium">质量检测目标</div>
|
|
<p className="text-xs text-default-500 mt-0.5">
|
|
用于实时隧道质量检测和 best 最优出口评分,留空使用
|
|
www.bing.com:443
|
|
</p>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-[1fr_140px] gap-3">
|
|
<Input
|
|
errorMessage={errors.probeTargetHost}
|
|
isInvalid={!!errors.probeTargetHost}
|
|
label="Host"
|
|
placeholder="www.bing.com"
|
|
value={form.probeTargetHost || ""}
|
|
variant="bordered"
|
|
onChange={(e) =>
|
|
setForm((prev) => ({
|
|
...prev,
|
|
probeTargetHost: e.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
<Input
|
|
errorMessage={errors.probeTargetPort}
|
|
isInvalid={!!errors.probeTargetPort}
|
|
label="Port"
|
|
max={65535}
|
|
min={1}
|
|
placeholder="443"
|
|
type="number"
|
|
value={
|
|
form.probeTargetPort
|
|
? String(form.probeTargetPort)
|
|
: ""
|
|
}
|
|
variant="bordered"
|
|
onChange={(e) =>
|
|
setForm((prev) => ({
|
|
...prev,
|
|
probeTargetPort: e.target.value
|
|
? Number(e.target.value)
|
|
: 0,
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Divider />
|
|
<h3 className="text-lg font-semibold">入口配置</h3>
|
|
|
|
<div className="space-y-2">
|
|
<Select
|
|
disabledKeys={[
|
|
...nodes
|
|
.filter(
|
|
(node) =>
|
|
node.status !== 1 &&
|
|
!(
|
|
isEdit &&
|
|
form.inNodeId.some(
|
|
(ct) => ct.nodeId === node.id,
|
|
)
|
|
),
|
|
)
|
|
.map((node) => node.id.toString()),
|
|
...(form.outNodeId || []).map((ct) =>
|
|
ct.nodeId.toString(),
|
|
),
|
|
...getSelectedChainNodeIds().map((id) => id.toString()),
|
|
]}
|
|
errorMessage={errors.inNodeId}
|
|
isInvalid={!!errors.inNodeId}
|
|
label="入口节点"
|
|
placeholder="请选择入口节点(可多选)"
|
|
selectedKeys={form.inNodeId.map((ct) =>
|
|
ct.nodeId.toString(),
|
|
)}
|
|
selectionMode="multiple"
|
|
variant="bordered"
|
|
onSelectionChange={(keys) => {
|
|
const selectedIds = toSelectedNodeIds(keys);
|
|
|
|
setForm((prev) => ({
|
|
...prev,
|
|
inNodeId: mergeOrderedNodes(
|
|
prev.inNodeId,
|
|
selectedIds,
|
|
(nodeId) => ({ nodeId, chainType: 1 }),
|
|
),
|
|
}));
|
|
}}
|
|
>
|
|
{nodes.map((node) => (
|
|
<SelectItem key={node.id} textValue={`${node.name}`}>
|
|
<div className="flex items-center justify-between">
|
|
<span>{node.name}</span>
|
|
<div className="flex items-center gap-2">
|
|
<Chip
|
|
color={
|
|
node.status === 1 ? "success" : "default"
|
|
}
|
|
size="sm"
|
|
variant="flat"
|
|
>
|
|
{node.status === 1 ? "在线" : "离线"}
|
|
</Chip>
|
|
{form.outNodeId &&
|
|
form.outNodeId.some(
|
|
(ct) => ct.nodeId === node.id,
|
|
) && (
|
|
<Chip color="danger" size="sm" variant="flat">
|
|
已选为出口
|
|
</Chip>
|
|
)}
|
|
{getSelectedChainNodeIds().includes(node.id) && (
|
|
<Chip color="primary" size="sm" variant="flat">
|
|
已选为转发链
|
|
</Chip>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 隧道转发时显示转发链配置 */}
|
|
{form.type === 2 && (
|
|
<>
|
|
<Divider />
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-lg font-semibold">转发链配置</h3>
|
|
<Button
|
|
color="primary"
|
|
size="sm"
|
|
startContent={
|
|
<svg
|
|
aria-hidden="true"
|
|
className="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
d="M12 4v16m8-8H4"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
/>
|
|
</svg>
|
|
}
|
|
variant="flat"
|
|
onPress={() => {
|
|
// 添加新的一跳(一个空组,或包含占位节点)
|
|
setForm((prev) => ({
|
|
...prev,
|
|
chainNodes: [
|
|
...(prev.chainNodes || []),
|
|
[
|
|
{
|
|
nodeId: -1,
|
|
chainType: 2,
|
|
protocol: "tls",
|
|
strategy: "round",
|
|
},
|
|
],
|
|
],
|
|
}));
|
|
}}
|
|
>
|
|
添加一跳
|
|
</Button>
|
|
</div>
|
|
|
|
{getChainGroups().length > 0 && (
|
|
<div className="space-y-3">
|
|
{getChainGroups().map((groupNodes, groupIndex) => {
|
|
const protocol =
|
|
groupNodes.length > 0
|
|
? groupNodes[0].protocol || "tls"
|
|
: "tls";
|
|
const strategy =
|
|
groupNodes.length > 0
|
|
? groupNodes[0].strategy || "round"
|
|
: "round";
|
|
const groupSelectedNodeIds = groupNodes
|
|
.filter((ct) => ct.nodeId !== -1)
|
|
.map((ct) => ct.nodeId);
|
|
const groupIpOptions =
|
|
getCommonIpOptions(groupSelectedNodeIds);
|
|
const isMultiNodeGroup =
|
|
groupSelectedNodeIds.length > 1;
|
|
const selectedGroupConnectIp =
|
|
groupNodes.length > 0
|
|
? groupNodes[0].connectIp || ""
|
|
: "";
|
|
|
|
return (
|
|
<div
|
|
key={groupIndex}
|
|
className="border border-default-200 rounded-lg p-3"
|
|
>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm font-medium text-default-600">
|
|
第{groupIndex + 1}跳
|
|
</span>
|
|
<Button
|
|
isIconOnly
|
|
aria-label={`删除第${groupIndex + 1}跳`}
|
|
color="danger"
|
|
size="sm"
|
|
variant="light"
|
|
onPress={() => removeChainNode(groupIndex)}
|
|
>
|
|
<svg
|
|
aria-hidden="true"
|
|
className="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
d="M6 18L18 6M6 6l12 12"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
/>
|
|
</svg>
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-2">
|
|
{/* 节点选择 - 移动端100%,桌面端50% */}
|
|
<div className="col-span-1 md:col-span-2">
|
|
<Select
|
|
classNames={{
|
|
label: "text-xs",
|
|
value: "text-sm",
|
|
}}
|
|
disabledKeys={[
|
|
...nodes
|
|
.filter(
|
|
(node) =>
|
|
node.status !== 1 &&
|
|
!(
|
|
isEdit &&
|
|
groupNodes.some(
|
|
(ct) =>
|
|
ct.nodeId === node.id &&
|
|
ct.nodeId !== -1,
|
|
)
|
|
),
|
|
)
|
|
.map((node) => node.id.toString()),
|
|
...form.inNodeId.map((ct) =>
|
|
ct.nodeId.toString(),
|
|
),
|
|
...(form.outNodeId || []).map((ct) =>
|
|
ct.nodeId.toString(),
|
|
),
|
|
// 排除其他跳数已选的节点
|
|
...(form.chainNodes || [])
|
|
.flatMap((group, idx) =>
|
|
idx !== groupIndex
|
|
? group.map((ct) => ct.nodeId)
|
|
: [],
|
|
)
|
|
.filter((id) => id !== -1)
|
|
.map((id) => id.toString()),
|
|
]}
|
|
dropdownPlacement="top"
|
|
label="节点"
|
|
placeholder="选择节点(可多选)"
|
|
selectedKeys={groupNodes
|
|
.filter((ct) => ct.nodeId !== -1)
|
|
.map((ct) => ct.nodeId.toString())}
|
|
selectionMode="multiple"
|
|
size="sm"
|
|
variant="bordered"
|
|
onSelectionChange={(keys) => {
|
|
syncChainGroupNodes(
|
|
groupIndex,
|
|
toSelectedNodeIds(keys),
|
|
);
|
|
}}
|
|
>
|
|
{nodes.map((node) => (
|
|
<SelectItem
|
|
key={node.id}
|
|
textValue={`${node.name}`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm">
|
|
{node.name}
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<Chip
|
|
color={
|
|
node.status === 1
|
|
? "success"
|
|
: "default"
|
|
}
|
|
size="sm"
|
|
variant="flat"
|
|
>
|
|
{node.status === 1
|
|
? "在线"
|
|
: "离线"}
|
|
</Chip>
|
|
{form.inNodeId.some(
|
|
(ct) => ct.nodeId === node.id,
|
|
) && (
|
|
<Chip
|
|
color="warning"
|
|
size="sm"
|
|
variant="flat"
|
|
>
|
|
已选为入口
|
|
</Chip>
|
|
)}
|
|
{form.outNodeId &&
|
|
form.outNodeId.some(
|
|
(ct) => ct.nodeId === node.id,
|
|
) && (
|
|
<Chip
|
|
color="danger"
|
|
size="sm"
|
|
variant="flat"
|
|
>
|
|
已选为出口
|
|
</Chip>
|
|
)}
|
|
{/* 显示是否在其他跳数中被选择 */}
|
|
{(form.chainNodes || []).some(
|
|
(group, idx) =>
|
|
idx !== groupIndex &&
|
|
group.some(
|
|
(ct) =>
|
|
ct.nodeId === node.id &&
|
|
ct.nodeId !== -1,
|
|
),
|
|
) && (
|
|
<Chip
|
|
color="primary"
|
|
size="sm"
|
|
variant="flat"
|
|
>
|
|
已选为其他跳
|
|
</Chip>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 协议选择 - 25% */}
|
|
<Select
|
|
classNames={{
|
|
label: "text-xs",
|
|
value: "text-sm",
|
|
}}
|
|
label="协议"
|
|
placeholder="选择协议"
|
|
selectedKeys={[protocol]}
|
|
size="sm"
|
|
variant="bordered"
|
|
onSelectionChange={(keys) => {
|
|
const selectedKey = Array.from(
|
|
keys,
|
|
)[0] as string;
|
|
|
|
if (selectedKey) {
|
|
updateChainProtocol(
|
|
groupIndex,
|
|
selectedKey,
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
<SelectItem key="tls">TLS</SelectItem>
|
|
<SelectItem key="wss">WSS</SelectItem>
|
|
<SelectItem key="tcp">TCP</SelectItem>
|
|
<SelectItem key="mtls">MTLS</SelectItem>
|
|
<SelectItem key="mwss">MWSS</SelectItem>
|
|
<SelectItem key="mtcp">MTCP</SelectItem>
|
|
<SelectItem key="kcp">KCP</SelectItem>
|
|
</Select>
|
|
|
|
{/* 负载策略 - 25% */}
|
|
<Select
|
|
classNames={{
|
|
label: "text-xs",
|
|
value: "text-sm",
|
|
}}
|
|
label="负载策略"
|
|
placeholder="选择策略"
|
|
selectedKeys={[strategy]}
|
|
size="sm"
|
|
variant="bordered"
|
|
onSelectionChange={(keys) => {
|
|
const selectedKey = Array.from(
|
|
keys,
|
|
)[0] as string;
|
|
|
|
if (selectedKey) {
|
|
updateChainStrategy(
|
|
groupIndex,
|
|
selectedKey,
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
<SelectItem key="fifo">主备</SelectItem>
|
|
<SelectItem key="round">轮询</SelectItem>
|
|
<SelectItem key="rand">随机</SelectItem>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 连接IP - 转发链节点 */}
|
|
<Select
|
|
classNames={{
|
|
label: "text-xs",
|
|
value: "text-sm",
|
|
}}
|
|
description={
|
|
isMultiNodeGroup
|
|
? "多节点跳不支持设置自定义连接IP,使用各节点默认IP"
|
|
: "按当前跳所选节点的共有IP进行选择,留空使用默认"
|
|
}
|
|
isDisabled={
|
|
groupSelectedNodeIds.length === 0 ||
|
|
groupIpOptions.length === 0 ||
|
|
isMultiNodeGroup
|
|
}
|
|
label="连接IP"
|
|
placeholder={
|
|
isMultiNodeGroup
|
|
? "多节点跳使用节点默认IP"
|
|
: groupSelectedNodeIds.length === 0
|
|
? "请先选择节点"
|
|
: groupIpOptions.length > 0
|
|
? "选择连接IP"
|
|
: "所选节点无共同可选IP"
|
|
}
|
|
selectedKeys={[
|
|
selectedGroupConnectIp || "__default__",
|
|
]}
|
|
size="sm"
|
|
variant="bordered"
|
|
onSelectionChange={(keys) => {
|
|
const selectedKey = Array.from(
|
|
keys,
|
|
)[0] as string;
|
|
|
|
updateChainConnectIp(
|
|
groupIndex,
|
|
selectedKey === "__default__"
|
|
? ""
|
|
: selectedKey,
|
|
);
|
|
}}
|
|
>
|
|
<SelectItem key="__default__">
|
|
默认连接IP
|
|
</SelectItem>
|
|
{groupIpOptions.map((ip) => (
|
|
<SelectItem key={ip}>{ip}</SelectItem>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{getChainGroups().length === 0 && (
|
|
<div className="text-center py-8 bg-default-50 dark:bg-default-100/50 rounded border border-dashed border-default-300">
|
|
<p className="text-sm text-default-500">
|
|
还没有添加转发链,点击上方"添加一跳"按钮开始添加
|
|
</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* 隧道转发时显示出口配置 */}
|
|
{form.type === 2 && (
|
|
<>
|
|
<Divider />
|
|
<h3 className="text-lg font-semibold">出口配置</h3>
|
|
|
|
{(() => {
|
|
const selectedOutNodeIds = (form.outNodeId || [])
|
|
.filter((ct) => ct.nodeId !== -1)
|
|
.map((ct) => ct.nodeId);
|
|
const isMultiExit = selectedOutNodeIds.length > 1;
|
|
const commonOutIpOptions =
|
|
getCommonIpOptions(selectedOutNodeIds);
|
|
|
|
return (
|
|
<>
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-2">
|
|
{/* 节点选择 - 移动端100%,桌面端50% */}
|
|
<div className="col-span-1 md:col-span-2">
|
|
<Select
|
|
classNames={{
|
|
label: "text-xs",
|
|
value: "text-sm",
|
|
}}
|
|
disabledKeys={[
|
|
...nodes
|
|
.filter(
|
|
(node) =>
|
|
node.status !== 1 &&
|
|
!(
|
|
isEdit &&
|
|
(form.outNodeId || []).some(
|
|
(ct) =>
|
|
ct.nodeId === node.id &&
|
|
ct.nodeId !== -1,
|
|
)
|
|
),
|
|
)
|
|
.map((node) => node.id.toString()),
|
|
...form.inNodeId.map((ct) =>
|
|
ct.nodeId.toString(),
|
|
),
|
|
...getSelectedChainNodeIds().map((id) =>
|
|
id.toString(),
|
|
),
|
|
]}
|
|
dropdownPlacement="top"
|
|
errorMessage={errors.outNodeId}
|
|
isInvalid={!!errors.outNodeId}
|
|
label="节点"
|
|
placeholder="请选择出口节点(可多选)"
|
|
selectedKeys={
|
|
form.outNodeId
|
|
? form.outNodeId
|
|
.filter((ct) => ct.nodeId !== -1)
|
|
.map((ct) => ct.nodeId.toString())
|
|
: []
|
|
}
|
|
selectionMode="multiple"
|
|
variant="bordered"
|
|
onSelectionChange={(keys) => {
|
|
const selectedIds = toSelectedNodeIds(keys);
|
|
|
|
setForm((prev) => {
|
|
const currentOutNodes =
|
|
prev.outNodeId || [];
|
|
const protocol =
|
|
currentOutNodes[0]?.protocol || "tls";
|
|
const strategy =
|
|
currentOutNodes[0]?.strategy || "round";
|
|
const realNodes = currentOutNodes.filter(
|
|
(ct) => ct.nodeId !== -1,
|
|
);
|
|
|
|
return {
|
|
...prev,
|
|
outNodeId: mergeOrderedNodes(
|
|
realNodes,
|
|
selectedIds,
|
|
(nodeId) => ({
|
|
nodeId,
|
|
chainType: 3,
|
|
protocol,
|
|
strategy,
|
|
}),
|
|
),
|
|
};
|
|
});
|
|
}}
|
|
>
|
|
{nodes.map((node) => (
|
|
<SelectItem
|
|
key={node.id}
|
|
textValue={`${node.name}`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span>{node.name}</span>
|
|
<div className="flex items-center gap-2">
|
|
<Chip
|
|
color={
|
|
node.status === 1
|
|
? "success"
|
|
: "default"
|
|
}
|
|
size="sm"
|
|
variant="flat"
|
|
>
|
|
{node.status === 1
|
|
? "在线"
|
|
: "离线"}
|
|
</Chip>
|
|
{form.inNodeId.some(
|
|
(ct) => ct.nodeId === node.id,
|
|
) && (
|
|
<Chip
|
|
color="warning"
|
|
size="sm"
|
|
variant="flat"
|
|
>
|
|
已选为入口
|
|
</Chip>
|
|
)}
|
|
{getSelectedChainNodeIds().includes(
|
|
node.id,
|
|
) && (
|
|
<Chip
|
|
color="primary"
|
|
size="sm"
|
|
variant="flat"
|
|
>
|
|
已选为转发链
|
|
</Chip>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 协议选择 - 25% */}
|
|
<Select
|
|
classNames={{
|
|
label: "text-xs",
|
|
value: "text-sm",
|
|
}}
|
|
errorMessage={errors.protocol}
|
|
isInvalid={!!errors.protocol}
|
|
label="协议"
|
|
placeholder="选择协议"
|
|
selectedKeys={[
|
|
(() => {
|
|
if (
|
|
!form.outNodeId ||
|
|
form.outNodeId.length === 0
|
|
)
|
|
return "tls";
|
|
|
|
return form.outNodeId[0].protocol || "tls";
|
|
})(),
|
|
]}
|
|
variant="bordered"
|
|
onSelectionChange={(keys) => {
|
|
const selectedKey = Array.from(
|
|
keys,
|
|
)[0] as string;
|
|
|
|
if (selectedKey) {
|
|
setForm((prev) => {
|
|
const currentOutNodes =
|
|
prev.outNodeId || [];
|
|
const currentStrategy =
|
|
currentOutNodes.length > 0
|
|
? currentOutNodes[0].strategy ||
|
|
"round"
|
|
: "round";
|
|
|
|
if (currentOutNodes.length === 0) {
|
|
// 如果还没有出口节点,创建一个占位节点保存设置
|
|
return {
|
|
...prev,
|
|
outNodeId: [
|
|
{
|
|
nodeId: -1,
|
|
chainType: 3,
|
|
protocol: selectedKey,
|
|
strategy: currentStrategy,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
// 更新所有出口节点的协议
|
|
return {
|
|
...prev,
|
|
outNodeId: currentOutNodes.map(
|
|
(ct) => ({
|
|
...ct,
|
|
protocol: selectedKey,
|
|
}),
|
|
),
|
|
};
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
<SelectItem key="tls">TLS</SelectItem>
|
|
<SelectItem key="wss">WSS</SelectItem>
|
|
<SelectItem key="tcp">TCP</SelectItem>
|
|
<SelectItem key="mtls">MTLS</SelectItem>
|
|
<SelectItem key="mwss">MWSS</SelectItem>
|
|
<SelectItem key="mtcp">MTCP</SelectItem>
|
|
<SelectItem key="kcp">KCP</SelectItem>
|
|
</Select>
|
|
|
|
{/* 负载策略 - 25% */}
|
|
<Select
|
|
classNames={{
|
|
label: "text-xs",
|
|
value: "text-sm",
|
|
}}
|
|
label="负载策略"
|
|
placeholder="选择策略"
|
|
selectedKeys={[
|
|
(() => {
|
|
if (
|
|
!form.outNodeId ||
|
|
form.outNodeId.length === 0
|
|
)
|
|
return "round";
|
|
|
|
return (
|
|
form.outNodeId[0].strategy || "round"
|
|
);
|
|
})(),
|
|
]}
|
|
variant="bordered"
|
|
onSelectionChange={(keys) => {
|
|
const selectedKey = Array.from(
|
|
keys,
|
|
)[0] as string;
|
|
|
|
if (selectedKey) {
|
|
setForm((prev) => {
|
|
const currentOutNodes =
|
|
prev.outNodeId || [];
|
|
const currentProtocol =
|
|
currentOutNodes.length > 0
|
|
? currentOutNodes[0].protocol || "tls"
|
|
: "tls";
|
|
|
|
if (currentOutNodes.length === 0) {
|
|
return {
|
|
...prev,
|
|
outNodeId: [
|
|
{
|
|
nodeId: -1,
|
|
chainType: 3,
|
|
protocol: currentProtocol,
|
|
strategy: selectedKey,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
return {
|
|
...prev,
|
|
outNodeId: currentOutNodes.map(
|
|
(ct) => ({
|
|
...ct,
|
|
strategy: selectedKey,
|
|
}),
|
|
),
|
|
};
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
<SelectItem key="fifo">主备</SelectItem>
|
|
<SelectItem key="round">轮询</SelectItem>
|
|
<SelectItem key="rand">随机</SelectItem>
|
|
<SelectItem key="best">最优</SelectItem>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 连接IP - 出口节点 */}
|
|
<Select
|
|
classNames={{
|
|
label: "text-xs",
|
|
value: "text-sm",
|
|
}}
|
|
description={
|
|
isMultiExit
|
|
? "多出口隧道不支持设置自定义连接IP,使用各节点默认IP"
|
|
: "按出口节点共同可用IP选择,留空使用默认"
|
|
}
|
|
isDisabled={
|
|
selectedOutNodeIds.length === 0 ||
|
|
commonOutIpOptions.length === 0 ||
|
|
isMultiExit
|
|
}
|
|
label="连接IP"
|
|
placeholder={
|
|
isMultiExit
|
|
? "多出口隧道使用节点默认IP"
|
|
: selectedOutNodeIds.length === 0
|
|
? "请先选择出口节点"
|
|
: commonOutIpOptions.length > 0
|
|
? "选择连接IP"
|
|
: "所选节点无共同可选IP"
|
|
}
|
|
selectedKeys={[
|
|
form.outNodeId && form.outNodeId.length > 0
|
|
? form.outNodeId[0].connectIp || "__default__"
|
|
: "__default__",
|
|
]}
|
|
size="sm"
|
|
variant="bordered"
|
|
onSelectionChange={(keys) => {
|
|
const selectedKey = Array.from(
|
|
keys,
|
|
)[0] as string;
|
|
const value =
|
|
selectedKey === "__default__"
|
|
? ""
|
|
: selectedKey;
|
|
|
|
setForm((prev) => {
|
|
const currentOutNodes = prev.outNodeId || [];
|
|
|
|
if (currentOutNodes.length === 0) {
|
|
return {
|
|
...prev,
|
|
outNodeId: [
|
|
{
|
|
nodeId: -1,
|
|
chainType: 3,
|
|
protocol: "tls",
|
|
strategy: "round",
|
|
connectIp: value,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
return {
|
|
...prev,
|
|
outNodeId: currentOutNodes.map((ct) => ({
|
|
...ct,
|
|
connectIp: value,
|
|
})),
|
|
};
|
|
});
|
|
}}
|
|
>
|
|
<SelectItem key="__default__">
|
|
默认连接IP
|
|
</SelectItem>
|
|
{commonOutIpOptions.map((ip) => (
|
|
<SelectItem key={ip}>{ip}</SelectItem>
|
|
))}
|
|
</Select>
|
|
</>
|
|
);
|
|
})()}
|
|
</>
|
|
)}
|
|
</div>
|
|
</ModalBody>
|
|
<ModalFooter>
|
|
<Button variant="light" onPress={onClose}>
|
|
取消
|
|
</Button>
|
|
<Button
|
|
color="primary"
|
|
isLoading={submitLoading}
|
|
onPress={handleSubmit}
|
|
>
|
|
{submitLoading
|
|
? isEdit
|
|
? "更新中..."
|
|
: "创建中..."
|
|
: isEdit
|
|
? "更新"
|
|
: "创建"}
|
|
</Button>
|
|
</ModalFooter>
|
|
</>
|
|
)}
|
|
</ModalContent>
|
|
</Modal>
|
|
|
|
{/* 删除确认模态框 */}
|
|
<Modal
|
|
backdrop="blur"
|
|
classNames={{
|
|
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
|
|
}}
|
|
isOpen={deleteModalOpen}
|
|
placement="center"
|
|
scrollBehavior="inside"
|
|
size="2xl"
|
|
onOpenChange={handleDeleteModalOpenChange}
|
|
>
|
|
<ModalContent>
|
|
{(onClose) => (
|
|
<>
|
|
<ModalHeader className="flex flex-col gap-1">
|
|
<h2 className="text-lg font-bold sm:text-xl">删除隧道</h2>
|
|
<p className="text-xs font-normal leading-5 text-default-500 sm:text-sm">
|
|
{tunnelDeletePreview?.tunnelName || tunnelToDelete?.name
|
|
? `即将删除“${tunnelDeletePreview?.tunnelName || tunnelToDelete?.name}”,删除前会先检查是否有关联规则。`
|
|
: "删除前会先检查是否有关联规则。"}
|
|
</p>
|
|
</ModalHeader>
|
|
<ModalBody className="space-y-3 sm:space-y-4">
|
|
{deletePreviewLoading ? (
|
|
<div className="flex items-center gap-3 rounded-xl border border-divider bg-content2/40 px-3 py-5 text-sm text-default-600 sm:px-4 sm:py-6">
|
|
<Spinner size="sm" />
|
|
正在检查是否有规则正在使用该隧道...
|
|
</div>
|
|
) : deleteHasForwardDependencies ? (
|
|
<>
|
|
<Alert
|
|
color="warning"
|
|
description={`隧道 \"${tunnelDeletePreview?.tunnelName || tunnelToDelete?.name || ""}\" 当前被 ${deletePreviewForwardCount} 条规则使用。删除前需要先处理这些规则。`}
|
|
title="发现关联规则"
|
|
variant="flat"
|
|
/>
|
|
|
|
{(tunnelDeletePreview?.sampleForwards?.length ?? 0) > 0 ? (
|
|
<div className="space-y-3 rounded-xl border border-divider bg-content2/40 p-3 sm:p-4">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<h3 className="text-sm font-semibold text-foreground">
|
|
关联规则预览
|
|
</h3>
|
|
<span className="text-xs text-default-500">
|
|
前{" "}
|
|
{tunnelDeletePreview?.sampleForwards?.length ?? 0}{" "}
|
|
条
|
|
</span>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{tunnelDeletePreview?.sampleForwards?.map(
|
|
(forward) => (
|
|
<div
|
|
key={forward.id}
|
|
className="rounded-lg border border-divider/70 bg-background/80 px-2.5 py-2 sm:px-3"
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="truncate text-sm font-medium text-foreground">
|
|
{forward.name}
|
|
</span>
|
|
<span className="shrink-0 font-mono text-xs text-default-500">
|
|
:{forward.inPort || 0}
|
|
</span>
|
|
</div>
|
|
<p className="mt-1 text-xs text-default-500">
|
|
用户:
|
|
{forward.userName || `#${forward.userId}`}
|
|
</p>
|
|
</div>
|
|
),
|
|
)}
|
|
</div>
|
|
{deletePreviewForwardCount >
|
|
(tunnelDeletePreview?.sampleForwards?.length ?? 0) ? (
|
|
<p className="text-xs text-default-500">
|
|
还有{" "}
|
|
{deletePreviewForwardCount -
|
|
(tunnelDeletePreview?.sampleForwards?.length ??
|
|
0)}{" "}
|
|
条规则未展开显示。
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
<RadioGroup
|
|
label="处理方式"
|
|
value={deleteAction}
|
|
onValueChange={(value) => {
|
|
const nextAction = value as TunnelDeleteAction;
|
|
|
|
setDeleteAction(nextAction);
|
|
if (nextAction !== "replace") {
|
|
setDeleteTargetTunnelId(null);
|
|
|
|
return;
|
|
}
|
|
|
|
setDeleteTargetTunnelId(
|
|
deleteReplacementTunnels[0]?.id ?? null,
|
|
);
|
|
}}
|
|
>
|
|
<Radio value="replace">
|
|
保留规则,迁移到其他隧道
|
|
{deleteReplaceUnavailable
|
|
? "(当前无可用目标)"
|
|
: "(推荐)"}
|
|
</Radio>
|
|
<Radio value="delete_forwards">
|
|
直接删除这些关联规则
|
|
</Radio>
|
|
</RadioGroup>
|
|
|
|
{deleteReplaceUnavailable ? (
|
|
<Alert
|
|
color="warning"
|
|
description="当前没有其他启用中的隧道可用于承接这些规则,只能删除关联规则后再删除该隧道。"
|
|
variant="flat"
|
|
/>
|
|
) : null}
|
|
|
|
{deleteAction === "replace" && !deleteReplaceUnavailable ? (
|
|
<div className="space-y-2">
|
|
<Select
|
|
label="目标隧道"
|
|
placeholder="请选择目标隧道"
|
|
selectedKeys={
|
|
deleteTargetTunnelId
|
|
? [String(deleteTargetTunnelId)]
|
|
: []
|
|
}
|
|
variant="bordered"
|
|
onSelectionChange={(keys) => {
|
|
const selected = Array.from(keys)[0];
|
|
|
|
setDeleteTargetTunnelId(
|
|
selected ? Number(selected) : null,
|
|
);
|
|
}}
|
|
>
|
|
{deleteReplacementTunnels.map((tunnel) => (
|
|
<SelectItem key={String(tunnel.id)}>
|
|
{tunnel.name}
|
|
</SelectItem>
|
|
))}
|
|
</Select>
|
|
<p className="text-xs text-default-500">
|
|
关联规则会迁移到这里,当前要删除的隧道不会出现在可选项里。
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<Alert
|
|
color="warning"
|
|
description={`当前未发现关联规则。确认后将直接删除“${tunnelToDelete?.name || "该隧道"}”,此操作不可撤销。`}
|
|
title="可以直接删除"
|
|
variant="flat"
|
|
/>
|
|
)}
|
|
</ModalBody>
|
|
<ModalFooter>
|
|
<Button variant="light" onPress={onClose}>
|
|
取消
|
|
</Button>
|
|
<Button
|
|
color="danger"
|
|
isDisabled={
|
|
deletePreviewLoading ||
|
|
(deleteHasForwardDependencies &&
|
|
deleteAction === "replace" &&
|
|
(!deleteTargetTunnelId || deleteReplaceUnavailable))
|
|
}
|
|
isLoading={deleteLoading}
|
|
onPress={confirmDelete}
|
|
>
|
|
{deleteLoading ? "删除中..." : deleteConfirmLabel}
|
|
</Button>
|
|
</ModalFooter>
|
|
</>
|
|
)}
|
|
</ModalContent>
|
|
</Modal>
|
|
|
|
{/* 诊断结果模态框 */}
|
|
<Modal
|
|
backdrop="blur"
|
|
classNames={{
|
|
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden [&>div]:bg-content1 [&>div]:dark:bg-content1",
|
|
}}
|
|
isOpen={diagnosisModalOpen}
|
|
placement="center"
|
|
scrollBehavior="inside"
|
|
size="4xl"
|
|
onOpenChange={(open) => {
|
|
setDiagnosisModalOpen(open);
|
|
if (!open) {
|
|
diagnosisAbortRef.current?.abort();
|
|
diagnosisAbortRef.current = null;
|
|
setDiagnosisLoading(false);
|
|
}
|
|
}}
|
|
>
|
|
<ModalContent>
|
|
{(onClose) => (
|
|
<>
|
|
<ModalHeader className="flex flex-col gap-1 bg-content1 border-b border-divider">
|
|
<h2 className="text-xl font-bold">隧道诊断结果</h2>
|
|
{currentDiagnosisTunnel && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-small text-default-500">
|
|
{currentDiagnosisTunnel.name}
|
|
</span>
|
|
<Chip
|
|
color={
|
|
currentDiagnosisTunnel.type === 1
|
|
? "primary"
|
|
: "secondary"
|
|
}
|
|
size="sm"
|
|
variant="flat"
|
|
>
|
|
{currentDiagnosisTunnel.type === 1
|
|
? "端口转发"
|
|
: "隧道转发"}
|
|
</Chip>
|
|
</div>
|
|
)}
|
|
</ModalHeader>
|
|
<ModalBody className="bg-transparent">
|
|
{diagnosisResult ? (
|
|
<div className="space-y-4">
|
|
{diagnosisLoading && (
|
|
<div className="flex items-center justify-between rounded-lg border border-primary/20 bg-primary/5 px-3 py-2">
|
|
<div className="flex items-center gap-2 text-sm text-primary">
|
|
<Spinner size="sm" />
|
|
<span>
|
|
正在诊断 {diagnosisProgress.completed}/
|
|
{diagnosisProgress.total > 0
|
|
? diagnosisProgress.total
|
|
: "?"}
|
|
</span>
|
|
</div>
|
|
<Chip color="primary" size="sm" variant="flat">
|
|
流式更新中
|
|
</Chip>
|
|
</div>
|
|
)}
|
|
|
|
{diagnosisProgress.timedOut && (
|
|
<Alert
|
|
color="warning"
|
|
description="诊断超时(单条30秒 / 整体2分钟),以下为当前已完成结果。"
|
|
title="诊断超时"
|
|
variant="flat"
|
|
/>
|
|
)}
|
|
|
|
{/* 统计摘要 */}
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div className="text-center p-3 bg-default-100 rounded-lg border border-divider">
|
|
<div className="text-2xl font-bold text-foreground">
|
|
{diagnosisProgress.total > 0
|
|
? diagnosisProgress.total
|
|
: diagnosisResult.results.length}
|
|
</div>
|
|
<div className="text-xs text-default-500 mt-1">
|
|
总测试数
|
|
</div>
|
|
</div>
|
|
<div className="text-center p-3 bg-success-50 dark:bg-success-900/20 rounded-lg border border-success-200 dark:border-success-700">
|
|
<div className="text-2xl font-bold text-success-600 dark:text-success-400">
|
|
{diagnosisProgress.completed > 0 ||
|
|
diagnosisProgress.total > 0
|
|
? diagnosisProgress.success
|
|
: diagnosisResult.results.filter((r) => r.success)
|
|
.length}
|
|
</div>
|
|
<div className="text-xs text-success-600 dark:text-success-400/80 mt-1">
|
|
成功
|
|
</div>
|
|
</div>
|
|
<div className="text-center p-3 bg-danger-50 dark:bg-danger-900/20 rounded-lg border border-danger-200 dark:border-danger-700">
|
|
<div className="text-2xl font-bold text-danger-600 dark:text-danger-400">
|
|
{diagnosisProgress.completed > 0 ||
|
|
diagnosisProgress.total > 0
|
|
? diagnosisProgress.failed
|
|
: diagnosisResult.results.filter((r) => !r.success)
|
|
.length}
|
|
</div>
|
|
<div className="text-xs text-danger-600 dark:text-danger-400/80 mt-1">
|
|
失败
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 桌面端表格展示 */}
|
|
<div className="hidden md:block space-y-3">
|
|
{(() => {
|
|
// 使用后端返回的 chainType 和 inx 字段进行分组
|
|
const groupedResults = {
|
|
entry: diagnosisResult.results.filter(
|
|
(r) => r.fromChainType === 1,
|
|
),
|
|
chains: {} as Record<
|
|
number,
|
|
typeof diagnosisResult.results
|
|
>,
|
|
exit: diagnosisResult.results.filter(
|
|
(r) => r.fromChainType === 3,
|
|
),
|
|
};
|
|
|
|
// 按 inx 分组链路测试
|
|
diagnosisResult.results.forEach((r) => {
|
|
if (r.fromChainType === 2 && r.fromInx != null) {
|
|
if (!groupedResults.chains[r.fromInx]) {
|
|
groupedResults.chains[r.fromInx] = [];
|
|
}
|
|
groupedResults.chains[r.fromInx].push(r);
|
|
}
|
|
});
|
|
|
|
const renderTableSection = (
|
|
title: string,
|
|
results: typeof diagnosisResult.results,
|
|
) => {
|
|
if (results.length === 0) return null;
|
|
|
|
return (
|
|
<div
|
|
key={title}
|
|
className="border border-divider rounded-lg overflow-hidden"
|
|
>
|
|
<div className="bg-primary/10 dark:bg-primary/20 px-3 py-2 border-b border-divider">
|
|
<h3 className="text-sm font-semibold text-primary">
|
|
{title}
|
|
</h3>
|
|
</div>
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-default-100">
|
|
<tr>
|
|
<th className="px-3 py-2 text-left font-semibold text-xs">
|
|
路径
|
|
</th>
|
|
<th className="px-3 py-2 text-center font-semibold text-xs w-20">
|
|
状态
|
|
</th>
|
|
<th className="px-3 py-2 text-center font-semibold text-xs w-24">
|
|
延迟(ms)
|
|
</th>
|
|
<th className="px-3 py-2 text-center font-semibold text-xs w-24">
|
|
丢包率
|
|
</th>
|
|
<th className="px-3 py-2 text-center font-semibold text-xs w-20">
|
|
质量
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-divider bg-content1">
|
|
{results.map((result, index) => {
|
|
const isDiagnosing = Boolean(
|
|
result.diagnosing,
|
|
);
|
|
const isSuccess = result.success === true;
|
|
const quality = getDiagnosisQualityDisplay(
|
|
result.averageTime,
|
|
result.packetLoss,
|
|
);
|
|
|
|
return (
|
|
<tr
|
|
key={index}
|
|
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
|
|
isDiagnosing
|
|
? "bg-warning-50 dark:bg-warning-900/20"
|
|
: isSuccess
|
|
? "bg-content1"
|
|
: "bg-danger-50 dark:bg-danger-900/30"
|
|
}`}
|
|
>
|
|
<td className="px-3 py-2">
|
|
<div className="flex items-center gap-2">
|
|
{isDiagnosing ? (
|
|
<Spinner size="sm" />
|
|
) : (
|
|
<span
|
|
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
|
|
isSuccess
|
|
? "bg-success text-white"
|
|
: "bg-danger text-white"
|
|
}`}
|
|
>
|
|
{isSuccess ? "✓" : "✗"}
|
|
</span>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium text-foreground truncate">
|
|
{result.description}
|
|
</div>
|
|
<div className="text-xs text-default-500 truncate">
|
|
{result.targetIp}:
|
|
{result.targetPort}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-3 py-2 text-center">
|
|
<Chip
|
|
color={
|
|
isDiagnosing
|
|
? "warning"
|
|
: isSuccess
|
|
? "success"
|
|
: "danger"
|
|
}
|
|
size="sm"
|
|
variant="flat"
|
|
>
|
|
{isDiagnosing
|
|
? "诊断中"
|
|
: isSuccess
|
|
? "成功"
|
|
: "失败"}
|
|
</Chip>
|
|
</td>
|
|
<td className="px-3 py-2 text-center">
|
|
{isSuccess ? (
|
|
<span className="font-semibold text-primary">
|
|
{result.averageTime?.toFixed(0)}
|
|
</span>
|
|
) : (
|
|
<span className="text-default-400">
|
|
-
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-2 text-center">
|
|
{isSuccess ? (
|
|
<span
|
|
className={`font-semibold ${
|
|
(result.packetLoss || 0) > 0
|
|
? "text-warning"
|
|
: "text-success"
|
|
}`}
|
|
>
|
|
{result.packetLoss?.toFixed(1)}%
|
|
</span>
|
|
) : (
|
|
<span className="text-default-400">
|
|
-
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-2 text-center">
|
|
{isSuccess && quality ? (
|
|
<Chip
|
|
className="text-xs whitespace-nowrap"
|
|
color={quality.color as any}
|
|
size="sm"
|
|
variant="flat"
|
|
>
|
|
{quality.text}
|
|
</Chip>
|
|
) : (
|
|
<span className="text-default-400">
|
|
-
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* 入口测试 */}
|
|
{renderTableSection(
|
|
"🚪 入口测试",
|
|
groupedResults.entry,
|
|
)}
|
|
|
|
{/* 链路测试(按跳数排序) */}
|
|
{Object.keys(groupedResults.chains)
|
|
.map(Number)
|
|
.sort((a, b) => a - b)
|
|
.map((hop) =>
|
|
renderTableSection(
|
|
`🔗 转发链 - 第${hop}跳`,
|
|
groupedResults.chains[hop],
|
|
),
|
|
)}
|
|
|
|
{/* 出口测试 */}
|
|
{renderTableSection(
|
|
"🚀 出口测试",
|
|
groupedResults.exit,
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
{/* 移动端卡片展示 */}
|
|
<div className="md:hidden space-y-3">
|
|
{(() => {
|
|
// 使用后端返回的 chainType 和 inx 字段进行分组
|
|
const groupedResults = {
|
|
entry: diagnosisResult.results.filter(
|
|
(r) => r.fromChainType === 1,
|
|
),
|
|
chains: {} as Record<
|
|
number,
|
|
typeof diagnosisResult.results
|
|
>,
|
|
exit: diagnosisResult.results.filter(
|
|
(r) => r.fromChainType === 3,
|
|
),
|
|
};
|
|
|
|
// 按 inx 分组链路测试
|
|
diagnosisResult.results.forEach((r) => {
|
|
if (r.fromChainType === 2 && r.fromInx != null) {
|
|
if (!groupedResults.chains[r.fromInx]) {
|
|
groupedResults.chains[r.fromInx] = [];
|
|
}
|
|
groupedResults.chains[r.fromInx].push(r);
|
|
}
|
|
});
|
|
|
|
const renderCardSection = (
|
|
title: string,
|
|
results: typeof diagnosisResult.results,
|
|
) => {
|
|
if (results.length === 0) return null;
|
|
|
|
return (
|
|
<div key={title} className="space-y-2">
|
|
<div className="px-2 py-1.5 bg-primary/10 dark:bg-primary/20 rounded-lg border border-primary/30">
|
|
<h3 className="text-sm font-semibold text-primary">
|
|
{title}
|
|
</h3>
|
|
</div>
|
|
{results.map((result, index) => {
|
|
const isDiagnosing = Boolean(result.diagnosing);
|
|
const isSuccess = result.success === true;
|
|
const quality = getDiagnosisQualityDisplay(
|
|
result.averageTime,
|
|
result.packetLoss,
|
|
);
|
|
|
|
return (
|
|
<div
|
|
key={index}
|
|
className={`border rounded-lg p-3 ${
|
|
isDiagnosing
|
|
? "border-warning-200 dark:border-warning-300/30 bg-warning-50 dark:bg-warning-900/20"
|
|
: isSuccess
|
|
? "border-divider bg-content1"
|
|
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
|
|
}`}
|
|
>
|
|
<div className="flex items-start gap-2 mb-2">
|
|
{isDiagnosing ? (
|
|
<Spinner size="sm" />
|
|
) : (
|
|
<span
|
|
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
|
|
isSuccess
|
|
? "bg-success text-white"
|
|
: "bg-danger text-white"
|
|
}`}
|
|
>
|
|
{isSuccess ? "✓" : "✗"}
|
|
</span>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-semibold text-sm text-foreground break-words">
|
|
{result.description}
|
|
</div>
|
|
<div className="text-xs text-default-500 mt-0.5 break-all">
|
|
{result.targetIp}:{result.targetPort}
|
|
</div>
|
|
</div>
|
|
<Chip
|
|
className="flex-shrink-0"
|
|
color={
|
|
isDiagnosing
|
|
? "warning"
|
|
: isSuccess
|
|
? "success"
|
|
: "danger"
|
|
}
|
|
size="sm"
|
|
variant="flat"
|
|
>
|
|
{isDiagnosing
|
|
? "诊断中"
|
|
: isSuccess
|
|
? "成功"
|
|
: "失败"}
|
|
</Chip>
|
|
</div>
|
|
|
|
{isSuccess ? (
|
|
<div className="grid grid-cols-3 gap-2 mt-2 pt-2 border-t border-divider">
|
|
<div className="text-center">
|
|
<div className="text-lg font-bold text-primary">
|
|
{result.averageTime?.toFixed(0)}
|
|
</div>
|
|
<div className="text-xs text-default-500">
|
|
延迟(ms)
|
|
</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div
|
|
className={`text-lg font-bold ${
|
|
(result.packetLoss || 0) > 0
|
|
? "text-warning"
|
|
: "text-success"
|
|
}`}
|
|
>
|
|
{result.packetLoss?.toFixed(1)}%
|
|
</div>
|
|
<div className="text-xs text-default-500">
|
|
丢包率
|
|
</div>
|
|
</div>
|
|
<div className="text-center">
|
|
{quality && (
|
|
<>
|
|
<Chip
|
|
className="text-xs whitespace-nowrap"
|
|
color={quality.color as any}
|
|
size="sm"
|
|
variant="flat"
|
|
>
|
|
{quality.text}
|
|
</Chip>
|
|
<div className="text-xs text-default-500 mt-0.5">
|
|
质量
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="mt-2 pt-2 border-t border-divider">
|
|
<div
|
|
className={`text-xs ${
|
|
isDiagnosing
|
|
? "text-warning"
|
|
: "text-danger"
|
|
}`}
|
|
>
|
|
{isDiagnosing
|
|
? result.message || "诊断中..."
|
|
: result.message || "连接失败"}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* 入口测试 */}
|
|
{renderCardSection(
|
|
"🚪 入口测试",
|
|
groupedResults.entry,
|
|
)}
|
|
|
|
{/* 链路测试(按跳数排序) */}
|
|
{Object.keys(groupedResults.chains)
|
|
.map(Number)
|
|
.sort((a, b) => a - b)
|
|
.map((hop) =>
|
|
renderCardSection(
|
|
`🔗 转发链 - 第${hop}跳`,
|
|
groupedResults.chains[hop],
|
|
),
|
|
)}
|
|
|
|
{/* 出口测试 */}
|
|
{renderCardSection(
|
|
"🚀 出口测试",
|
|
groupedResults.exit,
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
{/* 失败详情(仅桌面端显示,移动端已在卡片中显示) */}
|
|
{diagnosisResult.results.some(
|
|
(r) => r.success === false && !r.diagnosing,
|
|
) && (
|
|
<div className="space-y-2 hidden md:block">
|
|
<h4 className="text-sm font-semibold text-danger">
|
|
失败详情
|
|
</h4>
|
|
<div className="space-y-2">
|
|
{diagnosisResult.results
|
|
.filter((r) => r.success === false && !r.diagnosing)
|
|
.map((result, index) => (
|
|
<Alert
|
|
key={index}
|
|
className="text-xs"
|
|
color="danger"
|
|
description={result.message || "连接失败"}
|
|
title={result.description}
|
|
variant="flat"
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-16">
|
|
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
<svg
|
|
aria-hidden="true"
|
|
className="w-8 h-8 text-default-400"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.5}
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-foreground">
|
|
暂无诊断数据
|
|
</h3>
|
|
</div>
|
|
)}
|
|
</ModalBody>
|
|
<ModalFooter className="border-t border-divider">
|
|
<Button variant="light" onPress={onClose}>
|
|
关闭
|
|
</Button>
|
|
{currentDiagnosisTunnel && (
|
|
<Button
|
|
color="primary"
|
|
isLoading={diagnosisLoading}
|
|
onPress={() => handleDiagnose(currentDiagnosisTunnel)}
|
|
>
|
|
重新诊断
|
|
</Button>
|
|
)}
|
|
</ModalFooter>
|
|
</>
|
|
)}
|
|
</ModalContent>
|
|
</Modal>
|
|
|
|
<Modal
|
|
backdrop="blur"
|
|
classNames={{
|
|
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
|
|
}}
|
|
isOpen={batchDeleteModalOpen}
|
|
onOpenChange={handleBatchDeleteModalOpenChange}
|
|
>
|
|
<ModalContent>
|
|
{(onClose) => (
|
|
<>
|
|
<ModalHeader className="flex flex-col gap-1">
|
|
<h2 className="text-lg font-bold sm:text-xl">批量删除隧道</h2>
|
|
<p className="text-xs font-normal leading-5 text-default-500 sm:text-sm">
|
|
即将删除这 {selectedTunnelIdList.length}{" "}
|
|
条隧道,删除前会先检查是否有关联规则。
|
|
</p>
|
|
</ModalHeader>
|
|
<ModalBody className="space-y-3 sm:space-y-4">
|
|
{batchDeletePreviewLoading ? (
|
|
<div className="flex items-center gap-3 rounded-xl border border-divider bg-content2/40 px-3 py-5 text-sm text-default-600 sm:px-4 sm:py-6">
|
|
<Spinner size="sm" />
|
|
正在检查选中隧道是否有关联规则...
|
|
</div>
|
|
) : batchDeleteHasForwardDependencies ? (
|
|
<>
|
|
<Alert
|
|
color="warning"
|
|
description={`已选 ${selectedTunnelIdList.length} 条隧道,其中 ${batchDeleteDependentTunnelCount} 条仍被规则使用,共 ${batchDeleteTotalForwardCount} 条规则待处理。${batchDeleteDirectDeleteTunnelCount > 0 ? `其余 ${batchDeleteDirectDeleteTunnelCount} 条会直接删除。` : ""}`}
|
|
title="发现关联规则"
|
|
variant="flat"
|
|
/>
|
|
|
|
<div className="max-h-64 space-y-3 overflow-y-auto scrollbar-hide rounded-xl border border-divider bg-content2/40 p-3 sm:max-h-72 sm:p-4">
|
|
{batchDeleteDependentItems.map((item) => (
|
|
<div
|
|
key={item.tunnelId}
|
|
className="rounded-lg border border-divider/70 bg-background/80 p-2.5 sm:p-3"
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-medium text-foreground">
|
|
{item.tunnelName}
|
|
</p>
|
|
<p className="mt-1 text-xs text-default-500">
|
|
{item.forwardCount} 条规则依赖
|
|
</p>
|
|
</div>
|
|
<Chip color="warning" size="sm" variant="flat">
|
|
有关联
|
|
</Chip>
|
|
</div>
|
|
|
|
{item.sampleForwards.length > 0 ? (
|
|
<div className="mt-3 space-y-2">
|
|
{item.sampleForwards.map((forward) => (
|
|
<div
|
|
key={forward.id}
|
|
className="rounded-md bg-content1/70 px-2.5 py-2 sm:px-3"
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="truncate text-xs font-medium text-foreground">
|
|
{forward.name}
|
|
</span>
|
|
<span className="shrink-0 font-mono text-[11px] text-default-500">
|
|
:{forward.inPort || 0}
|
|
</span>
|
|
</div>
|
|
<p className="mt-1 text-[11px] text-default-500">
|
|
用户:
|
|
{forward.userName || `#${forward.userId}`}
|
|
</p>
|
|
</div>
|
|
))}
|
|
{item.forwardCount >
|
|
item.sampleForwards.length ? (
|
|
<p className="text-[11px] text-default-500">
|
|
还有{" "}
|
|
{item.forwardCount -
|
|
item.sampleForwards.length}{" "}
|
|
条规则未展开显示。
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<RadioGroup
|
|
label="处理方式"
|
|
value={batchDeleteAction}
|
|
onValueChange={(value) => {
|
|
const nextAction = value as TunnelDeleteAction;
|
|
|
|
setBatchDeleteAction(nextAction);
|
|
if (nextAction !== "replace") {
|
|
setBatchDeleteTargetTunnelId(null);
|
|
|
|
return;
|
|
}
|
|
|
|
setBatchDeleteTargetTunnelId(
|
|
batchDeleteReplacementTunnels[0]?.id ?? null,
|
|
);
|
|
}}
|
|
>
|
|
<Radio value="replace">
|
|
保留规则,统一迁移到其他隧道
|
|
{batchDeleteReplaceUnavailable
|
|
? "(当前无可用目标)"
|
|
: "(推荐)"}
|
|
</Radio>
|
|
<Radio value="delete_forwards">
|
|
直接删除这些关联规则
|
|
</Radio>
|
|
</RadioGroup>
|
|
|
|
{batchDeleteReplaceUnavailable ? (
|
|
<Alert
|
|
color="warning"
|
|
description="当前没有可承接这些规则的启用隧道,只能删除关联规则后再删除所选隧道。"
|
|
variant="flat"
|
|
/>
|
|
) : null}
|
|
|
|
{batchDeleteAction === "replace" &&
|
|
!batchDeleteReplaceUnavailable ? (
|
|
<div className="space-y-2">
|
|
<Select
|
|
label="目标隧道"
|
|
placeholder="请选择目标隧道"
|
|
selectedKeys={
|
|
batchDeleteTargetTunnelId
|
|
? [String(batchDeleteTargetTunnelId)]
|
|
: []
|
|
}
|
|
variant="bordered"
|
|
onSelectionChange={(keys) => {
|
|
const selected = Array.from(keys)[0];
|
|
|
|
setBatchDeleteTargetTunnelId(
|
|
selected ? Number(selected) : null,
|
|
);
|
|
}}
|
|
>
|
|
{batchDeleteReplacementTunnels.map((tunnel) => (
|
|
<SelectItem key={String(tunnel.id)}>
|
|
{tunnel.name}
|
|
</SelectItem>
|
|
))}
|
|
</Select>
|
|
<p className="text-xs text-default-500">
|
|
所有关联规则都会迁移到这里,删除列表中的隧道不会出现在可选项里。
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<Alert
|
|
color="warning"
|
|
description={`已选 ${selectedTunnelIdList.length} 条隧道,当前未发现关联规则。确认后将直接删除这些隧道,此操作不可撤销。`}
|
|
title="可以直接删除"
|
|
variant="flat"
|
|
/>
|
|
)}
|
|
</ModalBody>
|
|
<ModalFooter>
|
|
<Button variant="light" onPress={onClose}>
|
|
取消
|
|
</Button>
|
|
<Button
|
|
color="danger"
|
|
isDisabled={
|
|
batchDeletePreviewLoading ||
|
|
(batchDeleteHasForwardDependencies &&
|
|
batchDeleteAction === "replace" &&
|
|
(!batchDeleteTargetTunnelId ||
|
|
batchDeleteReplaceUnavailable))
|
|
}
|
|
isLoading={batchLoading}
|
|
onPress={handleBatchDelete}
|
|
>
|
|
{batchLoading ? "删除中..." : batchDeleteConfirmLabel}
|
|
</Button>
|
|
</ModalFooter>
|
|
</>
|
|
)}
|
|
</ModalContent>
|
|
</Modal>
|
|
|
|
<BatchActionResultModal
|
|
failures={batchResultModal.failures}
|
|
isOpen={batchResultModal.open}
|
|
summary={batchResultModal.summary}
|
|
title={batchResultModal.title}
|
|
onOpenChange={(open) => {
|
|
if (open) {
|
|
setBatchResultModal((prev) => ({ ...prev, open: true }));
|
|
|
|
return;
|
|
}
|
|
setBatchResultModal(EMPTY_BATCH_RESULT_MODAL_STATE);
|
|
}}
|
|
/>
|
|
</AnimatedPage>
|
|
);
|
|
}
|