import type { SpeedLimitApiItem } from "@/api/types"; import { useState, useEffect, useMemo } from "react"; import toast from "react-hot-toast"; import { DndContext, closestCenter, KeyboardSensor, MouseSensor, TouchSensor, useSensor, useSensors, DragEndEvent, } from "@dnd-kit/core"; import { arrayMove, SortableContext, sortableKeyboardCoordinates, rectSortingStrategy, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { SearchBar } from "@/components/search-bar"; import { AnimatedPage } from "@/components/animated-page"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Button } from "@/shadcn-bridge/heroui/button"; import { Input } from "@/shadcn-bridge/heroui/input"; import { Textarea } from "@/shadcn-bridge/heroui/input"; import { Select, SelectItem } from "@/shadcn-bridge/heroui/select"; import { Table, TableBody, TableCell, TableColumn, TableHeader, TableRow, } from "@/shadcn-bridge/heroui/table"; 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 { Switch } from "@/shadcn-bridge/heroui/switch"; import { Alert } from "@/shadcn-bridge/heroui/alert"; import { Checkbox } from "@/shadcn-bridge/heroui/checkbox"; import { createForward, getForwardList, getSpeedLimitList, getPeerShareList, getPeerRemoteUsageList, updateForward, deleteForward, forceDeleteForward, userTunnel, pauseForwardService, resumeForwardService, diagnoseForward, updateForwardOrder, } from "@/api"; import { type ForwardAddressItem, formatInAddress, formatRemoteAddress, hasMultipleAddresses, resolveForwardAddressAction, } from "@/pages/forward/address"; import { buildForwardDiagnosisFallbackResult, getForwardDiagnosisQualityDisplay, type ForwardDiagnosisResult, } from "@/pages/forward/diagnosis"; import { executeForwardBatchChangeTunnel, executeForwardBatchDelete, executeForwardBatchRedeploy, executeForwardBatchToggleService, } from "@/pages/forward/batch-actions"; import { buildForwardOrder, FORWARD_ORDER_KEY } from "@/pages/forward/order"; import { PageLoadingState } from "@/components/page-state"; import { useMobileBreakpoint } from "@/hooks/useMobileBreakpoint"; import { useLocalStorageState } from "@/hooks/use-local-storage-state"; import { saveOrder } from "@/utils/order-storage"; import { JwtUtil } from "@/utils/jwt"; interface Forward { id: number; name: string; tunnelId: number; tunnelName: string; inIp: string; inPort: number; remoteAddr: string; interfaceName?: string; strategy: string; status: number; inFlow: number; outFlow: number; serviceRunning: boolean; federationShareFlow?: number; createdTime: string; userName?: string; userId?: number; inx?: number; speedId?: number | null; } interface Tunnel { id: number; name: string; inNodePortSta?: number; inNodePortEnd?: number; } interface ForwardForm { id?: number; userId?: number; name: string; tunnelId: number | null; inPort: number | null; remoteAddr: string; interfaceName?: string; strategy: string; speedId: number | null; } export default function ForwardPage() { const [loading, setLoading] = useState(true); const [forwards, setForwards] = useState([]); const [tunnels, setTunnels] = useState([]); const [speedLimits, setSpeedLimits] = useState([]); const isMobile = useMobileBreakpoint(); const [searchKeyword, setSearchKeyword] = useLocalStorageState( "forward-search-keyword", "", ); const [isSearchVisible, setIsSearchVisible] = useState(false); // 显示模式状态 - 从localStorage读取,默认为平铺显示 const [viewMode, setViewMode] = useState<"grouped" | "direct">(() => { try { const savedMode = localStorage.getItem("forward-view-mode"); return (savedMode as "grouped" | "direct") || "direct"; } catch { return "direct"; } }); // 筛选状态 const [filterUserId, setFilterUserId, resetFilterUserId] = useLocalStorageState("forward-filter-user-id", "all"); const [filterTunnelId, setFilterTunnelId, resetFilterTunnelId] = useLocalStorageState("forward-filter-tunnel-id", "all"); // 拖拽排序相关状态 const [forwardOrder, setForwardOrder] = useState([]); // 模态框状态 const [modalOpen, setModalOpen] = useState(false); const [isFilterModalOpen, setIsFilterModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [addressModalOpen, setAddressModalOpen] = useState(false); const [diagnosisModalOpen, setDiagnosisModalOpen] = useState(false); const [isEdit, setIsEdit] = useState(false); const [submitLoading, setSubmitLoading] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false); const [diagnosisLoading, setDiagnosisLoading] = useState(false); const [forwardToDelete, setForwardToDelete] = useState(null); const [currentDiagnosisForward, setCurrentDiagnosisForward] = useState(null); const [diagnosisResult, setDiagnosisResult] = useState(null); const [addressModalTitle, setAddressModalTitle] = useState(""); const [addressList, setAddressList] = useState([]); // 导出相关状态 const [exportModalOpen, setExportModalOpen] = useState(false); const [exportData, setExportData] = useState(""); const [exportLoading, setExportLoading] = useState(false); const [selectedTunnelForExport, setSelectedTunnelForExport] = useState< number | null >(null); // 导入相关状态 const [importModalOpen, setImportModalOpen] = useState(false); const [importData, setImportData] = useState(""); const [importLoading, setImportLoading] = useState(false); const [selectedTunnelForImport, setSelectedTunnelForImport] = useState< number | null >(null); const [importResults, setImportResults] = useState< Array<{ line: string; success: boolean; message: string; forwardName?: string; }> >([]); // 表单状态 const [form, setForm] = useState({ name: "", tunnelId: null, inPort: null, remoteAddr: "", interfaceName: "", strategy: "fifo", speedId: null, }); // 表单验证错误 const [errors, setErrors] = useState<{ [key: string]: string }>({}); // 批量操作相关状态 const [selectMode, setSelectMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false); const [batchChangeTunnelModalOpen, setBatchChangeTunnelModalOpen] = useState(false); const [batchTargetTunnelId, setBatchTargetTunnelId] = useState( null, ); const [batchLoading, setBatchLoading] = useState(false); const parseShareIdFromTunnelName = (tunnelName: string): number | null => { const normalized = (tunnelName || "").trim(); if (!normalized.startsWith("Share-")) { return null; } const raw = normalized.slice("Share-".length); const idx = raw.indexOf("-Port-"); if (idx <= 0) { return null; } const shareId = Number(raw.slice(0, idx).trim()); return Number.isFinite(shareId) && shareId > 0 ? shareId : null; }; const mergeFederationShareFlow = async ( forwardsData: Forward[], ): Promise => { if (forwardsData.length === 0) { return forwardsData; } try { const [usageRes, localShareRes] = await Promise.all([ getPeerRemoteUsageList(), getPeerShareList(), ]); const flowByShare = new Map(); const shareIdsByTunnel = new Map>(); if (usageRes.code === 0 && Array.isArray(usageRes.data)) { usageRes.data.forEach((item: Record) => { const shareId = Number(item.shareId || 0); const currentFlow = Number(item.currentFlow || 0); if ( Number.isFinite(shareId) && shareId > 0 && Number.isFinite(currentFlow) && currentFlow > 0 ) { const prev = flowByShare.get(shareId) || 0; flowByShare.set(shareId, Math.max(prev, currentFlow)); } if (Number.isFinite(shareId) && shareId > 0) { const bindings = Array.isArray(item.bindings) ? (item.bindings as Array>) : []; bindings.forEach((binding) => { const tunnelId = Number(binding.tunnelId || 0); const chainType = Number(binding.chainType || 0); if (!Number.isFinite(tunnelId) || tunnelId <= 0) { return; } if (Number.isFinite(chainType) && chainType !== 1) { return; } let shareSet = shareIdsByTunnel.get(tunnelId); if (!shareSet) { shareSet = new Set(); shareIdsByTunnel.set(tunnelId, shareSet); } shareSet.add(shareId); }); } }); } if (localShareRes.code === 0 && Array.isArray(localShareRes.data)) { localShareRes.data.forEach((item: Record) => { const shareId = Number(item.id || 0); const currentFlow = Number(item.currentFlow || 0); if ( Number.isFinite(shareId) && shareId > 0 && Number.isFinite(currentFlow) && currentFlow > 0 ) { const prev = flowByShare.get(shareId) || 0; flowByShare.set(shareId, Math.max(prev, currentFlow)); } }); } if (flowByShare.size === 0) { return forwardsData; } const resolveShareIdForForward = (forward: Forward): number | null => { const candidates = new Set(); const shareIdFromName = parseShareIdFromTunnelName( forward.tunnelName || "", ); if (shareIdFromName) { candidates.add(shareIdFromName); } const tunnelId = Number(forward.tunnelId || 0); const shareSetByTunnel = shareIdsByTunnel.get(tunnelId); if (shareSetByTunnel && shareSetByTunnel.size > 0) { shareSetByTunnel.forEach((shareId) => { if (Number.isFinite(shareId) && shareId > 0) { candidates.add(shareId); } }); } if (candidates.size === 0) { return null; } let bestShareId: number | null = null; let bestFlow = 0; candidates.forEach((shareId) => { const shareFlow = flowByShare.get(shareId) || 0; if (shareFlow > bestFlow) { bestFlow = shareFlow; bestShareId = shareId; } }); return bestShareId; }; const resolvedShareByForwardId = new Map(); forwardsData.forEach((forward) => { const shareId = resolveShareIdForForward(forward); if (shareId) { resolvedShareByForwardId.set(forward.id, shareId); } }); const forwardCountByShare = new Map(); forwardsData.forEach((forward) => { const shareId = resolvedShareByForwardId.get(forward.id) || null; if (!shareId || !flowByShare.has(shareId)) { return; } forwardCountByShare.set( shareId, (forwardCountByShare.get(shareId) || 0) + 1, ); }); return forwardsData.map((forward) => { const shareId = resolvedShareByForwardId.get(forward.id) || null; if (!shareId) { return { ...forward, federationShareFlow: undefined }; } const shareFlow = flowByShare.get(shareId) || 0; if (shareFlow <= 0) { return { ...forward, federationShareFlow: undefined }; } const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0); if (directFlow > 0) { return { ...forward, federationShareFlow: undefined }; } const count = forwardCountByShare.get(shareId) || 1; const estimated = Math.max(1, Math.floor(shareFlow / count)); return { ...forward, federationShareFlow: estimated }; }); } catch { return forwardsData; } }; const getForwardDisplayFlow = (forward: Forward): number => { const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0); if (directFlow > 0) { return directFlow; } return forward.federationShareFlow || 0; }; useEffect(() => { loadData(); }, []); // 切换显示模式并保存到localStorage const handleViewModeChange = () => { const newMode = viewMode === "grouped" ? "direct" : "grouped"; setViewMode(newMode); try { localStorage.setItem("forward-view-mode", newMode); } catch {} }; // 加载所有数据 const loadData = async (lod = true) => { setLoading(lod); try { const [forwardsRes, tunnelsRes, speedLimitsRes] = await Promise.all([ getForwardList(), userTunnel(), getSpeedLimitList(), ]); if (forwardsRes.code === 0) { const forwardsData = forwardsRes.data?.map((forward: any) => ({ ...forward, serviceRunning: forward.status === 1, })) || []; const mergedForwards = await mergeFederationShareFlow(forwardsData); setForwards(mergedForwards); // 初始化拖拽排序顺序 const currentUserId = JwtUtil.getUserIdFromToken(); const { order, fromDatabase } = buildForwardOrder( mergedForwards, currentUserId, ); setForwardOrder(order); if (fromDatabase) { saveOrder(FORWARD_ORDER_KEY, order); } } else { toast.error(forwardsRes.msg || "获取转发列表失败"); } if (tunnelsRes.code === 0) { setTunnels(tunnelsRes.data || []); } else { } if (speedLimitsRes.code === 0) { setSpeedLimits(speedLimitsRes.data || []); } } catch { toast.error("加载数据失败"); } finally { setLoading(false); } }; // 表单验证 const noLimitSpeedLimitIds = useMemo(() => { return new Set( speedLimits .filter((speedLimit) => speedLimit.name.trim() === "不限速") .map((speedLimit) => speedLimit.id), ); }, [speedLimits]); const availableSpeedLimits = useMemo(() => { return speedLimits.filter( (speedLimit) => !noLimitSpeedLimitIds.has(speedLimit.id), ); }, [speedLimits, noLimitSpeedLimitIds]); const normalizeSpeedId = (speedId?: number | null): number | null => { if (speedId === null || speedId === undefined) { return null; } return noLimitSpeedLimitIds.has(speedId) ? null : speedId; }; const selectedSpeedId = normalizeSpeedId(form.speedId); const validateForm = (): boolean => { const newErrors: { [key: string]: string } = {}; if (!form.name.trim()) { newErrors.name = "请输入转发名称"; } else if (form.name.length < 2 || form.name.length > 50) { newErrors.name = "转发名称长度应在2-50个字符之间"; } if (!form.tunnelId) { newErrors.tunnelId = "请选择关联隧道"; } // 验证入口端口(可选,如果填写则验证) if (form.inPort !== null && form.inPort !== undefined) { const port = Number(form.inPort); if (isNaN(port) || port < 1 || port > 65535) { newErrors.inPort = "端口必须在 1-65535 之间"; } } if (!form.remoteAddr.trim()) { newErrors.remoteAddr = "请输入远程地址"; } else { // 验证地址格式 const addresses = form.remoteAddr .split("\n") .map((addr) => addr.trim()) .filter((addr) => addr); const ipv4Pattern = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?):\d+$/; const ipv6FullPattern = /^\[((([0-9a-fA-F]{1,4}:){7}([0-9a-fA-F]{1,4}|:))|(([0-9a-fA-F]{1,4}:){6}(:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-fA-F]{1,4}:){5}(((:[0-9a-fA-F]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-fA-F]{1,4}:){4}(((:[0-9a-fA-F]{1,4}){1,3})|((:[0-9a-fA-F]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){3}(((:[0-9a-fA-F]{1,4}){1,4})|((:[0-9a-fA-F]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){2}(((:[0-9a-fA-F]{1,4}){1,5})|((:[0-9a-fA-F]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){1}(((:[0-9a-fA-F]{1,4}){1,6})|((:[0-9a-fA-F]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9a-fA-F]{1,4}){1,7})|((:[0-9a-fA-F]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))\]:\d+$/; const domainPattern = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*:\d+$/; for (let i = 0; i < addresses.length; i++) { const addr = addresses[i]; if ( !ipv4Pattern.test(addr) && !ipv6FullPattern.test(addr) && !domainPattern.test(addr) ) { newErrors.remoteAddr = `第${i + 1}行地址格式错误`; break; } } } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; // 新增转发 const handleAdd = () => { setIsEdit(false); setForm({ name: "", tunnelId: null, inPort: null, remoteAddr: "", interfaceName: "", strategy: "fifo", speedId: null, }); setErrors({}); setModalOpen(true); }; // 编辑转发 const handleEdit = (forward: Forward) => { setIsEdit(true); setForm({ id: forward.id, userId: forward.userId, name: forward.name, tunnelId: forward.tunnelId, inPort: forward.inPort, remoteAddr: forward.remoteAddr.split(",").join("\n"), interfaceName: forward.interfaceName || "", strategy: forward.strategy || "fifo", speedId: normalizeSpeedId(forward.speedId), }); setErrors({}); setModalOpen(true); }; // 显示删除确认 const handleDelete = (forward: Forward) => { setForwardToDelete(forward); setDeleteModalOpen(true); }; // 确认删除转发 const confirmDelete = async () => { if (!forwardToDelete) return; setDeleteLoading(true); try { const res = await deleteForward(forwardToDelete.id); if (res.code === 0) { toast.success("删除成功"); setDeleteModalOpen(false); loadData(); } else { // 删除失败,询问是否强制删除 const confirmed = window.confirm( `常规删除失败:${res.msg || "删除失败"}\n\n是否需要强制删除?\n\n⚠️ 注意:强制删除不会去验证节点端是否已经删除对应的转发服务。`, ); if (confirmed) { const forceRes = await forceDeleteForward(forwardToDelete.id); if (forceRes.code === 0) { toast.success("强制删除成功"); setDeleteModalOpen(false); loadData(); } else { toast.error(forceRes.msg || "强制删除失败"); } } } } catch { toast.error("删除失败"); } finally { setDeleteLoading(false); } }; // 处理隧道选择变化 const handleTunnelChange = (tunnelId: string) => { setForm((prev) => ({ ...prev, tunnelId: parseInt(tunnelId) })); }; // 提交表单 const handleSubmit = async () => { if (!validateForm()) return; setSubmitLoading(true); try { const processedRemoteAddr = form.remoteAddr .split("\n") .map((addr) => addr.trim()) .filter((addr) => addr) .join(","); const addressCount = processedRemoteAddr.split(",").length; let res: { code: number; msg: string }; if (isEdit) { // 更新时确保包含必要字段 const updateData = { id: form.id, userId: form.userId, name: form.name, tunnelId: form.tunnelId, inPort: form.inPort, remoteAddr: processedRemoteAddr, strategy: addressCount > 1 ? form.strategy : "fifo", speedId: normalizeSpeedId(form.speedId), }; res = await updateForward(updateData); } else { // 创建时不需要id和userId(后端会自动设置) const createData = { name: form.name, tunnelId: form.tunnelId, inPort: form.inPort, remoteAddr: processedRemoteAddr, strategy: addressCount > 1 ? form.strategy : "fifo", speedId: normalizeSpeedId(form.speedId), }; res = await createForward(createData); } if (res.code === 0) { toast.success(isEdit ? "修改成功" : "创建成功"); setModalOpen(false); loadData(); } else { toast.error(res.msg || "操作失败"); } } catch { toast.error("操作失败"); } finally { setSubmitLoading(false); } }; // 处理服务开关 const handleServiceToggle = async (forward: Forward) => { if (forward.status !== 1 && forward.status !== 0) { toast.error("转发状态异常,无法操作"); return; } const targetState = !forward.serviceRunning; try { // 乐观更新UI setForwards((prev) => prev.map((f) => f.id === forward.id ? { ...f, serviceRunning: targetState } : f, ), ); let res: { code: number; msg: string }; if (targetState) { res = await resumeForwardService(forward.id); } else { res = await pauseForwardService(forward.id); } if (res.code === 0) { toast.success(targetState ? "服务已启动" : "服务已暂停"); // 更新转发状态 setForwards((prev) => prev.map((f) => f.id === forward.id ? { ...f, status: targetState ? 1 : 0 } : f, ), ); } else { // 操作失败,恢复UI状态 setForwards((prev) => prev.map((f) => f.id === forward.id ? { ...f, serviceRunning: !targetState } : f, ), ); toast.error(res.msg || "操作失败"); } } catch { // 操作失败,恢复UI状态 setForwards((prev) => prev.map((f) => f.id === forward.id ? { ...f, serviceRunning: !targetState } : f, ), ); toast.error("网络错误,操作失败"); } }; // 诊断转发 const handleDiagnose = async (forward: Forward) => { setCurrentDiagnosisForward(forward); setDiagnosisModalOpen(true); setDiagnosisLoading(true); setDiagnosisResult(null); try { const response = await diagnoseForward(forward.id); if (response.code === 0) { setDiagnosisResult(response.data as ForwardDiagnosisResult); } else { toast.error(response.msg || "诊断失败"); setDiagnosisResult( buildForwardDiagnosisFallbackResult({ forwardName: forward.name, remoteAddr: forward.remoteAddr, description: "诊断失败", message: response.msg || "诊断过程中发生错误", }), ); } } catch { toast.error("网络错误,请重试"); setDiagnosisResult( buildForwardDiagnosisFallbackResult({ forwardName: forward.name, remoteAddr: forward.remoteAddr, description: "网络错误", message: "无法连接到服务器", }), ); } finally { setDiagnosisLoading(false); } }; // 格式化流量 const formatFlow = (value: number): string => { if (value === 0) return "0 B"; if (value < 1024) return value + " B"; if (value < 1024 * 1024) return (value / 1024).toFixed(2) + " KB"; if (value < 1024 * 1024 * 1024) return (value / (1024 * 1024)).toFixed(2) + " MB"; return (value / (1024 * 1024 * 1024)).toFixed(2) + " GB"; }; // 显示地址列表弹窗 const showAddressModal = ( addressString: string, port: number | null, title: string, ) => { const action = resolveForwardAddressAction(addressString, port, title); if (action.type === "none") { return; } if (action.type === "copy") { copyToClipboard(action.text, action.label); return; } setAddressList(action.items); setAddressModalTitle(action.title); setAddressModalOpen(true); }; // 复制到剪贴板 const copyToClipboard = async (text: string, label: string = "内容") => { try { await navigator.clipboard.writeText(text); toast.success(`已复制${label}`); } catch { toast.error("复制失败"); } }; // 复制地址 const copyAddress = async (addressItem: ForwardAddressItem) => { try { setAddressList((prev) => prev.map((item) => item.id === addressItem.id ? { ...item, copying: true } : item, ), ); await copyToClipboard(addressItem.address, "地址"); } catch { toast.error("复制失败"); } finally { setAddressList((prev) => prev.map((item) => item.id === addressItem.id ? { ...item, copying: false } : item, ), ); } }; // 复制所有地址 const copyAllAddresses = async () => { if (addressList.length === 0) return; const allAddresses = addressList.map((item) => item.address).join("\n"); await copyToClipboard(allAddresses, "所有地址"); }; // 导出转发数据 const handleExport = () => { setSelectedTunnelForExport(null); setExportData(""); setExportModalOpen(true); }; // 执行导出 const executeExport = () => { if (!selectedTunnelForExport) { toast.error("请选择要导出的隧道"); return; } setExportLoading(true); try { // 获取要导出的转发列表 const forwardsToExport = sortedForwards.filter( (forward) => forward.tunnelId === selectedTunnelForExport, ); if (forwardsToExport.length === 0) { toast.error("所选隧道没有转发数据"); setExportLoading(false); return; } // 格式化导出数据:remoteAddr|name|inPort const exportLines = forwardsToExport.map((forward) => { return `${forward.remoteAddr}|${forward.name}|${forward.inPort}`; }); const exportText = exportLines.join("\n"); setExportData(exportText); } catch { toast.error("导出失败"); } finally { setExportLoading(false); } }; // 复制导出数据 const copyExportData = async () => { await copyToClipboard(exportData, "转发数据"); }; // 导入转发数据 const handleImport = () => { setImportData(""); setImportResults([]); setSelectedTunnelForImport(null); setImportModalOpen(true); }; // 执行导入 const executeImport = async () => { if (!importData.trim()) { toast.error("请输入要导入的数据"); return; } if (!selectedTunnelForImport) { toast.error("请选择要导入的隧道"); return; } setImportLoading(true); setImportResults([]); // 清空之前的结果 try { const lines = importData .trim() .split("\n") .filter((line) => line.trim()); for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); const parts = line.split("|"); if (parts.length < 2) { setImportResults((prev) => [ { line, success: false, message: "格式错误:需要至少包含目标地址和转发名称", }, ...prev, ]); continue; } const [remoteAddr, name, inPort] = parts; if (!remoteAddr.trim() || !name.trim()) { setImportResults((prev) => [ { line, success: false, message: "目标地址和转发名称不能为空", }, ...prev, ]); continue; } // 验证远程地址格式 - 支持单个地址或多个地址用逗号分隔 const addresses = remoteAddr.trim().split(","); const addressPattern = /^[^:]+:\d+$/; const isValidFormat = addresses.every((addr) => addressPattern.test(addr.trim()), ); if (!isValidFormat) { setImportResults((prev) => [ { line, success: false, message: "目标地址格式错误,应为 地址:端口 格式,多个地址用逗号分隔", }, ...prev, ]); continue; } try { // 处理入口端口 let portNumber: number | null = null; if (inPort && inPort.trim()) { const port = parseInt(inPort.trim()); if (isNaN(port) || port < 1 || port > 65535) { setImportResults((prev) => [ { line, success: false, message: "入口端口格式错误,应为1-65535之间的数字", }, ...prev, ]); continue; } portNumber = port; } // 调用创建转发接口 const response = await createForward({ name: name.trim(), tunnelId: selectedTunnelForImport, // 使用用户选择的隧道 inPort: portNumber, // 使用指定端口或自动分配 remoteAddr: remoteAddr.trim(), strategy: "fifo", }); if (response.code === 0) { setImportResults((prev) => [ { line, success: true, message: "创建成功", forwardName: name.trim(), }, ...prev, ]); } else { setImportResults((prev) => [ { line, success: false, message: response.msg || "创建失败", }, ...prev, ]); } } catch { setImportResults((prev) => [ { line, success: false, message: "网络错误,创建失败", }, ...prev, ]); } } toast.success(`导入执行完成`); // 导入完成后刷新转发列表 await loadData(false); } catch { toast.error("导入过程中发生错误"); } finally { setImportLoading(false); } }; // 获取状态显示 const getStatusDisplay = (status: number) => { switch (status) { case 1: return { color: "success", text: "正常" }; case 0: return { color: "warning", text: "暂停" }; case -1: return { color: "danger", text: "异常" }; default: return { color: "default", text: "未知" }; } }; // 获取策略显示 const getStrategyDisplay = (strategy: string) => { switch (strategy) { case "fifo": return { color: "primary", text: "主备" }; case "round": return { color: "success", text: "轮询" }; case "rand": return { color: "warning", text: "随机" }; default: return { color: "default", text: "未知" }; } }; // 获取地址数量 const getAddressCount = (addressString: string): number => { if (!addressString) return 0; const addresses = addressString .split("\n") .map((addr) => addr.trim()) .filter((addr) => addr); return addresses.length; }; // 处理拖拽结束 const handleDragEnd = async (event: DragEndEvent) => { const { active, over } = event; if (!active || !over || active.id === over.id) return; // 确保 forwardOrder 存在且有效 if (!forwardOrder || forwardOrder.length === 0) return; const activeId = Number(active.id); const overId = Number(over.id); // 检查 ID 是否有效 if (isNaN(activeId) || isNaN(overId)) return; const oldIndex = forwardOrder.indexOf(activeId); const newIndex = forwardOrder.indexOf(overId); if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) { const newOrder = arrayMove(forwardOrder, oldIndex, newIndex); setForwardOrder(newOrder); saveOrder(FORWARD_ORDER_KEY, newOrder); // 持久化到数据库 try { const forwardsToUpdate = newOrder.map((id, index) => ({ id, inx: index, })); const response = await updateForwardOrder({ forwards: forwardsToUpdate, }); if (response.code === 0) { // 更新本地数据中的 inx 字段 setForwards((prev) => prev.map((forward) => { const updatedForward = forwardsToUpdate.find( (f) => f.id === forward.id, ); if (updatedForward) { return { ...forward, inx: updatedForward.inx }; } return forward; }), ); } 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 = sortedForwards.map((f) => f.id); setSelectedIds(new Set(allIds)); }; const deselectAll = () => { setSelectedIds(new Set()); }; const handleBatchDelete = async () => { if (selectedIds.size === 0) return; setBatchLoading(true); try { const outcome = await executeForwardBatchDelete(Array.from(selectedIds)); if (outcome.toastVariant === "success") { toast.success(outcome.toastMessage); } else { toast.error(outcome.toastMessage); } if (outcome.shouldRefresh) { setSelectedIds(new Set()); setSelectMode(false); if (outcome.closeDeleteModal) { setBatchDeleteModalOpen(false); } loadData(false); } } finally { setBatchLoading(false); } }; const handleBatchToggleService = async (enable: boolean) => { if (selectedIds.size === 0) return; setBatchLoading(true); try { const outcome = await executeForwardBatchToggleService( Array.from(selectedIds), enable, ); if (outcome.toastVariant === "success") { toast.success(outcome.toastMessage); } else { toast.error(outcome.toastMessage); } if (outcome.shouldRefresh) { setSelectedIds(new Set()); setSelectMode(false); loadData(false); } } finally { setBatchLoading(false); } }; const handleBatchRedeploy = async () => { if (selectedIds.size === 0) return; setBatchLoading(true); try { const outcome = await executeForwardBatchRedeploy( Array.from(selectedIds), ); if (outcome.toastVariant === "success") { toast.success(outcome.toastMessage); } else { toast.error(outcome.toastMessage); } if (outcome.shouldRefresh) { setSelectedIds(new Set()); setSelectMode(false); loadData(false); } } finally { setBatchLoading(false); } }; const handleBatchChangeTunnel = async () => { if (selectedIds.size === 0 || !batchTargetTunnelId) return; setBatchLoading(true); try { const outcome = await executeForwardBatchChangeTunnel( Array.from(selectedIds), batchTargetTunnelId, ); if (outcome.toastVariant === "success") { toast.success(outcome.toastMessage); } else { toast.error(outcome.toastMessage); } if (outcome.shouldRefresh) { setSelectedIds(new Set()); setSelectMode(false); if (outcome.closeChangeTunnelModal) { setBatchChangeTunnelModalOpen(false); } if (outcome.resetTargetTunnel) { setBatchTargetTunnelId(null); } loadData(false); } } finally { setBatchLoading(false); } }; // 传感器配置 - 使用默认配置避免错误 const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 8, }, }), useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 8, }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); const tokenUserId = JwtUtil.getUserIdFromToken(); // 根据排序顺序获取转发列表 const sortedForwards = useMemo((): Forward[] => { // 确保 forwards 数组存在且有效 if (!forwards || forwards.length === 0) { return []; } let filteredForwards = forwards; if (filterUserId !== "all") { const targetUserId = parseInt(filterUserId); filteredForwards = filteredForwards.filter( (f) => f.userId === targetUserId || (targetUserId === 0 && !f.userId), ); } if (filterTunnelId !== "all") { const targetTunnelId = parseInt(filterTunnelId); filteredForwards = filteredForwards.filter( (f) => f.tunnelId === targetTunnelId, ); } if (searchKeyword.trim()) { const lowerKeyword = searchKeyword.toLowerCase(); filteredForwards = filteredForwards.filter( (f) => (f.name && f.name.toLowerCase().includes(lowerKeyword)) || (f.remoteAddr && f.remoteAddr.toLowerCase().includes(lowerKeyword)) || (f.userName && f.userName.toLowerCase().includes(lowerKeyword)), ); } // 确保过滤后的转发列表有效 if (!filteredForwards || filteredForwards.length === 0) { return []; } // 优先使用数据库中的 inx 字段进行排序 const sortedByDb = [...filteredForwards].sort((a, b) => { const aInx = a.inx ?? 0; const bInx = b.inx ?? 0; if (aInx !== bInx) { return aInx - bInx; } return (a.id ?? 0) - (b.id ?? 0); }); // 如果数据库中没有排序信息,则使用本地存储的顺序 if ( forwardOrder && forwardOrder.length > 0 && sortedByDb.every((f) => f.inx === undefined || f.inx === 0) ) { const forwardMap = new Map(filteredForwards.map((f) => [f.id, f])); const localSortedForwards: Forward[] = []; forwardOrder.forEach((id) => { const forward = forwardMap.get(id); if (forward) { localSortedForwards.push(forward); } }); // 添加不在排序列表中的转发(新添加的) filteredForwards.forEach((forward) => { if (!forwardOrder.includes(forward.id)) { localSortedForwards.push(forward); } }); return localSortedForwards; } return sortedByDb; }, [ forwards, forwardOrder, viewMode, tokenUserId, filterUserId, filterTunnelId, searchKeyword, ]); const sortableForwardIds = useMemo( () => sortedForwards.map((f) => f.id).filter((id) => id > 0), [sortedForwards], ); // 可拖拽的转发卡片组件 const SortableForwardCard = ({ forward }: { forward: Forward }) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: forward.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 (
{renderForwardCard(forward, listeners)}
); }; // 生成用作筛选项的用户和隧道列表 const uniqueUsers = useMemo(() => { const userMap = new Map(); forwards.forEach((f) => { const uId = f.userId || 0; if (!userMap.has(uId)) { userMap.set(uId, { id: uId, name: f.userName || "未知用户" }); } }); return Array.from(userMap.values()); }, [forwards]); // 可拖拽的表格行组件 const SortableTableRow = ({ forward, selectMode, selectedIds, toggleSelect, getStrategyDisplay, formatInAddress, formatRemoteAddress, handleServiceToggle, handleEdit, handleDelete, handleDiagnose, showAddressModal, hasMultipleAddresses, formatFlow, }: any) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: forward.id }); const style = { 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, backgroundColor: isDragging ? "var(--nextui-default-100)" : undefined, }; const strategyDisplay = getStrategyDisplay(forward.strategy); return ( {selectMode && ( toggleSelect(forward.id)} /> )}
{forward.userName || "未知用户"} {forward.name} {forward.tunnelName} {strategyDisplay.text} {formatFlow(getForwardDisplayFlow(forward))}
handleServiceToggle(forward)} />
); }; // 渲染转发卡片 const renderForwardCard = (forward: Forward, listeners?: any) => { const statusDisplay = getStatusDisplay(forward.status); const strategyDisplay = getStrategyDisplay(forward.strategy); return (
{selectMode && ( toggleSelect(forward.id)} /> )}

{forward.name}

{forward.tunnelName}

{viewMode === "direct" && (
)} handleServiceToggle(forward)} /> {statusDisplay.text}
{/* 地址信息 */}
{/* 统计信息 */}
{strategyDisplay.text} {(forward.inFlow || 0) + (forward.outFlow || 0) > 0 ? ( <>
↑{formatFlow(forward.inFlow || 0)}
↓{formatFlow(forward.outFlow || 0)} ) : (forward.federationShareFlow || 0) > 0 ? ( 共享 {formatFlow(forward.federationShareFlow || 0)} ) : ( 总流量 {formatFlow(0)} )}
); }; if (loading) { return ; } return ( {/* 页面头部 */}
setIsSearchVisible(false)} onOpen={() => setIsSearchVisible(true)} />
{selectMode ? ( <> 已选择 {selectedIds.size} 项 ) : ( <> {/* 筛选按钮 */} {/* 显示模式切换按钮 */} {/* 导入按钮 */} {/* 导出按钮 */} )}
{/* 根据显示模式渲染不同内容 */} {viewMode === "grouped" ? ( sortedForwards.length > 0 ? (
{selectMode && ( 选择 )} 用户 名称 隧道 入口 目标 策略 总流量 状态 操作 {(forward) => ( )}
) : ( /* 空状态 */

暂无转发配置

还没有创建任何转发配置,点击上方按钮开始创建

) ) : /* 直接显示模式 */ forwards.length > 0 ? ( {}} // 添加空的 onDragStart 处理器 >
{sortedForwards.map((forward) => forward && forward.id ? ( ) : null, )}
) : ( /* 空状态 */

暂无转发配置

还没有创建任何转发配置,点击上方按钮开始创建

)} {/* 新增/编辑模态框 */} {(onClose) => ( <>

{isEdit ? "编辑转发" : "新增转发"}

{isEdit ? "修改现有转发配置的信息" : "创建新的转发配置"}

setForm((prev) => ({ ...prev, name: e.target.value })) } /> { const value = e.target.value; setForm((prev) => ({ ...prev, inPort: value ? parseInt(value) : null, })); }} />