{ "info": { "id": "ses_32fb9de62ffeXG3aOGlPDusGE8", "slug": "happy-pixel", "projectID": "03c3dd2e5791a80ef100ef53744e013d779d671c", "directory": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "title": "规则页精简模式显示规则数量", "version": "1.2.21", "summary": { "additions": 4529, "deletions": 65, "files": 11 }, "time": { "created": 1773020783005, "updated": 1773022814998 } }, "messages": [ { "info": { "role": "user", "time": { "created": 1773020783596 }, "summary": { "diffs": [ { "file": "vite-frontend/src/pages/forward.tsx", "before": "import type { ForwardApiItem, SpeedLimitApiItem } from \"@/api/types\";\n\nimport { useState, useEffect, useMemo, useRef, useCallback } from \"react\";\nimport toast from \"react-hot-toast\";\nimport {\n DndContext,\n closestCenter,\n KeyboardSensor,\n MouseSensor,\n TouchSensor,\n useSensor,\n useSensors,\n DragEndEvent,\n} from \"@dnd-kit/core\";\nimport {\n arrayMove,\n SortableContext,\n sortableKeyboardCoordinates,\n rectSortingStrategy,\n verticalListSortingStrategy,\n} from \"@dnd-kit/sortable\";\nimport { useSortable } from \"@dnd-kit/sortable\";\nimport { CSS } from \"@dnd-kit/utilities\";\n\nimport { SearchBar } from \"@/components/search-bar\";\nimport { AnimatedPage } from \"@/components/animated-page\";\nimport { Card, CardBody, CardHeader } from \"@/shadcn-bridge/heroui/card\";\nimport { Button } from \"@/shadcn-bridge/heroui/button\";\nimport { Input } from \"@/shadcn-bridge/heroui/input\";\nimport { Textarea } from \"@/shadcn-bridge/heroui/input\";\nimport { Select, SelectItem } from \"@/shadcn-bridge/heroui/select\";\nimport {\n Table,\n TableBody,\n TableCell,\n TableColumn,\n TableHeader,\n TableRow,\n} from \"@/shadcn-bridge/heroui/table\";\nimport {\n Modal,\n ModalContent,\n ModalHeader,\n ModalBody,\n ModalFooter,\n} from \"@/shadcn-bridge/heroui/modal\";\nimport { Chip } from \"@/shadcn-bridge/heroui/chip\";\nimport { Spinner } from \"@/shadcn-bridge/heroui/spinner\";\nimport { Switch } from \"@/shadcn-bridge/heroui/switch\";\nimport { Alert } from \"@/shadcn-bridge/heroui/alert\";\nimport { Progress } from \"@/shadcn-bridge/heroui/progress\";\nimport { Checkbox } from \"@/shadcn-bridge/heroui/checkbox\";\nimport {\n createForward,\n getForwardList,\n getSpeedLimitList,\n getPeerShareList,\n getPeerRemoteUsageList,\n updateForward,\n deleteForward,\n forceDeleteForward,\n userTunnel,\n getTunnelList,\n getNodeList,\n pauseForwardService,\n resumeForwardService,\n diagnoseForward,\n updateForwardOrder,\n getConfigByName,\n updateConfig,\n} from \"@/api\";\nimport {\n type ForwardAddressItem,\n formatInAddress,\n formatRemoteAddress,\n hasMultipleAddresses,\n resolveForwardAddressAction,\n} from \"@/pages/forward/address\";\nimport {\n buildForwardDiagnosisFallbackResult,\n getForwardDiagnosisQualityDisplay,\n type ForwardDiagnosisResult,\n} from \"@/pages/forward/diagnosis\";\nimport { diagnoseForwardStream } from \"@/api/diagnosis-stream\";\nimport {\n executeForwardBatchChangeTunnel,\n executeForwardBatchDelete,\n executeForwardBatchRedeploy,\n executeForwardBatchToggleService,\n} from \"@/pages/forward/batch-actions\";\nimport {\n convertNyItemToForwardInput,\n parseNyFormatData,\n} from \"@/pages/forward/import-format\";\nimport { buildForwardOrder, FORWARD_ORDER_KEY } from \"@/pages/forward/order\";\nimport { PageLoadingState } from \"@/components/page-state\";\nimport { useMobileBreakpoint } from \"@/hooks/useMobileBreakpoint\";\nimport { useLocalStorageState } from \"@/hooks/use-local-storage-state\";\nimport { saveOrder } from \"@/utils/order-storage\";\nimport { JwtUtil } from \"@/utils/jwt\";\n\ninterface Forward {\n id: number;\n name: string;\n tunnelId: number;\n tunnelName: string;\n tunnelTrafficRatio?: number;\n inIp: string;\n inPort: number;\n remoteAddr: string;\n interfaceName?: string;\n strategy: string;\n status: number;\n inFlow: number;\n outFlow: number;\n serviceRunning: boolean;\n federationShareFlow?: number;\n createdTime: string;\n userName?: string;\n userId?: number;\n inx?: number;\n speedId?: number | null;\n}\n\ninterface Tunnel {\n id: number;\n name: string;\n type?: number;\n inIp?: string;\n inNodeId?: Array<{ nodeId: number }>;\n inNodePortSta?: number;\n inNodePortEnd?: number;\n portRangeMin?: number;\n portRangeMax?: number;\n}\n\ninterface Node {\n id: number;\n name?: string;\n serverIp?: string;\n serverIpV4?: string;\n serverIpV6?: string;\n extraIPs?: string;\n}\n\ninterface ForwardForm {\n id?: number;\n userId?: number;\n name: string;\n tunnelId: number | null;\n inPort: number | null;\n inIp: string;\n remoteAddr: string;\n interfaceName?: string;\n strategy: string;\n speedId: number | null;\n}\n\ninterface ForwardUserGroup {\n userId: number;\n userName: string;\n tunnels: ForwardTunnelGroup[];\n}\n\ninterface ForwardTunnelGroup {\n tunnelKey: string;\n tunnelName: string;\n tunnelTrafficRatio?: number;\n items: Forward[];\n}\n\ninterface BatchProgressState {\n active: boolean;\n label: string;\n percent: number;\n}\n\ntype ForwardGroupOrderMap = Record;\ntype ForwardGroupCollapsedMap = Record;\n\nconst UNKNOWN_FORWARD_USER_NAME = \"未知用户\";\nconst UNCATEGORIZED_FORWARD_TUNNEL_NAME = \"未分类\";\nconst FORWARD_COMPACT_MODE_CONFIG_KEY = \"forward_compact_mode\";\nconst FORWARD_COMPACT_MODE_EVENT = \"forwardCompactModeChanged\";\nconst FORWARD_GROUP_ORDER_CONFIG_KEY = \"forward_group_order_map\";\nconst FORWARD_GROUP_COLLAPSED_CONFIG_KEY = \"forward_group_collapsed_map\";\nconst FORWARD_GROUP_ORDER_LOCAL_STORAGE_PREFIX = \"forward-group-order\";\nconst FORWARD_GROUP_COLLAPSED_LOCAL_STORAGE_PREFIX = \"forward-group-collapsed\";\nconst FORWARD_TUNNEL_GROUP_SORTABLE_PREFIX = \"forward-tunnel-group\";\nconst FORWARD_GROUPED_TABLE_MIN_WIDTH_CLASS = \"min-w-[1320px]\";\nconst FORWARD_GROUPED_TABLE_COLUMN_CLASS = {\n select: \"w-14\",\n drag: \"w-10 pl-4\",\n name: \"w-[200px]\",\n inbound: \"w-[280px]\",\n target: \"w-[280px]\",\n strategy: \"w-[100px]\",\n totalFlow: \"w-[120px]\",\n status: \"w-[100px]\",\n actions: \"w-[144px] text-right\",\n} as const;\n\nconst normalizeForwardUserName = (userName?: string): string => {\n const normalized = (userName || UNKNOWN_FORWARD_USER_NAME).trim();\n\n return normalized || UNKNOWN_FORWARD_USER_NAME;\n};\n\nconst compareForwardUserNameAsc = (a: string, b: string): number => {\n return a.localeCompare(b, \"en\", {\n sensitivity: \"base\",\n numeric: true,\n });\n};\n\nconst normalizeForwardTunnelName = (tunnelName?: string): string => {\n const normalized = (tunnelName || \"\").trim();\n\n return normalized || UNCATEGORIZED_FORWARD_TUNNEL_NAME;\n};\n\nconst buildForwardTunnelGroupKey = (tunnelName?: string): string => {\n const normalized = normalizeForwardTunnelName(tunnelName);\n\n if (normalized === UNCATEGORIZED_FORWARD_TUNNEL_NAME) {\n return \"__uncategorized__\";\n }\n\n return normalized.toLocaleLowerCase();\n};\n\nconst compareForwardTunnelNameAsc = (a: string, b: string): number => {\n return a.localeCompare(b, \"en\", {\n sensitivity: \"base\",\n numeric: true,\n });\n};\n\nconst compareForwardTunnelGroupKeyAsc = (a: string, b: string): number => {\n const aIsUncategorized = a === \"__uncategorized__\";\n const bIsUncategorized = b === \"__uncategorized__\";\n\n if (aIsUncategorized !== bIsUncategorized) {\n return aIsUncategorized ? 1 : -1;\n }\n\n return compareForwardTunnelNameAsc(a, b);\n};\n\nconst normalizeTunnelTrafficRatio = (value: unknown): number => {\n if (typeof value === \"number\" && Number.isFinite(value) && value > 0) {\n return value;\n }\n\n if (typeof value === \"string\") {\n const parsed = Number(value);\n\n if (Number.isFinite(parsed) && parsed > 0) {\n return parsed;\n }\n }\n\n return 1;\n};\n\nconst formatTunnelTrafficRatio = (value?: number): string => {\n const ratio = normalizeTunnelTrafficRatio(value);\n\n if (Number.isInteger(ratio)) {\n return `${ratio}x`;\n }\n\n return `${parseFloat(ratio.toFixed(2))}x`;\n};\n\nconst buildForwardGroupOrderLocalKey = (tokenUserId: number): string => {\n return `${FORWARD_GROUP_ORDER_LOCAL_STORAGE_PREFIX}:u:${tokenUserId}`;\n};\n\nconst buildForwardGroupCollapsedLocalKey = (tokenUserId: number): string => {\n return `${FORWARD_GROUP_COLLAPSED_LOCAL_STORAGE_PREFIX}:u:${tokenUserId}`;\n};\n\nconst parsePreferenceMap = (\n raw: string | null,\n): Record | null => {\n if (!raw) {\n return null;\n }\n\n try {\n const parsed = JSON.parse(raw);\n\n if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n return null;\n }\n\n return parsed as Record;\n } catch {\n return null;\n }\n};\n\nconst parseGroupOrderMap = (raw: string | null): ForwardGroupOrderMap => {\n const parsed = parsePreferenceMap(raw);\n\n if (!parsed) {\n return {};\n }\n\n const result: ForwardGroupOrderMap = {};\n\n Object.entries(parsed).forEach(([userId, value]) => {\n if (!Array.isArray(value)) {\n return;\n }\n\n const keys = value\n .map((item) => (typeof item === \"string\" ? item.trim() : \"\"))\n .filter((item) => item !== \"\");\n\n if (keys.length > 0) {\n result[userId] = Array.from(new Set(keys));\n }\n });\n\n return result;\n};\n\nconst parseGroupCollapsedMap = (\n raw: string | null,\n): ForwardGroupCollapsedMap => {\n const parsed = parsePreferenceMap(raw);\n\n if (!parsed) {\n return {};\n }\n\n const result: ForwardGroupCollapsedMap = {};\n\n Object.entries(parsed).forEach(([key, value]) => {\n if (typeof value === \"boolean\") {\n result[key] = value;\n }\n });\n\n return result;\n};\n\nconst sanitizeGroupOrderMap = (\n source: ForwardGroupOrderMap,\n availableTunnelKeysByUser: Map>,\n): ForwardGroupOrderMap => {\n const sanitized: ForwardGroupOrderMap = {};\n\n availableTunnelKeysByUser.forEach((availableKeys, userId) => {\n if (availableKeys.size === 0) {\n return;\n }\n\n const orderFromSource = source[userId.toString()] || [];\n const used = new Set();\n const merged: string[] = [];\n\n orderFromSource.forEach((key) => {\n if (!availableKeys.has(key) || used.has(key)) {\n return;\n }\n\n used.add(key);\n merged.push(key);\n });\n\n Array.from(availableKeys)\n .sort(compareForwardTunnelGroupKeyAsc)\n .forEach((key) => {\n if (!used.has(key)) {\n used.add(key);\n merged.push(key);\n }\n });\n\n if (merged.length > 0) {\n sanitized[userId.toString()] = merged;\n }\n });\n\n return sanitized;\n};\n\nconst sanitizeGroupCollapsedMap = (\n source: ForwardGroupCollapsedMap,\n availableCollapseKeys: Set,\n): ForwardGroupCollapsedMap => {\n const sanitized: ForwardGroupCollapsedMap = {};\n\n availableCollapseKeys.forEach((key) => {\n if (source[key] === true) {\n sanitized[key] = true;\n }\n });\n\n return sanitized;\n};\n\nconst buildTunnelGroupCollapseKey = (\n userId: number,\n tunnelKey: string,\n): string => {\n return `${userId}:${tunnelKey}`;\n};\n\nconst buildTunnelGroupSortableId = (\n userId: number,\n tunnelKey: string,\n): string => {\n return `${FORWARD_TUNNEL_GROUP_SORTABLE_PREFIX}:${userId}:${tunnelKey}`;\n};\n\nconst parseTunnelGroupSortableId = (\n value: unknown,\n): { userId: number; tunnelKey: string } | null => {\n if (typeof value !== \"string\") {\n return null;\n }\n\n if (!value.startsWith(`${FORWARD_TUNNEL_GROUP_SORTABLE_PREFIX}:`)) {\n return null;\n }\n\n const parts = value.split(\":\");\n\n if (parts.length < 3) {\n return null;\n }\n\n const userId = Number(parts[1]);\n const tunnelKey = parts.slice(2).join(\":\").trim();\n\n if (!Number.isFinite(userId) || tunnelKey === \"\") {\n return null;\n }\n\n return { userId, tunnelKey };\n};\n\nconst buildAvailableGroupData = (\n forwards: Forward[],\n): {\n availableTunnelKeysByUser: Map>;\n availableCollapseKeys: Set;\n} => {\n const availableTunnelKeysByUser = new Map>();\n const availableCollapseKeys = new Set();\n\n forwards.forEach((forward) => {\n const userId = forward.userId ?? 0;\n const tunnelKey = buildForwardTunnelGroupKey(forward.tunnelName);\n\n let set = availableTunnelKeysByUser.get(userId);\n\n if (!set) {\n set = new Set();\n availableTunnelKeysByUser.set(userId, set);\n }\n\n set.add(tunnelKey);\n availableCollapseKeys.add(buildTunnelGroupCollapseKey(userId, tunnelKey));\n });\n\n return { availableTunnelKeysByUser, availableCollapseKeys };\n};\n\nconst isSameStringArray = (a: string[], b: string[]): boolean => {\n if (a.length !== b.length) {\n return false;\n }\n\n for (let i = 0; i < a.length; i += 1) {\n if (a[i] !== b[i]) {\n return false;\n }\n }\n\n return true;\n};\n\nconst isSameGroupOrderMap = (\n a: ForwardGroupOrderMap,\n b: ForwardGroupOrderMap,\n): boolean => {\n const aKeys = Object.keys(a).sort(compareForwardTunnelNameAsc);\n const bKeys = Object.keys(b).sort(compareForwardTunnelNameAsc);\n\n if (!isSameStringArray(aKeys, bKeys)) {\n return false;\n }\n\n for (const key of aKeys) {\n if (!isSameStringArray(a[key] || [], b[key] || [])) {\n return false;\n }\n }\n\n return true;\n};\n\nconst isSameGroupCollapsedMap = (\n a: ForwardGroupCollapsedMap,\n b: ForwardGroupCollapsedMap,\n): boolean => {\n const aKeys = Object.keys(a).sort(compareForwardTunnelNameAsc);\n const bKeys = Object.keys(b).sort(compareForwardTunnelNameAsc);\n\n if (!isSameStringArray(aKeys, bKeys)) {\n return false;\n }\n\n for (const key of aKeys) {\n if (a[key] !== b[key]) {\n return false;\n }\n }\n\n return true;\n};\n\nconst normalizeForwardItems = (items: Forward[]): Forward[] => {\n return items.map((forward) => ({\n ...forward,\n serviceRunning: forward.status === 1,\n }));\n};\n\nconst mapForwardApiItems = (items: ForwardApiItem[]): Forward[] => {\n return (items || []).map((forward) => ({\n id: forward.id,\n name: forward.name,\n tunnelId: forward.tunnelId ?? 0,\n tunnelName: forward.tunnelName || \"\",\n tunnelTrafficRatio: normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio),\n inIp: forward.inIp || \"\",\n inPort: forward.inPort ?? 0,\n remoteAddr: forward.remoteAddr || \"\",\n strategy: typeof forward.strategy === \"string\" ? forward.strategy : \"fifo\",\n status: typeof forward.status === \"number\" ? forward.status : 0,\n inFlow: forward.inFlow ?? 0,\n outFlow: forward.outFlow ?? 0,\n createdTime:\n typeof forward.createdTime === \"string\" ? forward.createdTime : \"\",\n userName:\n typeof forward.userName === \"string\" ? forward.userName : undefined,\n userId: typeof forward.userId === \"number\" ? forward.userId : undefined,\n inx: typeof forward.inx === \"number\" ? forward.inx : undefined,\n speedId:\n typeof forward.speedId === \"number\" || forward.speedId === null\n ? forward.speedId\n : undefined,\n serviceRunning: forward.status === 1,\n }));\n};\n\nexport default function ForwardPage() {\n const [loading, setLoading] = useState(true);\n const [forwards, setForwards] = useState([]);\n const [tunnels, setTunnels] = useState([]);\n const [allTunnels, setAllTunnels] = useState([]);\n const [nodes, setNodes] = useState([]);\n const [speedLimits, setSpeedLimits] = useState([]);\n const isMobile = useMobileBreakpoint();\n const [searchKeyword, setSearchKeyword] = useLocalStorageState(\n \"forward-search-keyword\",\n \"\",\n );\n const [isSearchVisible, setIsSearchVisible] = useState(false);\n const [compactMode, setCompactMode] = useState(false);\n\n // 显示模式状态 - 从localStorage读取,默认为平铺显示\n const [viewMode, setViewMode] = useState<\"grouped\" | \"direct\">(() => {\n try {\n const savedMode = localStorage.getItem(\"forward-view-mode\");\n\n return (savedMode as \"grouped\" | \"direct\") || \"direct\";\n } catch {\n return \"direct\";\n }\n });\n\n // 筛选状态\n const [filterUserId, setFilterUserId, resetFilterUserId] =\n useLocalStorageState(\"forward-filter-user-id\", \"all\");\n const [filterTunnelId, setFilterTunnelId, resetFilterTunnelId] =\n useLocalStorageState(\"forward-filter-tunnel-id\", \"all\");\n\n // 拖拽排序相关状态\n const [forwardOrder, setForwardOrder] = useState([]);\n\n // 模态框状态\n const [modalOpen, setModalOpen] = useState(false);\n const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);\n const [deleteModalOpen, setDeleteModalOpen] = useState(false);\n const [addressModalOpen, setAddressModalOpen] = useState(false);\n const [diagnosisModalOpen, setDiagnosisModalOpen] = useState(false);\n const [isEdit, setIsEdit] = useState(false);\n const [submitLoading, setSubmitLoading] = useState(false);\n const [deleteLoading, setDeleteLoading] = useState(false);\n const [diagnosisLoading, setDiagnosisLoading] = useState(false);\n const [forwardToDelete, setForwardToDelete] = useState(null);\n const [currentDiagnosisForward, setCurrentDiagnosisForward] =\n useState(null);\n const [diagnosisResult, setDiagnosisResult] =\n useState(null);\n const [diagnosisProgress, setDiagnosisProgress] = useState({\n total: 0,\n completed: 0,\n success: 0,\n failed: 0,\n timedOut: false,\n });\n const diagnosisAbortRef = useRef(null);\n const [addressModalTitle, setAddressModalTitle] = useState(\"\");\n const [addressList, setAddressList] = useState([]);\n\n // 导出相关状态\n const [exportModalOpen, setExportModalOpen] = useState(false);\n const [exportData, setExportData] = useState(\"\");\n const [exportLoading, setExportLoading] = useState(false);\n const [selectedTunnelForExport, setSelectedTunnelForExport] = useState<\n number | null\n >(null);\n\n // 导入相关状态\n type ImportFormat = \"flvx\" | \"ny\";\n const [importModalOpen, setImportModalOpen] = useState(false);\n const [importData, setImportData] = useState(\"\");\n const [importLoading, setImportLoading] = useState(false);\n const [importFormat, setImportFormat] = useState(\"flvx\");\n const [selectedTunnelForImport, setSelectedTunnelForImport] = useState<\n number | null\n >(null);\n const [importResults, setImportResults] = useState<\n Array<{\n line: string;\n success: boolean;\n message: string;\n forwardName?: string;\n }>\n >([]);\n\n // 表单状态\n const [form, setForm] = useState({\n name: \"\",\n tunnelId: null,\n inPort: null,\n inIp: \"\",\n remoteAddr: \"\",\n interfaceName: \"\",\n strategy: \"fifo\",\n speedId: null,\n });\n const [inIpTouched, setInIpTouched] = useState(false);\n\n // 表单验证错误\n const [errors, setErrors] = useState<{ [key: string]: string }>({});\n\n // 批量操作相关状态\n const [selectMode, setSelectMode] = useState(false);\n const [selectedIds, setSelectedIds] = useState>(new Set());\n const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);\n const [batchChangeTunnelModalOpen, setBatchChangeTunnelModalOpen] =\n useState(false);\n const [batchTargetTunnelId, setBatchTargetTunnelId] = useState(\n null,\n );\n const [batchLoading, setBatchLoading] = useState(false);\n const [batchProgress, setBatchProgress] = useState({\n active: false,\n label: \"\",\n percent: 0,\n });\n const [groupOrderMap, setGroupOrderMap] = useState({});\n const [collapsedTunnelGroups, setCollapsedTunnelGroups] =\n useState({});\n const [groupPreferenceHydrated, setGroupPreferenceHydrated] = useState(false);\n const tokenUserId = JwtUtil.getUserIdFromToken();\n const tokenRoleId = JwtUtil.getRoleIdFromToken();\n const isAdmin = tokenRoleId === 0;\n\n const parseNodeIPs = (node?: Node): string[] => {\n if (!node) {\n return [];\n }\n\n const ips: string[] = [];\n const add = (value?: string) => {\n const trimmed = (value || \"\").trim();\n\n if (trimmed) {\n ips.push(trimmed);\n }\n };\n\n add(node.serverIpV4);\n add(node.serverIpV6);\n add(node.serverIp);\n\n (node.extraIPs || \"\")\n .split(\",\")\n .map((v) => v.trim())\n .filter((v) => v)\n .forEach((v) => ips.push(v));\n\n return Array.from(new Set(ips));\n };\n\n const tunnelInIpOptionMap = useMemo(() => {\n const map = new Map();\n const nodeMap = new Map(nodes.map((n) => [n.id, n]));\n\n for (const tunnel of allTunnels) {\n const collected: string[] = [];\n const entryNodes = tunnel.inNodeId || [];\n\n for (const entry of entryNodes) {\n collected.push(...parseNodeIPs(nodeMap.get(entry.nodeId)));\n }\n\n if (collected.length === 0) {\n (tunnel.inIp || \"\")\n .split(\",\")\n .map((v) => v.trim())\n .filter((v) => v)\n .forEach((v) => collected.push(v));\n }\n\n map.set(tunnel.id, Array.from(new Set(collected)));\n }\n\n return map;\n }, [allTunnels, nodes]);\n\n const currentTunnelIpOptions = useMemo(() => {\n if (!form.tunnelId) {\n return [];\n }\n\n return tunnelInIpOptionMap.get(form.tunnelId) || [];\n }, [form.tunnelId, tunnelInIpOptionMap]);\n\n const isCurrentTunnelMultiEntrance = useMemo(() => {\n if (!form.tunnelId) {\n return false;\n }\n\n const currentTunnel = allTunnels.find(\n (tunnel) => tunnel.id === form.tunnelId,\n );\n\n return (currentTunnel?.inNodeId?.length || 0) > 1;\n }, [allTunnels, form.tunnelId]);\n\n const currentTunnelPortRange = useMemo(() => {\n if (!form.tunnelId) {\n return null;\n }\n\n const currentTunnel = allTunnels.find(\n (tunnel) => tunnel.id === form.tunnelId,\n );\n\n if (\n currentTunnel?.portRangeMin &&\n currentTunnel?.portRangeMax &&\n currentTunnel.portRangeMin > 0 &&\n currentTunnel.portRangeMax > 0\n ) {\n return {\n min: currentTunnel.portRangeMin,\n max: currentTunnel.portRangeMax,\n };\n }\n\n return null;\n }, [allTunnels, form.tunnelId]);\n\n useEffect(() => {\n return () => {\n diagnosisAbortRef.current?.abort();\n diagnosisAbortRef.current = null;\n };\n }, []);\n\n const persistGroupOrderToLocal = (nextOrderMap: ForwardGroupOrderMap) => {\n if (tokenUserId === null) {\n return;\n }\n\n try {\n localStorage.setItem(\n buildForwardGroupOrderLocalKey(tokenUserId),\n JSON.stringify(nextOrderMap),\n );\n } catch {}\n };\n\n const persistGroupCollapsedToLocal = (\n nextCollapsedMap: ForwardGroupCollapsedMap,\n ) => {\n if (tokenUserId === null) {\n return;\n }\n\n try {\n localStorage.setItem(\n buildForwardGroupCollapsedLocalKey(tokenUserId),\n JSON.stringify(nextCollapsedMap),\n );\n } catch {}\n };\n\n const persistGroupOrderToGlobal = async (\n nextOrderMap: ForwardGroupOrderMap,\n ): Promise => {\n if (!isAdmin || tokenUserId === null) {\n return;\n }\n\n try {\n const currentRes = await getConfigByName(FORWARD_GROUP_ORDER_CONFIG_KEY);\n const globalMap =\n parsePreferenceMap(\n currentRes.code === 0 && typeof currentRes.data?.value === \"string\"\n ? currentRes.data.value\n : null,\n ) || {};\n\n globalMap[tokenUserId.toString()] = nextOrderMap;\n\n const saveRes = await updateConfig(\n FORWARD_GROUP_ORDER_CONFIG_KEY,\n JSON.stringify(globalMap),\n );\n\n if (saveRes.code !== 0) {\n toast.error(saveRes.msg || \"保存分组排序失败\");\n }\n } catch {\n toast.error(\"保存分组排序失败\");\n }\n };\n\n const persistGroupCollapsedToGlobal = async (\n nextCollapsedMap: ForwardGroupCollapsedMap,\n ): Promise => {\n if (!isAdmin || tokenUserId === null) {\n return;\n }\n\n try {\n const currentRes = await getConfigByName(\n FORWARD_GROUP_COLLAPSED_CONFIG_KEY,\n );\n const globalMap =\n parsePreferenceMap(\n currentRes.code === 0 && typeof currentRes.data?.value === \"string\"\n ? currentRes.data.value\n : null,\n ) || {};\n\n globalMap[tokenUserId.toString()] = nextCollapsedMap;\n\n const saveRes = await updateConfig(\n FORWARD_GROUP_COLLAPSED_CONFIG_KEY,\n JSON.stringify(globalMap),\n );\n\n if (saveRes.code !== 0) {\n toast.error(saveRes.msg || \"保存分组折叠状态失败\");\n }\n } catch {\n toast.error(\"保存分组折叠状态失败\");\n }\n };\n\n useEffect(() => {\n let cancelled = false;\n\n const loadGroupPreferences = async () => {\n if (tokenUserId === null) {\n if (!cancelled) {\n setGroupOrderMap({});\n setCollapsedTunnelGroups({});\n setGroupPreferenceHydrated(true);\n }\n\n return;\n }\n\n let localOrderMap: ForwardGroupOrderMap = {};\n let localCollapsedMap: ForwardGroupCollapsedMap = {};\n\n try {\n localOrderMap = parseGroupOrderMap(\n localStorage.getItem(buildForwardGroupOrderLocalKey(tokenUserId)),\n );\n } catch {\n localOrderMap = {};\n }\n\n try {\n localCollapsedMap = parseGroupCollapsedMap(\n localStorage.getItem(buildForwardGroupCollapsedLocalKey(tokenUserId)),\n );\n } catch {\n localCollapsedMap = {};\n }\n\n if (isAdmin) {\n try {\n const [globalOrderRes, globalCollapsedRes] = await Promise.all([\n getConfigByName(FORWARD_GROUP_ORDER_CONFIG_KEY),\n getConfigByName(FORWARD_GROUP_COLLAPSED_CONFIG_KEY),\n ]);\n\n const globalOrderMap = parsePreferenceMap(\n globalOrderRes.code === 0 &&\n typeof globalOrderRes.data?.value === \"string\"\n ? globalOrderRes.data.value\n : null,\n );\n const globalCollapsedMap =\n parsePreferenceMap(\n globalCollapsedRes.code === 0 &&\n typeof globalCollapsedRes.data?.value === \"string\"\n ? globalCollapsedRes.data.value\n : null,\n );\n\n const globalOrderBucket = globalOrderMap?.[tokenUserId.toString()];\n const globalCollapsedBucket =\n globalCollapsedMap?.[tokenUserId.toString()];\n\n if (\n globalOrderBucket &&\n typeof globalOrderBucket === \"object\" &&\n !Array.isArray(globalOrderBucket)\n ) {\n localOrderMap = parseGroupOrderMap(\n JSON.stringify(globalOrderBucket),\n );\n }\n\n if (\n globalCollapsedBucket &&\n typeof globalCollapsedBucket === \"object\" &&\n !Array.isArray(globalCollapsedBucket)\n ) {\n localCollapsedMap = parseGroupCollapsedMap(\n JSON.stringify(globalCollapsedBucket),\n );\n }\n } catch {}\n }\n\n if (cancelled) {\n return;\n }\n\n setGroupOrderMap(localOrderMap);\n setCollapsedTunnelGroups(localCollapsedMap);\n persistGroupOrderToLocal(localOrderMap);\n persistGroupCollapsedToLocal(localCollapsedMap);\n setGroupPreferenceHydrated(true);\n };\n\n setGroupPreferenceHydrated(false);\n loadGroupPreferences();\n\n return () => {\n cancelled = true;\n };\n }, [tokenUserId, isAdmin]);\n\n useEffect(() => {\n const loadForwardCompactMode = async () => {\n try {\n const response = await getConfigByName(FORWARD_COMPACT_MODE_CONFIG_KEY);\n const enabled =\n response.code === 0 &&\n typeof response.data?.value === \"string\" &&\n response.data.value === \"true\";\n\n setCompactMode(enabled);\n } catch {\n setCompactMode(false);\n }\n };\n\n const handleCompactModeChanged = (event: Event) => {\n const customEvent = event as CustomEvent<{ enabled?: boolean }>;\n\n if (typeof customEvent.detail?.enabled === \"boolean\") {\n setCompactMode(customEvent.detail.enabled);\n }\n };\n\n loadForwardCompactMode();\n window.addEventListener(\n FORWARD_COMPACT_MODE_EVENT,\n handleCompactModeChanged,\n );\n\n return () => {\n window.removeEventListener(\n FORWARD_COMPACT_MODE_EVENT,\n handleCompactModeChanged,\n );\n };\n }, []);\n\n const parseShareIdFromTunnelName = (tunnelName: string): number | null => {\n const normalized = (tunnelName || \"\").trim();\n\n if (!normalized.startsWith(\"Share-\")) {\n return null;\n }\n\n const raw = normalized.slice(\"Share-\".length);\n const idx = raw.indexOf(\"-Port-\");\n\n if (idx <= 0) {\n return null;\n }\n\n const shareId = Number(raw.slice(0, idx).trim());\n\n return Number.isFinite(shareId) && shareId > 0 ? shareId : null;\n };\n\n const mergeFederationShareFlow = useCallback(\n async (forwardsData: Forward[]): Promise => {\n if (forwardsData.length === 0) {\n return forwardsData;\n }\n\n try {\n const [usageRes, localShareRes] = await Promise.all([\n getPeerRemoteUsageList(),\n getPeerShareList(),\n ]);\n\n const flowByShare = new Map();\n const shareIdsByTunnel = new Map>();\n\n if (usageRes.code === 0 && Array.isArray(usageRes.data)) {\n usageRes.data.forEach((item: Record) => {\n const shareId = Number(item.shareId || 0);\n const currentFlow = Number(item.currentFlow || 0);\n\n if (\n Number.isFinite(shareId) &&\n shareId > 0 &&\n Number.isFinite(currentFlow) &&\n currentFlow > 0\n ) {\n const prev = flowByShare.get(shareId) || 0;\n\n flowByShare.set(shareId, Math.max(prev, currentFlow));\n }\n\n if (Number.isFinite(shareId) && shareId > 0) {\n const bindings = Array.isArray(item.bindings)\n ? (item.bindings as Array>)\n : [];\n\n bindings.forEach((binding) => {\n const tunnelId = Number(binding.tunnelId || 0);\n const chainType = Number(binding.chainType || 0);\n\n if (!Number.isFinite(tunnelId) || tunnelId <= 0) {\n return;\n }\n\n if (Number.isFinite(chainType) && chainType !== 1) {\n return;\n }\n\n let shareSet = shareIdsByTunnel.get(tunnelId);\n\n if (!shareSet) {\n shareSet = new Set();\n shareIdsByTunnel.set(tunnelId, shareSet);\n }\n\n shareSet.add(shareId);\n });\n }\n });\n }\n\n if (localShareRes.code === 0 && Array.isArray(localShareRes.data)) {\n localShareRes.data.forEach((item: Record) => {\n const shareId = Number(item.id || 0);\n const currentFlow = Number(item.currentFlow || 0);\n\n if (\n Number.isFinite(shareId) &&\n shareId > 0 &&\n Number.isFinite(currentFlow) &&\n currentFlow > 0\n ) {\n const prev = flowByShare.get(shareId) || 0;\n\n flowByShare.set(shareId, Math.max(prev, currentFlow));\n }\n });\n }\n\n if (flowByShare.size === 0) {\n return forwardsData;\n }\n\n const resolveShareIdForForward = (forward: Forward): number | null => {\n const candidates = new Set();\n const shareIdFromName = parseShareIdFromTunnelName(\n forward.tunnelName || \"\",\n );\n\n if (shareIdFromName) {\n candidates.add(shareIdFromName);\n }\n\n const tunnelId = Number(forward.tunnelId || 0);\n const shareSetByTunnel = shareIdsByTunnel.get(tunnelId);\n\n if (shareSetByTunnel && shareSetByTunnel.size > 0) {\n shareSetByTunnel.forEach((shareId) => {\n if (Number.isFinite(shareId) && shareId > 0) {\n candidates.add(shareId);\n }\n });\n }\n\n if (candidates.size === 0) {\n return null;\n }\n\n let bestShareId: number | null = null;\n let bestFlow = 0;\n\n candidates.forEach((shareId) => {\n const shareFlow = flowByShare.get(shareId) || 0;\n\n if (shareFlow > bestFlow) {\n bestFlow = shareFlow;\n bestShareId = shareId;\n }\n });\n\n return bestShareId;\n };\n\n const resolvedShareByForwardId = new Map();\n\n forwardsData.forEach((forward) => {\n const shareId = resolveShareIdForForward(forward);\n\n if (shareId) {\n resolvedShareByForwardId.set(forward.id, shareId);\n }\n });\n\n const forwardCountByShare = new Map();\n\n forwardsData.forEach((forward) => {\n const shareId = resolvedShareByForwardId.get(forward.id) || null;\n\n if (!shareId || !flowByShare.has(shareId)) {\n return;\n }\n\n forwardCountByShare.set(\n shareId,\n (forwardCountByShare.get(shareId) || 0) + 1,\n );\n });\n\n return forwardsData.map((forward) => {\n const shareId = resolvedShareByForwardId.get(forward.id) || null;\n\n if (!shareId) {\n return { ...forward, federationShareFlow: undefined };\n }\n\n const shareFlow = flowByShare.get(shareId) || 0;\n\n if (shareFlow <= 0) {\n return { ...forward, federationShareFlow: undefined };\n }\n\n const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);\n\n if (directFlow > 0) {\n return { ...forward, federationShareFlow: undefined };\n }\n\n const count = forwardCountByShare.get(shareId) || 1;\n const estimated = Math.max(1, Math.floor(shareFlow / count));\n\n return { ...forward, federationShareFlow: estimated };\n });\n } catch {\n return forwardsData;\n }\n },\n [],\n );\n\n const getForwardDisplayFlow = (forward: Forward): number => {\n const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);\n\n if (directFlow > 0) {\n return directFlow;\n }\n\n return forward.federationShareFlow || 0;\n };\n\n // 切换显示模式并保存到localStorage\n const handleViewModeChange = () => {\n const newMode = viewMode === \"grouped\" ? \"direct\" : \"grouped\";\n\n setViewMode(newMode);\n try {\n localStorage.setItem(\"forward-view-mode\", newMode);\n } catch {}\n };\n\n const applyForwardList = useCallback(\n async (items: Forward[]) => {\n const mergedForwards = await mergeFederationShareFlow(\n normalizeForwardItems(items),\n );\n\n setForwards(mergedForwards);\n\n const currentUserId = JwtUtil.getUserIdFromToken();\n const { order, fromDatabase } = buildForwardOrder(\n mergedForwards,\n currentUserId,\n );\n\n setForwardOrder(order);\n\n if (fromDatabase) {\n saveOrder(FORWARD_ORDER_KEY, order);\n }\n },\n [mergeFederationShareFlow],\n );\n\n const refreshForwardList = useCallback(\n async (lod = true) => {\n if (lod) {\n setLoading(true);\n }\n\n try {\n const forwardsRes = await getForwardList();\n\n if (forwardsRes.code === 0) {\n await applyForwardList(mapForwardApiItems(forwardsRes.data || []));\n } else {\n toast.error(forwardsRes.msg || \"获取规则列表失败\");\n }\n } catch {\n toast.error(\"获取规则列表失败\");\n } finally {\n if (lod) {\n setLoading(false);\n }\n }\n },\n [applyForwardList],\n );\n\n // 加载所有数据\n const loadData = useCallback(\n async (lod = true) => {\n setLoading(lod);\n try {\n const [tunnelsRes, speedLimitsRes] = await Promise.all([\n userTunnel(),\n getSpeedLimitList(),\n ]);\n const [allTunnelsRes, nodesRes] = await Promise.allSettled([\n getTunnelList(),\n getNodeList(),\n ]);\n\n await refreshForwardList(false);\n\n if (tunnelsRes.code === 0) {\n setTunnels(tunnelsRes.data || []);\n } else {\n }\n\n if (\n allTunnelsRes.status === \"fulfilled\" &&\n allTunnelsRes.value.code === 0\n ) {\n setAllTunnels((allTunnelsRes.value.data || []) as Tunnel[]);\n }\n\n if (nodesRes.status === \"fulfilled\" && nodesRes.value.code === 0) {\n setNodes((nodesRes.value.data || []) as Node[]);\n }\n\n if (speedLimitsRes.code === 0) {\n setSpeedLimits(speedLimitsRes.data || []);\n }\n } catch {\n toast.error(\"加载数据失败\");\n } finally {\n setLoading(false);\n }\n },\n [refreshForwardList],\n );\n\n useEffect(() => {\n loadData();\n }, [loadData]);\n\n // 表单验证\n const noLimitSpeedLimitIds = useMemo(() => {\n return new Set(\n speedLimits\n .filter((speedLimit) => speedLimit.name.trim() === \"不限速\")\n .map((speedLimit) => speedLimit.id),\n );\n }, [speedLimits]);\n\n const speedLimitIds = useMemo(() => {\n return new Set(speedLimits.map((speedLimit) => speedLimit.id));\n }, [speedLimits]);\n\n const availableSpeedLimits = useMemo(() => {\n return speedLimits.filter(\n (speedLimit) => !noLimitSpeedLimitIds.has(speedLimit.id),\n );\n }, [speedLimits, noLimitSpeedLimitIds]);\n\n const normalizeSpeedId = (speedId?: number | null): number | null => {\n if (speedId === null || speedId === undefined) {\n return null;\n }\n\n if (noLimitSpeedLimitIds.has(speedId)) {\n return null;\n }\n\n if (speedLimits.length > 0 && !speedLimitIds.has(speedId)) {\n return null;\n }\n\n return speedId;\n };\n\n const isMissingSpeedLimit = (speedId?: number | null): boolean => {\n if (speedId === null || speedId === undefined) {\n return false;\n }\n\n if (speedLimits.length === 0 || noLimitSpeedLimitIds.has(speedId)) {\n return false;\n }\n\n return !speedLimitIds.has(speedId);\n };\n\n const selectedSpeedId = normalizeSpeedId(form.speedId);\n\n const validateForm = (): boolean => {\n const newErrors: { [key: string]: string } = {};\n\n if (!form.name.trim()) {\n newErrors.name = \"请输入规则名称\";\n } else if (form.name.length < 2 || form.name.length > 50) {\n newErrors.name = \"规则名称长度应在2-50个字符之间\";\n }\n\n if (!form.tunnelId) {\n newErrors.tunnelId = \"请选择关联隧道\";\n }\n\n if (\n form.inPort !== null &&\n form.inPort !== undefined &&\n form.inPort > 0 &&\n currentTunnelPortRange\n ) {\n if (\n form.inPort < currentTunnelPortRange.min ||\n form.inPort > currentTunnelPortRange.max\n ) {\n newErrors.inPort = `端口 ${currentTunnelPortRange.min}-${currentTunnelPortRange.max} 超出允许范围`;\n }\n }\n\n if (!form.remoteAddr.trim()) {\n newErrors.remoteAddr = \"请输入远程地址\";\n } else {\n // 验证地址格式\n const addresses = form.remoteAddr\n .split(\"\\n\")\n .map((addr) => addr.trim())\n .filter((addr) => addr);\n const ipv4Pattern =\n /^(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+$/;\n const ipv6FullPattern =\n /^\\[((([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+$/;\n const domainPattern =\n /^[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+$/;\n\n for (let i = 0; i < addresses.length; i++) {\n const addr = addresses[i];\n\n if (\n !ipv4Pattern.test(addr) &&\n !ipv6FullPattern.test(addr) &&\n !domainPattern.test(addr)\n ) {\n newErrors.remoteAddr = `第${i + 1}行地址格式错误`;\n break;\n }\n }\n }\n\n setErrors(newErrors);\n\n return Object.keys(newErrors).length === 0;\n };\n\n // 新增规则\n const handleAdd = () => {\n setIsEdit(false);\n setInIpTouched(false);\n setForm({\n name: \"\",\n tunnelId: null,\n inPort: null,\n inIp: \"\",\n remoteAddr: \"\",\n interfaceName: \"\",\n strategy: \"fifo\",\n speedId: null,\n });\n setErrors({});\n setModalOpen(true);\n };\n\n // 编辑规则\n const handleEdit = (forward: Forward) => {\n setIsEdit(true);\n setInIpTouched(false);\n setForm({\n id: forward.id,\n userId: forward.userId,\n name: forward.name,\n tunnelId: forward.tunnelId,\n inPort: forward.inPort,\n inIp: forward.inIp || \"\",\n remoteAddr: forward.remoteAddr.split(\",\").join(\"\\n\"),\n interfaceName: forward.interfaceName || \"\",\n strategy: forward.strategy || \"fifo\",\n speedId: normalizeSpeedId(forward.speedId),\n });\n setErrors({});\n setModalOpen(true);\n };\n\n // 显示删除确认\n const handleDelete = (forward: Forward) => {\n setForwardToDelete(forward);\n setDeleteModalOpen(true);\n };\n\n // 确认删除规则\n const confirmDelete = async () => {\n if (!forwardToDelete) return;\n\n setDeleteLoading(true);\n try {\n const res = await deleteForward(forwardToDelete.id);\n\n if (res.code === 0) {\n toast.success(\"删除成功\");\n setDeleteModalOpen(false);\n setForwardToDelete(null);\n setForwards((prev) =>\n prev.filter((forward) => forward.id !== forwardToDelete.id),\n );\n setForwardOrder((prev) => {\n const next = prev.filter((id) => id !== forwardToDelete.id);\n\n saveOrder(FORWARD_ORDER_KEY, next);\n\n return next;\n });\n setSelectedIds((prev) => {\n const next = new Set(prev);\n\n next.delete(forwardToDelete.id);\n\n return next;\n });\n } else {\n // 删除失败,询问是否强制删除\n const confirmed = window.confirm(\n `常规删除失败:${res.msg || \"删除失败\"}\\n\\n是否需要强制删除?\\n\\n⚠️ 注意:强制删除不会去验证节点端是否已经删除对应的规则服务。`,\n );\n\n if (confirmed) {\n const forceRes = await forceDeleteForward(forwardToDelete.id);\n\n if (forceRes.code === 0) {\n toast.success(\"强制删除成功\");\n setDeleteModalOpen(false);\n setForwardToDelete(null);\n setForwards((prev) =>\n prev.filter((forward) => forward.id !== forwardToDelete.id),\n );\n setForwardOrder((prev) => {\n const next = prev.filter((id) => id !== forwardToDelete.id);\n\n saveOrder(FORWARD_ORDER_KEY, next);\n\n return next;\n });\n setSelectedIds((prev) => {\n const next = new Set(prev);\n\n next.delete(forwardToDelete.id);\n\n return next;\n });\n } else {\n toast.error(forceRes.msg || \"强制删除失败\");\n }\n }\n }\n } catch {\n toast.error(\"删除失败\");\n } finally {\n setDeleteLoading(false);\n }\n };\n\n // 处理隧道选择变化\n const handleTunnelChange = (tunnelId: string) => {\n const nextTunnelId = parseInt(tunnelId);\n const options = tunnelInIpOptionMap.get(nextTunnelId) || [];\n\n setInIpTouched(false);\n\n setForm((prev) => {\n const tunnelChanged = prev.tunnelId !== nextTunnelId;\n\n return {\n ...prev,\n tunnelId: nextTunnelId,\n inIp: tunnelChanged ? \"\" : options.includes(prev.inIp) ? prev.inIp : \"\",\n };\n });\n };\n\n // 提交表单\n const handleSubmit = async () => {\n if (!validateForm()) return;\n\n setSubmitLoading(true);\n try {\n const processedRemoteAddr = form.remoteAddr\n .split(\"\\n\")\n .map((addr) => addr.trim())\n .filter((addr) => addr)\n .join(\",\");\n\n const addressCount = processedRemoteAddr.split(\",\").length;\n\n let res: { code: number; msg: string };\n const normalizedSpeedId = normalizeSpeedId(form.speedId);\n const speedLimitAutoCleared = isMissingSpeedLimit(form.speedId);\n\n if (isEdit) {\n const updateData = {\n id: form.id,\n name: form.name,\n tunnelId: form.tunnelId,\n inPort: form.inPort,\n ...(inIpTouched ? { inIp: form.inIp || \"\" } : {}),\n remoteAddr: processedRemoteAddr,\n strategy: addressCount > 1 ? form.strategy : \"fifo\",\n speedId: normalizedSpeedId,\n };\n\n res = await updateForward(updateData);\n } else {\n const createData = {\n name: form.name,\n tunnelId: form.tunnelId,\n inPort: form.inPort,\n inIp: form.inIp || undefined,\n remoteAddr: processedRemoteAddr,\n strategy: addressCount > 1 ? form.strategy : \"fifo\",\n speedId: normalizedSpeedId,\n };\n\n res = await createForward(createData);\n }\n\n if (res.code === 0) {\n const warningItems = Array.isArray((res as any).data?.warnings)\n ? (res as any).data.warnings\n .map((item: unknown) =>\n typeof item === \"string\" ? item.trim() : \"\",\n )\n .filter((item: string) => item)\n : [];\n\n warningItems.forEach((warning: string) => {\n toast(warning, {\n icon: \"⚠️\",\n duration: 5000,\n });\n });\n if (speedLimitAutoCleared) {\n toast(\"所选限速规则不存在,已自动清除为不限速\", {\n icon: \"⚠️\",\n duration: 5000,\n });\n }\n toast.success(isEdit ? \"修改成功\" : \"创建成功\");\n setModalOpen(false);\n await refreshForwardList(false);\n } else {\n toast.error(res.msg || \"操作失败\");\n }\n } catch {\n toast.error(\"操作失败\");\n } finally {\n setSubmitLoading(false);\n }\n };\n\n // 处理服务开关\n const handleServiceToggle = async (forward: Forward) => {\n if (forward.status !== 1 && forward.status !== 0) {\n toast.error(\"规则状态异常,无法操作\");\n\n return;\n }\n\n const targetState = !forward.serviceRunning;\n\n try {\n // 乐观更新UI\n setForwards((prev) =>\n prev.map((f) =>\n f.id === forward.id ? { ...f, serviceRunning: targetState } : f,\n ),\n );\n\n let res: { code: number; msg: string };\n\n if (targetState) {\n res = await resumeForwardService(forward.id);\n } else {\n res = await pauseForwardService(forward.id);\n }\n\n if (res.code === 0) {\n toast.success(targetState ? \"服务已启动\" : \"服务已暂停\");\n // 更新规则状态\n setForwards((prev) =>\n prev.map((f) =>\n f.id === forward.id ? { ...f, status: targetState ? 1 : 0 } : f,\n ),\n );\n } else {\n // 操作失败,恢复UI状态\n setForwards((prev) =>\n prev.map((f) =>\n f.id === forward.id ? { ...f, serviceRunning: !targetState } : f,\n ),\n );\n toast.error(res.msg || \"操作失败\");\n }\n } catch {\n // 操作失败,恢复UI状态\n setForwards((prev) =>\n prev.map((f) =>\n f.id === forward.id ? { ...f, serviceRunning: !targetState } : f,\n ),\n );\n toast.error(\"网络错误,操作失败\");\n }\n };\n\n // 诊断规则\n const handleDiagnose = async (forward: Forward) => {\n diagnosisAbortRef.current?.abort();\n const abortController = new AbortController();\n\n diagnosisAbortRef.current = abortController;\n\n setCurrentDiagnosisForward(forward);\n setDiagnosisModalOpen(true);\n setDiagnosisLoading(true);\n setDiagnosisProgress({\n total: 0,\n completed: 0,\n success: 0,\n failed: 0,\n timedOut: false,\n });\n setDiagnosisResult({\n forwardName: forward.name,\n timestamp: Date.now(),\n results: [],\n });\n\n try {\n let streamErrorMessage = \"\";\n const streamResult = await diagnoseForwardStream(\n forward.id,\n {\n onStart: (payload) => {\n const startForwardName =\n typeof payload.forwardName === \"string\" &&\n payload.forwardName.trim() !== \"\"\n ? payload.forwardName\n : forward.name;\n const startTotal = Number(payload.total);\n const startItems = Array.isArray(payload.items)\n ? (payload.items as ForwardDiagnosisResult[\"results\"])\n : [];\n\n setDiagnosisResult((prev) => ({\n forwardName: startForwardName,\n timestamp: Date.now(),\n results: startItems.length > 0 ? startItems : prev?.results || [],\n }));\n if (Number.isFinite(startTotal) && startTotal >= 0) {\n setDiagnosisProgress((prev) => ({\n ...prev,\n total: startTotal,\n }));\n }\n },\n onItem: ({ result, progress }) => {\n setDiagnosisResult((prev) => {\n const base: ForwardDiagnosisResult = prev || {\n forwardName: forward.name,\n timestamp: Date.now(),\n results: [],\n };\n const nextResults = [...base.results];\n const existingIndex = nextResults.findIndex(\n (item) =>\n item.description === result.description &&\n item.nodeId === result.nodeId &&\n item.targetIp === result.targetIp &&\n item.targetPort === result.targetPort,\n );\n\n if (existingIndex >= 0) {\n nextResults[existingIndex] = {\n ...result,\n diagnosing: false,\n };\n } else {\n nextResults.push({\n ...result,\n diagnosing: false,\n });\n }\n\n return {\n ...base,\n timestamp: Date.now(),\n results: nextResults,\n };\n });\n setDiagnosisProgress({\n total: progress.total,\n completed: progress.completed,\n success: progress.success,\n failed: progress.failed,\n timedOut: Boolean(progress.timedOut),\n });\n },\n onDone: (progress) => {\n setDiagnosisProgress({\n total: progress.total,\n completed: progress.completed,\n success: progress.success,\n failed: progress.failed,\n timedOut: Boolean(progress.timedOut),\n });\n },\n onError: (message) => {\n streamErrorMessage = message;\n },\n },\n abortController.signal,\n );\n\n if (streamResult.fallback) {\n const response = await diagnoseForward(forward.id);\n\n if (response.code === 0) {\n const resultData = response.data as ForwardDiagnosisResult;\n const successCount = resultData.results.filter(\n (r) => r.success,\n ).length;\n const failedCount = resultData.results.length - successCount;\n\n setDiagnosisResult(resultData);\n setDiagnosisProgress({\n total: resultData.results.length,\n completed: resultData.results.length,\n success: successCount,\n failed: failedCount,\n timedOut: false,\n });\n } else {\n toast.error(response.msg || \"诊断失败\");\n setDiagnosisResult(\n buildForwardDiagnosisFallbackResult({\n forwardName: forward.name,\n remoteAddr: forward.remoteAddr,\n description: \"诊断失败\",\n message: response.msg || \"诊断过程中发生错误\",\n }),\n );\n setDiagnosisProgress({\n total: 1,\n completed: 1,\n success: 0,\n failed: 1,\n timedOut: false,\n });\n }\n\n return;\n }\n\n if (streamErrorMessage) {\n toast.error(streamErrorMessage);\n }\n if (streamResult.timedOut) {\n toast.error(\"诊断超时(单条30秒 / 整体2分钟),已返回当前结果\");\n }\n } catch {\n if (abortController.signal.aborted) {\n return;\n }\n toast.error(\"网络错误,请重试\");\n setDiagnosisResult(\n buildForwardDiagnosisFallbackResult({\n forwardName: forward.name,\n remoteAddr: forward.remoteAddr,\n description: \"网络错误\",\n message: \"无法连接到服务器\",\n }),\n );\n setDiagnosisProgress({\n total: 1,\n completed: 1,\n success: 0,\n failed: 1,\n timedOut: false,\n });\n } finally {\n if (diagnosisAbortRef.current === abortController) {\n diagnosisAbortRef.current = null;\n }\n setDiagnosisLoading(false);\n }\n };\n\n // 格式化流量\n const formatFlow = (value: number): string => {\n if (value === 0) return \"0 B\";\n if (value < 1024) return value + \" B\";\n if (value < 1024 * 1024) return (value / 1024).toFixed(2) + \" KB\";\n if (value < 1024 * 1024 * 1024)\n return (value / (1024 * 1024)).toFixed(2) + \" MB\";\n\n return (value / (1024 * 1024 * 1024)).toFixed(2) + \" GB\";\n };\n\n // 显示地址列表弹窗\n const showAddressModal = (\n addressString: string,\n port: number | null,\n title: string,\n ) => {\n const action = resolveForwardAddressAction(addressString, port, title);\n\n if (action.type === \"none\") {\n return;\n }\n\n if (action.type === \"copy\") {\n copyToClipboard(action.text, action.label);\n\n return;\n }\n\n setAddressList(action.items);\n setAddressModalTitle(action.title);\n setAddressModalOpen(true);\n };\n\n // 复制到剪贴板\n const copyToClipboard = async (text: string, label: string = \"内容\") => {\n try {\n await navigator.clipboard.writeText(text);\n toast.success(`已复制${label}`);\n } catch {\n toast.error(\"复制失败\");\n }\n };\n\n // 复制地址\n const copyAddress = async (addressItem: ForwardAddressItem) => {\n try {\n setAddressList((prev) =>\n prev.map((item) =>\n item.id === addressItem.id ? { ...item, copying: true } : item,\n ),\n );\n await copyToClipboard(addressItem.address, \"地址\");\n } catch {\n toast.error(\"复制失败\");\n } finally {\n setAddressList((prev) =>\n prev.map((item) =>\n item.id === addressItem.id ? { ...item, copying: false } : item,\n ),\n );\n }\n };\n\n // 复制所有地址\n const copyAllAddresses = async () => {\n if (addressList.length === 0) return;\n const allAddresses = addressList.map((item) => item.address).join(\"\\n\");\n\n await copyToClipboard(allAddresses, \"所有地址\");\n };\n\n // 导出规则数据\n const handleExport = () => {\n setSelectedTunnelForExport(null);\n setExportData(\"\");\n setExportModalOpen(true);\n };\n\n // 执行导出\n const executeExport = () => {\n if (!selectedTunnelForExport) {\n toast.error(\"请选择要导出的隧道\");\n\n return;\n }\n\n setExportLoading(true);\n\n try {\n // 获取要导出的规则列表\n const forwardsToExport = sortedForwards.filter(\n (forward) => forward.tunnelId === selectedTunnelForExport,\n );\n\n if (forwardsToExport.length === 0) {\n toast.error(\"所选隧道没有规则数据\");\n setExportLoading(false);\n\n return;\n }\n\n // 格式化导出数据:remoteAddr|name|inPort\n const exportLines = forwardsToExport.map((forward) => {\n return `${forward.remoteAddr}|${forward.name}|${forward.inPort}`;\n });\n\n const exportText = exportLines.join(\"\\n\");\n\n setExportData(exportText);\n } catch {\n toast.error(\"导出失败\");\n } finally {\n setExportLoading(false);\n }\n };\n\n // 复制导出数据\n const copyExportData = async () => {\n await copyToClipboard(exportData, \"规则数据\");\n };\n\n // 导入规则数据\n const handleImport = () => {\n setImportData(\"\");\n setImportResults([]);\n setSelectedTunnelForImport(null);\n setImportModalOpen(true);\n };\n\n // 执行导入\n const executeImport = async () => {\n if (!importData.trim()) {\n toast.error(\"请输入要导入的数据\");\n\n return;\n }\n\n if (!selectedTunnelForImport) {\n toast.error(\"请选择要导入的隧道\");\n\n return;\n }\n\n setImportLoading(true);\n setImportResults([]);\n\n try {\n if (importFormat === \"ny\") {\n const parsedItems = parseNyFormatData(importData);\n\n if (parsedItems.length === 0) {\n toast.error(\"未解析到有效的ny格式数据\");\n\n setImportLoading(false);\n\n return;\n }\n\n for (const item of parsedItems) {\n if (item.error) {\n setImportResults((prev) => [\n {\n line: item.line,\n success: false,\n message: item.error || \"解析失败\",\n },\n ...prev,\n ]);\n\n continue;\n }\n\n if (!item.parsed) {\n setImportResults((prev) => [\n {\n line: item.line,\n success: false,\n message: \"解析失败\",\n },\n ...prev,\n ]);\n\n continue;\n }\n\n const parsedNyItem = item.parsed;\n const nyForwardInput = convertNyItemToForwardInput(parsedNyItem);\n\n try {\n const response = await createForward({\n name: nyForwardInput.name,\n tunnelId: selectedTunnelForImport,\n inPort: nyForwardInput.inPort,\n remoteAddr: nyForwardInput.remoteAddr,\n strategy: nyForwardInput.strategy,\n });\n\n if (response.code === 0) {\n setImportResults((prev) => [\n {\n line: item.line,\n success: true,\n message: `创建成功 (${parsedNyItem.dest.length}个目标)`,\n forwardName: nyForwardInput.name,\n },\n ...prev,\n ]);\n } else {\n setImportResults((prev) => [\n {\n line: item.line,\n success: false,\n message: response.msg || \"创建失败\",\n },\n ...prev,\n ]);\n }\n } catch {\n setImportResults((prev) => [\n {\n line: item.line,\n success: false,\n message: \"网络错误,创建失败\",\n },\n ...prev,\n ]);\n }\n }\n } else {\n const lines = importData\n .trim()\n .split(\"\\n\")\n .filter((line) => line.trim());\n\n for (let i = 0; i < lines.length; i++) {\n const line = lines[i].trim();\n const parts = line.split(\"|\");\n\n if (parts.length < 2) {\n setImportResults((prev) => [\n {\n line,\n success: false,\n message: \"格式错误:需要至少包含目标地址和规则名称\",\n },\n ...prev,\n ]);\n continue;\n }\n\n const [remoteAddr, name, inPort] = parts;\n\n if (!remoteAddr.trim() || !name.trim()) {\n setImportResults((prev) => [\n {\n line,\n success: false,\n message: \"目标地址和规则名称不能为空\",\n },\n ...prev,\n ]);\n continue;\n }\n\n const addresses = remoteAddr.trim().split(\",\");\n const addressPattern = /^[^:]+:\\d+$/;\n const isValidFormat = addresses.every((addr) =>\n addressPattern.test(addr.trim()),\n );\n\n if (!isValidFormat) {\n setImportResults((prev) => [\n {\n line,\n success: false,\n message:\n \"目标地址格式错误,应为 地址:端口 格式,多个地址用逗号分隔\",\n },\n ...prev,\n ]);\n continue;\n }\n\n try {\n let portNumber: number | null = null;\n\n if (inPort && inPort.trim()) {\n const port = parseInt(inPort.trim());\n\n if (isNaN(port) || port < 1 || port > 65535) {\n setImportResults((prev) => [\n {\n line,\n success: false,\n message: \"入口端口格式错误,应为1-65535之间的数字\",\n },\n ...prev,\n ]);\n continue;\n }\n portNumber = port;\n }\n\n const response = await createForward({\n name: name.trim(),\n tunnelId: selectedTunnelForImport,\n inPort: portNumber,\n remoteAddr: remoteAddr.trim(),\n strategy: \"fifo\",\n });\n\n if (response.code === 0) {\n setImportResults((prev) => [\n {\n line,\n success: true,\n message: \"创建成功\",\n forwardName: name.trim(),\n },\n ...prev,\n ]);\n } else {\n setImportResults((prev) => [\n {\n line,\n success: false,\n message: response.msg || \"创建失败\",\n },\n ...prev,\n ]);\n }\n } catch {\n setImportResults((prev) => [\n {\n line,\n success: false,\n message: \"网络错误,创建失败\",\n },\n ...prev,\n ]);\n }\n }\n }\n\n toast.success(\"导入执行完成\");\n\n await refreshForwardList(false);\n } catch {\n toast.error(\"导入过程中发生错误\");\n } finally {\n setImportLoading(false);\n }\n };\n\n // 获取状态显示\n const getStatusDisplay = (status: number) => {\n switch (status) {\n case 1:\n return { color: \"success\", text: \"正常\" };\n case 0:\n return { color: \"warning\", text: \"暂停\" };\n case -1:\n return { color: \"danger\", text: \"异常\" };\n default:\n return { color: \"default\", text: \"未知\" };\n }\n };\n\n // 获取策略显示\n const getStrategyDisplay = (strategy: string) => {\n switch (strategy) {\n case \"fifo\":\n return { color: \"primary\", text: \"主备\" };\n case \"round\":\n return { color: \"success\", text: \"轮询\" };\n case \"rand\":\n return { color: \"warning\", text: \"随机\" };\n default:\n return { color: \"default\", text: \"未知\" };\n }\n };\n\n // 获取地址数量\n const getAddressCount = (addressString: string): number => {\n if (!addressString) return 0;\n const addresses = addressString\n .split(\"\\n\")\n .map((addr) => addr.trim())\n .filter((addr) => addr);\n\n return addresses.length;\n };\n\n // 处理拖拽结束\n const handleDragEnd = async (event: DragEndEvent) => {\n const { active, over } = event;\n\n if (!active || !over || active.id === over.id) return;\n\n const activeGroup = parseTunnelGroupSortableId(active.id);\n const overGroup = parseTunnelGroupSortableId(over.id);\n\n if (activeGroup && overGroup) {\n if (compactMode || !groupPreferenceHydrated) {\n return;\n }\n\n if (activeGroup.userId !== overGroup.userId) {\n return;\n }\n\n const userIdKey = activeGroup.userId.toString();\n const currentOrder = groupOrderMap[userIdKey] || [];\n const oldIndex = currentOrder.indexOf(activeGroup.tunnelKey);\n const newIndex = currentOrder.indexOf(overGroup.tunnelKey);\n\n if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) {\n return;\n }\n\n const moved = arrayMove(currentOrder, oldIndex, newIndex);\n const nextOrderMap: ForwardGroupOrderMap = {\n ...groupOrderMap,\n [userIdKey]: moved,\n };\n\n setGroupOrderMap(nextOrderMap);\n persistGroupOrderToLocal(nextOrderMap);\n void persistGroupOrderToGlobal(nextOrderMap);\n\n return;\n }\n\n // 确保 forwardOrder 存在且有效\n if (!forwardOrder || forwardOrder.length === 0) return;\n\n const activeId = Number(active.id);\n const overId = Number(over.id);\n\n // 检查 ID 是否有效\n if (isNaN(activeId) || isNaN(overId)) return;\n\n const activeForward = forwards.find((forward) => forward.id === activeId);\n const overForward = forwards.find((forward) => forward.id === overId);\n const activeUserId = activeForward?.userId ?? 0;\n const overUserId = overForward?.userId ?? 0;\n const activeTunnelGroupKey = buildForwardTunnelGroupKey(\n activeForward?.tunnelName,\n );\n const overTunnelGroupKey = buildForwardTunnelGroupKey(\n overForward?.tunnelName,\n );\n\n // 非精简模式仅允许在同一用户+隧道分组内拖拽,避免混排\n if (!compactMode) {\n if (\n activeUserId !== overUserId ||\n activeTunnelGroupKey !== overTunnelGroupKey\n ) {\n return;\n }\n }\n\n let oldIndex: number;\n let newIndex: number;\n let currentOrder: number[];\n\n if (compactMode) {\n currentOrder = sortedForwards.map((f) => f.id);\n oldIndex = currentOrder.indexOf(activeId);\n newIndex = currentOrder.indexOf(overId);\n } else {\n currentOrder = forwardOrder;\n oldIndex = forwardOrder.indexOf(activeId);\n newIndex = forwardOrder.indexOf(overId);\n }\n\n if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {\n const newOrder = arrayMove(currentOrder, oldIndex, newIndex);\n\n if (!compactMode) {\n setForwardOrder(newOrder);\n saveOrder(FORWARD_ORDER_KEY, newOrder);\n }\n\n // 持久化到数据库\n try {\n const forwardsToUpdate = newOrder.map((id, index) => ({\n id,\n inx: index,\n }));\n\n const response = await updateForwardOrder({\n forwards: forwardsToUpdate,\n });\n\n if (response.code === 0) {\n // 更新本地数据中的 inx 字段\n setForwards((prev) =>\n prev.map((forward) => {\n const updatedForward = forwardsToUpdate.find(\n (f) => f.id === forward.id,\n );\n\n if (updatedForward) {\n return { ...forward, inx: updatedForward.inx };\n }\n\n return forward;\n }),\n );\n } else {\n toast.error(\"保存排序失败:\" + (response.msg || \"未知错误\"));\n }\n } catch {\n toast.error(\"保存排序失败,请重试\");\n }\n }\n };\n\n const toggleSelectMode = () => {\n setSelectMode(!selectMode);\n if (selectMode) {\n setSelectedIds(new Set());\n }\n };\n\n const toggleSelect = (id: number) => {\n const newSet = new Set(selectedIds);\n\n if (newSet.has(id)) {\n newSet.delete(id);\n } else {\n newSet.add(id);\n }\n setSelectedIds(newSet);\n };\n\n const selectAll = () => {\n const allIds = sortedForwards.map((f) => f.id);\n\n setSelectedIds(new Set(allIds));\n };\n\n const deselectAll = () => {\n setSelectedIds(new Set());\n };\n\n const handleBatchDelete = async () => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\n setBatchProgress({\n active: true,\n label: `正在删除 ${selectedIds.size} 项规则...`,\n percent: 30,\n });\n try {\n const outcome = await executeForwardBatchDelete(Array.from(selectedIds));\n\n if (outcome.toastVariant === \"success\") {\n toast.success(outcome.toastMessage);\n } else {\n toast.error(outcome.toastMessage);\n }\n\n if (outcome.shouldRefresh) {\n setBatchProgress({\n active: true,\n label: outcome.progressLabel || \"正在刷新规则列表...\",\n percent: outcome.progressPercent ?? 75,\n });\n setSelectedIds(new Set());\n setSelectMode(false);\n if (outcome.closeDeleteModal) {\n setBatchDeleteModalOpen(false);\n }\n await refreshForwardList(false);\n }\n } finally {\n setBatchProgress({ active: false, label: \"\", percent: 0 });\n setBatchLoading(false);\n }\n };\n\n const handleBatchToggleService = async (enable: boolean) => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\n setBatchProgress({\n active: true,\n label: `正在${enable ? \"启用\" : \"停用\"} ${selectedIds.size} 项规则...`,\n percent: 30,\n });\n try {\n const outcome = await executeForwardBatchToggleService(\n Array.from(selectedIds),\n enable,\n );\n\n if (outcome.toastVariant === \"success\") {\n toast.success(outcome.toastMessage);\n } else {\n toast.error(outcome.toastMessage);\n }\n\n if (outcome.shouldRefresh) {\n setBatchProgress({\n active: true,\n label: outcome.progressLabel || \"正在刷新规则列表...\",\n percent: outcome.progressPercent ?? 75,\n });\n setSelectedIds(new Set());\n setSelectMode(false);\n await refreshForwardList(false);\n }\n } finally {\n setBatchProgress({ active: false, label: \"\", percent: 0 });\n setBatchLoading(false);\n }\n };\n\n const handleBatchRedeploy = async () => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\n setBatchProgress({\n active: true,\n label: `正在重新下发 ${selectedIds.size} 项规则...`,\n percent: 30,\n });\n try {\n const outcome = await executeForwardBatchRedeploy(\n Array.from(selectedIds),\n );\n\n if (outcome.toastVariant === \"success\") {\n toast.success(outcome.toastMessage);\n } else {\n toast.error(outcome.toastMessage);\n }\n\n if (outcome.shouldRefresh) {\n setBatchProgress({\n active: true,\n label: outcome.progressLabel || \"正在刷新规则列表...\",\n percent: outcome.progressPercent ?? 75,\n });\n setSelectedIds(new Set());\n setSelectMode(false);\n await refreshForwardList(false);\n }\n } finally {\n setBatchProgress({ active: false, label: \"\", percent: 0 });\n setBatchLoading(false);\n }\n };\n\n const handleBatchChangeTunnel = async () => {\n if (selectedIds.size === 0 || !batchTargetTunnelId) return;\n setBatchLoading(true);\n setBatchProgress({\n active: true,\n label: `正在为 ${selectedIds.size} 项规则切换隧道...`,\n percent: 30,\n });\n try {\n const outcome = await executeForwardBatchChangeTunnel(\n Array.from(selectedIds),\n batchTargetTunnelId,\n );\n\n if (outcome.toastVariant === \"success\") {\n toast.success(outcome.toastMessage);\n } else {\n toast.error(outcome.toastMessage);\n }\n\n if (outcome.shouldRefresh) {\n setBatchProgress({\n active: true,\n label: outcome.progressLabel || \"正在刷新规则列表...\",\n percent: outcome.progressPercent ?? 75,\n });\n setSelectedIds(new Set());\n setSelectMode(false);\n if (outcome.closeChangeTunnelModal) {\n setBatchChangeTunnelModalOpen(false);\n }\n if (outcome.resetTargetTunnel) {\n setBatchTargetTunnelId(null);\n }\n await refreshForwardList(false);\n }\n } finally {\n setBatchProgress({ active: false, label: \"\", percent: 0 });\n setBatchLoading(false);\n }\n };\n\n // 传感器配置 - 使用默认配置避免错误\n const sensors = useSensors(\n useSensor(MouseSensor, {\n activationConstraint: {\n distance: 8,\n },\n }),\n useSensor(TouchSensor, {\n activationConstraint: {\n delay: 250,\n tolerance: 8,\n },\n }),\n useSensor(KeyboardSensor, {\n coordinateGetter: sortableKeyboardCoordinates,\n }),\n );\n\n // 根据排序顺序获取规则列表\n const orderedForwards = useMemo((): Forward[] => {\n // 确保 forwards 数组存在且有效\n if (!forwards || forwards.length === 0) {\n return [];\n }\n\n let filteredForwards = forwards;\n\n if (filterUserId !== \"all\") {\n const targetUserId = parseInt(filterUserId);\n\n filteredForwards = filteredForwards.filter(\n (f) => f.userId === targetUserId || (targetUserId === 0 && !f.userId),\n );\n }\n if (filterTunnelId !== \"all\") {\n const targetTunnelId = parseInt(filterTunnelId);\n\n filteredForwards = filteredForwards.filter(\n (f) => f.tunnelId === targetTunnelId,\n );\n }\n\n if (searchKeyword.trim()) {\n const lowerKeyword = searchKeyword.toLowerCase();\n\n filteredForwards = filteredForwards.filter(\n (f) =>\n (f.name && f.name.toLowerCase().includes(lowerKeyword)) ||\n (f.remoteAddr && f.remoteAddr.toLowerCase().includes(lowerKeyword)) ||\n (f.userName && f.userName.toLowerCase().includes(lowerKeyword)),\n );\n }\n\n // 确保过滤后的规则列表有效\n if (!filteredForwards || filteredForwards.length === 0) {\n return [];\n }\n\n // 优先使用数据库中的 inx 字段进行排序\n const sortedByDb = [...filteredForwards].sort((a, b) => {\n const aInx = a.inx ?? 0;\n const bInx = b.inx ?? 0;\n\n if (aInx !== bInx) {\n return aInx - bInx;\n }\n\n return (a.id ?? 0) - (b.id ?? 0);\n });\n\n // 如果数据库中没有排序信息,则使用本地存储的顺序\n if (\n forwardOrder &&\n forwardOrder.length > 0 &&\n sortedByDb.every((f) => f.inx === undefined || f.inx === 0)\n ) {\n const forwardMap = new Map(filteredForwards.map((f) => [f.id, f]));\n const localSortedForwards: Forward[] = [];\n\n forwardOrder.forEach((id) => {\n const forward = forwardMap.get(id);\n\n if (forward) {\n localSortedForwards.push(forward);\n }\n });\n\n // 添加不在排序列表中的规则(新添加的)\n filteredForwards.forEach((forward) => {\n if (!forwardOrder.includes(forward.id)) {\n localSortedForwards.push(forward);\n }\n });\n\n return localSortedForwards;\n }\n\n return sortedByDb;\n }, [forwards, forwardOrder, filterUserId, filterTunnelId, searchKeyword]);\n\n const availableGroupData = useMemo(\n () => buildAvailableGroupData(forwards),\n [forwards],\n );\n\n const sanitizedGroupOrderMap = useMemo(\n () =>\n sanitizeGroupOrderMap(\n groupOrderMap,\n availableGroupData.availableTunnelKeysByUser,\n ),\n [groupOrderMap, availableGroupData],\n );\n\n const sanitizedCollapsedTunnelGroups = useMemo(\n () =>\n sanitizeGroupCollapsedMap(\n collapsedTunnelGroups,\n availableGroupData.availableCollapseKeys,\n ),\n [collapsedTunnelGroups, availableGroupData],\n );\n\n useEffect(() => {\n if (!groupPreferenceHydrated || tokenUserId === null) {\n return;\n }\n\n if (forwards.length === 0) {\n return;\n }\n\n if (!isSameGroupOrderMap(groupOrderMap, sanitizedGroupOrderMap)) {\n setGroupOrderMap(sanitizedGroupOrderMap);\n persistGroupOrderToLocal(sanitizedGroupOrderMap);\n void persistGroupOrderToGlobal(sanitizedGroupOrderMap);\n }\n\n if (\n !isSameGroupCollapsedMap(\n collapsedTunnelGroups,\n sanitizedCollapsedTunnelGroups,\n )\n ) {\n setCollapsedTunnelGroups(sanitizedCollapsedTunnelGroups);\n persistGroupCollapsedToLocal(sanitizedCollapsedTunnelGroups);\n void persistGroupCollapsedToGlobal(sanitizedCollapsedTunnelGroups);\n }\n }, [\n groupPreferenceHydrated,\n tokenUserId,\n forwards,\n groupOrderMap,\n sanitizedGroupOrderMap,\n collapsedTunnelGroups,\n sanitizedCollapsedTunnelGroups,\n ]);\n\n const groupedForwards = useMemo((): ForwardUserGroup[] => {\n if (orderedForwards.length === 0) {\n return [];\n }\n\n type MutableForwardUserGroup = {\n userId: number;\n userName: string;\n tunnelMap: Map;\n };\n\n const userGroupMap = new Map();\n\n orderedForwards.forEach((forward) => {\n const userId = forward.userId ?? 0;\n const userName = normalizeForwardUserName(forward.userName);\n const tunnelName = normalizeForwardTunnelName(forward.tunnelName);\n const tunnelKey = buildForwardTunnelGroupKey(forward.tunnelName);\n\n let existingGroup = userGroupMap.get(userId);\n\n if (!existingGroup) {\n existingGroup = {\n userId,\n userName,\n tunnelMap: new Map(),\n };\n userGroupMap.set(userId, existingGroup);\n } else if (\n existingGroup.userName === UNKNOWN_FORWARD_USER_NAME &&\n userName !== UNKNOWN_FORWARD_USER_NAME\n ) {\n existingGroup.userName = userName;\n }\n\n const existingTunnelGroup = existingGroup.tunnelMap.get(tunnelKey);\n\n if (!existingTunnelGroup) {\n existingGroup.tunnelMap.set(tunnelKey, {\n tunnelKey,\n tunnelName,\n tunnelTrafficRatio: normalizeTunnelTrafficRatio(\n forward.tunnelTrafficRatio,\n ),\n items: [forward],\n });\n\n return;\n }\n\n existingTunnelGroup.items.push(forward);\n\n if (\n existingTunnelGroup.tunnelName === UNCATEGORIZED_FORWARD_TUNNEL_NAME &&\n tunnelName !== UNCATEGORIZED_FORWARD_TUNNEL_NAME\n ) {\n existingTunnelGroup.tunnelName = tunnelName;\n }\n\n if (\n normalizeTunnelTrafficRatio(existingTunnelGroup.tunnelTrafficRatio) ===\n 1 &&\n normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio) !== 1\n ) {\n existingTunnelGroup.tunnelTrafficRatio = normalizeTunnelTrafficRatio(\n forward.tunnelTrafficRatio,\n );\n }\n });\n\n const groups = Array.from(userGroupMap.values()).map((group) => {\n const tunnels = Array.from(group.tunnelMap.values());\n const tunnelOrder = sanitizedGroupOrderMap[group.userId.toString()] || [];\n const tunnelOrderIndex = new Map();\n\n tunnelOrder.forEach((key, index) => {\n tunnelOrderIndex.set(key, index);\n });\n\n tunnels.sort((a, b) => {\n const aIndex = tunnelOrderIndex.get(a.tunnelKey);\n const bIndex = tunnelOrderIndex.get(b.tunnelKey);\n\n if (aIndex !== undefined || bIndex !== undefined) {\n if (aIndex === undefined) {\n return 1;\n }\n\n if (bIndex === undefined) {\n return -1;\n }\n\n return aIndex - bIndex;\n }\n\n const nameCompare = compareForwardTunnelNameAsc(\n a.tunnelName,\n b.tunnelName,\n );\n\n if (nameCompare !== 0) {\n return nameCompare;\n }\n\n return compareForwardTunnelNameAsc(a.tunnelKey, b.tunnelKey);\n });\n\n return {\n userId: group.userId,\n userName: group.userName,\n tunnels,\n };\n });\n\n groups.sort((a, b) => {\n if (isAdmin && tokenUserId !== null) {\n const aIsSelf = a.userId === tokenUserId;\n const bIsSelf = b.userId === tokenUserId;\n\n if (aIsSelf !== bIsSelf) {\n return aIsSelf ? -1 : 1;\n }\n }\n\n const nameCompare = compareForwardUserNameAsc(a.userName, b.userName);\n\n if (nameCompare !== 0) {\n return nameCompare;\n }\n\n return a.userId - b.userId;\n });\n\n return groups;\n }, [orderedForwards, isAdmin, tokenUserId, sanitizedGroupOrderMap]);\n\n const sortedForwards = useMemo(() => {\n if (compactMode) {\n return orderedForwards;\n }\n\n return groupedForwards.flatMap((group) =>\n group.tunnels.flatMap((tunnel) => tunnel.items),\n );\n }, [compactMode, orderedForwards, groupedForwards]);\n\n const sortableForwardIds = useMemo(\n () => sortedForwards.map((f) => f.id).filter((id) => id > 0),\n [sortedForwards],\n );\n\n const toggleTunnelGroupCollapsed = (userId: number, tunnelKey: string) => {\n const collapseKey = buildTunnelGroupCollapseKey(userId, tunnelKey);\n const nextCollapsedMap: ForwardGroupCollapsedMap = {\n ...sanitizedCollapsedTunnelGroups,\n };\n\n if (nextCollapsedMap[collapseKey] === true) {\n delete nextCollapsedMap[collapseKey];\n } else {\n nextCollapsedMap[collapseKey] = true;\n }\n\n setCollapsedTunnelGroups(nextCollapsedMap);\n persistGroupCollapsedToLocal(nextCollapsedMap);\n void persistGroupCollapsedToGlobal(nextCollapsedMap);\n };\n\n const SortableTunnelGroupContainer = ({\n groupUserId,\n tunnel,\n collapsed,\n onToggleCollapsed,\n wrapperClassName,\n headerClassName,\n titleClassName,\n countClassName,\n bodyClassName,\n children,\n }: {\n groupUserId: number;\n tunnel: ForwardTunnelGroup;\n collapsed: boolean;\n onToggleCollapsed: () => void;\n wrapperClassName: string;\n headerClassName: string;\n titleClassName: string;\n countClassName: string;\n bodyClassName: string;\n children: React.ReactNode;\n }) => {\n const sortableId = buildTunnelGroupSortableId(\n groupUserId,\n tunnel.tunnelKey,\n );\n const {\n attributes,\n listeners,\n setNodeRef,\n transform,\n transition,\n isDragging,\n } = useSortable({ id: sortableId });\n\n const style: React.CSSProperties = {\n transform: transform\n ? CSS.Transform.toString({\n ...transform,\n x: Math.round(transform.x),\n y: Math.round(transform.y),\n })\n : undefined,\n transition: isDragging ? undefined : transition || undefined,\n opacity: isDragging ? 0.55 : 1,\n willChange: isDragging ? \"transform\" : undefined,\n zIndex: isDragging ? 1 : undefined,\n };\n\n return (\n
\n
\n
\n \n \n \n \n \n {tunnel.tunnelName}\n \n {formatTunnelTrafficRatio(tunnel.tunnelTrafficRatio)}\n \n
\n
\n {tunnel.items.length} 条规则\n \n \n \n \n
\n
\n
\n {!collapsed &&
{children}
}\n \n );\n };\n\n // 可拖拽的规则卡片组件\n const SortableForwardCard = ({ forward }: { forward: Forward }) => {\n const {\n attributes,\n listeners,\n setNodeRef,\n transform,\n transition,\n isDragging,\n } = useSortable({ id: forward.id });\n\n const style: React.CSSProperties = {\n transform: transform\n ? CSS.Transform.toString({\n ...transform,\n x: Math.round(transform.x),\n y: Math.round(transform.y),\n })\n : undefined,\n transition: isDragging ? undefined : transition || undefined,\n opacity: isDragging ? 0.5 : 1,\n willChange: isDragging ? \"transform\" : undefined,\n };\n\n return (\n
\n {renderForwardCard(forward, listeners)}\n
\n );\n };\n\n // 生成用作筛选项的用户和隧道列表\n const uniqueUsers = useMemo(() => {\n const userMap = new Map();\n\n forwards.forEach((f) => {\n const uId = f.userId ?? 0;\n const userName = normalizeForwardUserName(f.userName);\n const existingUser = userMap.get(uId);\n\n if (!existingUser) {\n userMap.set(uId, { id: uId, name: userName });\n\n return;\n }\n\n if (\n existingUser.name === UNKNOWN_FORWARD_USER_NAME &&\n userName !== UNKNOWN_FORWARD_USER_NAME\n ) {\n existingUser.name = userName;\n }\n });\n\n const users = Array.from(userMap.values());\n\n users.sort((a, b) => {\n if (isAdmin && tokenUserId !== null) {\n const aIsSelf = a.id === tokenUserId;\n const bIsSelf = b.id === tokenUserId;\n\n if (aIsSelf !== bIsSelf) {\n return aIsSelf ? -1 : 1;\n }\n }\n\n const nameCompare = compareForwardUserNameAsc(a.name, b.name);\n\n if (nameCompare !== 0) {\n return nameCompare;\n }\n\n return a.id - b.id;\n });\n\n return users;\n }, [forwards, isAdmin, tokenUserId]);\n\n // 可拖拽的表格行组件\n const SortableTableRow = ({\n forward,\n selectMode,\n selectedIds,\n toggleSelect,\n getStrategyDisplay,\n formatInAddress,\n formatRemoteAddress,\n handleServiceToggle,\n handleEdit,\n handleDelete,\n handleDiagnose,\n showAddressModal,\n hasMultipleAddresses,\n formatFlow,\n }: any) => {\n const {\n attributes,\n listeners,\n setNodeRef,\n transform,\n transition,\n isDragging,\n } = useSortable({ id: forward.id });\n\n const style = {\n transform: transform\n ? CSS.Transform.toString({\n ...transform,\n x: Math.round(transform.x),\n y: Math.round(transform.y),\n })\n : undefined,\n transition: isDragging ? undefined : transition || undefined,\n opacity: isDragging ? 0.5 : 1,\n backgroundColor: isDragging ? \"var(--nextui-default-100)\" : undefined,\n };\n\n const strategyDisplay = getStrategyDisplay(forward.strategy);\n\n return (\n \n {selectMode && (\n \n toggleSelect(forward.id)}\n />\n \n )}\n \n \n \n \n \n \n \n \n {forward.name}\n \n \n \n showAddressModal(forward.inIp, forward.inPort, \"入口端口\")\n }\n >\n {formatInAddress(forward.inIp, forward.inPort)}\n \n \n \n \n showAddressModal(forward.remoteAddr, null, \"目标地址\")\n }\n >\n {formatRemoteAddress(forward.remoteAddr)}\n \n \n \n \n {strategyDisplay.text}\n \n \n \n \n {formatFlow(getForwardDisplayFlow(forward))}\n \n \n \n
\n handleServiceToggle(forward)}\n />\n
\n
\n \n
\n handleEdit(forward)}\n >\n \n \n \n \n handleDiagnose(forward)}\n >\n \n \n \n \n handleDelete(forward)}\n >\n \n \n \n \n
\n
\n
\n );\n };\n\n const SortableCompactTableRow = ({\n forward,\n selectMode,\n selectedIds,\n toggleSelect,\n getStrategyDisplay,\n formatInAddress,\n formatRemoteAddress,\n handleServiceToggle,\n handleEdit,\n handleDelete,\n handleDiagnose,\n showAddressModal,\n hasMultipleAddresses,\n formatFlow,\n }: any) => {\n const {\n attributes,\n listeners,\n setNodeRef,\n transform,\n transition,\n isDragging,\n } = useSortable({ id: forward.id });\n\n const style = {\n transform: transform\n ? CSS.Transform.toString({\n ...transform,\n x: Math.round(transform.x),\n y: Math.round(transform.y),\n })\n : undefined,\n transition: isDragging ? undefined : transition || undefined,\n opacity: isDragging ? 0.5 : 1,\n backgroundColor: isDragging ? \"var(--nextui-default-100)\" : undefined,\n };\n\n const strategyDisplay = getStrategyDisplay(forward.strategy);\n\n return (\n \n {selectMode && (\n \n toggleSelect(forward.id)}\n />\n \n )}\n \n \n \n \n \n \n \n \n \n {forward.userName || \"未知用户\"}\n \n \n \n {forward.name}\n \n \n \n \n {forward.tunnelName}\n \n \n {formatTunnelTrafficRatio(forward.tunnelTrafficRatio)}\n \n \n \n \n \n showAddressModal(forward.inIp, forward.inPort, \"入口端口\")\n }\n >\n {formatInAddress(forward.inIp, forward.inPort)}\n \n \n \n \n showAddressModal(forward.remoteAddr, null, \"目标地址\")\n }\n >\n {formatRemoteAddress(forward.remoteAddr)}\n \n \n \n \n {strategyDisplay.text}\n \n \n \n \n {formatFlow(getForwardDisplayFlow(forward))}\n \n \n \n
\n handleServiceToggle(forward)}\n />\n
\n
\n \n
\n handleEdit(forward)}\n >\n \n \n \n \n handleDiagnose(forward)}\n >\n \n \n \n \n handleDelete(forward)}\n >\n \n \n \n \n
\n
\n
\n );\n };\n\n // 渲染规则卡片\n const renderForwardCard = (forward: Forward, listeners?: any) => {\n const statusDisplay = getStatusDisplay(forward.status);\n const strategyDisplay = getStrategyDisplay(forward.strategy);\n\n return (\n \n \n
\n {selectMode && (\n toggleSelect(forward.id)}\n />\n )}\n
\n

\n {forward.name}\n

\n

\n {`${normalizeForwardTunnelName(forward.tunnelName)} · ${formatTunnelTrafficRatio(forward.tunnelTrafficRatio)}`}\n

\n
\n
\n {viewMode === \"direct\" && (\n \n \n \n \n
\n )}\n handleServiceToggle(forward)}\n />\n \n {statusDisplay.text}\n \n
\n \n
\n\n \n
\n {/* 地址信息 */}\n
\n \n showAddressModal(forward.inIp, forward.inPort, \"入口端口\")\n }\n >\n
\n
\n \n 入口:\n \n \n {formatInAddress(forward.inIp, forward.inPort)}\n \n
\n {hasMultipleAddresses(forward.inIp) && (\n \n \n \n )}\n
\n \n\n \n showAddressModal(forward.remoteAddr, null, \"目标地址\")\n }\n >\n
\n
\n \n 目标:\n \n \n {formatRemoteAddress(forward.remoteAddr)}\n \n
\n {hasMultipleAddresses(forward.remoteAddr) && (\n \n \n \n )}\n
\n \n
\n\n {/* 统计信息 */}\n
\n \n {strategyDisplay.text}\n \n {(forward.inFlow || 0) + (forward.outFlow || 0) > 0 ? (\n <>\n
\n \n ↑{formatFlow(forward.inFlow || 0)}\n \n
\n \n ↓{formatFlow(forward.outFlow || 0)}\n \n \n ) : (forward.federationShareFlow || 0) > 0 ? (\n \n 共享 {formatFlow(forward.federationShareFlow || 0)}\n \n ) : (\n \n 总流量 {formatFlow(0)}\n \n )}\n
\n
\n\n
\n \n \n \n }\n variant=\"flat\"\n onPress={() => handleEdit(forward)}\n >\n 编辑\n \n \n \n \n }\n variant=\"flat\"\n onPress={() => handleDiagnose(forward)}\n >\n 诊断\n \n \n \n \n \n }\n variant=\"flat\"\n onPress={() => handleDelete(forward)}\n >\n 删除\n \n
\n
\n \n );\n };\n\n if (loading || !groupPreferenceHydrated) {\n return ;\n }\n\n return (\n \n {/* 页面头部 */}\n
\n
\n setIsSearchVisible(false)}\n onOpen={() => setIsSearchVisible(true)}\n />\n
\n
\n
*]:shrink-0\">\n {selectMode ? (\n <>\n \n 已选择 {selectedIds.size} 项\n \n \n 全选\n \n \n 清空\n \n setBatchDeleteModalOpen(true)}\n >\n 删除\n \n handleBatchToggleService(false)}\n >\n 停用\n \n handleBatchToggleService(true)}\n >\n 启用\n \n \n 下发\n \n setBatchChangeTunnelModalOpen(true)}\n >\n 隧道\n \n \n 退出\n \n \n ) : (\n <>\n {/* 筛选按钮 */}\n setIsFilterModalOpen(true)}\n >\n \n \n \n {(filterUserId !== \"all\" || filterTunnelId !== \"all\") && (\n \n )}\n \n {/* 显示模式切换按钮 */}\n \n {viewMode === \"grouped\" ? (\n \n \n \n ) : (\n \n \n \n )}\n \n\n {/* 导入按钮 */}\n \n 导入\n \n\n {/* 导出按钮 */}\n \n 导出\n \n\n \n 批量\n \n\n \n 新增\n \n \n )}\n
\n
\n
\n\n {batchProgress.active && (\n
\n \n \n
\n )}\n\n {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n
\n \n \n \n \n {selectMode && (\n 选择\n )}\n \n 用户\n 名称\n 隧道\n 入口\n 目标\n 策略\n 总流量\n 状态\n 操作\n \n \n {(forward) => (\n \n )}\n \n \n \n \n
\n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : sortedForwards.length > 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n {}}\n >\n \n
\n {sortedForwards.map((forward) =>\n forward && forward.id ? (\n \n ) : null,\n )}\n
\n \n \n \n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n
\n {groupedForwards.map((group) => {\n const isSelfGroup =\n isAdmin && tokenUserId !== null && group.userId === tokenUserId;\n const groupForwardCount = group.tunnels.reduce(\n (total, tunnel) => total + tunnel.items.length,\n 0,\n );\n\n return (\n \n
\n
\n \n {group.userName}\n \n {isSelfGroup && (\n \n 管理员本人\n \n )}\n
\n \n {groupForwardCount} 条规则\n \n
\n\n
\n \n \n buildTunnelGroupSortableId(\n group.userId,\n tunnel.tunnelKey,\n ),\n )}\n strategy={verticalListSortingStrategy}\n >\n {group.tunnels.map((tunnel) => {\n const tunnelSortableForwardIds = tunnel.items\n .map((item) => item.id)\n .filter((id) => id > 0);\n const collapsed =\n sanitizedCollapsedTunnelGroups[\n buildTunnelGroupCollapseKey(\n group.userId,\n tunnel.tunnelKey,\n )\n ] === true;\n\n return (\n \n toggleTunnelGroupCollapsed(\n group.userId,\n tunnel.tunnelKey,\n )\n }\n >\n \n \n \n {selectMode && (\n \n 选择\n \n )}\n \n \n 名称\n \n \n 入口\n \n \n 目标\n \n \n 策略\n \n \n 总流量\n \n \n 状态\n \n \n 操作\n \n \n \n {(forward) => (\n \n \n \n )}\n \n \n \n \n );\n })}\n \n \n
\n
\n );\n })}\n \n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : sortedForwards.length > 0 ? (\n
\n {groupedForwards.map((group) => {\n const isSelfGroup =\n isAdmin && tokenUserId !== null && group.userId === tokenUserId;\n const groupForwardCount = group.tunnels.reduce(\n (total, tunnel) => total + tunnel.items.length,\n 0,\n );\n\n return (\n \n
\n
\n \n {group.userName}\n \n {isSelfGroup && (\n \n 管理员本人\n \n )}\n
\n \n {groupForwardCount} 条规则\n \n
\n\n
\n \n \n buildTunnelGroupSortableId(\n group.userId,\n tunnel.tunnelKey,\n ),\n )}\n strategy={verticalListSortingStrategy}\n >\n {group.tunnels.map((tunnel) => {\n const tunnelSortableForwardIds = tunnel.items\n .map((item) => item.id)\n .filter((id) => id > 0);\n const collapsed =\n sanitizedCollapsedTunnelGroups[\n buildTunnelGroupCollapseKey(\n group.userId,\n tunnel.tunnelKey,\n )\n ] === true;\n\n return (\n \n toggleTunnelGroupCollapsed(\n group.userId,\n tunnel.tunnelKey,\n )\n }\n >\n {}}\n >\n \n
\n {tunnel.items.map((forward) =>\n forward && forward.id ? (\n \n ) : null,\n )}\n
\n \n \n \n );\n })}\n \n \n
\n
\n );\n })}\n \n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

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

\n {isEdit ? \"编辑规则\" : \"新增规则\"}\n

\n

\n {isEdit ? \"修改现有规则配置的信息\" : \"创建新的规则配置\"}\n

\n
\n \n
\n \n setForm((prev) => ({ ...prev, name: e.target.value }))\n }\n />\n\n {isAdmin && (\n {\n const selectedKey = Array.from(keys)[0] as\n | string\n | undefined;\n\n setForm((prev) => ({\n ...prev,\n speedId: selectedKey ? Number(selectedKey) : null,\n }));\n }}\n >\n {availableSpeedLimits.map((speedLimit) => (\n \n {speedLimit.name}\n \n ))}\n \n )}\n\n {\n const selectedKey = Array.from(keys)[0] as string;\n\n if (selectedKey) {\n handleTunnelChange(selectedKey);\n }\n }}\n >\n {tunnels.map((tunnel) => (\n {tunnel.name}\n ))}\n \n\n {\n const value = e.target.value;\n\n setForm((prev) => ({\n ...prev,\n inPort: value ? parseInt(value) : null,\n }));\n }}\n />\n\n 0\n ? \"选择入口监听IP\"\n : \"当前隧道入口节点暂无可选IP\"\n : \"请先选择隧道\"\n }\n selectedKeys={[form.inIp || \"__default__\"]}\n variant=\"bordered\"\n onSelectionChange={(keys) => {\n const selectedKey = Array.from(keys)[0] as string;\n\n setInIpTouched(true);\n\n setForm((prev) => ({\n ...prev,\n inIp: selectedKey === \"__default__\" ? \"\" : selectedKey,\n }));\n }}\n >\n 默认入口IP\n {currentTunnelIpOptions.map((ip) => (\n {ip}\n ))}\n \n\n \n setForm((prev) => ({\n ...prev,\n remoteAddr: e.target.value,\n }))\n }\n />\n\n {getAddressCount(form.remoteAddr) > 1 && (\n {\n const selectedKey = Array.from(keys)[0] as string;\n\n setForm((prev) => ({ ...prev, strategy: selectedKey }));\n }}\n >\n 主备模式 - 自上而下\n 轮询模式 - 依次轮换\n 随机模式 - 随机选择\n 哈希模式 - IP哈希\n \n )}\n
\n
\n \n \n \n {isEdit ? \"保存修改\" : \"创建规则\"}\n \n \n \n )}\n
\n \n\n {/* 删除确认模态框 */}\n \n \n {(onClose) => (\n <>\n \n

确认删除

\n
\n \n

\n 确定要删除规则{\" \"}\n \n "{forwardToDelete?.name}"\n {\" \"}\n 吗?\n

\n

\n 此操作无法撤销,删除后该规则将永久消失。\n

\n
\n \n \n \n 确认删除\n \n \n \n )}\n
\n \n\n {/* 地址列表弹窗 */}\n setAddressModalOpen(false)}\n >\n \n {addressModalTitle}\n \n
\n \n
\n\n
\n {addressList.map((item) => (\n \n \n {item.address}\n \n copyAddress(item)}\n >\n 复制\n \n
\n ))}\n \n
\n
\n \n\n {/* 导出数据模态框 */}\n {\n setExportModalOpen(false);\n setSelectedTunnelForExport(null);\n setExportData(\"\");\n }}\n >\n \n \n

导出规则数据

\n

\n 格式:目标地址|规则名称|入口端口\n

\n
\n \n
\n {/* 隧道选择 */}\n
\n {\n const selectedKey = Array.from(keys)[0] as string;\n\n setSelectedTunnelForExport(\n selectedKey ? parseInt(selectedKey) : null,\n );\n }}\n >\n {tunnels.map((tunnel) => (\n \n {tunnel.name}\n \n ))}\n \n
\n\n {/* 导出按钮和数据 */}\n {exportData && (\n
\n \n \n \n }\n onPress={executeExport}\n >\n 重新生成\n \n \n \n \n \n }\n onPress={copyExportData}\n >\n 复制\n \n
\n )}\n\n {/* 初始导出按钮 */}\n {!exportData && (\n
\n \n \n \n }\n onPress={executeExport}\n >\n 生成导出数据\n \n
\n )}\n\n {/* 导出数据显示 */}\n {exportData && (\n
\n \n
\n )}\n
\n
\n \n \n \n
\n \n\n {/* 导入数据模态框 */}\n setImportModalOpen(false)}\n >\n \n \n

导入规则数据

\n {importFormat === \"flvx\" ? (\n <>\n

\n 格式:目标地址|规则名称|入口端口,每行一个,入口端口留空将自动分配可用端口\n

\n

\n 目标地址支持单个地址(如:example.com:8080)或多个地址用逗号分隔(如:3.3.3.3:3,4.4.4.4:4)\n

\n \n ) : (\n <>\n

\n ny格式:JSON对象,支持多个目标地址(负载均衡),按所选隧道导入\n

\n

\n 格式:{"dest":["地址:端口"],"listen_port":端口,"name":"名称"}(listen_port可省略,自动分配端口)\n

\n \n )}\n
\n \n
\n {/* 格式选择 */}\n {\n const selectedKey = Array.from(keys)[0] as ImportFormat;\n\n if (selectedKey) {\n setImportFormat(selectedKey);\n setSelectedTunnelForImport(null);\n setImportData(\"\");\n setImportResults([]);\n }\n }}\n >\n \n flvx格式(管道分隔)\n \n \n ny格式(JSON)\n \n \n\n {/* 隧道选择 - 两种格式都需要 */}\n {\n const selectedKey = Array.from(keys)[0] as string;\n\n setSelectedTunnelForImport(\n selectedKey ? parseInt(selectedKey) : null,\n );\n }}\n >\n {tunnels.map((tunnel) => (\n \n {tunnel.name}\n \n ))}\n \n\n {/* 输入区域 */}\n setImportData(e.target.value)}\n />\n\n {/* 导入结果 */}\n {importResults.length > 0 && (\n
\n
\n

导入结果

\n \n 成功:{importResults.filter((r) => r.success).length} /\n 总计:{importResults.length}\n \n
\n\n \n {importResults.map((result, index) => (\n \n
\n {result.success ? (\n \n \n \n ) : (\n \n \n \n )}\n
\n
\n \n {result.success ? \"成功\" : \"失败\"}\n \n \n |\n \n \n {result.line}\n \n
\n \n {result.message}\n
\n
\n
\n
\n ))}\n \n \n )}\n \n
\n \n \n \n 开始导入\n \n \n
\n \n\n {/* 诊断结果模态框 */}\n {\n setDiagnosisModalOpen(open);\n if (!open) {\n diagnosisAbortRef.current?.abort();\n diagnosisAbortRef.current = null;\n setDiagnosisLoading(false);\n }\n }}\n >\n \n {(onClose) => (\n <>\n \n

规则诊断结果

\n {currentDiagnosisForward && (\n
\n \n {currentDiagnosisForward.name}\n \n \n 规则服务\n \n
\n )}\n
\n \n {diagnosisResult ? (\n
\n {diagnosisLoading && (\n
\n
\n \n \n 正在诊断 {diagnosisProgress.completed}/\n {diagnosisProgress.total > 0\n ? diagnosisProgress.total\n : \"?\"}\n \n
\n \n 流式更新中\n \n
\n )}\n\n {diagnosisProgress.timedOut && (\n \n )}\n\n {/* 统计摘要 */}\n
\n
\n
\n {diagnosisProgress.total > 0\n ? diagnosisProgress.total\n : diagnosisResult.results.length}\n
\n
\n 总测试数\n
\n
\n
\n
\n {diagnosisProgress.completed > 0 ||\n diagnosisProgress.total > 0\n ? diagnosisProgress.success\n : diagnosisResult.results.filter((r) => r.success)\n .length}\n
\n
\n 成功\n
\n
\n
\n
\n {diagnosisProgress.completed > 0 ||\n diagnosisProgress.total > 0\n ? diagnosisProgress.failed\n : diagnosisResult.results.filter((r) => !r.success)\n .length}\n
\n
\n 失败\n
\n
\n
\n\n {/* 桌面端表格展示 */}\n
\n {(() => {\n // 使用后端返回的 chainType 和 inx 字段进行分组\n const groupedResults = {\n entry: diagnosisResult.results.filter(\n (r) => r.fromChainType === 1,\n ),\n chains: {} as Record<\n number,\n typeof diagnosisResult.results\n >,\n exit: diagnosisResult.results.filter(\n (r) => r.fromChainType === 3,\n ),\n };\n\n // 按 inx 分组链路测试\n diagnosisResult.results.forEach((r) => {\n if (r.fromChainType === 2 && r.fromInx != null) {\n if (!groupedResults.chains[r.fromInx]) {\n groupedResults.chains[r.fromInx] = [];\n }\n groupedResults.chains[r.fromInx].push(r);\n }\n });\n\n const renderTableSection = (\n title: string,\n results: typeof diagnosisResult.results,\n ) => {\n if (results.length === 0) return null;\n\n return (\n \n
\n

\n {title}\n

\n
\n \n \n \n \n \n \n \n \n \n \n \n {results.map((result, index) => {\n const isDiagnosing = Boolean(\n result.diagnosing,\n );\n const isSuccess = result.success === true;\n const quality =\n getForwardDiagnosisQualityDisplay(\n result.averageTime,\n result.packetLoss,\n );\n\n return (\n \n \n \n \n \n \n \n );\n })}\n \n
\n 路径\n \n 状态\n \n 延迟(ms)\n \n 丢包率\n \n 质量\n
\n
\n {isDiagnosing ? (\n \n ) : (\n \n {isSuccess ? \"✓\" : \"✗\"}\n \n )}\n
\n
\n {result.description}\n
\n
\n {result.targetIp}:\n {result.targetPort}\n
\n
\n
\n
\n \n {isDiagnosing\n ? \"诊断中\"\n : isSuccess\n ? \"成功\"\n : \"失败\"}\n \n \n {isSuccess ? (\n \n {result.averageTime?.toFixed(0)}\n \n ) : (\n \n -\n \n )}\n \n {isSuccess ? (\n 0\n ? \"text-warning\"\n : \"text-success\"\n }`}\n >\n {result.packetLoss?.toFixed(1)}%\n \n ) : (\n \n -\n \n )}\n \n {isSuccess && quality ? (\n \n {quality.text}\n \n ) : (\n \n -\n \n )}\n
\n
\n );\n };\n\n return (\n <>\n {/* 入口测试 */}\n {renderTableSection(\n \"🚪 入口测试\",\n groupedResults.entry,\n )}\n\n {/* 链路测试(按跳数排序) */}\n {Object.keys(groupedResults.chains)\n .map(Number)\n .sort((a, b) => a - b)\n .map((hop) =>\n renderTableSection(\n `🔗 转发链 - 第${hop}跳`,\n groupedResults.chains[hop],\n ),\n )}\n\n {/* 出口测试 */}\n {renderTableSection(\n \"🚀 出口测试\",\n groupedResults.exit,\n )}\n \n );\n })()}\n
\n\n {/* 移动端卡片展示 */}\n
\n {(() => {\n // 使用后端返回的 chainType 和 inx 字段进行分组\n const groupedResults = {\n entry: diagnosisResult.results.filter(\n (r) => r.fromChainType === 1,\n ),\n chains: {} as Record<\n number,\n typeof diagnosisResult.results\n >,\n exit: diagnosisResult.results.filter(\n (r) => r.fromChainType === 3,\n ),\n };\n\n // 按 inx 分组链路测试\n diagnosisResult.results.forEach((r) => {\n if (r.fromChainType === 2 && r.fromInx != null) {\n if (!groupedResults.chains[r.fromInx]) {\n groupedResults.chains[r.fromInx] = [];\n }\n groupedResults.chains[r.fromInx].push(r);\n }\n });\n\n const renderCardSection = (\n title: string,\n results: typeof diagnosisResult.results,\n ) => {\n if (results.length === 0) return null;\n\n return (\n
\n
\n

\n {title}\n

\n
\n {results.map((result, index) => {\n const isDiagnosing = Boolean(result.diagnosing);\n const isSuccess = result.success === true;\n const quality =\n getForwardDiagnosisQualityDisplay(\n result.averageTime,\n result.packetLoss,\n );\n\n return (\n \n
\n {isDiagnosing ? (\n \n ) : (\n \n {isSuccess ? \"✓\" : \"✗\"}\n \n )}\n
\n
\n {result.description}\n
\n
\n {result.targetIp}:{result.targetPort}\n
\n
\n \n {isDiagnosing\n ? \"诊断中\"\n : isSuccess\n ? \"成功\"\n : \"失败\"}\n \n
\n\n {isSuccess ? (\n
\n
\n
\n {result.averageTime?.toFixed(0)}\n
\n
\n 延迟(ms)\n
\n
\n
\n 0\n ? \"text-warning\"\n : \"text-success\"\n }`}\n >\n {result.packetLoss?.toFixed(1)}%\n
\n
\n 丢包率\n
\n
\n
\n {quality && (\n <>\n \n {quality.text}\n \n
\n 质量\n
\n \n )}\n
\n
\n ) : (\n
\n \n {isDiagnosing\n ? result.message || \"诊断中...\"\n : result.message || \"连接失败\"}\n
\n
\n )}\n \n );\n })}\n \n );\n };\n\n return (\n <>\n {/* 入口测试 */}\n {renderCardSection(\n \"🚪 入口测试\",\n groupedResults.entry,\n )}\n\n {/* 链路测试(按跳数排序) */}\n {Object.keys(groupedResults.chains)\n .map(Number)\n .sort((a, b) => a - b)\n .map((hop) =>\n renderCardSection(\n `🔗 转发链 - 第${hop}跳`,\n groupedResults.chains[hop],\n ),\n )}\n\n {/* 出口测试 */}\n {renderCardSection(\n \"🚀 出口测试\",\n groupedResults.exit,\n )}\n \n );\n })()}\n \n\n {/* 失败详情(仅桌面端显示,移动端已在卡片中显示) */}\n {diagnosisResult.results.some(\n (r) => r.success === false && !r.diagnosing,\n ) && (\n \n )}\n \n ) : (\n
\n
\n \n \n \n
\n

\n 暂无诊断数据\n

\n
\n )}\n
\n \n \n {currentDiagnosisForward && (\n handleDiagnose(currentDiagnosisForward)}\n >\n 重新诊断\n \n )}\n \n \n )}\n
\n \n\n {/* 批量删除确认模态框 */}\n \n \n {(onClose) => (\n <>\n 确认删除\n \n

\n 确定要删除选中的 {selectedIds.size} 项规则吗?此操作不可撤销。\n

\n
\n \n \n \n 确认删除\n \n \n \n )}\n
\n \n\n {/* 批量换隧道模态框 */}\n \n \n {(onClose) => (\n <>\n 隧道\n \n

\n 将选中的 {selectedIds.size} 项规则迁移到新隧道:\n

\n {\n const selected = Array.from(keys)[0];\n\n setBatchTargetTunnelId(selected ? Number(selected) : null);\n }}\n >\n {tunnels.map((tunnel) => (\n \n {tunnel.name}\n \n ))}\n \n
\n \n \n \n 确认换隧道\n \n \n \n )}\n
\n \n\n {/* 筛选模态框 */}\n \n \n {(onClose) => (\n <>\n \n 筛选条件\n \n \n
\n
\n

按用户筛选

\n {\n const key = Array.from(keys)[0] as string;\n\n setFilterUserId(key || \"all\");\n }}\n >\n 全部用户\n {uniqueUsers.map((user) => (\n \n {user.name}\n \n ))}\n \n
\n
\n

按隧道筛选

\n {\n const key = Array.from(keys)[0] as string;\n\n setFilterTunnelId(key || \"all\");\n }}\n >\n 全部隧道\n {tunnels.map((tunnel) => (\n \n {tunnel.name}\n \n ))}\n \n
\n
\n
\n \n {\n resetFilterUserId();\n resetFilterTunnelId();\n }}\n >\n 重置\n \n \n \n \n )}\n
\n \n
\n );\n}\n", "after": "import type { ForwardApiItem, SpeedLimitApiItem } from \"@/api/types\";\n\nimport { useState, useEffect, useMemo, useRef, useCallback } from \"react\";\nimport toast from \"react-hot-toast\";\nimport {\n DndContext,\n closestCenter,\n KeyboardSensor,\n MouseSensor,\n TouchSensor,\n useSensor,\n useSensors,\n DragEndEvent,\n} from \"@dnd-kit/core\";\nimport {\n arrayMove,\n SortableContext,\n sortableKeyboardCoordinates,\n rectSortingStrategy,\n verticalListSortingStrategy,\n} from \"@dnd-kit/sortable\";\nimport { useSortable } from \"@dnd-kit/sortable\";\nimport { CSS } from \"@dnd-kit/utilities\";\n\nimport { SearchBar } from \"@/components/search-bar\";\nimport { AnimatedPage } from \"@/components/animated-page\";\nimport { Card, CardBody, CardHeader } from \"@/shadcn-bridge/heroui/card\";\nimport { Button } from \"@/shadcn-bridge/heroui/button\";\nimport { Input } from \"@/shadcn-bridge/heroui/input\";\nimport { Textarea } from \"@/shadcn-bridge/heroui/input\";\nimport { Select, SelectItem } from \"@/shadcn-bridge/heroui/select\";\nimport {\n Table,\n TableBody,\n TableCell,\n TableColumn,\n TableHeader,\n TableRow,\n} from \"@/shadcn-bridge/heroui/table\";\nimport {\n Modal,\n ModalContent,\n ModalHeader,\n ModalBody,\n ModalFooter,\n} from \"@/shadcn-bridge/heroui/modal\";\nimport { Chip } from \"@/shadcn-bridge/heroui/chip\";\nimport { Spinner } from \"@/shadcn-bridge/heroui/spinner\";\nimport { Switch } from \"@/shadcn-bridge/heroui/switch\";\nimport { Alert } from \"@/shadcn-bridge/heroui/alert\";\nimport { Progress } from \"@/shadcn-bridge/heroui/progress\";\nimport { Checkbox } from \"@/shadcn-bridge/heroui/checkbox\";\nimport {\n createForward,\n getForwardList,\n getSpeedLimitList,\n getPeerShareList,\n getPeerRemoteUsageList,\n updateForward,\n deleteForward,\n forceDeleteForward,\n userTunnel,\n getTunnelList,\n getNodeList,\n pauseForwardService,\n resumeForwardService,\n diagnoseForward,\n updateForwardOrder,\n getConfigByName,\n updateConfig,\n} from \"@/api\";\nimport {\n type ForwardAddressItem,\n formatInAddress,\n formatRemoteAddress,\n hasMultipleAddresses,\n resolveForwardAddressAction,\n} from \"@/pages/forward/address\";\nimport {\n buildForwardDiagnosisFallbackResult,\n getForwardDiagnosisQualityDisplay,\n type ForwardDiagnosisResult,\n} from \"@/pages/forward/diagnosis\";\nimport { diagnoseForwardStream } from \"@/api/diagnosis-stream\";\nimport {\n executeForwardBatchChangeTunnel,\n executeForwardBatchDelete,\n executeForwardBatchRedeploy,\n executeForwardBatchToggleService,\n} from \"@/pages/forward/batch-actions\";\nimport {\n convertNyItemToForwardInput,\n parseNyFormatData,\n} from \"@/pages/forward/import-format\";\nimport { buildForwardOrder, FORWARD_ORDER_KEY } from \"@/pages/forward/order\";\nimport { PageLoadingState } from \"@/components/page-state\";\nimport { useMobileBreakpoint } from \"@/hooks/useMobileBreakpoint\";\nimport { useLocalStorageState } from \"@/hooks/use-local-storage-state\";\nimport { saveOrder } from \"@/utils/order-storage\";\nimport { JwtUtil } from \"@/utils/jwt\";\n\ninterface Forward {\n id: number;\n name: string;\n tunnelId: number;\n tunnelName: string;\n tunnelTrafficRatio?: number;\n inIp: string;\n inPort: number;\n remoteAddr: string;\n interfaceName?: string;\n strategy: string;\n status: number;\n inFlow: number;\n outFlow: number;\n serviceRunning: boolean;\n federationShareFlow?: number;\n createdTime: string;\n userName?: string;\n userId?: number;\n inx?: number;\n speedId?: number | null;\n}\n\ninterface Tunnel {\n id: number;\n name: string;\n type?: number;\n inIp?: string;\n inNodeId?: Array<{ nodeId: number }>;\n inNodePortSta?: number;\n inNodePortEnd?: number;\n portRangeMin?: number;\n portRangeMax?: number;\n}\n\ninterface Node {\n id: number;\n name?: string;\n serverIp?: string;\n serverIpV4?: string;\n serverIpV6?: string;\n extraIPs?: string;\n}\n\ninterface ForwardForm {\n id?: number;\n userId?: number;\n name: string;\n tunnelId: number | null;\n inPort: number | null;\n inIp: string;\n remoteAddr: string;\n interfaceName?: string;\n strategy: string;\n speedId: number | null;\n}\n\ninterface ForwardUserGroup {\n userId: number;\n userName: string;\n tunnels: ForwardTunnelGroup[];\n}\n\ninterface ForwardTunnelGroup {\n tunnelKey: string;\n tunnelName: string;\n tunnelTrafficRatio?: number;\n items: Forward[];\n}\n\ninterface BatchProgressState {\n active: boolean;\n label: string;\n percent: number;\n}\n\ntype ForwardGroupOrderMap = Record;\ntype ForwardGroupCollapsedMap = Record;\n\nconst UNKNOWN_FORWARD_USER_NAME = \"未知用户\";\nconst UNCATEGORIZED_FORWARD_TUNNEL_NAME = \"未分类\";\nconst FORWARD_COMPACT_MODE_CONFIG_KEY = \"forward_compact_mode\";\nconst FORWARD_COMPACT_MODE_EVENT = \"forwardCompactModeChanged\";\nconst FORWARD_GROUP_ORDER_CONFIG_KEY = \"forward_group_order_map\";\nconst FORWARD_GROUP_COLLAPSED_CONFIG_KEY = \"forward_group_collapsed_map\";\nconst FORWARD_GROUP_ORDER_LOCAL_STORAGE_PREFIX = \"forward-group-order\";\nconst FORWARD_GROUP_COLLAPSED_LOCAL_STORAGE_PREFIX = \"forward-group-collapsed\";\nconst FORWARD_TUNNEL_GROUP_SORTABLE_PREFIX = \"forward-tunnel-group\";\nconst FORWARD_GROUPED_TABLE_MIN_WIDTH_CLASS = \"min-w-[1320px]\";\nconst FORWARD_GROUPED_TABLE_COLUMN_CLASS = {\n select: \"w-14\",\n drag: \"w-10 pl-4\",\n name: \"w-[200px]\",\n inbound: \"w-[280px]\",\n target: \"w-[280px]\",\n strategy: \"w-[100px]\",\n totalFlow: \"w-[120px]\",\n status: \"w-[100px]\",\n actions: \"w-[144px] text-right\",\n} as const;\n\nconst normalizeForwardUserName = (userName?: string): string => {\n const normalized = (userName || UNKNOWN_FORWARD_USER_NAME).trim();\n\n return normalized || UNKNOWN_FORWARD_USER_NAME;\n};\n\nconst compareForwardUserNameAsc = (a: string, b: string): number => {\n return a.localeCompare(b, \"en\", {\n sensitivity: \"base\",\n numeric: true,\n });\n};\n\nconst normalizeForwardTunnelName = (tunnelName?: string): string => {\n const normalized = (tunnelName || \"\").trim();\n\n return normalized || UNCATEGORIZED_FORWARD_TUNNEL_NAME;\n};\n\nconst buildForwardTunnelGroupKey = (tunnelName?: string): string => {\n const normalized = normalizeForwardTunnelName(tunnelName);\n\n if (normalized === UNCATEGORIZED_FORWARD_TUNNEL_NAME) {\n return \"__uncategorized__\";\n }\n\n return normalized.toLocaleLowerCase();\n};\n\nconst compareForwardTunnelNameAsc = (a: string, b: string): number => {\n return a.localeCompare(b, \"en\", {\n sensitivity: \"base\",\n numeric: true,\n });\n};\n\nconst compareForwardTunnelGroupKeyAsc = (a: string, b: string): number => {\n const aIsUncategorized = a === \"__uncategorized__\";\n const bIsUncategorized = b === \"__uncategorized__\";\n\n if (aIsUncategorized !== bIsUncategorized) {\n return aIsUncategorized ? 1 : -1;\n }\n\n return compareForwardTunnelNameAsc(a, b);\n};\n\nconst normalizeTunnelTrafficRatio = (value: unknown): number => {\n if (typeof value === \"number\" && Number.isFinite(value) && value > 0) {\n return value;\n }\n\n if (typeof value === \"string\") {\n const parsed = Number(value);\n\n if (Number.isFinite(parsed) && parsed > 0) {\n return parsed;\n }\n }\n\n return 1;\n};\n\nconst formatTunnelTrafficRatio = (value?: number): string => {\n const ratio = normalizeTunnelTrafficRatio(value);\n\n if (Number.isInteger(ratio)) {\n return `${ratio}x`;\n }\n\n return `${parseFloat(ratio.toFixed(2))}x`;\n};\n\nconst buildForwardGroupOrderLocalKey = (tokenUserId: number): string => {\n return `${FORWARD_GROUP_ORDER_LOCAL_STORAGE_PREFIX}:u:${tokenUserId}`;\n};\n\nconst buildForwardGroupCollapsedLocalKey = (tokenUserId: number): string => {\n return `${FORWARD_GROUP_COLLAPSED_LOCAL_STORAGE_PREFIX}:u:${tokenUserId}`;\n};\n\nconst parsePreferenceMap = (\n raw: string | null,\n): Record | null => {\n if (!raw) {\n return null;\n }\n\n try {\n const parsed = JSON.parse(raw);\n\n if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n return null;\n }\n\n return parsed as Record;\n } catch {\n return null;\n }\n};\n\nconst parseGroupOrderMap = (raw: string | null): ForwardGroupOrderMap => {\n const parsed = parsePreferenceMap(raw);\n\n if (!parsed) {\n return {};\n }\n\n const result: ForwardGroupOrderMap = {};\n\n Object.entries(parsed).forEach(([userId, value]) => {\n if (!Array.isArray(value)) {\n return;\n }\n\n const keys = value\n .map((item) => (typeof item === \"string\" ? item.trim() : \"\"))\n .filter((item) => item !== \"\");\n\n if (keys.length > 0) {\n result[userId] = Array.from(new Set(keys));\n }\n });\n\n return result;\n};\n\nconst parseGroupCollapsedMap = (\n raw: string | null,\n): ForwardGroupCollapsedMap => {\n const parsed = parsePreferenceMap(raw);\n\n if (!parsed) {\n return {};\n }\n\n const result: ForwardGroupCollapsedMap = {};\n\n Object.entries(parsed).forEach(([key, value]) => {\n if (typeof value === \"boolean\") {\n result[key] = value;\n }\n });\n\n return result;\n};\n\nconst sanitizeGroupOrderMap = (\n source: ForwardGroupOrderMap,\n availableTunnelKeysByUser: Map>,\n): ForwardGroupOrderMap => {\n const sanitized: ForwardGroupOrderMap = {};\n\n availableTunnelKeysByUser.forEach((availableKeys, userId) => {\n if (availableKeys.size === 0) {\n return;\n }\n\n const orderFromSource = source[userId.toString()] || [];\n const used = new Set();\n const merged: string[] = [];\n\n orderFromSource.forEach((key) => {\n if (!availableKeys.has(key) || used.has(key)) {\n return;\n }\n\n used.add(key);\n merged.push(key);\n });\n\n Array.from(availableKeys)\n .sort(compareForwardTunnelGroupKeyAsc)\n .forEach((key) => {\n if (!used.has(key)) {\n used.add(key);\n merged.push(key);\n }\n });\n\n if (merged.length > 0) {\n sanitized[userId.toString()] = merged;\n }\n });\n\n return sanitized;\n};\n\nconst sanitizeGroupCollapsedMap = (\n source: ForwardGroupCollapsedMap,\n availableCollapseKeys: Set,\n): ForwardGroupCollapsedMap => {\n const sanitized: ForwardGroupCollapsedMap = {};\n\n availableCollapseKeys.forEach((key) => {\n if (source[key] === true) {\n sanitized[key] = true;\n }\n });\n\n return sanitized;\n};\n\nconst buildTunnelGroupCollapseKey = (\n userId: number,\n tunnelKey: string,\n): string => {\n return `${userId}:${tunnelKey}`;\n};\n\nconst buildTunnelGroupSortableId = (\n userId: number,\n tunnelKey: string,\n): string => {\n return `${FORWARD_TUNNEL_GROUP_SORTABLE_PREFIX}:${userId}:${tunnelKey}`;\n};\n\nconst parseTunnelGroupSortableId = (\n value: unknown,\n): { userId: number; tunnelKey: string } | null => {\n if (typeof value !== \"string\") {\n return null;\n }\n\n if (!value.startsWith(`${FORWARD_TUNNEL_GROUP_SORTABLE_PREFIX}:`)) {\n return null;\n }\n\n const parts = value.split(\":\");\n\n if (parts.length < 3) {\n return null;\n }\n\n const userId = Number(parts[1]);\n const tunnelKey = parts.slice(2).join(\":\").trim();\n\n if (!Number.isFinite(userId) || tunnelKey === \"\") {\n return null;\n }\n\n return { userId, tunnelKey };\n};\n\nconst buildAvailableGroupData = (\n forwards: Forward[],\n): {\n availableTunnelKeysByUser: Map>;\n availableCollapseKeys: Set;\n} => {\n const availableTunnelKeysByUser = new Map>();\n const availableCollapseKeys = new Set();\n\n forwards.forEach((forward) => {\n const userId = forward.userId ?? 0;\n const tunnelKey = buildForwardTunnelGroupKey(forward.tunnelName);\n\n let set = availableTunnelKeysByUser.get(userId);\n\n if (!set) {\n set = new Set();\n availableTunnelKeysByUser.set(userId, set);\n }\n\n set.add(tunnelKey);\n availableCollapseKeys.add(buildTunnelGroupCollapseKey(userId, tunnelKey));\n });\n\n return { availableTunnelKeysByUser, availableCollapseKeys };\n};\n\nconst isSameStringArray = (a: string[], b: string[]): boolean => {\n if (a.length !== b.length) {\n return false;\n }\n\n for (let i = 0; i < a.length; i += 1) {\n if (a[i] !== b[i]) {\n return false;\n }\n }\n\n return true;\n};\n\nconst isSameGroupOrderMap = (\n a: ForwardGroupOrderMap,\n b: ForwardGroupOrderMap,\n): boolean => {\n const aKeys = Object.keys(a).sort(compareForwardTunnelNameAsc);\n const bKeys = Object.keys(b).sort(compareForwardTunnelNameAsc);\n\n if (!isSameStringArray(aKeys, bKeys)) {\n return false;\n }\n\n for (const key of aKeys) {\n if (!isSameStringArray(a[key] || [], b[key] || [])) {\n return false;\n }\n }\n\n return true;\n};\n\nconst isSameGroupCollapsedMap = (\n a: ForwardGroupCollapsedMap,\n b: ForwardGroupCollapsedMap,\n): boolean => {\n const aKeys = Object.keys(a).sort(compareForwardTunnelNameAsc);\n const bKeys = Object.keys(b).sort(compareForwardTunnelNameAsc);\n\n if (!isSameStringArray(aKeys, bKeys)) {\n return false;\n }\n\n for (const key of aKeys) {\n if (a[key] !== b[key]) {\n return false;\n }\n }\n\n return true;\n};\n\nconst normalizeForwardItems = (items: Forward[]): Forward[] => {\n return items.map((forward) => ({\n ...forward,\n serviceRunning: forward.status === 1,\n }));\n};\n\nconst mapForwardApiItems = (items: ForwardApiItem[]): Forward[] => {\n return (items || []).map((forward) => ({\n id: forward.id,\n name: forward.name,\n tunnelId: forward.tunnelId ?? 0,\n tunnelName: forward.tunnelName || \"\",\n tunnelTrafficRatio: normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio),\n inIp: forward.inIp || \"\",\n inPort: forward.inPort ?? 0,\n remoteAddr: forward.remoteAddr || \"\",\n strategy: typeof forward.strategy === \"string\" ? forward.strategy : \"fifo\",\n status: typeof forward.status === \"number\" ? forward.status : 0,\n inFlow: forward.inFlow ?? 0,\n outFlow: forward.outFlow ?? 0,\n createdTime:\n typeof forward.createdTime === \"string\" ? forward.createdTime : \"\",\n userName:\n typeof forward.userName === \"string\" ? forward.userName : undefined,\n userId: typeof forward.userId === \"number\" ? forward.userId : undefined,\n inx: typeof forward.inx === \"number\" ? forward.inx : undefined,\n speedId:\n typeof forward.speedId === \"number\" || forward.speedId === null\n ? forward.speedId\n : undefined,\n serviceRunning: forward.status === 1,\n }));\n};\n\nexport default function ForwardPage() {\n const [loading, setLoading] = useState(true);\n const [forwards, setForwards] = useState([]);\n const [tunnels, setTunnels] = useState([]);\n const [allTunnels, setAllTunnels] = useState([]);\n const [nodes, setNodes] = useState([]);\n const [speedLimits, setSpeedLimits] = useState([]);\n const isMobile = useMobileBreakpoint();\n const [searchKeyword, setSearchKeyword] = useLocalStorageState(\n \"forward-search-keyword\",\n \"\",\n );\n const [isSearchVisible, setIsSearchVisible] = useState(false);\n const [compactMode, setCompactMode] = useState(false);\n\n // 显示模式状态 - 从localStorage读取,默认为平铺显示\n const [viewMode, setViewMode] = useState<\"grouped\" | \"direct\">(() => {\n try {\n const savedMode = localStorage.getItem(\"forward-view-mode\");\n\n return (savedMode as \"grouped\" | \"direct\") || \"direct\";\n } catch {\n return \"direct\";\n }\n });\n\n // 筛选状态\n const [filterUserId, setFilterUserId, resetFilterUserId] =\n useLocalStorageState(\"forward-filter-user-id\", \"all\");\n const [filterTunnelId, setFilterTunnelId, resetFilterTunnelId] =\n useLocalStorageState(\"forward-filter-tunnel-id\", \"all\");\n\n // 拖拽排序相关状态\n const [forwardOrder, setForwardOrder] = useState([]);\n\n // 模态框状态\n const [modalOpen, setModalOpen] = useState(false);\n const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);\n const [deleteModalOpen, setDeleteModalOpen] = useState(false);\n const [addressModalOpen, setAddressModalOpen] = useState(false);\n const [diagnosisModalOpen, setDiagnosisModalOpen] = useState(false);\n const [isEdit, setIsEdit] = useState(false);\n const [submitLoading, setSubmitLoading] = useState(false);\n const [deleteLoading, setDeleteLoading] = useState(false);\n const [diagnosisLoading, setDiagnosisLoading] = useState(false);\n const [forwardToDelete, setForwardToDelete] = useState(null);\n const [currentDiagnosisForward, setCurrentDiagnosisForward] =\n useState(null);\n const [diagnosisResult, setDiagnosisResult] =\n useState(null);\n const [diagnosisProgress, setDiagnosisProgress] = useState({\n total: 0,\n completed: 0,\n success: 0,\n failed: 0,\n timedOut: false,\n });\n const diagnosisAbortRef = useRef(null);\n const [addressModalTitle, setAddressModalTitle] = useState(\"\");\n const [addressList, setAddressList] = useState([]);\n\n // 导出相关状态\n const [exportModalOpen, setExportModalOpen] = useState(false);\n const [exportData, setExportData] = useState(\"\");\n const [exportLoading, setExportLoading] = useState(false);\n const [selectedTunnelForExport, setSelectedTunnelForExport] = useState<\n number | null\n >(null);\n\n // 导入相关状态\n type ImportFormat = \"flvx\" | \"ny\";\n const [importModalOpen, setImportModalOpen] = useState(false);\n const [importData, setImportData] = useState(\"\");\n const [importLoading, setImportLoading] = useState(false);\n const [importFormat, setImportFormat] = useState(\"flvx\");\n const [selectedTunnelForImport, setSelectedTunnelForImport] = useState<\n number | null\n >(null);\n const [importResults, setImportResults] = useState<\n Array<{\n line: string;\n success: boolean;\n message: string;\n forwardName?: string;\n }>\n >([]);\n\n // 表单状态\n const [form, setForm] = useState({\n name: \"\",\n tunnelId: null,\n inPort: null,\n inIp: \"\",\n remoteAddr: \"\",\n interfaceName: \"\",\n strategy: \"fifo\",\n speedId: null,\n });\n const [inIpTouched, setInIpTouched] = useState(false);\n\n // 表单验证错误\n const [errors, setErrors] = useState<{ [key: string]: string }>({});\n\n // 批量操作相关状态\n const [selectMode, setSelectMode] = useState(false);\n const [selectedIds, setSelectedIds] = useState>(new Set());\n const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);\n const [batchChangeTunnelModalOpen, setBatchChangeTunnelModalOpen] =\n useState(false);\n const [batchTargetTunnelId, setBatchTargetTunnelId] = useState(\n null,\n );\n const [batchLoading, setBatchLoading] = useState(false);\n const [batchProgress, setBatchProgress] = useState({\n active: false,\n label: \"\",\n percent: 0,\n });\n const [groupOrderMap, setGroupOrderMap] = useState({});\n const [collapsedTunnelGroups, setCollapsedTunnelGroups] =\n useState({});\n const [groupPreferenceHydrated, setGroupPreferenceHydrated] = useState(false);\n const tokenUserId = JwtUtil.getUserIdFromToken();\n const tokenRoleId = JwtUtil.getRoleIdFromToken();\n const isAdmin = tokenRoleId === 0;\n\n const parseNodeIPs = (node?: Node): string[] => {\n if (!node) {\n return [];\n }\n\n const ips: string[] = [];\n const add = (value?: string) => {\n const trimmed = (value || \"\").trim();\n\n if (trimmed) {\n ips.push(trimmed);\n }\n };\n\n add(node.serverIpV4);\n add(node.serverIpV6);\n add(node.serverIp);\n\n (node.extraIPs || \"\")\n .split(\",\")\n .map((v) => v.trim())\n .filter((v) => v)\n .forEach((v) => ips.push(v));\n\n return Array.from(new Set(ips));\n };\n\n const tunnelInIpOptionMap = useMemo(() => {\n const map = new Map();\n const nodeMap = new Map(nodes.map((n) => [n.id, n]));\n\n for (const tunnel of allTunnels) {\n const collected: string[] = [];\n const entryNodes = tunnel.inNodeId || [];\n\n for (const entry of entryNodes) {\n collected.push(...parseNodeIPs(nodeMap.get(entry.nodeId)));\n }\n\n if (collected.length === 0) {\n (tunnel.inIp || \"\")\n .split(\",\")\n .map((v) => v.trim())\n .filter((v) => v)\n .forEach((v) => collected.push(v));\n }\n\n map.set(tunnel.id, Array.from(new Set(collected)));\n }\n\n return map;\n }, [allTunnels, nodes]);\n\n const currentTunnelIpOptions = useMemo(() => {\n if (!form.tunnelId) {\n return [];\n }\n\n return tunnelInIpOptionMap.get(form.tunnelId) || [];\n }, [form.tunnelId, tunnelInIpOptionMap]);\n\n const isCurrentTunnelMultiEntrance = useMemo(() => {\n if (!form.tunnelId) {\n return false;\n }\n\n const currentTunnel = allTunnels.find(\n (tunnel) => tunnel.id === form.tunnelId,\n );\n\n return (currentTunnel?.inNodeId?.length || 0) > 1;\n }, [allTunnels, form.tunnelId]);\n\n const currentTunnelPortRange = useMemo(() => {\n if (!form.tunnelId) {\n return null;\n }\n\n const currentTunnel = allTunnels.find(\n (tunnel) => tunnel.id === form.tunnelId,\n );\n\n if (\n currentTunnel?.portRangeMin &&\n currentTunnel?.portRangeMax &&\n currentTunnel.portRangeMin > 0 &&\n currentTunnel.portRangeMax > 0\n ) {\n return {\n min: currentTunnel.portRangeMin,\n max: currentTunnel.portRangeMax,\n };\n }\n\n return null;\n }, [allTunnels, form.tunnelId]);\n\n useEffect(() => {\n return () => {\n diagnosisAbortRef.current?.abort();\n diagnosisAbortRef.current = null;\n };\n }, []);\n\n const persistGroupOrderToLocal = (nextOrderMap: ForwardGroupOrderMap) => {\n if (tokenUserId === null) {\n return;\n }\n\n try {\n localStorage.setItem(\n buildForwardGroupOrderLocalKey(tokenUserId),\n JSON.stringify(nextOrderMap),\n );\n } catch {}\n };\n\n const persistGroupCollapsedToLocal = (\n nextCollapsedMap: ForwardGroupCollapsedMap,\n ) => {\n if (tokenUserId === null) {\n return;\n }\n\n try {\n localStorage.setItem(\n buildForwardGroupCollapsedLocalKey(tokenUserId),\n JSON.stringify(nextCollapsedMap),\n );\n } catch {}\n };\n\n const persistGroupOrderToGlobal = async (\n nextOrderMap: ForwardGroupOrderMap,\n ): Promise => {\n if (!isAdmin || tokenUserId === null) {\n return;\n }\n\n try {\n const currentRes = await getConfigByName(FORWARD_GROUP_ORDER_CONFIG_KEY);\n const globalMap =\n parsePreferenceMap(\n currentRes.code === 0 && typeof currentRes.data?.value === \"string\"\n ? currentRes.data.value\n : null,\n ) || {};\n\n globalMap[tokenUserId.toString()] = nextOrderMap;\n\n const saveRes = await updateConfig(\n FORWARD_GROUP_ORDER_CONFIG_KEY,\n JSON.stringify(globalMap),\n );\n\n if (saveRes.code !== 0) {\n toast.error(saveRes.msg || \"保存分组排序失败\");\n }\n } catch {\n toast.error(\"保存分组排序失败\");\n }\n };\n\n const persistGroupCollapsedToGlobal = async (\n nextCollapsedMap: ForwardGroupCollapsedMap,\n ): Promise => {\n if (!isAdmin || tokenUserId === null) {\n return;\n }\n\n try {\n const currentRes = await getConfigByName(\n FORWARD_GROUP_COLLAPSED_CONFIG_KEY,\n );\n const globalMap =\n parsePreferenceMap(\n currentRes.code === 0 && typeof currentRes.data?.value === \"string\"\n ? currentRes.data.value\n : null,\n ) || {};\n\n globalMap[tokenUserId.toString()] = nextCollapsedMap;\n\n const saveRes = await updateConfig(\n FORWARD_GROUP_COLLAPSED_CONFIG_KEY,\n JSON.stringify(globalMap),\n );\n\n if (saveRes.code !== 0) {\n toast.error(saveRes.msg || \"保存分组折叠状态失败\");\n }\n } catch {\n toast.error(\"保存分组折叠状态失败\");\n }\n };\n\n useEffect(() => {\n let cancelled = false;\n\n const loadGroupPreferences = async () => {\n if (tokenUserId === null) {\n if (!cancelled) {\n setGroupOrderMap({});\n setCollapsedTunnelGroups({});\n setGroupPreferenceHydrated(true);\n }\n\n return;\n }\n\n let localOrderMap: ForwardGroupOrderMap = {};\n let localCollapsedMap: ForwardGroupCollapsedMap = {};\n\n try {\n localOrderMap = parseGroupOrderMap(\n localStorage.getItem(buildForwardGroupOrderLocalKey(tokenUserId)),\n );\n } catch {\n localOrderMap = {};\n }\n\n try {\n localCollapsedMap = parseGroupCollapsedMap(\n localStorage.getItem(buildForwardGroupCollapsedLocalKey(tokenUserId)),\n );\n } catch {\n localCollapsedMap = {};\n }\n\n if (isAdmin) {\n try {\n const [globalOrderRes, globalCollapsedRes] = await Promise.all([\n getConfigByName(FORWARD_GROUP_ORDER_CONFIG_KEY),\n getConfigByName(FORWARD_GROUP_COLLAPSED_CONFIG_KEY),\n ]);\n\n const globalOrderMap = parsePreferenceMap(\n globalOrderRes.code === 0 &&\n typeof globalOrderRes.data?.value === \"string\"\n ? globalOrderRes.data.value\n : null,\n );\n const globalCollapsedMap =\n parsePreferenceMap(\n globalCollapsedRes.code === 0 &&\n typeof globalCollapsedRes.data?.value === \"string\"\n ? globalCollapsedRes.data.value\n : null,\n );\n\n const globalOrderBucket = globalOrderMap?.[tokenUserId.toString()];\n const globalCollapsedBucket =\n globalCollapsedMap?.[tokenUserId.toString()];\n\n if (\n globalOrderBucket &&\n typeof globalOrderBucket === \"object\" &&\n !Array.isArray(globalOrderBucket)\n ) {\n localOrderMap = parseGroupOrderMap(\n JSON.stringify(globalOrderBucket),\n );\n }\n\n if (\n globalCollapsedBucket &&\n typeof globalCollapsedBucket === \"object\" &&\n !Array.isArray(globalCollapsedBucket)\n ) {\n localCollapsedMap = parseGroupCollapsedMap(\n JSON.stringify(globalCollapsedBucket),\n );\n }\n } catch {}\n }\n\n if (cancelled) {\n return;\n }\n\n setGroupOrderMap(localOrderMap);\n setCollapsedTunnelGroups(localCollapsedMap);\n persistGroupOrderToLocal(localOrderMap);\n persistGroupCollapsedToLocal(localCollapsedMap);\n setGroupPreferenceHydrated(true);\n };\n\n setGroupPreferenceHydrated(false);\n loadGroupPreferences();\n\n return () => {\n cancelled = true;\n };\n }, [tokenUserId, isAdmin]);\n\n useEffect(() => {\n const loadForwardCompactMode = async () => {\n try {\n const response = await getConfigByName(FORWARD_COMPACT_MODE_CONFIG_KEY);\n const enabled =\n response.code === 0 &&\n typeof response.data?.value === \"string\" &&\n response.data.value === \"true\";\n\n setCompactMode(enabled);\n } catch {\n setCompactMode(false);\n }\n };\n\n const handleCompactModeChanged = (event: Event) => {\n const customEvent = event as CustomEvent<{ enabled?: boolean }>;\n\n if (typeof customEvent.detail?.enabled === \"boolean\") {\n setCompactMode(customEvent.detail.enabled);\n }\n };\n\n loadForwardCompactMode();\n window.addEventListener(\n FORWARD_COMPACT_MODE_EVENT,\n handleCompactModeChanged,\n );\n\n return () => {\n window.removeEventListener(\n FORWARD_COMPACT_MODE_EVENT,\n handleCompactModeChanged,\n );\n };\n }, []);\n\n const parseShareIdFromTunnelName = (tunnelName: string): number | null => {\n const normalized = (tunnelName || \"\").trim();\n\n if (!normalized.startsWith(\"Share-\")) {\n return null;\n }\n\n const raw = normalized.slice(\"Share-\".length);\n const idx = raw.indexOf(\"-Port-\");\n\n if (idx <= 0) {\n return null;\n }\n\n const shareId = Number(raw.slice(0, idx).trim());\n\n return Number.isFinite(shareId) && shareId > 0 ? shareId : null;\n };\n\n const mergeFederationShareFlow = useCallback(\n async (forwardsData: Forward[]): Promise => {\n if (forwardsData.length === 0) {\n return forwardsData;\n }\n\n try {\n const [usageRes, localShareRes] = await Promise.all([\n getPeerRemoteUsageList(),\n getPeerShareList(),\n ]);\n\n const flowByShare = new Map();\n const shareIdsByTunnel = new Map>();\n\n if (usageRes.code === 0 && Array.isArray(usageRes.data)) {\n usageRes.data.forEach((item: Record) => {\n const shareId = Number(item.shareId || 0);\n const currentFlow = Number(item.currentFlow || 0);\n\n if (\n Number.isFinite(shareId) &&\n shareId > 0 &&\n Number.isFinite(currentFlow) &&\n currentFlow > 0\n ) {\n const prev = flowByShare.get(shareId) || 0;\n\n flowByShare.set(shareId, Math.max(prev, currentFlow));\n }\n\n if (Number.isFinite(shareId) && shareId > 0) {\n const bindings = Array.isArray(item.bindings)\n ? (item.bindings as Array>)\n : [];\n\n bindings.forEach((binding) => {\n const tunnelId = Number(binding.tunnelId || 0);\n const chainType = Number(binding.chainType || 0);\n\n if (!Number.isFinite(tunnelId) || tunnelId <= 0) {\n return;\n }\n\n if (Number.isFinite(chainType) && chainType !== 1) {\n return;\n }\n\n let shareSet = shareIdsByTunnel.get(tunnelId);\n\n if (!shareSet) {\n shareSet = new Set();\n shareIdsByTunnel.set(tunnelId, shareSet);\n }\n\n shareSet.add(shareId);\n });\n }\n });\n }\n\n if (localShareRes.code === 0 && Array.isArray(localShareRes.data)) {\n localShareRes.data.forEach((item: Record) => {\n const shareId = Number(item.id || 0);\n const currentFlow = Number(item.currentFlow || 0);\n\n if (\n Number.isFinite(shareId) &&\n shareId > 0 &&\n Number.isFinite(currentFlow) &&\n currentFlow > 0\n ) {\n const prev = flowByShare.get(shareId) || 0;\n\n flowByShare.set(shareId, Math.max(prev, currentFlow));\n }\n });\n }\n\n if (flowByShare.size === 0) {\n return forwardsData;\n }\n\n const resolveShareIdForForward = (forward: Forward): number | null => {\n const candidates = new Set();\n const shareIdFromName = parseShareIdFromTunnelName(\n forward.tunnelName || \"\",\n );\n\n if (shareIdFromName) {\n candidates.add(shareIdFromName);\n }\n\n const tunnelId = Number(forward.tunnelId || 0);\n const shareSetByTunnel = shareIdsByTunnel.get(tunnelId);\n\n if (shareSetByTunnel && shareSetByTunnel.size > 0) {\n shareSetByTunnel.forEach((shareId) => {\n if (Number.isFinite(shareId) && shareId > 0) {\n candidates.add(shareId);\n }\n });\n }\n\n if (candidates.size === 0) {\n return null;\n }\n\n let bestShareId: number | null = null;\n let bestFlow = 0;\n\n candidates.forEach((shareId) => {\n const shareFlow = flowByShare.get(shareId) || 0;\n\n if (shareFlow > bestFlow) {\n bestFlow = shareFlow;\n bestShareId = shareId;\n }\n });\n\n return bestShareId;\n };\n\n const resolvedShareByForwardId = new Map();\n\n forwardsData.forEach((forward) => {\n const shareId = resolveShareIdForForward(forward);\n\n if (shareId) {\n resolvedShareByForwardId.set(forward.id, shareId);\n }\n });\n\n const forwardCountByShare = new Map();\n\n forwardsData.forEach((forward) => {\n const shareId = resolvedShareByForwardId.get(forward.id) || null;\n\n if (!shareId || !flowByShare.has(shareId)) {\n return;\n }\n\n forwardCountByShare.set(\n shareId,\n (forwardCountByShare.get(shareId) || 0) + 1,\n );\n });\n\n return forwardsData.map((forward) => {\n const shareId = resolvedShareByForwardId.get(forward.id) || null;\n\n if (!shareId) {\n return { ...forward, federationShareFlow: undefined };\n }\n\n const shareFlow = flowByShare.get(shareId) || 0;\n\n if (shareFlow <= 0) {\n return { ...forward, federationShareFlow: undefined };\n }\n\n const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);\n\n if (directFlow > 0) {\n return { ...forward, federationShareFlow: undefined };\n }\n\n const count = forwardCountByShare.get(shareId) || 1;\n const estimated = Math.max(1, Math.floor(shareFlow / count));\n\n return { ...forward, federationShareFlow: estimated };\n });\n } catch {\n return forwardsData;\n }\n },\n [],\n );\n\n const getForwardDisplayFlow = (forward: Forward): number => {\n const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);\n\n if (directFlow > 0) {\n return directFlow;\n }\n\n return forward.federationShareFlow || 0;\n };\n\n // 切换显示模式并保存到localStorage\n const handleViewModeChange = () => {\n const newMode = viewMode === \"grouped\" ? \"direct\" : \"grouped\";\n\n setViewMode(newMode);\n try {\n localStorage.setItem(\"forward-view-mode\", newMode);\n } catch {}\n };\n\n const applyForwardList = useCallback(\n async (items: Forward[]) => {\n const mergedForwards = await mergeFederationShareFlow(\n normalizeForwardItems(items),\n );\n\n setForwards(mergedForwards);\n\n const currentUserId = JwtUtil.getUserIdFromToken();\n const { order, fromDatabase } = buildForwardOrder(\n mergedForwards,\n currentUserId,\n );\n\n setForwardOrder(order);\n\n if (fromDatabase) {\n saveOrder(FORWARD_ORDER_KEY, order);\n }\n },\n [mergeFederationShareFlow],\n );\n\n const refreshForwardList = useCallback(\n async (lod = true) => {\n if (lod) {\n setLoading(true);\n }\n\n try {\n const forwardsRes = await getForwardList();\n\n if (forwardsRes.code === 0) {\n await applyForwardList(mapForwardApiItems(forwardsRes.data || []));\n } else {\n toast.error(forwardsRes.msg || \"获取规则列表失败\");\n }\n } catch {\n toast.error(\"获取规则列表失败\");\n } finally {\n if (lod) {\n setLoading(false);\n }\n }\n },\n [applyForwardList],\n );\n\n // 加载所有数据\n const loadData = useCallback(\n async (lod = true) => {\n setLoading(lod);\n try {\n const [tunnelsRes, speedLimitsRes] = await Promise.all([\n userTunnel(),\n getSpeedLimitList(),\n ]);\n const [allTunnelsRes, nodesRes] = await Promise.allSettled([\n getTunnelList(),\n getNodeList(),\n ]);\n\n await refreshForwardList(false);\n\n if (tunnelsRes.code === 0) {\n setTunnels(tunnelsRes.data || []);\n } else {\n }\n\n if (\n allTunnelsRes.status === \"fulfilled\" &&\n allTunnelsRes.value.code === 0\n ) {\n setAllTunnels((allTunnelsRes.value.data || []) as Tunnel[]);\n }\n\n if (nodesRes.status === \"fulfilled\" && nodesRes.value.code === 0) {\n setNodes((nodesRes.value.data || []) as Node[]);\n }\n\n if (speedLimitsRes.code === 0) {\n setSpeedLimits(speedLimitsRes.data || []);\n }\n } catch {\n toast.error(\"加载数据失败\");\n } finally {\n setLoading(false);\n }\n },\n [refreshForwardList],\n );\n\n useEffect(() => {\n loadData();\n }, [loadData]);\n\n // 表单验证\n const noLimitSpeedLimitIds = useMemo(() => {\n return new Set(\n speedLimits\n .filter((speedLimit) => speedLimit.name.trim() === \"不限速\")\n .map((speedLimit) => speedLimit.id),\n );\n }, [speedLimits]);\n\n const speedLimitIds = useMemo(() => {\n return new Set(speedLimits.map((speedLimit) => speedLimit.id));\n }, [speedLimits]);\n\n const availableSpeedLimits = useMemo(() => {\n return speedLimits.filter(\n (speedLimit) => !noLimitSpeedLimitIds.has(speedLimit.id),\n );\n }, [speedLimits, noLimitSpeedLimitIds]);\n\n const normalizeSpeedId = (speedId?: number | null): number | null => {\n if (speedId === null || speedId === undefined) {\n return null;\n }\n\n if (noLimitSpeedLimitIds.has(speedId)) {\n return null;\n }\n\n if (speedLimits.length > 0 && !speedLimitIds.has(speedId)) {\n return null;\n }\n\n return speedId;\n };\n\n const isMissingSpeedLimit = (speedId?: number | null): boolean => {\n if (speedId === null || speedId === undefined) {\n return false;\n }\n\n if (speedLimits.length === 0 || noLimitSpeedLimitIds.has(speedId)) {\n return false;\n }\n\n return !speedLimitIds.has(speedId);\n };\n\n const selectedSpeedId = normalizeSpeedId(form.speedId);\n\n const validateForm = (): boolean => {\n const newErrors: { [key: string]: string } = {};\n\n if (!form.name.trim()) {\n newErrors.name = \"请输入规则名称\";\n } else if (form.name.length < 2 || form.name.length > 50) {\n newErrors.name = \"规则名称长度应在2-50个字符之间\";\n }\n\n if (!form.tunnelId) {\n newErrors.tunnelId = \"请选择关联隧道\";\n }\n\n if (\n form.inPort !== null &&\n form.inPort !== undefined &&\n form.inPort > 0 &&\n currentTunnelPortRange\n ) {\n if (\n form.inPort < currentTunnelPortRange.min ||\n form.inPort > currentTunnelPortRange.max\n ) {\n newErrors.inPort = `端口 ${currentTunnelPortRange.min}-${currentTunnelPortRange.max} 超出允许范围`;\n }\n }\n\n if (!form.remoteAddr.trim()) {\n newErrors.remoteAddr = \"请输入远程地址\";\n } else {\n // 验证地址格式\n const addresses = form.remoteAddr\n .split(\"\\n\")\n .map((addr) => addr.trim())\n .filter((addr) => addr);\n const ipv4Pattern =\n /^(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+$/;\n const ipv6FullPattern =\n /^\\[((([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+$/;\n const domainPattern =\n /^[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+$/;\n\n for (let i = 0; i < addresses.length; i++) {\n const addr = addresses[i];\n\n if (\n !ipv4Pattern.test(addr) &&\n !ipv6FullPattern.test(addr) &&\n !domainPattern.test(addr)\n ) {\n newErrors.remoteAddr = `第${i + 1}行地址格式错误`;\n break;\n }\n }\n }\n\n setErrors(newErrors);\n\n return Object.keys(newErrors).length === 0;\n };\n\n // 新增规则\n const handleAdd = () => {\n setIsEdit(false);\n setInIpTouched(false);\n setForm({\n name: \"\",\n tunnelId: null,\n inPort: null,\n inIp: \"\",\n remoteAddr: \"\",\n interfaceName: \"\",\n strategy: \"fifo\",\n speedId: null,\n });\n setErrors({});\n setModalOpen(true);\n };\n\n // 编辑规则\n const handleEdit = (forward: Forward) => {\n setIsEdit(true);\n setInIpTouched(false);\n setForm({\n id: forward.id,\n userId: forward.userId,\n name: forward.name,\n tunnelId: forward.tunnelId,\n inPort: forward.inPort,\n inIp: forward.inIp || \"\",\n remoteAddr: forward.remoteAddr.split(\",\").join(\"\\n\"),\n interfaceName: forward.interfaceName || \"\",\n strategy: forward.strategy || \"fifo\",\n speedId: normalizeSpeedId(forward.speedId),\n });\n setErrors({});\n setModalOpen(true);\n };\n\n // 显示删除确认\n const handleDelete = (forward: Forward) => {\n setForwardToDelete(forward);\n setDeleteModalOpen(true);\n };\n\n // 确认删除规则\n const confirmDelete = async () => {\n if (!forwardToDelete) return;\n\n setDeleteLoading(true);\n try {\n const res = await deleteForward(forwardToDelete.id);\n\n if (res.code === 0) {\n toast.success(\"删除成功\");\n setDeleteModalOpen(false);\n setForwardToDelete(null);\n setForwards((prev) =>\n prev.filter((forward) => forward.id !== forwardToDelete.id),\n );\n setForwardOrder((prev) => {\n const next = prev.filter((id) => id !== forwardToDelete.id);\n\n saveOrder(FORWARD_ORDER_KEY, next);\n\n return next;\n });\n setSelectedIds((prev) => {\n const next = new Set(prev);\n\n next.delete(forwardToDelete.id);\n\n return next;\n });\n } else {\n // 删除失败,询问是否强制删除\n const confirmed = window.confirm(\n `常规删除失败:${res.msg || \"删除失败\"}\\n\\n是否需要强制删除?\\n\\n⚠️ 注意:强制删除不会去验证节点端是否已经删除对应的规则服务。`,\n );\n\n if (confirmed) {\n const forceRes = await forceDeleteForward(forwardToDelete.id);\n\n if (forceRes.code === 0) {\n toast.success(\"强制删除成功\");\n setDeleteModalOpen(false);\n setForwardToDelete(null);\n setForwards((prev) =>\n prev.filter((forward) => forward.id !== forwardToDelete.id),\n );\n setForwardOrder((prev) => {\n const next = prev.filter((id) => id !== forwardToDelete.id);\n\n saveOrder(FORWARD_ORDER_KEY, next);\n\n return next;\n });\n setSelectedIds((prev) => {\n const next = new Set(prev);\n\n next.delete(forwardToDelete.id);\n\n return next;\n });\n } else {\n toast.error(forceRes.msg || \"强制删除失败\");\n }\n }\n }\n } catch {\n toast.error(\"删除失败\");\n } finally {\n setDeleteLoading(false);\n }\n };\n\n // 处理隧道选择变化\n const handleTunnelChange = (tunnelId: string) => {\n const nextTunnelId = parseInt(tunnelId);\n const options = tunnelInIpOptionMap.get(nextTunnelId) || [];\n\n setInIpTouched(false);\n\n setForm((prev) => {\n const tunnelChanged = prev.tunnelId !== nextTunnelId;\n\n return {\n ...prev,\n tunnelId: nextTunnelId,\n inIp: tunnelChanged ? \"\" : options.includes(prev.inIp) ? prev.inIp : \"\",\n };\n });\n };\n\n // 提交表单\n const handleSubmit = async () => {\n if (!validateForm()) return;\n\n setSubmitLoading(true);\n try {\n const processedRemoteAddr = form.remoteAddr\n .split(\"\\n\")\n .map((addr) => addr.trim())\n .filter((addr) => addr)\n .join(\",\");\n\n const addressCount = processedRemoteAddr.split(\",\").length;\n\n let res: { code: number; msg: string };\n const normalizedSpeedId = normalizeSpeedId(form.speedId);\n const speedLimitAutoCleared = isMissingSpeedLimit(form.speedId);\n\n if (isEdit) {\n const updateData = {\n id: form.id,\n name: form.name,\n tunnelId: form.tunnelId,\n inPort: form.inPort,\n ...(inIpTouched ? { inIp: form.inIp || \"\" } : {}),\n remoteAddr: processedRemoteAddr,\n strategy: addressCount > 1 ? form.strategy : \"fifo\",\n speedId: normalizedSpeedId,\n };\n\n res = await updateForward(updateData);\n } else {\n const createData = {\n name: form.name,\n tunnelId: form.tunnelId,\n inPort: form.inPort,\n inIp: form.inIp || undefined,\n remoteAddr: processedRemoteAddr,\n strategy: addressCount > 1 ? form.strategy : \"fifo\",\n speedId: normalizedSpeedId,\n };\n\n res = await createForward(createData);\n }\n\n if (res.code === 0) {\n const warningItems = Array.isArray((res as any).data?.warnings)\n ? (res as any).data.warnings\n .map((item: unknown) =>\n typeof item === \"string\" ? item.trim() : \"\",\n )\n .filter((item: string) => item)\n : [];\n\n warningItems.forEach((warning: string) => {\n toast(warning, {\n icon: \"⚠️\",\n duration: 5000,\n });\n });\n if (speedLimitAutoCleared) {\n toast(\"所选限速规则不存在,已自动清除为不限速\", {\n icon: \"⚠️\",\n duration: 5000,\n });\n }\n toast.success(isEdit ? \"修改成功\" : \"创建成功\");\n setModalOpen(false);\n await refreshForwardList(false);\n } else {\n toast.error(res.msg || \"操作失败\");\n }\n } catch {\n toast.error(\"操作失败\");\n } finally {\n setSubmitLoading(false);\n }\n };\n\n // 处理服务开关\n const handleServiceToggle = async (forward: Forward) => {\n if (forward.status !== 1 && forward.status !== 0) {\n toast.error(\"规则状态异常,无法操作\");\n\n return;\n }\n\n const targetState = !forward.serviceRunning;\n\n try {\n // 乐观更新UI\n setForwards((prev) =>\n prev.map((f) =>\n f.id === forward.id ? { ...f, serviceRunning: targetState } : f,\n ),\n );\n\n let res: { code: number; msg: string };\n\n if (targetState) {\n res = await resumeForwardService(forward.id);\n } else {\n res = await pauseForwardService(forward.id);\n }\n\n if (res.code === 0) {\n toast.success(targetState ? \"服务已启动\" : \"服务已暂停\");\n // 更新规则状态\n setForwards((prev) =>\n prev.map((f) =>\n f.id === forward.id ? { ...f, status: targetState ? 1 : 0 } : f,\n ),\n );\n } else {\n // 操作失败,恢复UI状态\n setForwards((prev) =>\n prev.map((f) =>\n f.id === forward.id ? { ...f, serviceRunning: !targetState } : f,\n ),\n );\n toast.error(res.msg || \"操作失败\");\n }\n } catch {\n // 操作失败,恢复UI状态\n setForwards((prev) =>\n prev.map((f) =>\n f.id === forward.id ? { ...f, serviceRunning: !targetState } : f,\n ),\n );\n toast.error(\"网络错误,操作失败\");\n }\n };\n\n // 诊断规则\n const handleDiagnose = async (forward: Forward) => {\n diagnosisAbortRef.current?.abort();\n const abortController = new AbortController();\n\n diagnosisAbortRef.current = abortController;\n\n setCurrentDiagnosisForward(forward);\n setDiagnosisModalOpen(true);\n setDiagnosisLoading(true);\n setDiagnosisProgress({\n total: 0,\n completed: 0,\n success: 0,\n failed: 0,\n timedOut: false,\n });\n setDiagnosisResult({\n forwardName: forward.name,\n timestamp: Date.now(),\n results: [],\n });\n\n try {\n let streamErrorMessage = \"\";\n const streamResult = await diagnoseForwardStream(\n forward.id,\n {\n onStart: (payload) => {\n const startForwardName =\n typeof payload.forwardName === \"string\" &&\n payload.forwardName.trim() !== \"\"\n ? payload.forwardName\n : forward.name;\n const startTotal = Number(payload.total);\n const startItems = Array.isArray(payload.items)\n ? (payload.items as ForwardDiagnosisResult[\"results\"])\n : [];\n\n setDiagnosisResult((prev) => ({\n forwardName: startForwardName,\n timestamp: Date.now(),\n results: startItems.length > 0 ? startItems : prev?.results || [],\n }));\n if (Number.isFinite(startTotal) && startTotal >= 0) {\n setDiagnosisProgress((prev) => ({\n ...prev,\n total: startTotal,\n }));\n }\n },\n onItem: ({ result, progress }) => {\n setDiagnosisResult((prev) => {\n const base: ForwardDiagnosisResult = prev || {\n forwardName: forward.name,\n timestamp: Date.now(),\n results: [],\n };\n const nextResults = [...base.results];\n const existingIndex = nextResults.findIndex(\n (item) =>\n item.description === result.description &&\n item.nodeId === result.nodeId &&\n item.targetIp === result.targetIp &&\n item.targetPort === result.targetPort,\n );\n\n if (existingIndex >= 0) {\n nextResults[existingIndex] = {\n ...result,\n diagnosing: false,\n };\n } else {\n nextResults.push({\n ...result,\n diagnosing: false,\n });\n }\n\n return {\n ...base,\n timestamp: Date.now(),\n results: nextResults,\n };\n });\n setDiagnosisProgress({\n total: progress.total,\n completed: progress.completed,\n success: progress.success,\n failed: progress.failed,\n timedOut: Boolean(progress.timedOut),\n });\n },\n onDone: (progress) => {\n setDiagnosisProgress({\n total: progress.total,\n completed: progress.completed,\n success: progress.success,\n failed: progress.failed,\n timedOut: Boolean(progress.timedOut),\n });\n },\n onError: (message) => {\n streamErrorMessage = message;\n },\n },\n abortController.signal,\n );\n\n if (streamResult.fallback) {\n const response = await diagnoseForward(forward.id);\n\n if (response.code === 0) {\n const resultData = response.data as ForwardDiagnosisResult;\n const successCount = resultData.results.filter(\n (r) => r.success,\n ).length;\n const failedCount = resultData.results.length - successCount;\n\n setDiagnosisResult(resultData);\n setDiagnosisProgress({\n total: resultData.results.length,\n completed: resultData.results.length,\n success: successCount,\n failed: failedCount,\n timedOut: false,\n });\n } else {\n toast.error(response.msg || \"诊断失败\");\n setDiagnosisResult(\n buildForwardDiagnosisFallbackResult({\n forwardName: forward.name,\n remoteAddr: forward.remoteAddr,\n description: \"诊断失败\",\n message: response.msg || \"诊断过程中发生错误\",\n }),\n );\n setDiagnosisProgress({\n total: 1,\n completed: 1,\n success: 0,\n failed: 1,\n timedOut: false,\n });\n }\n\n return;\n }\n\n if (streamErrorMessage) {\n toast.error(streamErrorMessage);\n }\n if (streamResult.timedOut) {\n toast.error(\"诊断超时(单条30秒 / 整体2分钟),已返回当前结果\");\n }\n } catch {\n if (abortController.signal.aborted) {\n return;\n }\n toast.error(\"网络错误,请重试\");\n setDiagnosisResult(\n buildForwardDiagnosisFallbackResult({\n forwardName: forward.name,\n remoteAddr: forward.remoteAddr,\n description: \"网络错误\",\n message: \"无法连接到服务器\",\n }),\n );\n setDiagnosisProgress({\n total: 1,\n completed: 1,\n success: 0,\n failed: 1,\n timedOut: false,\n });\n } finally {\n if (diagnosisAbortRef.current === abortController) {\n diagnosisAbortRef.current = null;\n }\n setDiagnosisLoading(false);\n }\n };\n\n // 格式化流量\n const formatFlow = (value: number): string => {\n if (value === 0) return \"0 B\";\n if (value < 1024) return value + \" B\";\n if (value < 1024 * 1024) return (value / 1024).toFixed(2) + \" KB\";\n if (value < 1024 * 1024 * 1024)\n return (value / (1024 * 1024)).toFixed(2) + \" MB\";\n\n return (value / (1024 * 1024 * 1024)).toFixed(2) + \" GB\";\n };\n\n // 显示地址列表弹窗\n const showAddressModal = (\n addressString: string,\n port: number | null,\n title: string,\n ) => {\n const action = resolveForwardAddressAction(addressString, port, title);\n\n if (action.type === \"none\") {\n return;\n }\n\n if (action.type === \"copy\") {\n copyToClipboard(action.text, action.label);\n\n return;\n }\n\n setAddressList(action.items);\n setAddressModalTitle(action.title);\n setAddressModalOpen(true);\n };\n\n // 复制到剪贴板\n const copyToClipboard = async (text: string, label: string = \"内容\") => {\n try {\n await navigator.clipboard.writeText(text);\n toast.success(`已复制${label}`);\n } catch {\n toast.error(\"复制失败\");\n }\n };\n\n // 复制地址\n const copyAddress = async (addressItem: ForwardAddressItem) => {\n try {\n setAddressList((prev) =>\n prev.map((item) =>\n item.id === addressItem.id ? { ...item, copying: true } : item,\n ),\n );\n await copyToClipboard(addressItem.address, \"地址\");\n } catch {\n toast.error(\"复制失败\");\n } finally {\n setAddressList((prev) =>\n prev.map((item) =>\n item.id === addressItem.id ? { ...item, copying: false } : item,\n ),\n );\n }\n };\n\n // 复制所有地址\n const copyAllAddresses = async () => {\n if (addressList.length === 0) return;\n const allAddresses = addressList.map((item) => item.address).join(\"\\n\");\n\n await copyToClipboard(allAddresses, \"所有地址\");\n };\n\n // 导出规则数据\n const handleExport = () => {\n setSelectedTunnelForExport(null);\n setExportData(\"\");\n setExportModalOpen(true);\n };\n\n // 执行导出\n const executeExport = () => {\n if (!selectedTunnelForExport) {\n toast.error(\"请选择要导出的隧道\");\n\n return;\n }\n\n setExportLoading(true);\n\n try {\n // 获取要导出的规则列表\n const forwardsToExport = sortedForwards.filter(\n (forward) => forward.tunnelId === selectedTunnelForExport,\n );\n\n if (forwardsToExport.length === 0) {\n toast.error(\"所选隧道没有规则数据\");\n setExportLoading(false);\n\n return;\n }\n\n // 格式化导出数据:remoteAddr|name|inPort\n const exportLines = forwardsToExport.map((forward) => {\n return `${forward.remoteAddr}|${forward.name}|${forward.inPort}`;\n });\n\n const exportText = exportLines.join(\"\\n\");\n\n setExportData(exportText);\n } catch {\n toast.error(\"导出失败\");\n } finally {\n setExportLoading(false);\n }\n };\n\n // 复制导出数据\n const copyExportData = async () => {\n await copyToClipboard(exportData, \"规则数据\");\n };\n\n // 导入规则数据\n const handleImport = () => {\n setImportData(\"\");\n setImportResults([]);\n setSelectedTunnelForImport(null);\n setImportModalOpen(true);\n };\n\n // 执行导入\n const executeImport = async () => {\n if (!importData.trim()) {\n toast.error(\"请输入要导入的数据\");\n\n return;\n }\n\n if (!selectedTunnelForImport) {\n toast.error(\"请选择要导入的隧道\");\n\n return;\n }\n\n setImportLoading(true);\n setImportResults([]);\n\n try {\n if (importFormat === \"ny\") {\n const parsedItems = parseNyFormatData(importData);\n\n if (parsedItems.length === 0) {\n toast.error(\"未解析到有效的ny格式数据\");\n\n setImportLoading(false);\n\n return;\n }\n\n for (const item of parsedItems) {\n if (item.error) {\n setImportResults((prev) => [\n {\n line: item.line,\n success: false,\n message: item.error || \"解析失败\",\n },\n ...prev,\n ]);\n\n continue;\n }\n\n if (!item.parsed) {\n setImportResults((prev) => [\n {\n line: item.line,\n success: false,\n message: \"解析失败\",\n },\n ...prev,\n ]);\n\n continue;\n }\n\n const parsedNyItem = item.parsed;\n const nyForwardInput = convertNyItemToForwardInput(parsedNyItem);\n\n try {\n const response = await createForward({\n name: nyForwardInput.name,\n tunnelId: selectedTunnelForImport,\n inPort: nyForwardInput.inPort,\n remoteAddr: nyForwardInput.remoteAddr,\n strategy: nyForwardInput.strategy,\n });\n\n if (response.code === 0) {\n setImportResults((prev) => [\n {\n line: item.line,\n success: true,\n message: `创建成功 (${parsedNyItem.dest.length}个目标)`,\n forwardName: nyForwardInput.name,\n },\n ...prev,\n ]);\n } else {\n setImportResults((prev) => [\n {\n line: item.line,\n success: false,\n message: response.msg || \"创建失败\",\n },\n ...prev,\n ]);\n }\n } catch {\n setImportResults((prev) => [\n {\n line: item.line,\n success: false,\n message: \"网络错误,创建失败\",\n },\n ...prev,\n ]);\n }\n }\n } else {\n const lines = importData\n .trim()\n .split(\"\\n\")\n .filter((line) => line.trim());\n\n for (let i = 0; i < lines.length; i++) {\n const line = lines[i].trim();\n const parts = line.split(\"|\");\n\n if (parts.length < 2) {\n setImportResults((prev) => [\n {\n line,\n success: false,\n message: \"格式错误:需要至少包含目标地址和规则名称\",\n },\n ...prev,\n ]);\n continue;\n }\n\n const [remoteAddr, name, inPort] = parts;\n\n if (!remoteAddr.trim() || !name.trim()) {\n setImportResults((prev) => [\n {\n line,\n success: false,\n message: \"目标地址和规则名称不能为空\",\n },\n ...prev,\n ]);\n continue;\n }\n\n const addresses = remoteAddr.trim().split(\",\");\n const addressPattern = /^[^:]+:\\d+$/;\n const isValidFormat = addresses.every((addr) =>\n addressPattern.test(addr.trim()),\n );\n\n if (!isValidFormat) {\n setImportResults((prev) => [\n {\n line,\n success: false,\n message:\n \"目标地址格式错误,应为 地址:端口 格式,多个地址用逗号分隔\",\n },\n ...prev,\n ]);\n continue;\n }\n\n try {\n let portNumber: number | null = null;\n\n if (inPort && inPort.trim()) {\n const port = parseInt(inPort.trim());\n\n if (isNaN(port) || port < 1 || port > 65535) {\n setImportResults((prev) => [\n {\n line,\n success: false,\n message: \"入口端口格式错误,应为1-65535之间的数字\",\n },\n ...prev,\n ]);\n continue;\n }\n portNumber = port;\n }\n\n const response = await createForward({\n name: name.trim(),\n tunnelId: selectedTunnelForImport,\n inPort: portNumber,\n remoteAddr: remoteAddr.trim(),\n strategy: \"fifo\",\n });\n\n if (response.code === 0) {\n setImportResults((prev) => [\n {\n line,\n success: true,\n message: \"创建成功\",\n forwardName: name.trim(),\n },\n ...prev,\n ]);\n } else {\n setImportResults((prev) => [\n {\n line,\n success: false,\n message: response.msg || \"创建失败\",\n },\n ...prev,\n ]);\n }\n } catch {\n setImportResults((prev) => [\n {\n line,\n success: false,\n message: \"网络错误,创建失败\",\n },\n ...prev,\n ]);\n }\n }\n }\n\n toast.success(\"导入执行完成\");\n\n await refreshForwardList(false);\n } catch {\n toast.error(\"导入过程中发生错误\");\n } finally {\n setImportLoading(false);\n }\n };\n\n // 获取状态显示\n const getStatusDisplay = (status: number) => {\n switch (status) {\n case 1:\n return { color: \"success\", text: \"正常\" };\n case 0:\n return { color: \"warning\", text: \"暂停\" };\n case -1:\n return { color: \"danger\", text: \"异常\" };\n default:\n return { color: \"default\", text: \"未知\" };\n }\n };\n\n // 获取策略显示\n const getStrategyDisplay = (strategy: string) => {\n switch (strategy) {\n case \"fifo\":\n return { color: \"primary\", text: \"主备\" };\n case \"round\":\n return { color: \"success\", text: \"轮询\" };\n case \"rand\":\n return { color: \"warning\", text: \"随机\" };\n default:\n return { color: \"default\", text: \"未知\" };\n }\n };\n\n // 获取地址数量\n const getAddressCount = (addressString: string): number => {\n if (!addressString) return 0;\n const addresses = addressString\n .split(\"\\n\")\n .map((addr) => addr.trim())\n .filter((addr) => addr);\n\n return addresses.length;\n };\n\n // 处理拖拽结束\n const handleDragEnd = async (event: DragEndEvent) => {\n const { active, over } = event;\n\n if (!active || !over || active.id === over.id) return;\n\n const activeGroup = parseTunnelGroupSortableId(active.id);\n const overGroup = parseTunnelGroupSortableId(over.id);\n\n if (activeGroup && overGroup) {\n if (compactMode || !groupPreferenceHydrated) {\n return;\n }\n\n if (activeGroup.userId !== overGroup.userId) {\n return;\n }\n\n const userIdKey = activeGroup.userId.toString();\n const currentOrder = groupOrderMap[userIdKey] || [];\n const oldIndex = currentOrder.indexOf(activeGroup.tunnelKey);\n const newIndex = currentOrder.indexOf(overGroup.tunnelKey);\n\n if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) {\n return;\n }\n\n const moved = arrayMove(currentOrder, oldIndex, newIndex);\n const nextOrderMap: ForwardGroupOrderMap = {\n ...groupOrderMap,\n [userIdKey]: moved,\n };\n\n setGroupOrderMap(nextOrderMap);\n persistGroupOrderToLocal(nextOrderMap);\n void persistGroupOrderToGlobal(nextOrderMap);\n\n return;\n }\n\n // 确保 forwardOrder 存在且有效\n if (!forwardOrder || forwardOrder.length === 0) return;\n\n const activeId = Number(active.id);\n const overId = Number(over.id);\n\n // 检查 ID 是否有效\n if (isNaN(activeId) || isNaN(overId)) return;\n\n const activeForward = forwards.find((forward) => forward.id === activeId);\n const overForward = forwards.find((forward) => forward.id === overId);\n const activeUserId = activeForward?.userId ?? 0;\n const overUserId = overForward?.userId ?? 0;\n const activeTunnelGroupKey = buildForwardTunnelGroupKey(\n activeForward?.tunnelName,\n );\n const overTunnelGroupKey = buildForwardTunnelGroupKey(\n overForward?.tunnelName,\n );\n\n // 非精简模式仅允许在同一用户+隧道分组内拖拽,避免混排\n if (!compactMode) {\n if (\n activeUserId !== overUserId ||\n activeTunnelGroupKey !== overTunnelGroupKey\n ) {\n return;\n }\n }\n\n let oldIndex: number;\n let newIndex: number;\n let currentOrder: number[];\n\n if (compactMode) {\n currentOrder = sortedForwards.map((f) => f.id);\n oldIndex = currentOrder.indexOf(activeId);\n newIndex = currentOrder.indexOf(overId);\n } else {\n currentOrder = forwardOrder;\n oldIndex = forwardOrder.indexOf(activeId);\n newIndex = forwardOrder.indexOf(overId);\n }\n\n if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {\n const newOrder = arrayMove(currentOrder, oldIndex, newIndex);\n\n if (!compactMode) {\n setForwardOrder(newOrder);\n saveOrder(FORWARD_ORDER_KEY, newOrder);\n }\n\n // 持久化到数据库\n try {\n const forwardsToUpdate = newOrder.map((id, index) => ({\n id,\n inx: index,\n }));\n\n const response = await updateForwardOrder({\n forwards: forwardsToUpdate,\n });\n\n if (response.code === 0) {\n // 更新本地数据中的 inx 字段\n setForwards((prev) =>\n prev.map((forward) => {\n const updatedForward = forwardsToUpdate.find(\n (f) => f.id === forward.id,\n );\n\n if (updatedForward) {\n return { ...forward, inx: updatedForward.inx };\n }\n\n return forward;\n }),\n );\n } else {\n toast.error(\"保存排序失败:\" + (response.msg || \"未知错误\"));\n }\n } catch {\n toast.error(\"保存排序失败,请重试\");\n }\n }\n };\n\n const toggleSelectMode = () => {\n setSelectMode(!selectMode);\n if (selectMode) {\n setSelectedIds(new Set());\n }\n };\n\n const toggleSelect = (id: number) => {\n const newSet = new Set(selectedIds);\n\n if (newSet.has(id)) {\n newSet.delete(id);\n } else {\n newSet.add(id);\n }\n setSelectedIds(newSet);\n };\n\n const selectAll = () => {\n const allIds = sortedForwards.map((f) => f.id);\n\n setSelectedIds(new Set(allIds));\n };\n\n const deselectAll = () => {\n setSelectedIds(new Set());\n };\n\n const handleBatchDelete = async () => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\n setBatchProgress({\n active: true,\n label: `正在删除 ${selectedIds.size} 项规则...`,\n percent: 30,\n });\n try {\n const outcome = await executeForwardBatchDelete(Array.from(selectedIds));\n\n if (outcome.toastVariant === \"success\") {\n toast.success(outcome.toastMessage);\n } else {\n toast.error(outcome.toastMessage);\n }\n\n if (outcome.shouldRefresh) {\n setBatchProgress({\n active: true,\n label: outcome.progressLabel || \"正在刷新规则列表...\",\n percent: outcome.progressPercent ?? 75,\n });\n setSelectedIds(new Set());\n setSelectMode(false);\n if (outcome.closeDeleteModal) {\n setBatchDeleteModalOpen(false);\n }\n await refreshForwardList(false);\n }\n } finally {\n setBatchProgress({ active: false, label: \"\", percent: 0 });\n setBatchLoading(false);\n }\n };\n\n const handleBatchToggleService = async (enable: boolean) => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\n setBatchProgress({\n active: true,\n label: `正在${enable ? \"启用\" : \"停用\"} ${selectedIds.size} 项规则...`,\n percent: 30,\n });\n try {\n const outcome = await executeForwardBatchToggleService(\n Array.from(selectedIds),\n enable,\n );\n\n if (outcome.toastVariant === \"success\") {\n toast.success(outcome.toastMessage);\n } else {\n toast.error(outcome.toastMessage);\n }\n\n if (outcome.shouldRefresh) {\n setBatchProgress({\n active: true,\n label: outcome.progressLabel || \"正在刷新规则列表...\",\n percent: outcome.progressPercent ?? 75,\n });\n setSelectedIds(new Set());\n setSelectMode(false);\n await refreshForwardList(false);\n }\n } finally {\n setBatchProgress({ active: false, label: \"\", percent: 0 });\n setBatchLoading(false);\n }\n };\n\n const handleBatchRedeploy = async () => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\n setBatchProgress({\n active: true,\n label: `正在重新下发 ${selectedIds.size} 项规则...`,\n percent: 30,\n });\n try {\n const outcome = await executeForwardBatchRedeploy(\n Array.from(selectedIds),\n );\n\n if (outcome.toastVariant === \"success\") {\n toast.success(outcome.toastMessage);\n } else {\n toast.error(outcome.toastMessage);\n }\n\n if (outcome.shouldRefresh) {\n setBatchProgress({\n active: true,\n label: outcome.progressLabel || \"正在刷新规则列表...\",\n percent: outcome.progressPercent ?? 75,\n });\n setSelectedIds(new Set());\n setSelectMode(false);\n await refreshForwardList(false);\n }\n } finally {\n setBatchProgress({ active: false, label: \"\", percent: 0 });\n setBatchLoading(false);\n }\n };\n\n const handleBatchChangeTunnel = async () => {\n if (selectedIds.size === 0 || !batchTargetTunnelId) return;\n setBatchLoading(true);\n setBatchProgress({\n active: true,\n label: `正在为 ${selectedIds.size} 项规则切换隧道...`,\n percent: 30,\n });\n try {\n const outcome = await executeForwardBatchChangeTunnel(\n Array.from(selectedIds),\n batchTargetTunnelId,\n );\n\n if (outcome.toastVariant === \"success\") {\n toast.success(outcome.toastMessage);\n } else {\n toast.error(outcome.toastMessage);\n }\n\n if (outcome.shouldRefresh) {\n setBatchProgress({\n active: true,\n label: outcome.progressLabel || \"正在刷新规则列表...\",\n percent: outcome.progressPercent ?? 75,\n });\n setSelectedIds(new Set());\n setSelectMode(false);\n if (outcome.closeChangeTunnelModal) {\n setBatchChangeTunnelModalOpen(false);\n }\n if (outcome.resetTargetTunnel) {\n setBatchTargetTunnelId(null);\n }\n await refreshForwardList(false);\n }\n } finally {\n setBatchProgress({ active: false, label: \"\", percent: 0 });\n setBatchLoading(false);\n }\n };\n\n // 传感器配置 - 使用默认配置避免错误\n const sensors = useSensors(\n useSensor(MouseSensor, {\n activationConstraint: {\n distance: 8,\n },\n }),\n useSensor(TouchSensor, {\n activationConstraint: {\n delay: 250,\n tolerance: 8,\n },\n }),\n useSensor(KeyboardSensor, {\n coordinateGetter: sortableKeyboardCoordinates,\n }),\n );\n\n // 根据排序顺序获取规则列表\n const orderedForwards = useMemo((): Forward[] => {\n // 确保 forwards 数组存在且有效\n if (!forwards || forwards.length === 0) {\n return [];\n }\n\n let filteredForwards = forwards;\n\n if (filterUserId !== \"all\") {\n const targetUserId = parseInt(filterUserId);\n\n filteredForwards = filteredForwards.filter(\n (f) => f.userId === targetUserId || (targetUserId === 0 && !f.userId),\n );\n }\n if (filterTunnelId !== \"all\") {\n const targetTunnelId = parseInt(filterTunnelId);\n\n filteredForwards = filteredForwards.filter(\n (f) => f.tunnelId === targetTunnelId,\n );\n }\n\n if (searchKeyword.trim()) {\n const lowerKeyword = searchKeyword.toLowerCase();\n\n filteredForwards = filteredForwards.filter(\n (f) =>\n (f.name && f.name.toLowerCase().includes(lowerKeyword)) ||\n (f.remoteAddr && f.remoteAddr.toLowerCase().includes(lowerKeyword)) ||\n (f.userName && f.userName.toLowerCase().includes(lowerKeyword)),\n );\n }\n\n // 确保过滤后的规则列表有效\n if (!filteredForwards || filteredForwards.length === 0) {\n return [];\n }\n\n // 优先使用数据库中的 inx 字段进行排序\n const sortedByDb = [...filteredForwards].sort((a, b) => {\n const aInx = a.inx ?? 0;\n const bInx = b.inx ?? 0;\n\n if (aInx !== bInx) {\n return aInx - bInx;\n }\n\n return (a.id ?? 0) - (b.id ?? 0);\n });\n\n // 如果数据库中没有排序信息,则使用本地存储的顺序\n if (\n forwardOrder &&\n forwardOrder.length > 0 &&\n sortedByDb.every((f) => f.inx === undefined || f.inx === 0)\n ) {\n const forwardMap = new Map(filteredForwards.map((f) => [f.id, f]));\n const localSortedForwards: Forward[] = [];\n\n forwardOrder.forEach((id) => {\n const forward = forwardMap.get(id);\n\n if (forward) {\n localSortedForwards.push(forward);\n }\n });\n\n // 添加不在排序列表中的规则(新添加的)\n filteredForwards.forEach((forward) => {\n if (!forwardOrder.includes(forward.id)) {\n localSortedForwards.push(forward);\n }\n });\n\n return localSortedForwards;\n }\n\n return sortedByDb;\n }, [forwards, forwardOrder, filterUserId, filterTunnelId, searchKeyword]);\n\n const availableGroupData = useMemo(\n () => buildAvailableGroupData(forwards),\n [forwards],\n );\n\n const sanitizedGroupOrderMap = useMemo(\n () =>\n sanitizeGroupOrderMap(\n groupOrderMap,\n availableGroupData.availableTunnelKeysByUser,\n ),\n [groupOrderMap, availableGroupData],\n );\n\n const sanitizedCollapsedTunnelGroups = useMemo(\n () =>\n sanitizeGroupCollapsedMap(\n collapsedTunnelGroups,\n availableGroupData.availableCollapseKeys,\n ),\n [collapsedTunnelGroups, availableGroupData],\n );\n\n useEffect(() => {\n if (!groupPreferenceHydrated || tokenUserId === null) {\n return;\n }\n\n if (forwards.length === 0) {\n return;\n }\n\n if (!isSameGroupOrderMap(groupOrderMap, sanitizedGroupOrderMap)) {\n setGroupOrderMap(sanitizedGroupOrderMap);\n persistGroupOrderToLocal(sanitizedGroupOrderMap);\n void persistGroupOrderToGlobal(sanitizedGroupOrderMap);\n }\n\n if (\n !isSameGroupCollapsedMap(\n collapsedTunnelGroups,\n sanitizedCollapsedTunnelGroups,\n )\n ) {\n setCollapsedTunnelGroups(sanitizedCollapsedTunnelGroups);\n persistGroupCollapsedToLocal(sanitizedCollapsedTunnelGroups);\n void persistGroupCollapsedToGlobal(sanitizedCollapsedTunnelGroups);\n }\n }, [\n groupPreferenceHydrated,\n tokenUserId,\n forwards,\n groupOrderMap,\n sanitizedGroupOrderMap,\n collapsedTunnelGroups,\n sanitizedCollapsedTunnelGroups,\n ]);\n\n const groupedForwards = useMemo((): ForwardUserGroup[] => {\n if (orderedForwards.length === 0) {\n return [];\n }\n\n type MutableForwardUserGroup = {\n userId: number;\n userName: string;\n tunnelMap: Map;\n };\n\n const userGroupMap = new Map();\n\n orderedForwards.forEach((forward) => {\n const userId = forward.userId ?? 0;\n const userName = normalizeForwardUserName(forward.userName);\n const tunnelName = normalizeForwardTunnelName(forward.tunnelName);\n const tunnelKey = buildForwardTunnelGroupKey(forward.tunnelName);\n\n let existingGroup = userGroupMap.get(userId);\n\n if (!existingGroup) {\n existingGroup = {\n userId,\n userName,\n tunnelMap: new Map(),\n };\n userGroupMap.set(userId, existingGroup);\n } else if (\n existingGroup.userName === UNKNOWN_FORWARD_USER_NAME &&\n userName !== UNKNOWN_FORWARD_USER_NAME\n ) {\n existingGroup.userName = userName;\n }\n\n const existingTunnelGroup = existingGroup.tunnelMap.get(tunnelKey);\n\n if (!existingTunnelGroup) {\n existingGroup.tunnelMap.set(tunnelKey, {\n tunnelKey,\n tunnelName,\n tunnelTrafficRatio: normalizeTunnelTrafficRatio(\n forward.tunnelTrafficRatio,\n ),\n items: [forward],\n });\n\n return;\n }\n\n existingTunnelGroup.items.push(forward);\n\n if (\n existingTunnelGroup.tunnelName === UNCATEGORIZED_FORWARD_TUNNEL_NAME &&\n tunnelName !== UNCATEGORIZED_FORWARD_TUNNEL_NAME\n ) {\n existingTunnelGroup.tunnelName = tunnelName;\n }\n\n if (\n normalizeTunnelTrafficRatio(existingTunnelGroup.tunnelTrafficRatio) ===\n 1 &&\n normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio) !== 1\n ) {\n existingTunnelGroup.tunnelTrafficRatio = normalizeTunnelTrafficRatio(\n forward.tunnelTrafficRatio,\n );\n }\n });\n\n const groups = Array.from(userGroupMap.values()).map((group) => {\n const tunnels = Array.from(group.tunnelMap.values());\n const tunnelOrder = sanitizedGroupOrderMap[group.userId.toString()] || [];\n const tunnelOrderIndex = new Map();\n\n tunnelOrder.forEach((key, index) => {\n tunnelOrderIndex.set(key, index);\n });\n\n tunnels.sort((a, b) => {\n const aIndex = tunnelOrderIndex.get(a.tunnelKey);\n const bIndex = tunnelOrderIndex.get(b.tunnelKey);\n\n if (aIndex !== undefined || bIndex !== undefined) {\n if (aIndex === undefined) {\n return 1;\n }\n\n if (bIndex === undefined) {\n return -1;\n }\n\n return aIndex - bIndex;\n }\n\n const nameCompare = compareForwardTunnelNameAsc(\n a.tunnelName,\n b.tunnelName,\n );\n\n if (nameCompare !== 0) {\n return nameCompare;\n }\n\n return compareForwardTunnelNameAsc(a.tunnelKey, b.tunnelKey);\n });\n\n return {\n userId: group.userId,\n userName: group.userName,\n tunnels,\n };\n });\n\n groups.sort((a, b) => {\n if (isAdmin && tokenUserId !== null) {\n const aIsSelf = a.userId === tokenUserId;\n const bIsSelf = b.userId === tokenUserId;\n\n if (aIsSelf !== bIsSelf) {\n return aIsSelf ? -1 : 1;\n }\n }\n\n const nameCompare = compareForwardUserNameAsc(a.userName, b.userName);\n\n if (nameCompare !== 0) {\n return nameCompare;\n }\n\n return a.userId - b.userId;\n });\n\n return groups;\n }, [orderedForwards, isAdmin, tokenUserId, sanitizedGroupOrderMap]);\n\n const sortedForwards = useMemo(() => {\n if (compactMode) {\n return orderedForwards;\n }\n\n return groupedForwards.flatMap((group) =>\n group.tunnels.flatMap((tunnel) => tunnel.items),\n );\n }, [compactMode, orderedForwards, groupedForwards]);\n\n const sortableForwardIds = useMemo(\n () => sortedForwards.map((f) => f.id).filter((id) => id > 0),\n [sortedForwards],\n );\n\n const toggleTunnelGroupCollapsed = (userId: number, tunnelKey: string) => {\n const collapseKey = buildTunnelGroupCollapseKey(userId, tunnelKey);\n const nextCollapsedMap: ForwardGroupCollapsedMap = {\n ...sanitizedCollapsedTunnelGroups,\n };\n\n if (nextCollapsedMap[collapseKey] === true) {\n delete nextCollapsedMap[collapseKey];\n } else {\n nextCollapsedMap[collapseKey] = true;\n }\n\n setCollapsedTunnelGroups(nextCollapsedMap);\n persistGroupCollapsedToLocal(nextCollapsedMap);\n void persistGroupCollapsedToGlobal(nextCollapsedMap);\n };\n\n const SortableTunnelGroupContainer = ({\n groupUserId,\n tunnel,\n collapsed,\n onToggleCollapsed,\n wrapperClassName,\n headerClassName,\n titleClassName,\n countClassName,\n bodyClassName,\n children,\n }: {\n groupUserId: number;\n tunnel: ForwardTunnelGroup;\n collapsed: boolean;\n onToggleCollapsed: () => void;\n wrapperClassName: string;\n headerClassName: string;\n titleClassName: string;\n countClassName: string;\n bodyClassName: string;\n children: React.ReactNode;\n }) => {\n const sortableId = buildTunnelGroupSortableId(\n groupUserId,\n tunnel.tunnelKey,\n );\n const {\n attributes,\n listeners,\n setNodeRef,\n transform,\n transition,\n isDragging,\n } = useSortable({ id: sortableId });\n\n const style: React.CSSProperties = {\n transform: transform\n ? CSS.Transform.toString({\n ...transform,\n x: Math.round(transform.x),\n y: Math.round(transform.y),\n })\n : undefined,\n transition: isDragging ? undefined : transition || undefined,\n opacity: isDragging ? 0.55 : 1,\n willChange: isDragging ? \"transform\" : undefined,\n zIndex: isDragging ? 1 : undefined,\n };\n\n return (\n
\n
\n
\n \n \n \n \n \n {tunnel.tunnelName}\n \n {formatTunnelTrafficRatio(tunnel.tunnelTrafficRatio)}\n \n
\n
\n {tunnel.items.length} 条规则\n \n \n \n \n
\n
\n
\n {!collapsed &&
{children}
}\n \n );\n };\n\n // 可拖拽的规则卡片组件\n const SortableForwardCard = ({ forward }: { forward: Forward }) => {\n const {\n attributes,\n listeners,\n setNodeRef,\n transform,\n transition,\n isDragging,\n } = useSortable({ id: forward.id });\n\n const style: React.CSSProperties = {\n transform: transform\n ? CSS.Transform.toString({\n ...transform,\n x: Math.round(transform.x),\n y: Math.round(transform.y),\n })\n : undefined,\n transition: isDragging ? undefined : transition || undefined,\n opacity: isDragging ? 0.5 : 1,\n willChange: isDragging ? \"transform\" : undefined,\n };\n\n return (\n
\n {renderForwardCard(forward, listeners)}\n
\n );\n };\n\n // 生成用作筛选项的用户和隧道列表\n const uniqueUsers = useMemo(() => {\n const userMap = new Map();\n\n forwards.forEach((f) => {\n const uId = f.userId ?? 0;\n const userName = normalizeForwardUserName(f.userName);\n const existingUser = userMap.get(uId);\n\n if (!existingUser) {\n userMap.set(uId, { id: uId, name: userName });\n\n return;\n }\n\n if (\n existingUser.name === UNKNOWN_FORWARD_USER_NAME &&\n userName !== UNKNOWN_FORWARD_USER_NAME\n ) {\n existingUser.name = userName;\n }\n });\n\n const users = Array.from(userMap.values());\n\n users.sort((a, b) => {\n if (isAdmin && tokenUserId !== null) {\n const aIsSelf = a.id === tokenUserId;\n const bIsSelf = b.id === tokenUserId;\n\n if (aIsSelf !== bIsSelf) {\n return aIsSelf ? -1 : 1;\n }\n }\n\n const nameCompare = compareForwardUserNameAsc(a.name, b.name);\n\n if (nameCompare !== 0) {\n return nameCompare;\n }\n\n return a.id - b.id;\n });\n\n return users;\n }, [forwards, isAdmin, tokenUserId]);\n\n // 可拖拽的表格行组件\n const SortableTableRow = ({\n forward,\n selectMode,\n selectedIds,\n toggleSelect,\n getStrategyDisplay,\n formatInAddress,\n formatRemoteAddress,\n handleServiceToggle,\n handleEdit,\n handleDelete,\n handleDiagnose,\n showAddressModal,\n hasMultipleAddresses,\n formatFlow,\n }: any) => {\n const {\n attributes,\n listeners,\n setNodeRef,\n transform,\n transition,\n isDragging,\n } = useSortable({ id: forward.id });\n\n const style = {\n transform: transform\n ? CSS.Transform.toString({\n ...transform,\n x: Math.round(transform.x),\n y: Math.round(transform.y),\n })\n : undefined,\n transition: isDragging ? undefined : transition || undefined,\n opacity: isDragging ? 0.5 : 1,\n backgroundColor: isDragging ? \"var(--nextui-default-100)\" : undefined,\n };\n\n const strategyDisplay = getStrategyDisplay(forward.strategy);\n\n return (\n \n {selectMode && (\n \n toggleSelect(forward.id)}\n />\n \n )}\n \n \n \n \n \n \n \n \n {forward.name}\n \n \n \n showAddressModal(forward.inIp, forward.inPort, \"入口端口\")\n }\n >\n {formatInAddress(forward.inIp, forward.inPort)}\n \n \n \n \n showAddressModal(forward.remoteAddr, null, \"目标地址\")\n }\n >\n {formatRemoteAddress(forward.remoteAddr)}\n \n \n \n \n {strategyDisplay.text}\n \n \n \n \n {formatFlow(getForwardDisplayFlow(forward))}\n \n \n \n
\n handleServiceToggle(forward)}\n />\n
\n
\n \n
\n handleEdit(forward)}\n >\n \n \n \n \n handleDiagnose(forward)}\n >\n \n \n \n \n handleDelete(forward)}\n >\n \n \n \n \n
\n
\n
\n );\n };\n\n const SortableCompactTableRow = ({\n forward,\n selectMode,\n selectedIds,\n toggleSelect,\n getStrategyDisplay,\n formatInAddress,\n formatRemoteAddress,\n handleServiceToggle,\n handleEdit,\n handleDelete,\n handleDiagnose,\n showAddressModal,\n hasMultipleAddresses,\n formatFlow,\n }: any) => {\n const {\n attributes,\n listeners,\n setNodeRef,\n transform,\n transition,\n isDragging,\n } = useSortable({ id: forward.id });\n\n const style = {\n transform: transform\n ? CSS.Transform.toString({\n ...transform,\n x: Math.round(transform.x),\n y: Math.round(transform.y),\n })\n : undefined,\n transition: isDragging ? undefined : transition || undefined,\n opacity: isDragging ? 0.5 : 1,\n backgroundColor: isDragging ? \"var(--nextui-default-100)\" : undefined,\n };\n\n const strategyDisplay = getStrategyDisplay(forward.strategy);\n\n return (\n \n {selectMode && (\n \n toggleSelect(forward.id)}\n />\n \n )}\n \n \n \n \n \n \n \n \n \n {forward.userName || \"未知用户\"}\n \n \n \n {forward.name}\n \n \n \n \n {forward.tunnelName}\n \n \n {formatTunnelTrafficRatio(forward.tunnelTrafficRatio)}\n \n \n \n \n \n showAddressModal(forward.inIp, forward.inPort, \"入口端口\")\n }\n >\n {formatInAddress(forward.inIp, forward.inPort)}\n \n \n \n \n showAddressModal(forward.remoteAddr, null, \"目标地址\")\n }\n >\n {formatRemoteAddress(forward.remoteAddr)}\n \n \n \n \n {strategyDisplay.text}\n \n \n \n \n {formatFlow(getForwardDisplayFlow(forward))}\n \n \n \n
\n handleServiceToggle(forward)}\n />\n
\n
\n \n
\n handleEdit(forward)}\n >\n \n \n \n \n handleDiagnose(forward)}\n >\n \n \n \n \n handleDelete(forward)}\n >\n \n \n \n \n
\n
\n
\n );\n };\n\n // 渲染规则卡片\n const renderForwardCard = (forward: Forward, listeners?: any) => {\n const statusDisplay = getStatusDisplay(forward.status);\n const strategyDisplay = getStrategyDisplay(forward.strategy);\n\n return (\n \n \n
\n {selectMode && (\n toggleSelect(forward.id)}\n />\n )}\n
\n

\n {forward.name}\n

\n

\n {`${normalizeForwardTunnelName(forward.tunnelName)} · ${formatTunnelTrafficRatio(forward.tunnelTrafficRatio)}`}\n

\n
\n
\n {viewMode === \"direct\" && (\n \n \n \n \n
\n )}\n handleServiceToggle(forward)}\n />\n \n {statusDisplay.text}\n \n
\n \n
\n\n \n
\n {/* 地址信息 */}\n
\n \n showAddressModal(forward.inIp, forward.inPort, \"入口端口\")\n }\n >\n
\n
\n \n 入口:\n \n \n {formatInAddress(forward.inIp, forward.inPort)}\n \n
\n {hasMultipleAddresses(forward.inIp) && (\n \n \n \n )}\n
\n \n\n \n showAddressModal(forward.remoteAddr, null, \"目标地址\")\n }\n >\n
\n
\n \n 目标:\n \n \n {formatRemoteAddress(forward.remoteAddr)}\n \n
\n {hasMultipleAddresses(forward.remoteAddr) && (\n \n \n \n )}\n
\n \n
\n\n {/* 统计信息 */}\n
\n \n {strategyDisplay.text}\n \n {(forward.inFlow || 0) + (forward.outFlow || 0) > 0 ? (\n <>\n
\n \n ↑{formatFlow(forward.inFlow || 0)}\n \n
\n \n ↓{formatFlow(forward.outFlow || 0)}\n \n \n ) : (forward.federationShareFlow || 0) > 0 ? (\n \n 共享 {formatFlow(forward.federationShareFlow || 0)}\n \n ) : (\n \n 总流量 {formatFlow(0)}\n \n )}\n
\n
\n\n
\n \n \n \n }\n variant=\"flat\"\n onPress={() => handleEdit(forward)}\n >\n 编辑\n \n \n \n \n }\n variant=\"flat\"\n onPress={() => handleDiagnose(forward)}\n >\n 诊断\n \n \n \n \n \n }\n variant=\"flat\"\n onPress={() => handleDelete(forward)}\n >\n 删除\n \n
\n
\n \n );\n };\n\n if (loading || !groupPreferenceHydrated) {\n return ;\n }\n\n return (\n \n {/* 页面头部 */}\n
\n
\n setIsSearchVisible(false)}\n onOpen={() => setIsSearchVisible(true)}\n />\n
\n
\n
*]:shrink-0\">\n {selectMode ? (\n <>\n \n 已选择 {selectedIds.size} 项\n \n \n 全选\n \n \n 清空\n \n setBatchDeleteModalOpen(true)}\n >\n 删除\n \n handleBatchToggleService(false)}\n >\n 停用\n \n handleBatchToggleService(true)}\n >\n 启用\n \n \n 下发\n \n setBatchChangeTunnelModalOpen(true)}\n >\n 隧道\n \n \n 退出\n \n \n ) : (\n <>\n {/* 筛选按钮 */}\n setIsFilterModalOpen(true)}\n >\n \n \n \n {(filterUserId !== \"all\" || filterTunnelId !== \"all\") && (\n \n )}\n \n {/* 显示模式切换按钮 */}\n \n {viewMode === \"grouped\" ? (\n \n \n \n ) : (\n \n \n \n )}\n \n\n {/* 导入按钮 */}\n \n 导入\n \n\n {/* 导出按钮 */}\n \n 导出\n \n\n \n 批量\n \n\n \n 新增\n \n \n )}\n
\n
\n
\n\n {batchProgress.active && (\n
\n \n \n
\n )}\n\n {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n
\n \n \n \n \n {selectMode && (\n 选择\n )}\n \n 用户\n 名称\n 隧道\n 入口\n 目标\n 策略\n 总流量\n 状态\n 操作\n \n \n {(forward) => (\n \n )}\n \n \n \n \n
\n \n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : sortedForwards.length > 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n {}}\n >\n \n
\n {sortedForwards.map((forward) =>\n forward && forward.id ? (\n \n ) : null,\n )}\n
\n \n \n \n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n
\n {groupedForwards.map((group) => {\n const isSelfGroup =\n isAdmin && tokenUserId !== null && group.userId === tokenUserId;\n const groupForwardCount = group.tunnels.reduce(\n (total, tunnel) => total + tunnel.items.length,\n 0,\n );\n\n return (\n \n
\n
\n \n {group.userName}\n \n {isSelfGroup && (\n \n 管理员本人\n \n )}\n
\n \n {groupForwardCount} 条规则\n \n
\n\n
\n \n \n buildTunnelGroupSortableId(\n group.userId,\n tunnel.tunnelKey,\n ),\n )}\n strategy={verticalListSortingStrategy}\n >\n {group.tunnels.map((tunnel) => {\n const tunnelSortableForwardIds = tunnel.items\n .map((item) => item.id)\n .filter((id) => id > 0);\n const collapsed =\n sanitizedCollapsedTunnelGroups[\n buildTunnelGroupCollapseKey(\n group.userId,\n tunnel.tunnelKey,\n )\n ] === true;\n\n return (\n \n toggleTunnelGroupCollapsed(\n group.userId,\n tunnel.tunnelKey,\n )\n }\n >\n \n \n \n {selectMode && (\n \n 选择\n \n )}\n \n \n 名称\n \n \n 入口\n \n \n 目标\n \n \n 策略\n \n \n 总流量\n \n \n 状态\n \n \n 操作\n \n \n \n {(forward) => (\n \n \n \n )}\n \n \n \n \n );\n })}\n \n \n
\n
\n );\n })}\n \n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : sortedForwards.length > 0 ? (\n
\n {groupedForwards.map((group) => {\n const isSelfGroup =\n isAdmin && tokenUserId !== null && group.userId === tokenUserId;\n const groupForwardCount = group.tunnels.reduce(\n (total, tunnel) => total + tunnel.items.length,\n 0,\n );\n\n return (\n \n
\n
\n \n {group.userName}\n \n {isSelfGroup && (\n \n 管理员本人\n \n )}\n
\n \n {groupForwardCount} 条规则\n \n
\n\n
\n \n \n buildTunnelGroupSortableId(\n group.userId,\n tunnel.tunnelKey,\n ),\n )}\n strategy={verticalListSortingStrategy}\n >\n {group.tunnels.map((tunnel) => {\n const tunnelSortableForwardIds = tunnel.items\n .map((item) => item.id)\n .filter((id) => id > 0);\n const collapsed =\n sanitizedCollapsedTunnelGroups[\n buildTunnelGroupCollapseKey(\n group.userId,\n tunnel.tunnelKey,\n )\n ] === true;\n\n return (\n \n toggleTunnelGroupCollapsed(\n group.userId,\n tunnel.tunnelKey,\n )\n }\n >\n {}}\n >\n \n
\n {tunnel.items.map((forward) =>\n forward && forward.id ? (\n \n ) : null,\n )}\n
\n \n \n \n );\n })}\n \n \n
\n
\n );\n })}\n \n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

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

\n {isEdit ? \"编辑规则\" : \"新增规则\"}\n

\n

\n {isEdit ? \"修改现有规则配置的信息\" : \"创建新的规则配置\"}\n

\n
\n \n
\n \n setForm((prev) => ({ ...prev, name: e.target.value }))\n }\n />\n\n {isAdmin && (\n {\n const selectedKey = Array.from(keys)[0] as\n | string\n | undefined;\n\n setForm((prev) => ({\n ...prev,\n speedId: selectedKey ? Number(selectedKey) : null,\n }));\n }}\n >\n {availableSpeedLimits.map((speedLimit) => (\n \n {speedLimit.name}\n \n ))}\n \n )}\n\n {\n const selectedKey = Array.from(keys)[0] as string;\n\n if (selectedKey) {\n handleTunnelChange(selectedKey);\n }\n }}\n >\n {tunnels.map((tunnel) => (\n {tunnel.name}\n ))}\n \n\n {\n const value = e.target.value;\n\n setForm((prev) => ({\n ...prev,\n inPort: value ? parseInt(value) : null,\n }));\n }}\n />\n\n 0\n ? \"选择入口监听IP\"\n : \"当前隧道入口节点暂无可选IP\"\n : \"请先选择隧道\"\n }\n selectedKeys={[form.inIp || \"__default__\"]}\n variant=\"bordered\"\n onSelectionChange={(keys) => {\n const selectedKey = Array.from(keys)[0] as string;\n\n setInIpTouched(true);\n\n setForm((prev) => ({\n ...prev,\n inIp: selectedKey === \"__default__\" ? \"\" : selectedKey,\n }));\n }}\n >\n 默认入口IP\n {currentTunnelIpOptions.map((ip) => (\n {ip}\n ))}\n \n\n \n setForm((prev) => ({\n ...prev,\n remoteAddr: e.target.value,\n }))\n }\n />\n\n {getAddressCount(form.remoteAddr) > 1 && (\n {\n const selectedKey = Array.from(keys)[0] as string;\n\n setForm((prev) => ({ ...prev, strategy: selectedKey }));\n }}\n >\n 主备模式 - 自上而下\n 轮询模式 - 依次轮换\n 随机模式 - 随机选择\n 哈希模式 - IP哈希\n \n )}\n
\n
\n \n \n \n {isEdit ? \"保存修改\" : \"创建规则\"}\n \n \n \n )}\n
\n \n\n {/* 删除确认模态框 */}\n \n \n {(onClose) => (\n <>\n \n

确认删除

\n
\n \n

\n 确定要删除规则{\" \"}\n \n "{forwardToDelete?.name}"\n {\" \"}\n 吗?\n

\n

\n 此操作无法撤销,删除后该规则将永久消失。\n

\n
\n \n \n \n 确认删除\n \n \n \n )}\n
\n \n\n {/* 地址列表弹窗 */}\n setAddressModalOpen(false)}\n >\n \n {addressModalTitle}\n \n
\n \n
\n\n
\n {addressList.map((item) => (\n \n \n {item.address}\n \n copyAddress(item)}\n >\n 复制\n \n
\n ))}\n \n
\n
\n \n\n {/* 导出数据模态框 */}\n {\n setExportModalOpen(false);\n setSelectedTunnelForExport(null);\n setExportData(\"\");\n }}\n >\n \n \n

导出规则数据

\n

\n 格式:目标地址|规则名称|入口端口\n

\n
\n \n
\n {/* 隧道选择 */}\n
\n {\n const selectedKey = Array.from(keys)[0] as string;\n\n setSelectedTunnelForExport(\n selectedKey ? parseInt(selectedKey) : null,\n );\n }}\n >\n {tunnels.map((tunnel) => (\n \n {tunnel.name}\n \n ))}\n \n
\n\n {/* 导出按钮和数据 */}\n {exportData && (\n
\n \n \n \n }\n onPress={executeExport}\n >\n 重新生成\n \n \n \n \n \n }\n onPress={copyExportData}\n >\n 复制\n \n
\n )}\n\n {/* 初始导出按钮 */}\n {!exportData && (\n
\n \n \n \n }\n onPress={executeExport}\n >\n 生成导出数据\n \n
\n )}\n\n {/* 导出数据显示 */}\n {exportData && (\n
\n \n
\n )}\n
\n
\n \n \n \n
\n \n\n {/* 导入数据模态框 */}\n setImportModalOpen(false)}\n >\n \n \n

导入规则数据

\n {importFormat === \"flvx\" ? (\n <>\n

\n 格式:目标地址|规则名称|入口端口,每行一个,入口端口留空将自动分配可用端口\n

\n

\n 目标地址支持单个地址(如:example.com:8080)或多个地址用逗号分隔(如:3.3.3.3:3,4.4.4.4:4)\n

\n \n ) : (\n <>\n

\n ny格式:JSON对象,支持多个目标地址(负载均衡),按所选隧道导入\n

\n

\n 格式:{"dest":["地址:端口"],"listen_port":端口,"name":"名称"}(listen_port可省略,自动分配端口)\n

\n \n )}\n
\n \n
\n {/* 格式选择 */}\n {\n const selectedKey = Array.from(keys)[0] as ImportFormat;\n\n if (selectedKey) {\n setImportFormat(selectedKey);\n setSelectedTunnelForImport(null);\n setImportData(\"\");\n setImportResults([]);\n }\n }}\n >\n \n flvx格式(管道分隔)\n \n \n ny格式(JSON)\n \n \n\n {/* 隧道选择 - 两种格式都需要 */}\n {\n const selectedKey = Array.from(keys)[0] as string;\n\n setSelectedTunnelForImport(\n selectedKey ? parseInt(selectedKey) : null,\n );\n }}\n >\n {tunnels.map((tunnel) => (\n \n {tunnel.name}\n \n ))}\n \n\n {/* 输入区域 */}\n setImportData(e.target.value)}\n />\n\n {/* 导入结果 */}\n {importResults.length > 0 && (\n
\n
\n

导入结果

\n \n 成功:{importResults.filter((r) => r.success).length} /\n 总计:{importResults.length}\n \n
\n\n \n {importResults.map((result, index) => (\n \n
\n {result.success ? (\n \n \n \n ) : (\n \n \n \n )}\n
\n
\n \n {result.success ? \"成功\" : \"失败\"}\n \n \n |\n \n \n {result.line}\n \n
\n \n {result.message}\n
\n
\n
\n
\n ))}\n \n \n )}\n \n
\n \n \n \n 开始导入\n \n \n
\n \n\n {/* 诊断结果模态框 */}\n {\n setDiagnosisModalOpen(open);\n if (!open) {\n diagnosisAbortRef.current?.abort();\n diagnosisAbortRef.current = null;\n setDiagnosisLoading(false);\n }\n }}\n >\n \n {(onClose) => (\n <>\n \n

规则诊断结果

\n {currentDiagnosisForward && (\n
\n \n {currentDiagnosisForward.name}\n \n \n 规则服务\n \n
\n )}\n
\n \n {diagnosisResult ? (\n
\n {diagnosisLoading && (\n
\n
\n \n \n 正在诊断 {diagnosisProgress.completed}/\n {diagnosisProgress.total > 0\n ? diagnosisProgress.total\n : \"?\"}\n \n
\n \n 流式更新中\n \n
\n )}\n\n {diagnosisProgress.timedOut && (\n \n )}\n\n {/* 统计摘要 */}\n
\n
\n
\n {diagnosisProgress.total > 0\n ? diagnosisProgress.total\n : diagnosisResult.results.length}\n
\n
\n 总测试数\n
\n
\n
\n
\n {diagnosisProgress.completed > 0 ||\n diagnosisProgress.total > 0\n ? diagnosisProgress.success\n : diagnosisResult.results.filter((r) => r.success)\n .length}\n
\n
\n 成功\n
\n
\n
\n
\n {diagnosisProgress.completed > 0 ||\n diagnosisProgress.total > 0\n ? diagnosisProgress.failed\n : diagnosisResult.results.filter((r) => !r.success)\n .length}\n
\n
\n 失败\n
\n
\n
\n\n {/* 桌面端表格展示 */}\n
\n {(() => {\n // 使用后端返回的 chainType 和 inx 字段进行分组\n const groupedResults = {\n entry: diagnosisResult.results.filter(\n (r) => r.fromChainType === 1,\n ),\n chains: {} as Record<\n number,\n typeof diagnosisResult.results\n >,\n exit: diagnosisResult.results.filter(\n (r) => r.fromChainType === 3,\n ),\n };\n\n // 按 inx 分组链路测试\n diagnosisResult.results.forEach((r) => {\n if (r.fromChainType === 2 && r.fromInx != null) {\n if (!groupedResults.chains[r.fromInx]) {\n groupedResults.chains[r.fromInx] = [];\n }\n groupedResults.chains[r.fromInx].push(r);\n }\n });\n\n const renderTableSection = (\n title: string,\n results: typeof diagnosisResult.results,\n ) => {\n if (results.length === 0) return null;\n\n return (\n \n
\n

\n {title}\n

\n
\n \n \n \n \n \n \n \n \n \n \n \n {results.map((result, index) => {\n const isDiagnosing = Boolean(\n result.diagnosing,\n );\n const isSuccess = result.success === true;\n const quality =\n getForwardDiagnosisQualityDisplay(\n result.averageTime,\n result.packetLoss,\n );\n\n return (\n \n \n \n \n \n \n \n );\n })}\n \n
\n 路径\n \n 状态\n \n 延迟(ms)\n \n 丢包率\n \n 质量\n
\n
\n {isDiagnosing ? (\n \n ) : (\n \n {isSuccess ? \"✓\" : \"✗\"}\n \n )}\n
\n
\n {result.description}\n
\n
\n {result.targetIp}:\n {result.targetPort}\n
\n
\n
\n
\n \n {isDiagnosing\n ? \"诊断中\"\n : isSuccess\n ? \"成功\"\n : \"失败\"}\n \n \n {isSuccess ? (\n \n {result.averageTime?.toFixed(0)}\n \n ) : (\n \n -\n \n )}\n \n {isSuccess ? (\n 0\n ? \"text-warning\"\n : \"text-success\"\n }`}\n >\n {result.packetLoss?.toFixed(1)}%\n \n ) : (\n \n -\n \n )}\n \n {isSuccess && quality ? (\n \n {quality.text}\n \n ) : (\n \n -\n \n )}\n
\n
\n );\n };\n\n return (\n <>\n {/* 入口测试 */}\n {renderTableSection(\n \"🚪 入口测试\",\n groupedResults.entry,\n )}\n\n {/* 链路测试(按跳数排序) */}\n {Object.keys(groupedResults.chains)\n .map(Number)\n .sort((a, b) => a - b)\n .map((hop) =>\n renderTableSection(\n `🔗 转发链 - 第${hop}跳`,\n groupedResults.chains[hop],\n ),\n )}\n\n {/* 出口测试 */}\n {renderTableSection(\n \"🚀 出口测试\",\n groupedResults.exit,\n )}\n \n );\n })()}\n
\n\n {/* 移动端卡片展示 */}\n
\n {(() => {\n // 使用后端返回的 chainType 和 inx 字段进行分组\n const groupedResults = {\n entry: diagnosisResult.results.filter(\n (r) => r.fromChainType === 1,\n ),\n chains: {} as Record<\n number,\n typeof diagnosisResult.results\n >,\n exit: diagnosisResult.results.filter(\n (r) => r.fromChainType === 3,\n ),\n };\n\n // 按 inx 分组链路测试\n diagnosisResult.results.forEach((r) => {\n if (r.fromChainType === 2 && r.fromInx != null) {\n if (!groupedResults.chains[r.fromInx]) {\n groupedResults.chains[r.fromInx] = [];\n }\n groupedResults.chains[r.fromInx].push(r);\n }\n });\n\n const renderCardSection = (\n title: string,\n results: typeof diagnosisResult.results,\n ) => {\n if (results.length === 0) return null;\n\n return (\n
\n
\n

\n {title}\n

\n
\n {results.map((result, index) => {\n const isDiagnosing = Boolean(result.diagnosing);\n const isSuccess = result.success === true;\n const quality =\n getForwardDiagnosisQualityDisplay(\n result.averageTime,\n result.packetLoss,\n );\n\n return (\n \n
\n {isDiagnosing ? (\n \n ) : (\n \n {isSuccess ? \"✓\" : \"✗\"}\n \n )}\n
\n
\n {result.description}\n
\n
\n {result.targetIp}:{result.targetPort}\n
\n
\n \n {isDiagnosing\n ? \"诊断中\"\n : isSuccess\n ? \"成功\"\n : \"失败\"}\n \n
\n\n {isSuccess ? (\n
\n
\n
\n {result.averageTime?.toFixed(0)}\n
\n
\n 延迟(ms)\n
\n
\n
\n 0\n ? \"text-warning\"\n : \"text-success\"\n }`}\n >\n {result.packetLoss?.toFixed(1)}%\n
\n
\n 丢包率\n
\n
\n
\n {quality && (\n <>\n \n {quality.text}\n \n
\n 质量\n
\n \n )}\n
\n
\n ) : (\n
\n \n {isDiagnosing\n ? result.message || \"诊断中...\"\n : result.message || \"连接失败\"}\n
\n
\n )}\n \n );\n })}\n \n );\n };\n\n return (\n <>\n {/* 入口测试 */}\n {renderCardSection(\n \"🚪 入口测试\",\n groupedResults.entry,\n )}\n\n {/* 链路测试(按跳数排序) */}\n {Object.keys(groupedResults.chains)\n .map(Number)\n .sort((a, b) => a - b)\n .map((hop) =>\n renderCardSection(\n `🔗 转发链 - 第${hop}跳`,\n groupedResults.chains[hop],\n ),\n )}\n\n {/* 出口测试 */}\n {renderCardSection(\n \"🚀 出口测试\",\n groupedResults.exit,\n )}\n \n );\n })()}\n \n\n {/* 失败详情(仅桌面端显示,移动端已在卡片中显示) */}\n {diagnosisResult.results.some(\n (r) => r.success === false && !r.diagnosing,\n ) && (\n \n )}\n \n ) : (\n
\n
\n \n \n \n
\n

\n 暂无诊断数据\n

\n
\n )}\n
\n \n \n {currentDiagnosisForward && (\n handleDiagnose(currentDiagnosisForward)}\n >\n 重新诊断\n \n )}\n \n \n )}\n
\n \n\n {/* 批量删除确认模态框 */}\n \n \n {(onClose) => (\n <>\n 确认删除\n \n

\n 确定要删除选中的 {selectedIds.size} 项规则吗?此操作不可撤销。\n

\n
\n \n \n \n 确认删除\n \n \n \n )}\n
\n \n\n {/* 批量换隧道模态框 */}\n \n \n {(onClose) => (\n <>\n 隧道\n \n

\n 将选中的 {selectedIds.size} 项规则迁移到新隧道:\n

\n {\n const selected = Array.from(keys)[0];\n\n setBatchTargetTunnelId(selected ? Number(selected) : null);\n }}\n >\n {tunnels.map((tunnel) => (\n \n {tunnel.name}\n \n ))}\n \n
\n \n \n \n 确认换隧道\n \n \n \n )}\n
\n \n\n {/* 筛选模态框 */}\n \n \n {(onClose) => (\n <>\n \n 筛选条件\n \n \n
\n
\n

按用户筛选

\n {\n const key = Array.from(keys)[0] as string;\n\n setFilterUserId(key || \"all\");\n }}\n >\n 全部用户\n {uniqueUsers.map((user) => (\n \n {user.name}\n \n ))}\n \n
\n
\n

按隧道筛选

\n {\n const key = Array.from(keys)[0] as string;\n\n setFilterTunnelId(key || \"all\");\n }}\n >\n 全部隧道\n {tunnels.map((tunnel) => (\n \n {tunnel.name}\n \n ))}\n \n
\n
\n
\n \n {\n resetFilterUserId();\n resetFilterTunnelId();\n }}\n >\n 重置\n \n \n \n \n )}\n
\n \n
\n );\n}\n", "additions": 67, "deletions": 57, "status": "modified" } ] }, "agent": "build", "model": { "providerID": "api", "modelID": "gemini-3.1-pro-high" }, "id": "msg_cd046222b001Z6S2zXX4DUpg5y", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "text", "text": "规则标签页下,精简模式,和非精简模式一样要显示规则数量", "id": "prt_cd046222b0020zSreKFe1YRnVF", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd046222b001Z6S2zXX4DUpg5y" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020783607, "completed": 1773020788823 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 22102, "input": 21982, "output": 38, "reasoning": 82, "cache": { "read": 0, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd04623f70013zIyad05VZYhOF", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "6daca6c6afb546efe0ee47e8e73cd71242f7be8a", "id": "prt_cd04637b5001mkBCDHXKqiTp4U", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd04623f70013zIyad05VZYhOF" }, { "type": "tool", "callID": "bash-1773020788499617352-213", "tool": "bash", "state": { "status": "completed", "input": { "command": "grep -rn \"规则\" vite-frontend/src", "description": "Search for \"规则\" in frontend source code" }, "output": "vite-frontend/src/types/index.ts:52: speedId?: number | null; // 限速规则ID\nvite-frontend/src/types/index.ts:53: speedLimitName?: string; // 限速规则名称\nvite-frontend/src/layouts/h5.tsx:36: label: \"规则\",\nvite-frontend/src/layouts/admin.tsx:81: label: \"规则\",\nvite-frontend/src/api/index.ts:179:// 限速规则CRUD操作 - 全部使用POST请求\nvite-frontend/src/pages/settings.tsx:133: toast.success(`规则页面精简模式已${enabled ? \"开启\" : \"关闭\"}`);\nvite-frontend/src/pages/settings.tsx:223: 规则页面精简模式\nvite-frontend/src/pages/settings.tsx:226: 开启后,规则页面列表使用 2.1.6-alpha8 样式。{\" \"}\nvite-frontend/src/pages/dashboard.tsx:743: title=\"规则配额\"\nvite-frontend/src/pages/dashboard.tsx:777: title=\"已用规则\"\nvite-frontend/src/pages/dashboard.tsx:928: 规则配额\nvite-frontend/src/pages/dashboard.tsx:936: 已用规则\nvite-frontend/src/pages/dashboard.tsx:959: {/* 规则配置 */}\nvite-frontend/src/pages/dashboard.tsx:976: 规则配置\nvite-frontend/src/pages/dashboard.tsx:1000:

暂无规则配置

\nvite-frontend/src/pages/dashboard.tsx:1014: {group.forwards.length} 个规则\nvite-frontend/src/pages/user.tsx:608: toast(\"所选限速规则不存在,已自动清除为不限速\", {\nvite-frontend/src/pages/user.tsx:653: toast(\"所选限速规则不存在,已自动清除为不限速\", {\nvite-frontend/src/pages/user.tsx:987: 规则数量\nvite-frontend/src/pages/user.tsx:1143: label=\"规则数量\"\nvite-frontend/src/pages/user.tsx:1280: 流量限制、规则数量、到期时间、流量重置时间将自动继承用户设置\nvite-frontend/src/pages/user.tsx:1438: 规则数量\nvite-frontend/src/pages/user.tsx:1440: 限速规则\nvite-frontend/src/pages/user.tsx:1595: label=\"规则数量\"\nvite-frontend/src/pages/user.tsx:1613: label=\"限速规则\"\nvite-frontend/src/pages/user.tsx:1810: 删除后该用户将无法使用此隧道创建规则,此操作不可撤销。{\" \"}\nvite-frontend/src/pages/limit.tsx:95: toast.error(rulesRes.msg || \"获取限速规则失败\");\nvite-frontend/src/pages/limit.tsx:109: newErrors.name = \"请输入规则名称\";\nvite-frontend/src/pages/limit.tsx:111: newErrors.name = \"规则名称长度应在2-50个字符之间\";\nvite-frontend/src/pages/limit.tsx:123: // 新增规则\nvite-frontend/src/pages/limit.tsx:135: // 编辑规则\nvite-frontend/src/pages/limit.tsx:154: // 确认删除规则\nvite-frontend/src/pages/limit.tsx:226: placeholder=\"搜索规则名称\"\nvite-frontend/src/pages/limit.tsx:332: 暂无限速规则\nvite-frontend/src/pages/limit.tsx:335: 还没有创建任何限速规则,点击上方按钮开始创建\nvite-frontend/src/pages/limit.tsx:355: {isEdit ? \"编辑限速规则\" : \"新增限速规则\"}\nvite-frontend/src/pages/limit.tsx:358: {isEdit ? \"修改现有限速规则的配置信息\" : \"创建新的限速规则\"}\nvite-frontend/src/pages/limit.tsx:366: label=\"规则名称\"\nvite-frontend/src/pages/limit.tsx:367: placeholder=\"请输入限速规则名称\"\nvite-frontend/src/pages/limit.tsx:408: {isEdit ? \"保存修改\" : \"创建规则\"}\nvite-frontend/src/pages/limit.tsx:433: 确定要删除限速规则{\" \"}\nvite-frontend/src/pages/limit.tsx:440: 此操作无法撤销,删除后该规则将永久消失。\nvite-frontend/src/pages/config.tsx:122: label: \"规则页面精简模式\",\nvite-frontend/src/pages/config.tsx:123: description: \"开启后,规则页面列表使用 2.1.6-alpha8 样式(全局配置)\",\nvite-frontend/src/pages/config.tsx:156: { value: \"forwards\", label: \"规则\" },\nvite-frontend/src/pages/config.tsx:158: { value: \"speedLimits\", label: \"限速规则\" },\nvite-frontend/src/pages/tunnel.tsx:3032: 项隧道吗?此操作不可撤销,相关规则也将被删除。\nvite-frontend/src/pages/forward.tsx:1274: toast.error(forwardsRes.msg || \"获取规则列表失败\");\nvite-frontend/src/pages/forward.tsx:1277: toast.error(\"获取规则列表失败\");\nvite-frontend/src/pages/forward.tsx:1388: newErrors.name = \"请输入规则名称\";\nvite-frontend/src/pages/forward.tsx:1390: newErrors.name = \"规则名称长度应在2-50个字符之间\";\nvite-frontend/src/pages/forward.tsx:1445: // 新增规则\nvite-frontend/src/pages/forward.tsx:1463: // 编辑规则\nvite-frontend/src/pages/forward.tsx:1489: // 确认删除规则\nvite-frontend/src/pages/forward.tsx:1521: `常规删除失败:${res.msg || \"删除失败\"}\\n\\n是否需要强制删除?\\n\\n⚠️ 注意:强制删除不会去验证节点端是否已经删除对应的规则服务。`,\nvite-frontend/src/pages/forward.tsx:1639: toast(\"所选限速规则不存在,已自动清除为不限速\", {\nvite-frontend/src/pages/forward.tsx:1660: toast.error(\"规则状态异常,无法操作\");\nvite-frontend/src/pages/forward.tsx:1685: // 更新规则状态\nvite-frontend/src/pages/forward.tsx:1711: // 诊断规则\nvite-frontend/src/pages/forward.tsx:1966: // 导出规则数据\nvite-frontend/src/pages/forward.tsx:1984: // 获取要导出的规则列表\nvite-frontend/src/pages/forward.tsx:1990: toast.error(\"所选隧道没有规则数据\");\nvite-frontend/src/pages/forward.tsx:2013: await copyToClipboard(exportData, \"规则数据\");\nvite-frontend/src/pages/forward.tsx:2016: // 导入规则数据\nvite-frontend/src/pages/forward.tsx:2138: message: \"格式错误:需要至少包含目标地址和规则名称\",\nvite-frontend/src/pages/forward.tsx:2152: message: \"目标地址和规则名称不能为空\",\nvite-frontend/src/pages/forward.tsx:2448: label: `正在删除 ${selectedIds.size} 项规则...`,\nvite-frontend/src/pages/forward.tsx:2463: label: outcome.progressLabel || \"正在刷新规则列表...\",\nvite-frontend/src/pages/forward.tsx:2484: label: `正在${enable ? \"启用\" : \"停用\"} ${selectedIds.size} 项规则...`,\nvite-frontend/src/pages/forward.tsx:2502: label: outcome.progressLabel || \"正在刷新规则列表...\",\nvite-frontend/src/pages/forward.tsx:2520: label: `正在重新下发 ${selectedIds.size} 项规则...`,\nvite-frontend/src/pages/forward.tsx:2537: label: outcome.progressLabel || \"正在刷新规则列表...\",\nvite-frontend/src/pages/forward.tsx:2555: label: `正在为 ${selectedIds.size} 项规则切换隧道...`,\nvite-frontend/src/pages/forward.tsx:2573: label: outcome.progressLabel || \"正在刷新规则列表...\",\nvite-frontend/src/pages/forward.tsx:2610: // 根据排序顺序获取规则列表\nvite-frontend/src/pages/forward.tsx:2645: // 确保过滤后的规则列表有效\nvite-frontend/src/pages/forward.tsx:2679: // 添加不在排序列表中的规则(新添加的)\nvite-frontend/src/pages/forward.tsx:3004: {tunnel.items.length} 条规则\nvite-frontend/src/pages/forward.tsx:3027: // 可拖拽的规则卡片组件\nvite-frontend/src/pages/forward.tsx:3531: // 渲染规则卡片\nvite-frontend/src/pages/forward.tsx:3824: placeholder=\"搜索规则名称、地址或用户名\"\nvite-frontend/src/pages/forward.tsx:4067: aria-label=\"全部规则列表\"\nvite-frontend/src/pages/forward.tsx:4090: emptyContent=\"暂无规则配置\"\nvite-frontend/src/pages/forward.tsx:4120: 暂无规则配置\nvite-frontend/src/pages/forward.tsx:4123: 还没有创建任何规则配置,点击上方按钮开始创建\nvite-frontend/src/pages/forward.tsx:4132: 全部规则\nvite-frontend/src/pages/forward.tsx:4135: {sortedForwards.length} 条规则\nvite-frontend/src/pages/forward.tsx:4162: 暂无规则配置\nvite-frontend/src/pages/forward.tsx:4165: 还没有创建任何规则配置,点击上方按钮开始创建\nvite-frontend/src/pages/forward.tsx:4198: {groupForwardCount} 条规则\nvite-frontend/src/pages/forward.tsx:4253: aria-label={`${group.userName}-${tunnel.tunnelName}规则列表`}\nvite-frontend/src/pages/forward.tsx:4327: emptyContent=\"暂无规则配置\"\nvite-frontend/src/pages/forward.tsx:4379: 暂无规则配置\nvite-frontend/src/pages/forward.tsx:4382: 还没有创建任何规则配置,点击上方按钮开始创建\nvite-frontend/src/pages/forward.tsx:4414: {groupForwardCount} 条规则\nvite-frontend/src/pages/forward.tsx:4499: 暂无规则配置\nvite-frontend/src/pages/forward.tsx:4502: 还没有创建任何规则配置,点击上方按钮开始创建\nvite-frontend/src/pages/forward.tsx:4522: {isEdit ? \"编辑规则\" : \"新增规则\"}\nvite-frontend/src/pages/forward.tsx:4525: {isEdit ? \"修改现有规则配置的信息\" : \"创建新的规则配置\"}\nvite-frontend/src/pages/forward.tsx:4533: label=\"规则名称\"\nvite-frontend/src/pages/forward.tsx:4534: placeholder=\"请输入规则名称\"\nvite-frontend/src/pages/forward.tsx:4544: label=\"限速规则\"\nvite-frontend/src/pages/forward.tsx:4712: {isEdit ? \"保存修改\" : \"创建规则\"}\nvite-frontend/src/pages/forward.tsx:4737: 确定要删除规则{\" \"}\nvite-frontend/src/pages/forward.tsx:4744: 此操作无法撤销,删除后该规则将永久消失。\nvite-frontend/src/pages/forward.tsx:4819:

导出规则数据

\nvite-frontend/src/pages/forward.tsx:4821: 格式:目标地址|规则名称|入口端口\nvite-frontend/src/pages/forward.tsx:4971:

导入规则数据

\nvite-frontend/src/pages/forward.tsx:4975: 格式:目标地址|规则名称|入口端口,每行一个,入口端口留空将自动分配可用端口\nvite-frontend/src/pages/forward.tsx:5059: ? \"请输入要导入的规则数据,格式:目标地址|规则名称|入口端口\"\nvite-frontend/src/pages/forward.tsx:5060: : '请输入ny格式数据,每行一个JSON对象,如:{\"dest\":[\"1.2.3.4:80\"],\"listen_port\":8080,\"name\":\"规则1\"};listen_port可省略自动分配'\nvite-frontend/src/pages/forward.tsx:5200:

规则诊断结果

\nvite-frontend/src/pages/forward.tsx:5212: 规则服务\nvite-frontend/src/pages/forward.tsx:5771: 确定要删除选中的 {selectedIds.size} 项规则吗?此操作不可撤销。\nvite-frontend/src/pages/forward.tsx:5802: 将选中的 {selectedIds.size} 项规则迁移到新隧道:\n", "title": "Search for \"规则\" in frontend source code", "metadata": { "output": "vite-frontend/src/types/index.ts:52: speedId?: number | null; // 限速规则ID\nvite-frontend/src/types/index.ts:53: speedLimitName?: string; // 限速规则名称\nvite-frontend/src/layouts/h5.tsx:36: label: \"规则\",\nvite-frontend/src/layouts/admin.tsx:81: label: \"规则\",\nvite-frontend/src/api/index.ts:179:// 限速规则CRUD操作 - 全部使用POST请求\nvite-frontend/src/pages/settings.tsx:133: toast.success(`规则页面精简模式已${enabled ? \"开启\" : \"关闭\"}`);\nvite-frontend/src/pages/settings.tsx:223: 规则页面精简模式\nvite-frontend/src/pages/settings.tsx:226: 开启后,规则页面列表使用 2.1.6-alpha8 样式。{\" \"}\nvite-frontend/src/pages/dashboard.tsx:743: title=\"规则配额\"\nvite-frontend/src/pages/dashboard.tsx:777: title=\"已用规则\"\nvite-frontend/src/pages/dashboard.tsx:928: 规则配额\nvite-frontend/src/pages/dashboard.tsx:936: 已用规则\nvite-frontend/src/pages/dashboard.tsx:959: {/* 规则配置 */}\nvite-frontend/src/pages/dashboard.tsx:976: 规则配置\nvite-frontend/src/pages/dashboard.tsx:1000:

暂无规则配置

\nvite-frontend/src/pages/dashboard.tsx:1014: {group.forwards.length} 个规则\nvite-frontend/src/pages/user.tsx:608: toast(\"所选限速规则不存在,已自动清除为不限速\", {\nvite-frontend/src/pages/user.tsx:653: toast(\"所选限速规则不存在,已自动清除为不限速\", {\nvite-frontend/src/pages/user.tsx:987: 规则数量\nvite-frontend/src/pages/user.tsx:1143: label=\"规则数量\"\nvite-frontend/src/pages/user.tsx:1280: 流量限制、规则数量、到期时间、流量重置时间将自动继承用户设置\nvite-frontend/src/pages/user.tsx:1438: 规则数量\nvite-frontend/src/pages/user.tsx:1440: 限速规则\nvite-frontend/src/pages/user.tsx:1595: label=\"规则数量\"\nvite-frontend/src/pages/user.tsx:1613: label=\"限速规则\"\nvite-frontend/src/pages/user.tsx:1810: 删除后该用户将无法使用此隧道创建规则,此操作不可撤销。{\" \"}\nvite-frontend/src/pages/limit.tsx:95: toast.error(rulesRes.msg || \"获取限速规则失败\");\nvite-frontend/src/pages/limit.tsx:109: newErrors.name = \"请输入规则名称\";\nvite-frontend/src/pages/limit.tsx:111: newErrors.name = \"规则名称长度应在2-50个字符之间\";\nvite-frontend/src/pages/limit.tsx:123: // 新增规则\nvite-frontend/src/pages/limit.tsx:135: // 编辑规则\nvite-frontend/src/pages/limit.tsx:154: // 确认删除规则\nvite-frontend/src/pages/limit.tsx:226: placeholder=\"搜索规则名称\"\nvite-frontend/src/pages/limit.tsx:332: 暂无限速规则\nvite-frontend/src/pages/limit.tsx:335: 还没有创建任何限速规则,点击上方按钮开始创建\nvite-frontend/src/pages/limit.tsx:355: {isEdit ? \"编辑限速规则\" : \"新增限速规则\"}\nvite-frontend/src/pages/limit.tsx:358: {isEdit ? \"修改现有限速规则的配置信息\" : \"创建新的限速规则\"}\nvite-frontend/src/pages/limit.tsx:366: label=\"规则名称\"\nvite-frontend/src/pages/limit.tsx:367: placeholder=\"请输入限速规则名称\"\nvite-frontend/src/pages/limit.tsx:408: {isEdit ? \"保存修改\" : \"创建规则\"}\nvite-frontend/src/pages/limit.tsx:433: 确定要删除限速规则{\" \"}\nvite-frontend/src/pages/limit.tsx:440: 此操作无法撤销,删除后该规则将永久消失。\nvite-frontend/src/pages/config.tsx:122: label: \"规则页面精简模式\",\nvite-frontend/src/pages/config.tsx:123: description: \"开启后,规则页面列表使用 2.1.6-alpha8 样式(全局配置)\",\nvite-frontend/src/pages/config.tsx:156: { value: \"forwards\", label: \"规则\" },\nvite-frontend/src/pages/config.tsx:158: { value: \"speedLimits\", label: \"限速规则\" },\nvite-frontend/src/pages/tunnel.tsx:3032: 项隧道吗?此操作不可撤销,相关规则也将被删除。\nvite-frontend/src/pages/forward.tsx:1274: toast.error(forwardsRes.msg || \"获取规则列表失败\");\nvite-frontend/src/pages/forward.tsx:1277: toast.error(\"获取规则列表失败\");\nvite-frontend/src/pages/forward.tsx:1388: newErrors.name = \"请输入规则名称\";\nvite-frontend/src/pages/forward.tsx:1390: newErrors.name = \"规则名称长度应在2-50个字符之间\";\nvite-frontend/src/pages/forward.tsx:1445: // 新增规则\nvite-frontend/src/pages/forward.tsx:1463: // 编辑规则\nvite-frontend/src/pages/forward.tsx:1489: // 确认删除规则\nvite-frontend/src/pages/forward.tsx:1521: `常规删除失败:${res.msg || \"删除失败\"}\\n\\n是否需要强制删除?\\n\\n⚠️ 注意:强制删除不会去验证节点端是否已经删除对应的规则服务。`,\nvite-frontend/src/pages/forward.tsx:1639: toast(\"所选限速规则不存在,已自动清除为不限速\", {\nvite-frontend/src/pages/forward.tsx:1660: toast.error(\"规则状态异常,无法操作\");\nvite-frontend/src/pages/forward.tsx:1685: // 更新规则状态\nvite-frontend/src/pages/forward.tsx:1711: // 诊断规则\nvite-frontend/src/pages/forward.tsx:1966: // 导出规则数据\nvite-frontend/src/pages/forward.tsx:1984: // 获取要导出的规则列表\nvite-frontend/src/pages/forward.tsx:1990: toast.error(\"所选隧道没有规则数据\");\nvite-frontend/src/pages/forward.tsx:2013: await copyToClipboard(exportData, \"规则数据\");\nvite-frontend/src/pages/forward.tsx:2016: // 导入规则数据\nvite-frontend/src/pages/forward.tsx:2138: message: \"格式错误:需要至少包含目标地址和规则名称\",\nvite-frontend/src/pages/forward.tsx:2152: message: \"目标地址和规则名称不能为空\",\nvite-frontend/src/pages/forward.tsx:2448: label: `正在删除 ${selectedIds.size} 项规则...`,\nvite-frontend/src/pages/forward.tsx:2463: label: outcome.progressLabel || \"正在刷新规则列表...\",\nvite-frontend/src/pages/forward.tsx:2484: label: `正在${enable ? \"启用\" : \"停用\"} ${selectedIds.size} 项规则...`,\nvite-frontend/src/pages/forward.tsx:2502: label: outcome.progressLabel || \"正在刷新规则列表...\",\nvite-frontend/src/pages/forward.tsx:2520: label: `正在重新下发 ${selectedIds.size} 项规则...`,\nvite-frontend/src/pages/forward.tsx:2537: label: outcome.progressLabel || \"正在刷新规则列表...\",\nvite-frontend/src/pages/forward.tsx:2555: label: `正在为 ${selectedIds.size} 项规则切换隧道...`,\nvite-frontend/src/pages/forward.tsx:2573: label: outcome.progressLabel || \"正在刷新规则列表...\",\nvite-frontend/src/pages/forward.tsx:2610: // 根据排序顺序获取规则列表\nvite-frontend/src/pages/forward.tsx:2645: // 确保过滤后的规则列表有效\nvite-frontend/src/pages/forward.tsx:2679: // 添加不在排序列表中的规则(新添加的)\nvite-frontend/src/pages/forward.tsx:3004: {tunnel.items.length} 条规则\nvite-frontend/src/pages/forward.tsx:3027: // 可拖拽的规则卡片组件\nvite-frontend/src/pages/forward.tsx:3531: // 渲染规则卡片\nvite-frontend/src/pages/forward.tsx:3824: placeholder=\"搜索规则名称、地址或用户名\"\nvite-frontend/src/pages/forward.tsx:4067: aria-label=\"全部规则列表\"\nvite-frontend/src/pages/forward.tsx:4090: emptyContent=\"暂无规则配置\"\nvite-frontend/src/pages/forward.tsx:4120: 暂无规则配置\nvite-frontend/src/pages/forward.tsx:4123: 还没有创建任何规则配置,点击上方按钮开始创建\nvite-frontend/src/pages/forward.tsx:4132: 全部规则\nvite-frontend/src/pages/forward.tsx:4135: {sortedForwards.length} 条规则\nvite-frontend/src/pages/forward.tsx:4162: 暂无规则配置\nvite-frontend/src/pages/forward.tsx:4165: 还没有创建任何规则配置,点击上方按钮开始创建\nvite-frontend/src/pages/forward.tsx:4198: {groupForwardCount} 条规则\nvite-frontend/src/pages/forward.tsx:4253: aria-label={`${group.userName}-${tunnel.tunnelName}规则列表`}\nvite-frontend/src/pages/forward.tsx:4327: emptyContent=\"暂无规则配置\"\nvite-frontend/src/pages/forward.tsx:4379: 暂无规则配置\nvite-frontend/src/pages/forward.tsx:4382: 还没有创建任何规则配置,点击上方按钮开始创建\nvite-frontend/src/pages/forward.tsx:4414: {groupForwardCount} 条规则\nvite-frontend/src/pages/forward.tsx:4499: 暂无规则配置\nvite-frontend/src/pages/forward.tsx:4502: 还没有创建任何规则配置,点击上方按钮开始创建\nvite-frontend/src/pages/forward.tsx:4522: {isEdit ? \"编辑规则\" : \"新增规则\"}\nvite-frontend/src/pages/forward.tsx:4525: {isEdit ? \"修改现有规则配置的信息\" : \"创建新的规则配置\"}\nvite-frontend/src/pages/forward.tsx:4533: label=\"规则名称\"\nvite-frontend/src/pages/forward.tsx:4534: placeholder=\"请输入规则名称\"\nvite-frontend/src/pages/forward.tsx:4544: label=\"限速规则\"\nvite-frontend/src/pages/forward.tsx:4712: {isEdit ? \"保存修改\" : \"创建规则\"}\nvite-frontend/src/pages/forward.tsx:4737: 确定要删除规则{\" \"}\nvite-frontend/src/pages/forward.tsx:4744: 此操作无法撤销,删除后该规则将永久消失。\nvite-frontend/src/pages/forward.tsx:4819:

导出规则数据

\nvite-frontend/src/pages/forward.tsx:4821: 格式:目标地址|规则名称|入口端口\nvite-frontend/src/pages/forward.tsx:4971:

导入规则数据

\nvite-frontend/src/pages/forward.tsx:4975: 格式:目标地址|规则名称|入口端口,每行一个,入口端口留空将自动分配可用端口\nvite-frontend/src/pages/forward.tsx:5059: ? \"请输入要导入的规则数据,格式:目标地址|规则名称|入口端口\"\nvite-frontend/src/pages/forward.tsx:5060: : '请输入ny格式数据,每行一个JSON对象,如:{\"dest\":[\"1.2.3.4:80\"],\"listen_port\":8080,\"name\":\"规则1\"};listen_port可省略自动分配'\nvite-frontend/src/pages/forward.tsx:5200:

规则诊断结果

\nvite-frontend/src/pages/forward.tsx:5212: 规则服务\nvite-frontend/src/pages/forward.tsx:5771: 确定要删除选中的 {selectedIds.size} 项规则吗?此操作不可撤销。\nvite-frontend/src/pages/forward.tsx:5802: 将选中的 {selectedIds.size} 项规则迁移到新隧道:\n", "exit": 0, "description": "Search for \"规则\" in frontend source code", "truncated": false }, "time": { "start": 1773020788662, "end": 1773020788663 } }, "id": "prt_cd04637b6001kh7cg7D9S003oq", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd04623f70013zIyad05VZYhOF" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "6daca6c6afb546efe0ee47e8e73cd71242f7be8a", "cost": 0, "tokens": { "total": 22102, "input": 21982, "output": 38, "reasoning": 82, "cache": { "read": 0, "write": 0 } }, "id": "prt_cd04637b8001cg2YsmveKSrtKN", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd04623f70013zIyad05VZYhOF" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020788824, "completed": 1773020794422 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 25903, "input": 5300, "output": 54, "reasoning": 238, "cache": { "read": 20311, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd0463858001611FPFnQRX5vKC", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "6daca6c6afb546efe0ee47e8e73cd71242f7be8a", "id": "prt_cd0464d8f001faV84qkR8P1uEP", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0463858001611FPFnQRX5vKC" }, { "type": "tool", "callID": "bash-1773020794102031693-214", "tool": "bash", "state": { "status": "completed", "input": { "command": "sed -n '4100,4200p' vite-frontend/src/pages/forward.tsx", "description": "Check how rule count is displayed in forward.tsx" }, "output": " handleDelete={handleDelete}\n handleDiagnose={handleDiagnose}\n handleEdit={handleEdit}\n handleServiceToggle={handleServiceToggle}\n hasMultipleAddresses={hasMultipleAddresses}\n selectMode={selectMode}\n selectedIds={selectedIds}\n showAddressModal={showAddressModal}\n toggleSelect={toggleSelect}\n />\n )}\n \n \n \n \n \n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : sortedForwards.length > 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n {}}\n >\n \n
\n {sortedForwards.map((forward) =>\n forward && forward.id ? (\n \n ) : null,\n )}\n
\n \n \n \n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n
\n {groupedForwards.map((group) => {\n const isSelfGroup =\n isAdmin && tokenUserId !== null && group.userId === tokenUserId;\n const groupForwardCount = group.tunnels.reduce(\n (total, tunnel) => total + tunnel.items.length,\n 0,\n );\n\n return (\n \n
\n
\n \n {group.userName}\n \n {isSelfGroup && (\n \n 管理员本人\n \n )}\n
\n \n {groupForwardCount} 条规则\n \n
\n", "title": "Check how rule count is displayed in forward.tsx", "metadata": { "output": " handleDelete={handleDelete}\n handleDiagnose={handleDiagnose}\n handleEdit={handleEdit}\n handleServiceToggle={handleServiceToggle}\n hasMultipleAddresses={hasMultipleAddresses}\n selectMode={selectMode}\n selectedIds={selectedIds}\n showAddressModal={showAddressModal}\n toggleSelect={toggleSelect}\n />\n )}\n \n \n \n \n
\n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : sortedForwards.length > 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n {}}\n >\n \n
\n {sortedForwards.map((forward) =>\n forward && forward.id ? (\n \n ) : null,\n )}\n
\n \n \n \n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n
\n {groupedForwards.map((group) => {\n const isSelfGroup =\n isAdmin && tokenUserId !== null && group.userId === tokenUserId;\n const groupForwardCount = group.tunnels.reduce(\n (total, tunnel) => total + tunnel.items.length,\n 0,\n );\n\n return (\n \n
\n
\n \n {group.userName}\n \n {isSelfGroup && (\n \n 管理员本人\n \n )}\n
\n \n {groupForwardCount} 条规则\n \n
\n", "exit": 0, "description": "Check how rule count is displayed in forward.tsx", "truncated": false }, "time": { "start": 1773020794256, "end": 1773020794257 } }, "id": "prt_cd0464d90001oDekEc5sugiiKi", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0463858001611FPFnQRX5vKC" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "6daca6c6afb546efe0ee47e8e73cd71242f7be8a", "cost": 0, "tokens": { "total": 25903, "input": 5300, "output": 54, "reasoning": 238, "cache": { "read": 20311, "write": 0 } }, "id": "prt_cd0464d91001Rx6BkG8V9yVGCs", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0463858001611FPFnQRX5vKC" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020794423, "completed": 1773020798870 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 26792, "input": 2361, "output": 55, "reasoning": 0, "cache": { "read": 24376, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd0464e37001hdMgkqUhIXOSQN", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "6daca6c6afb546efe0ee47e8e73cd71242f7be8a", "id": "prt_cd0465eda00129exzqxVPe4qTw", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0464e37001hdMgkqUhIXOSQN" }, { "type": "tool", "callID": "bash-1773020798539803228-215", "tool": "bash", "state": { "status": "completed", "input": { "command": "sed -n '4300,4430p' vite-frontend/src/pages/forward.tsx", "description": "Check more how rule count is displayed in forward.tsx" }, "output": " }\n >\n 策略\n \n \n 总流量\n \n \n 状态\n \n \n 操作\n \n \n \n {(forward) => (\n \n \n \n )}\n \n \n \n \n );\n })}\n \n \n
\n \n );\n })}\n \n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : sortedForwards.length > 0 ? (\n
\n {groupedForwards.map((group) => {\n const isSelfGroup =\n isAdmin && tokenUserId !== null && group.userId === tokenUserId;\n const groupForwardCount = group.tunnels.reduce(\n (total, tunnel) => total + tunnel.items.length,\n 0,\n );\n\n return (\n \n
\n
\n \n {group.userName}\n \n {isSelfGroup && (\n \n 管理员本人\n \n )}\n
\n \n {groupForwardCount} 条规则\n \n
\n\n
\n \n \n buildTunnelGroupSortableId(\n group.userId,\n tunnel.tunnelKey,\n ),\n )}\n", "title": "Check more how rule count is displayed in forward.tsx", "metadata": { "output": " }\n >\n 策略\n \n \n 总流量\n \n \n 状态\n \n \n 操作\n \n \n \n {(forward) => (\n \n \n \n )}\n \n \n \n \n );\n })}\n \n \n
\n
\n );\n })}\n \n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : sortedForwards.length > 0 ? (\n
\n {groupedForwards.map((group) => {\n const isSelfGroup =\n isAdmin && tokenUserId !== null && group.userId === tokenUserId;\n const groupForwardCount = group.tunnels.reduce(\n (total, tunnel) => total + tunnel.items.length,\n 0,\n );\n\n return (\n \n
\n
\n \n {group.userName}\n \n {isSelfGroup && (\n \n 管理员本人\n \n )}\n
\n \n {groupForwardCount} 条规则\n \n
\n\n
\n \n \n buildTunnelGroupSortableId(\n group.userId,\n tunnel.tunnelKey,\n ),\n )}\n", "exit": 0, "description": "Check more how rule count is displayed in forward.tsx", "truncated": false }, "time": { "start": 1773020798683, "end": 1773020798684 } }, "id": "prt_cd0465eda002uXA1JR81UYPFWd", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0464e37001hdMgkqUhIXOSQN" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "6daca6c6afb546efe0ee47e8e73cd71242f7be8a", "cost": 0, "tokens": { "total": 26792, "input": 2361, "output": 55, "reasoning": 0, "cache": { "read": 24376, "write": 0 } }, "id": "prt_cd0465edd001H2UPQXEWDSaPNO", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0464e37001hdMgkqUhIXOSQN" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020798871, "completed": 1773020803534 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 27912, "input": 7542, "output": 55, "reasoning": 0, "cache": { "read": 20315, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd0465f970014iZ1lEvepznQvY", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "6daca6c6afb546efe0ee47e8e73cd71242f7be8a", "id": "prt_cd046711c001xg0FuKV5iEKXmZ", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0465f970014iZ1lEvepznQvY" }, { "type": "tool", "callID": "bash-1773020803201107760-216", "tool": "bash", "state": { "status": "completed", "input": { "command": "sed -n '4000,4100p' vite-frontend/src/pages/forward.tsx", "description": "Check more how rule count is displayed in forward.tsx" }, "output": " {/* 导出按钮 */}\n \n 导出\n \n\n \n 批量\n \n\n \n 新增\n \n \n )}\n
\n
\n \n\n {batchProgress.active && (\n
\n \n \n
\n )}\n\n {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n
\n \n \n \n \n {selectMode && (\n 选择\n )}\n \n 用户\n 名称\n 隧道\n 入口\n 目标\n 策略\n 总流量\n 状态\n 操作\n \n \n {(forward) => (\n \n 导出\n \n\n \n 批量\n \n\n \n 新增\n \n \n )}\n
\n \n \n\n {batchProgress.active && (\n
\n \n \n
\n )}\n\n {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n
\n \n \n \n \n {selectMode && (\n 选择\n )}\n \n 用户\n 名称\n 隧道\n 入口\n 目标\n 策略\n 总流量\n 状态\n 操作\n \n \n {(forward) => (\n 0 ? (\n
\n \n \n \n \n {selectMode && (\n 选择\n )}\n \n 用户\n 名称\n 隧道\n 入口\n 目标\n 策略\n 总流量\n 状态\n 操作\n \n \n {(forward) => (\n \n )}\n \n \n \n \n
\n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : sortedForwards.length > 0 ? (\n <>\n
\n", "title": "Check compact mode rule count", "metadata": { "output": " )}\n\n {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n
\n \n \n \n \n {selectMode && (\n 选择\n )}\n \n 用户\n 名称\n 隧道\n 入口\n 目标\n 策略\n 总流量\n 状态\n 操作\n \n \n {(forward) => (\n \n )}\n \n \n \n \n
\n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : sortedForwards.length > 0 ? (\n <>\n
\n", "exit": 0, "description": "Check compact mode rule count", "truncated": false }, "time": { "start": 1773020807085, "end": 1773020807085 } }, "id": "prt_cd0467fad001BOLbTI9ysrbgGm", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd04671ce00132JDbDZEGG6Piy" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "6daca6c6afb546efe0ee47e8e73cd71242f7be8a", "cost": 0, "tokens": { "total": 28817, "input": 4388, "output": 49, "reasoning": 0, "cache": { "read": 24380, "write": 0 } }, "id": "prt_cd0467fae0019PR108Kz6RaCjK", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd04671ce00132JDbDZEGG6Piy" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020807259, "completed": 1773020811316 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 29706, "input": 1210, "output": 52, "reasoning": 0, "cache": { "read": 28444, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd046805b001fnHtju6EoROApj", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "6daca6c6afb546efe0ee47e8e73cd71242f7be8a", "id": "prt_cd0468f7f001Q6fd3f7kSgKoJp", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd046805b001fnHtju6EoROApj" }, { "type": "tool", "callID": "bash-1773020810975593705-218", "tool": "bash", "state": { "status": "completed", "input": { "description": "Check missing rule count in compact grouped mode", "command": "sed -n '4050,4090p' vite-frontend/src/pages/forward.tsx" }, "output": " )}\n\n {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n
\n \n \n \n \n {selectMode && (\n 选择\n )}\n \n 用户\n 名称\n 隧道\n 入口\n 目标\n 策略\n 总流量\n 状态\n 操作\n \n 0 ? (\n
\n \n \n \n \n {selectMode && (\n 选择\n )}\n \n 用户\n 名称\n 隧道\n 入口\n 目标\n 策略\n 总流量\n 状态\n 操作\n \n 0 ? (\n
\n \n \n \n \n {selectMode && (\n 选择\n )}\n \n 用户\n 名称\n 隧道\n 入口\n 目标\n 策略\n 总流量\n 状态\n 操作\n \n \n {(forward) => (\n \n )}\n \n \n \n \n
\n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : sortedForwards.length > 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n {}}\n >\n \n
\n {sortedForwards.map((forward) =>\n forward && forward.id ? (\n \n ) : null,\n )}\n
\n \n \n \n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n
\n {groupedForwards.map((group) => {\n const isSelfGroup =\n isAdmin && tokenUserId !== null && group.userId === tokenUserId;\n const groupForwardCount = group.tunnels.reduce(\n (total, tunnel) => total + tunnel.items.length,\n 0,\n );\n\n return (\n \n
\n
\n \n {group.userName}\n \n {isSelfGroup && (\n \n 管理员本人\n \n )}\n
\n \n {groupForwardCount} 条规则\n \n
\n", "title": "Check missing rule count in compact grouped mode", "metadata": { "output": " )}\n\n {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n
\n \n \n \n \n {selectMode && (\n 选择\n )}\n \n 用户\n 名称\n 隧道\n 入口\n 目标\n 策略\n 总流量\n 状态\n 操作\n \n \n {(forward) => (\n \n )}\n \n \n \n \n
\n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : sortedForwards.length > 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n {}}\n >\n \n
\n {sortedForwards.map((forward) =>\n forward && forward.id ? (\n \n ) : null,\n )}\n
\n \n \n \n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n
\n {groupedForwards.map((group) => {\n const isSelfGroup =\n isAdmin && tokenUserId !== null && group.userId === tokenUserId;\n const groupForwardCount = group.tunnels.reduce(\n (total, tunnel) => total + tunnel.items.length,\n 0,\n );\n\n return (\n \n
\n
\n \n {group.userName}\n \n {isSelfGroup && (\n \n 管理员本人\n \n )}\n
\n \n {groupForwardCount} 条规则\n \n
\n", "exit": 0, "description": "Check missing rule count in compact grouped mode", "truncated": false }, "time": { "start": 1773020814834, "end": 1773020814835 } }, "id": "prt_cd0469df2001F2ky39L3jfOhPE", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0469036001GigI4fITmVAOks" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "6daca6c6afb546efe0ee47e8e73cd71242f7be8a", "cost": 0, "tokens": { "total": 30201, "input": 1707, "output": 52, "reasoning": 0, "cache": { "read": 28442, "write": 0 } }, "id": "prt_cd0469df4001ZFSD2XMHpg2ElG", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0469036001GigI4fITmVAOks" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020815007, "completed": 1773020818586 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 31825, "input": 7389, "output": 52, "reasoning": 0, "cache": { "read": 24384, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd0469e9f001dl0a9v7ATNEgKV", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "6daca6c6afb546efe0ee47e8e73cd71242f7be8a", "id": "prt_cd046abeb001eaZ3RAVfd7Qb2w", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0469e9f001dl0a9v7ATNEgKV" }, { "type": "tool", "callID": "bash-1773020818244223715-220", "tool": "bash", "state": { "status": "completed", "input": { "description": "Check missing rule count in compact grouped mode", "command": "sed -n '4050,4200p' vite-frontend/src/pages/forward.tsx" }, "output": " )}\n\n {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n
\n \n \n \n \n {selectMode && (\n 选择\n )}\n \n 用户\n 名称\n 隧道\n 入口\n 目标\n 策略\n 总流量\n 状态\n 操作\n \n \n {(forward) => (\n \n )}\n \n \n \n \n
\n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : sortedForwards.length > 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n {}}\n >\n \n
\n {sortedForwards.map((forward) =>\n forward && forward.id ? (\n \n ) : null,\n )}\n
\n \n \n \n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n
\n {groupedForwards.map((group) => {\n const isSelfGroup =\n isAdmin && tokenUserId !== null && group.userId === tokenUserId;\n const groupForwardCount = group.tunnels.reduce(\n (total, tunnel) => total + tunnel.items.length,\n 0,\n );\n\n return (\n \n
\n
\n \n {group.userName}\n \n {isSelfGroup && (\n \n 管理员本人\n \n )}\n
\n \n {groupForwardCount} 条规则\n \n
\n", "title": "Check missing rule count in compact grouped mode", "metadata": { "output": " )}\n\n {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n
\n \n \n \n \n {selectMode && (\n 选择\n )}\n \n 用户\n 名称\n 隧道\n 入口\n 目标\n 策略\n 总流量\n 状态\n 操作\n \n \n {(forward) => (\n \n )}\n \n \n \n \n
\n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : sortedForwards.length > 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n {}}\n >\n \n
\n {sortedForwards.map((forward) =>\n forward && forward.id ? (\n \n ) : null,\n )}\n
\n \n \n \n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n
\n {groupedForwards.map((group) => {\n const isSelfGroup =\n isAdmin && tokenUserId !== null && group.userId === tokenUserId;\n const groupForwardCount = group.tunnels.reduce(\n (total, tunnel) => total + tunnel.items.length,\n 0,\n );\n\n return (\n \n
\n
\n \n {group.userName}\n \n {isSelfGroup && (\n \n 管理员本人\n \n )}\n
\n \n {groupForwardCount} 条规则\n \n
\n", "exit": 0, "description": "Check missing rule count in compact grouped mode", "truncated": false }, "time": { "start": 1773020818412, "end": 1773020818412 } }, "id": "prt_cd046abec001ml6hhjsLlOX1HG", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0469e9f001dl0a9v7ATNEgKV" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "6daca6c6afb546efe0ee47e8e73cd71242f7be8a", "cost": 0, "tokens": { "total": 31825, "input": 7389, "output": 52, "reasoning": 0, "cache": { "read": 24384, "write": 0 } }, "id": "prt_cd046abed001SyfNd8wNjhrAee", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0469e9f001dl0a9v7ATNEgKV" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020818588, "completed": 1773020822379 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 33451, "input": 4943, "output": 54, "reasoning": 0, "cache": { "read": 28454, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd046ac9c001n7JhpaGQQF0TKb", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "6daca6c6afb546efe0ee47e8e73cd71242f7be8a", "id": "prt_cd046babc001Zlc46oIdYKRDYf", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd046ac9c001n7JhpaGQQF0TKb" }, { "type": "tool", "callID": "bash-1773020822037686263-221", "tool": "bash", "state": { "status": "completed", "input": { "command": "sed -n '4050,4065p' vite-frontend/src/pages/forward.tsx", "description": "Check exact context for adding count in compact grouped mode" }, "output": " )}\n\n {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n
\n \n \n", "title": "Check exact context for adding count in compact grouped mode", "metadata": { "output": " )}\n\n {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n
\n \n \n", "exit": 0, "description": "Check exact context for adding count in compact grouped mode", "truncated": false }, "time": { "start": 1773020822204, "end": 1773020822205 } }, "id": "prt_cd046babc002kguGKRL60F1WGf", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd046ac9c001n7JhpaGQQF0TKb" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "6daca6c6afb546efe0ee47e8e73cd71242f7be8a", "cost": 0, "tokens": { "total": 33451, "input": 4943, "output": 54, "reasoning": 0, "cache": { "read": 28454, "write": 0 } }, "id": "prt_cd046babd001nzJa9oGf13K8u9", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd046ac9c001n7JhpaGQQF0TKb" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020822380, "completed": 1773020826585 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 33642, "input": 5146, "output": 46, "reasoning": 0, "cache": { "read": 28450, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd046bb6c001qVdsMtk0e0VwSi", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "6daca6c6afb546efe0ee47e8e73cd71242f7be8a", "id": "prt_cd046cb24001je5b3Fa3fbdfox", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd046bb6c001qVdsMtk0e0VwSi" }, { "type": "tool", "callID": "bash-1773020826242568536-222", "tool": "bash", "state": { "status": "completed", "input": { "description": "Check context", "command": "sed -n '4050,4065p' vite-frontend/src/pages/forward.tsx" }, "output": " )}\n\n {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n
\n \n \n", "title": "Check context", "metadata": { "output": " )}\n\n {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n
\n \n \n", "exit": 0, "description": "Check context", "truncated": false }, "time": { "start": 1773020826405, "end": 1773020826405 } }, "id": "prt_cd046cb25001G16bSvDkPJBkro", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd046bb6c001qVdsMtk0e0VwSi" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "6daca6c6afb546efe0ee47e8e73cd71242f7be8a", "cost": 0, "tokens": { "total": 33642, "input": 5146, "output": 46, "reasoning": 0, "cache": { "read": 28450, "write": 0 } }, "id": "prt_cd046cb26001xmB4x3OXXz8cDl", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd046bb6c001qVdsMtk0e0VwSi" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020826587, "completed": 1773020833783 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 34233, "input": 1276, "output": 446, "reasoning": 0, "cache": { "read": 32511, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd046cbdb001lWSdfe7CcpfKx2", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "6daca6c6afb546efe0ee47e8e73cd71242f7be8a", "id": "prt_cd046e74c001kj3oxDx6J0slPO", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd046cbdb001lWSdfe7CcpfKx2" }, { "type": "tool", "callID": "bash-1773020833431366410-223", "tool": "bash", "state": { "status": "completed", "input": { "description": "Patch forward.tsx to add rule count to compact + grouped mode", "command": "cat << 'EOF' > patch.js\\nconst fs = require('fs');\\nconst file = 'vite-frontend/src/pages/forward.tsx';\\nlet content = fs.readFileSync(file, 'utf8');\\n\\nconst oldStr = ` {/* 根据显示模式渲染不同内容 */}\\n {compactMode ? (\\n viewMode === \\\"grouped\\\" ? (\\n sortedForwards.length > 0 ? (\\n
`;\\n\\nconst newStr = ` {/* 根据显示模式渲染不同内容 */}\\n {compactMode ? (\\n viewMode === \\\"grouped\\\" ? (\\n sortedForwards.length > 0 ? (\\n <>\\n
\\n \\n 全部规则\\n \\n \\n {sortedForwards.length} 条规则\\n \\n
\\n
`;\\n \\nconst oldEndStr = ` \\n \\n
\\n ) : (`;\\n \\nconst newEndStr = ` \\n \\n
\\n \\n ) : (`;\\n\\ncontent = content.replace(oldStr, newStr);\\ncontent = content.replace(oldEndStr, newEndStr);\\n\\nfs.writeFileSync(file, content);\\nconsole.log('Done');\\nEOF\\nnode patch.js" }, "output": "zsh:1: invalid mode specification\nzsh:1: command not found: nconst\nzsh:1: invalid mode specification\nzsh:1: parse error near `>'\nzsh:1: parse error in command substitution\n", "title": "Patch forward.tsx to add rule count to compact + grouped mode", "metadata": { "output": "zsh:1: invalid mode specification\nzsh:1: command not found: nconst\nzsh:1: invalid mode specification\nzsh:1: parse error near `>'\nzsh:1: parse error in command substitution\n", "exit": 1, "description": "Patch forward.tsx to add rule count to compact + grouped mode", "truncated": false }, "time": { "start": 1773020833612, "end": 1773020833613 } }, "id": "prt_cd046e74c0026UprphkFqnZ3UF", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd046cbdb001lWSdfe7CcpfKx2" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "6daca6c6afb546efe0ee47e8e73cd71242f7be8a", "cost": 0, "tokens": { "total": 34233, "input": 1276, "output": 446, "reasoning": 0, "cache": { "read": 32511, "write": 0 } }, "id": "prt_cd046e74d001yHZ0nMK2RuNvFv", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd046cbdb001lWSdfe7CcpfKx2" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020833785, "completed": 1773020840943 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 34818, "input": 1790, "output": 519, "reasoning": 0, "cache": { "read": 32509, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd046e7f90015frMHEhIsYCXJj", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "6daca6c6afb546efe0ee47e8e73cd71242f7be8a", "id": "prt_cd0470345001fef2FBtfde8t6n", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd046e7f90015frMHEhIsYCXJj" }, { "type": "tool", "callID": "bash-1773020840635197885-224", "tool": "bash", "state": { "status": "completed", "input": { "command": "node -e \"\\nconst fs = require('fs');\\nconst file = 'vite-frontend/src/pages/forward.tsx';\\nlet content = fs.readFileSync(file, 'utf8');\\n\\nconst oldStr = \\` {/* 根据显示模式渲染不同内容 */}\\n {compactMode ? (\\n viewMode === \\\\\\\"grouped\\\\\\\" ? (\\n sortedForwards.length > 0 ? (\\n
\\`;\\n\\nconst newStr = \\` {/* 根据显示模式渲染不同内容 */}\\n {compactMode ? (\\n viewMode === \\\\\\\"grouped\\\\\\\" ? (\\n sortedForwards.length > 0 ? (\\n <>\\n
\\n \\n 全部规则\\n \\n \\n {sortedForwards.length} 条规则\\n \\n
\\n
\\`;\\n\\nconst oldEndStr = \\` \\n \\n
\\n ) : (\\n \\`;\\n\\nconst newEndStr = \\` \\n \\n
\\n \\n ) : (\\n \\`;\\n\\ncontent = content.replace(oldStr, newStr);\\ncontent = content.replace(oldEndStr, newEndStr);\\n\\nfs.writeFileSync(file, content);\\nconsole.log('Done');\\n\"\\n", "description": "Patch forward.tsx properly" }, "output": "[eval]:1\n\\nconst fs = require('fs');\\nconst file = 'vite-frontend/src/pages/forward.tsx';\\nlet content = fs.readFileSync(file, 'utf8');\\n\\nconst oldStr = ` {/* 根据显示模式渲染不同内容 */}\\n {compactMode ? (\\n viewMode === \\\"grouped\\\" ? (\\n sortedForwards.length > 0 ? (\\n
`;\\n\\nconst newStr = ` {/* 根据显示模式渲染不同内容 */}\\n {compactMode ? (\\n viewMode === \\\"grouped\\\" ? (\\n sortedForwards.length > 0 ? (\\n <>\\n
\\n \\n 全部规则\\n \\n \\n {sortedForwards.length} 条规则\\n \\n
\\n
`;\\n\\nconst oldEndStr = ` \\n \\n
\\n ) : (\\n `;\\n\\nconst newEndStr = ` \\n \\n
\\n \\n ) : (\\n `;\\n\\ncontent = content.replace(oldStr, newStr);\\ncontent = content.replace(oldEndStr, newEndStr);\\n\\nfs.writeFileSync(file, content);\\nconsole.log('Done');\\nn\n^\nExpected unicode escape\n\nSyntaxError: Invalid or unexpected token\n at makeContextifyScript (node:internal/vm:194:14)\n at compileScript (node:internal/process/execution:388:10)\n at evalTypeScript (node:internal/process/execution:260:22)\n at node:internal/main/eval_string:71:3\n\nNode.js v25.4.0\n", "title": "Patch forward.tsx properly", "metadata": { "output": "[eval]:1\n\\nconst fs = require('fs');\\nconst file = 'vite-frontend/src/pages/forward.tsx';\\nlet content = fs.readFileSync(file, 'utf8');\\n\\nconst oldStr = ` {/* 根据显示模式渲染不同内容 */}\\n {compactMode ? (\\n viewMode === \\\"grouped\\\" ? (\\n sortedForwards.length > 0 ? (\\n
`;\\n\\nconst newStr = ` {/* 根据显示模式渲染不同内容 */}\\n {compactMode ? (\\n viewMode === \\\"grouped\\\" ? (\\n sortedForwards.length > 0 ? (\\n <>\\n
\\n \\n 全部规则\\n \\n \\n {sortedForwards.length} 条规则\\n \\n
\\n
`;\\n\\nconst oldEndStr = ` \\n \\n
\\n ) : (\\n `;\\n\\nconst newEndStr = ` \\n \\n
\\n \\n ) : (\\n `;\\n\\ncontent = content.replace(oldStr, newStr);\\ncontent = content.replace(oldEndStr, newEndStr);\\n\\nfs.writeFileSync(file, content);\\nconsole.log('Done');\\nn\n^\nExpected unicode escape\n\nSyntaxError: Invalid or unexpected token\n at makeContextifyScript (node:internal/vm:194:14)\n at compileScript (node:internal/process/execution:388:10)\n at evalTypeScript (node:internal/process/execution:260:22)\n at node:internal/main/eval_string:71:3\n\nNode.js v25.4.0\n", "exit": 1, "description": "Patch forward.tsx properly", "truncated": false }, "time": { "start": 1773020840774, "end": 1773020840794 } }, "id": "prt_cd0470345002ISvHClPIMcUiED", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd046e7f90015frMHEhIsYCXJj" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "6daca6c6afb546efe0ee47e8e73cd71242f7be8a", "cost": 0, "tokens": { "total": 34818, "input": 1790, "output": 519, "reasoning": 0, "cache": { "read": 32509, "write": 0 } }, "id": "prt_cd047035a001Z5Aya3UcNZRXvI", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd046e7f90015frMHEhIsYCXJj" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020840945, "completed": 1773020848109 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 35937, "input": 2945, "output": 480, "reasoning": 0, "cache": { "read": 32512, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd04703f1001yltCyPVktO1NIB", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "a2cd9fee8f201fbb8c75a43fe8ce7b8e12b473bd", "id": "prt_cd0471f1f001CkGrtPV7Mmdbyh", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd04703f1001yltCyPVktO1NIB" }, { "type": "tool", "callID": "bash-1773020847759854185-225", "tool": "bash", "state": { "status": "completed", "input": { "description": "Use multiline string without string interpolation formatting issues", "command": "cat << 'EOF' > patch.js\nconst fs = require('fs');\nconst file = 'vite-frontend/src/pages/forward.tsx';\nlet content = fs.readFileSync(file, 'utf8');\n\nconst oldStr = ` {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n
`;\n\nconst newStr = ` {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n
`;\n\nconst oldEndStr = ` \n \n
\n ) : (\n `;\n\nconst newEndStr = ` \n \n
\n \n ) : (\n `;\n\ncontent = content.replace(oldStr, newStr);\ncontent = content.replace(oldEndStr, newEndStr);\n\nfs.writeFileSync(file, content);\nconsole.log('Done');\nEOF\nnode patch.js" }, "output": "Done\n", "title": "Use multiline string without string interpolation formatting issues", "metadata": { "output": "Done\n", "exit": 0, "description": "Use multiline string without string interpolation formatting issues", "truncated": false }, "time": { "start": 1773020847904, "end": 1773020847904 } }, "id": "prt_cd0471f1f002lY0pReHdnqFg9p", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd04703f1001yltCyPVktO1NIB" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "25abc406002a114f2d7b880616f047d178072783", "cost": 0, "tokens": { "total": 35937, "input": 2945, "output": 480, "reasoning": 0, "cache": { "read": 32512, "write": 0 } }, "id": "prt_cd0471f20001AVRlbipXlW4sMA", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd04703f1001yltCyPVktO1NIB" }, { "type": "patch", "hash": "a2cd9fee8f201fbb8c75a43fe8ce7b8e12b473bd", "files": [ "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/forward.tsx" ], "id": "prt_cd0471fec001RiUlYa6uhlmCDO", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd04703f1001yltCyPVktO1NIB" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020848110, "completed": 1773020851756 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 35979, "input": 3443, "output": 26, "reasoning": 0, "cache": { "read": 32510, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd0471fee001EEMLCUfYMqJVj7", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "a423a59505d51bb889cf21d58195fb41f385e77b", "id": "prt_cd0472d86001J4I5qEAOciOVGg", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0471fee001EEMLCUfYMqJVj7" }, { "type": "tool", "callID": "bash-1773020851432117552-226", "tool": "bash", "state": { "status": "completed", "input": { "command": "rm patch.js", "description": "Clean up patch script" }, "output": "", "title": "Clean up patch script", "metadata": { "output": "", "exit": 0, "description": "Clean up patch script", "truncated": false }, "time": { "start": 1773020851591, "end": 1773020851591 } }, "id": "prt_cd0472d87001IDHR4BTvFae0BD", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0471fee001EEMLCUfYMqJVj7" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "a423a59505d51bb889cf21d58195fb41f385e77b", "cost": 0, "tokens": { "total": 35979, "input": 3443, "output": 26, "reasoning": 0, "cache": { "read": 32510, "write": 0 } }, "id": "prt_cd0472d880011bYSaGuiXxpfHn", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0471fee001EEMLCUfYMqJVj7" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020851760, "completed": 1773020861235 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 36026, "input": 3486, "output": 35, "reasoning": 0, "cache": { "read": 32505, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd0472e30001LY4sXoXWqDyJp4", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "a423a59505d51bb889cf21d58195fb41f385e77b", "id": "prt_cd0474052001tWT4KW0X0hnuaa", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0472e30001LY4sXoXWqDyJp4" }, { "type": "tool", "callID": "bash-1773020856280566989-227", "tool": "bash", "state": { "status": "completed", "input": { "command": "npm run build --prefix vite-frontend", "description": "Check if the build is successful with this fix" }, "output": "\n> flvx@0.0.0 build\n> tsc && vite build\n\nrolldown-vite v7.3.1 building client environment for production...\n\u001b[2K\rtransforming...✓ 3193 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/manifest.webmanifest 0.43 kB\ndist/index.html 7.51 kB │ gzip: 2.50 kB\ndist/assets/index-CAG6-7s5.css 115.55 kB │ gzip: 17.83 kB\ndist/assets/workbox-window.prod.es5-Bm4xbD7U.js 8.29 kB │ gzip: 2.81 kB\ndist/assets/index-0r9ArjZC.js 4,010.23 kB │ gzip: 827.74 kB\n✓ built in 823ms\n\nPWA v1.2.0\nmode generateSW\nprecache 9 entries (4044.54 KiB)\nfiles generated\n dist/sw.js\n dist/workbox-8c29f6e4.js\n", "title": "Check if the build is successful with this fix", "metadata": { "output": "\n> flvx@0.0.0 build\n> tsc && vite build\n\nrolldown-vite v7.3.1 building client environment for production...\n\u001b[2K\rtransforming...✓ 3193 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/manifest.webmanifest 0.43 kB\ndist/index.html 7.51 kB │ gzip: 2.50 kB\ndist/assets/index-CAG6-7s5.css 115.55 kB │ gzip: 17.83 kB\ndist/assets/workbox-window.prod.es5-Bm4xbD7U.js 8.29 kB │ gzip: 2.81 kB\ndist/assets/index-0r9ArjZC.js 4,010.23 kB │ gzip: 827.74 kB\n✓ built in 823ms\n\nPWA v1.2.0\nmode generateSW\nprecache 9 entries (4044.54 KiB)\nfiles generated\n dist/sw.js\n dist/workbox-8c29f6e4.js\n", "exit": 0, "description": "Check if the build is successful with this fix", "truncated": false }, "time": { "start": 1773020856403, "end": 1773020861019 } }, "id": "prt_cd04740520025lTKBuGw0rkqUP", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0472e30001LY4sXoXWqDyJp4" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "a423a59505d51bb889cf21d58195fb41f385e77b", "cost": 0, "tokens": { "total": 36026, "input": 3486, "output": 35, "reasoning": 0, "cache": { "read": 32505, "write": 0 } }, "id": "prt_cd047525c001Vz0VS7qgQtEWxX", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0472e30001LY4sXoXWqDyJp4" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020861237, "completed": 1773020864892 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 36367, "input": 3832, "output": 33, "reasoning": 0, "cache": { "read": 32502, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd0475335001X5uGyk4b8JVd5F", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "a423a59505d51bb889cf21d58195fb41f385e77b", "id": "prt_cd04760cd001Ds4yue2uIt9kMh", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0475335001X5uGyk4b8JVd5F" }, { "type": "tool", "callID": "bash-1773020864561987848-228", "tool": "bash", "state": { "status": "completed", "input": { "command": "git diff vite-frontend/src/pages/forward.tsx", "description": "Check diff" }, "output": "diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx\nindex aa897fd..9c815f1 100644\n--- a/vite-frontend/src/pages/forward.tsx\n+++ b/vite-frontend/src/pages/forward.tsx\n@@ -538,9 +538,7 @@ const mapForwardApiItems = (items: ForwardApiItem[]): Forward[] => {\n name: forward.name,\n tunnelId: forward.tunnelId ?? 0,\n tunnelName: forward.tunnelName || \"\",\n- tunnelTrafficRatio: normalizeTunnelTrafficRatio(\n- forward.tunnelTrafficRatio,\n- ),\n+ tunnelTrafficRatio: normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio),\n inIp: forward.inIp || \"\",\n inPort: forward.inPort ?? 0,\n remoteAddr: forward.remoteAddr || \"\",\n@@ -776,7 +774,10 @@ export default function ForwardPage() {\n currentTunnel.portRangeMin > 0 &&\n currentTunnel.portRangeMax > 0\n ) {\n- return { min: currentTunnel.portRangeMin, max: currentTunnel.portRangeMax };\n+ return {\n+ min: currentTunnel.portRangeMin,\n+ max: currentTunnel.portRangeMax,\n+ };\n }\n \n return null;\n@@ -1036,183 +1037,184 @@ export default function ForwardPage() {\n return Number.isFinite(shareId) && shareId > 0 ? shareId : null;\n };\n \n- const mergeFederationShareFlow = useCallback(async (\n- forwardsData: Forward[],\n- ): Promise => {\n- if (forwardsData.length === 0) {\n- return forwardsData;\n- }\n-\n- try {\n- const [usageRes, localShareRes] = await Promise.all([\n- getPeerRemoteUsageList(),\n- getPeerShareList(),\n- ]);\n+ const mergeFederationShareFlow = useCallback(\n+ async (forwardsData: Forward[]): Promise => {\n+ if (forwardsData.length === 0) {\n+ return forwardsData;\n+ }\n \n- const flowByShare = new Map();\n- const shareIdsByTunnel = new Map>();\n+ try {\n+ const [usageRes, localShareRes] = await Promise.all([\n+ getPeerRemoteUsageList(),\n+ getPeerShareList(),\n+ ]);\n+\n+ const flowByShare = new Map();\n+ const shareIdsByTunnel = new Map>();\n+\n+ if (usageRes.code === 0 && Array.isArray(usageRes.data)) {\n+ usageRes.data.forEach((item: Record) => {\n+ const shareId = Number(item.shareId || 0);\n+ const currentFlow = Number(item.currentFlow || 0);\n+\n+ if (\n+ Number.isFinite(shareId) &&\n+ shareId > 0 &&\n+ Number.isFinite(currentFlow) &&\n+ currentFlow > 0\n+ ) {\n+ const prev = flowByShare.get(shareId) || 0;\n+\n+ flowByShare.set(shareId, Math.max(prev, currentFlow));\n+ }\n \n- if (usageRes.code === 0 && Array.isArray(usageRes.data)) {\n- usageRes.data.forEach((item: Record) => {\n- const shareId = Number(item.shareId || 0);\n- const currentFlow = Number(item.currentFlow || 0);\n+ if (Number.isFinite(shareId) && shareId > 0) {\n+ const bindings = Array.isArray(item.bindings)\n+ ? (item.bindings as Array>)\n+ : [];\n \n- if (\n- Number.isFinite(shareId) &&\n- shareId > 0 &&\n- Number.isFinite(currentFlow) &&\n- currentFlow > 0\n- ) {\n- const prev = flowByShare.get(shareId) || 0;\n+ bindings.forEach((binding) => {\n+ const tunnelId = Number(binding.tunnelId || 0);\n+ const chainType = Number(binding.chainType || 0);\n \n- flowByShare.set(shareId, Math.max(prev, currentFlow));\n- }\n+ if (!Number.isFinite(tunnelId) || tunnelId <= 0) {\n+ return;\n+ }\n \n- if (Number.isFinite(shareId) && shareId > 0) {\n- const bindings = Array.isArray(item.bindings)\n- ? (item.bindings as Array>)\n- : [];\n+ if (Number.isFinite(chainType) && chainType !== 1) {\n+ return;\n+ }\n \n- bindings.forEach((binding) => {\n- const tunnelId = Number(binding.tunnelId || 0);\n- const chainType = Number(binding.chainType || 0);\n+ let shareSet = shareIdsByTunnel.get(tunnelId);\n \n- if (!Number.isFinite(tunnelId) || tunnelId <= 0) {\n- return;\n- }\n+ if (!shareSet) {\n+ shareSet = new Set();\n+ shareIdsByTunnel.set(tunnelId, shareSet);\n+ }\n \n- if (Number.isFinite(chainType) && chainType !== 1) {\n- return;\n- }\n+ shareSet.add(shareId);\n+ });\n+ }\n+ });\n+ }\n \n- let shareSet = shareIdsByTunnel.get(tunnelId);\n+ if (localShareRes.code === 0 && Array.isArray(localShareRes.data)) {\n+ localShareRes.data.forEach((item: Record) => {\n+ const shareId = Number(item.id || 0);\n+ const currentFlow = Number(item.currentFlow || 0);\n \n- if (!shareSet) {\n- shareSet = new Set();\n- shareIdsByTunnel.set(tunnelId, shareSet);\n- }\n+ if (\n+ Number.isFinite(shareId) &&\n+ shareId > 0 &&\n+ Number.isFinite(currentFlow) &&\n+ currentFlow > 0\n+ ) {\n+ const prev = flowByShare.get(shareId) || 0;\n \n- shareSet.add(shareId);\n- });\n- }\n- });\n- }\n+ flowByShare.set(shareId, Math.max(prev, currentFlow));\n+ }\n+ });\n+ }\n \n- if (localShareRes.code === 0 && Array.isArray(localShareRes.data)) {\n- localShareRes.data.forEach((item: Record) => {\n- const shareId = Number(item.id || 0);\n- const currentFlow = Number(item.currentFlow || 0);\n+ if (flowByShare.size === 0) {\n+ return forwardsData;\n+ }\n \n- if (\n- Number.isFinite(shareId) &&\n- shareId > 0 &&\n- Number.isFinite(currentFlow) &&\n- currentFlow > 0\n- ) {\n- const prev = flowByShare.get(shareId) || 0;\n+ const resolveShareIdForForward = (forward: Forward): number | null => {\n+ const candidates = new Set();\n+ const shareIdFromName = parseShareIdFromTunnelName(\n+ forward.tunnelName || \"\",\n+ );\n \n- flowByShare.set(shareId, Math.max(prev, currentFlow));\n+ if (shareIdFromName) {\n+ candidates.add(shareIdFromName);\n }\n- });\n- }\n \n- if (flowByShare.size === 0) {\n- return forwardsData;\n- }\n+ const tunnelId = Number(forward.tunnelId || 0);\n+ const shareSetByTunnel = shareIdsByTunnel.get(tunnelId);\n \n- const resolveShareIdForForward = (forward: Forward): number | null => {\n- const candidates = new Set();\n- const shareIdFromName = parseShareIdFromTunnelName(\n- forward.tunnelName || \"\",\n- );\n+ if (shareSetByTunnel && shareSetByTunnel.size > 0) {\n+ shareSetByTunnel.forEach((shareId) => {\n+ if (Number.isFinite(shareId) && shareId > 0) {\n+ candidates.add(shareId);\n+ }\n+ });\n+ }\n \n- if (shareIdFromName) {\n- candidates.add(shareIdFromName);\n- }\n+ if (candidates.size === 0) {\n+ return null;\n+ }\n \n- const tunnelId = Number(forward.tunnelId || 0);\n- const shareSetByTunnel = shareIdsByTunnel.get(tunnelId);\n+ let bestShareId: number | null = null;\n+ let bestFlow = 0;\n \n- if (shareSetByTunnel && shareSetByTunnel.size > 0) {\n- shareSetByTunnel.forEach((shareId) => {\n- if (Number.isFinite(shareId) && shareId > 0) {\n- candidates.add(shareId);\n+ candidates.forEach((shareId) => {\n+ const shareFlow = flowByShare.get(shareId) || 0;\n+\n+ if (shareFlow > bestFlow) {\n+ bestFlow = shareFlow;\n+ bestShareId = shareId;\n }\n });\n- }\n \n- if (candidates.size === 0) {\n- return null;\n- }\n+ return bestShareId;\n+ };\n \n- let bestShareId: number | null = null;\n- let bestFlow = 0;\n+ const resolvedShareByForwardId = new Map();\n \n- candidates.forEach((shareId) => {\n- const shareFlow = flowByShare.get(shareId) || 0;\n+ forwardsData.forEach((forward) => {\n+ const shareId = resolveShareIdForForward(forward);\n \n- if (shareFlow > bestFlow) {\n- bestFlow = shareFlow;\n- bestShareId = shareId;\n+ if (shareId) {\n+ resolvedShareByForwardId.set(forward.id, shareId);\n }\n });\n \n- return bestShareId;\n- };\n-\n- const resolvedShareByForwardId = new Map();\n+ const forwardCountByShare = new Map();\n \n- forwardsData.forEach((forward) => {\n- const shareId = resolveShareIdForForward(forward);\n+ forwardsData.forEach((forward) => {\n+ const shareId = resolvedShareByForwardId.get(forward.id) || null;\n \n- if (shareId) {\n- resolvedShareByForwardId.set(forward.id, shareId);\n- }\n- });\n-\n- const forwardCountByShare = new Map();\n-\n- forwardsData.forEach((forward) => {\n- const shareId = resolvedShareByForwardId.get(forward.id) || null;\n-\n- if (!shareId || !flowByShare.has(shareId)) {\n- return;\n- }\n+ if (!shareId || !flowByShare.has(shareId)) {\n+ return;\n+ }\n \n- forwardCountByShare.set(\n- shareId,\n- (forwardCountByShare.get(shareId) || 0) + 1,\n- );\n- });\n+ forwardCountByShare.set(\n+ shareId,\n+ (forwardCountByShare.get(shareId) || 0) + 1,\n+ );\n+ });\n \n- return forwardsData.map((forward) => {\n- const shareId = resolvedShareByForwardId.get(forward.id) || null;\n+ return forwardsData.map((forward) => {\n+ const shareId = resolvedShareByForwardId.get(forward.id) || null;\n \n- if (!shareId) {\n- return { ...forward, federationShareFlow: undefined };\n- }\n+ if (!shareId) {\n+ return { ...forward, federationShareFlow: undefined };\n+ }\n \n- const shareFlow = flowByShare.get(shareId) || 0;\n+ const shareFlow = flowByShare.get(shareId) || 0;\n \n- if (shareFlow <= 0) {\n- return { ...forward, federationShareFlow: undefined };\n- }\n+ if (shareFlow <= 0) {\n+ return { ...forward, federationShareFlow: undefined };\n+ }\n \n- const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);\n+ const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);\n \n- if (directFlow > 0) {\n- return { ...forward, federationShareFlow: undefined };\n- }\n+ if (directFlow > 0) {\n+ return { ...forward, federationShareFlow: undefined };\n+ }\n \n- const count = forwardCountByShare.get(shareId) || 1;\n- const estimated = Math.max(1, Math.floor(shareFlow / count));\n+ const count = forwardCountByShare.get(shareId) || 1;\n+ const estimated = Math.max(1, Math.floor(shareFlow / count));\n \n- return { ...forward, federationShareFlow: estimated };\n- });\n- } catch {\n- return forwardsData;\n- }\n- }, []);\n+ return { ...forward, federationShareFlow: estimated };\n+ });\n+ } catch {\n+ return forwardsData;\n+ }\n+ },\n+ [],\n+ );\n \n const getForwardDisplayFlow = (forward: Forward): number => {\n const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);\n@@ -1283,45 +1285,48 @@ export default function ForwardPage() {\n );\n \n // 加载所有数据\n- const loadData = useCallback(async (lod = true) => {\n- setLoading(lod);\n- try {\n- const [tunnelsRes, speedLimitsRes] = await Promise.all([\n- userTunnel(),\n- getSpeedLimitList(),\n- ]);\n- const [allTunnelsRes, nodesRes] = await Promise.allSettled([\n- getTunnelList(),\n- getNodeList(),\n- ]);\n+ const loadData = useCallback(\n+ async (lod = true) => {\n+ setLoading(lod);\n+ try {\n+ const [tunnelsRes, speedLimitsRes] = await Promise.all([\n+ userTunnel(),\n+ getSpeedLimitList(),\n+ ]);\n+ const [allTunnelsRes, nodesRes] = await Promise.allSettled([\n+ getTunnelList(),\n+ getNodeList(),\n+ ]);\n \n- await refreshForwardList(false);\n+ await refreshForwardList(false);\n \n- if (tunnelsRes.code === 0) {\n- setTunnels(tunnelsRes.data || []);\n- } else {\n- }\n+ if (tunnelsRes.code === 0) {\n+ setTunnels(tunnelsRes.data || []);\n+ } else {\n+ }\n \n- if (\n- allTunnelsRes.status === \"fulfilled\" &&\n- allTunnelsRes.value.code === 0\n- ) {\n- setAllTunnels((allTunnelsRes.value.data || []) as Tunnel[]);\n- }\n+ if (\n+ allTunnelsRes.status === \"fulfilled\" &&\n+ allTunnelsRes.value.code === 0\n+ ) {\n+ setAllTunnels((allTunnelsRes.value.data || []) as Tunnel[]);\n+ }\n \n- if (nodesRes.status === \"fulfilled\" && nodesRes.value.code === 0) {\n- setNodes((nodesRes.value.data || []) as Node[]);\n- }\n+ if (nodesRes.status === \"fulfilled\" && nodesRes.value.code === 0) {\n+ setNodes((nodesRes.value.data || []) as Node[]);\n+ }\n \n- if (speedLimitsRes.code === 0) {\n- setSpeedLimits(speedLimitsRes.data || []);\n+ if (speedLimitsRes.code === 0) {\n+ setSpeedLimits(speedLimitsRes.data || []);\n+ }\n+ } catch {\n+ toast.error(\"加载数据失败\");\n+ } finally {\n+ setLoading(false);\n }\n- } catch {\n- toast.error(\"加载数据失败\");\n- } finally {\n- setLoading(false);\n- }\n- }, [refreshForwardList]);\n+ },\n+ [refreshForwardList],\n+ );\n \n useEffect(() => {\n loadData();\n@@ -1389,8 +1394,16 @@ export default function ForwardPage() {\n newErrors.tunnelId = \"请选择关联隧道\";\n }\n \n- if (form.inPort !== null && form.inPort !== undefined && form.inPort > 0 && currentTunnelPortRange) {\n- if (form.inPort < currentTunnelPortRange.min || form.inPort > currentTunnelPortRange.max) {\n+ if (\n+ form.inPort !== null &&\n+ form.inPort !== undefined &&\n+ form.inPort > 0 &&\n+ currentTunnelPortRange\n+ ) {\n+ if (\n+ form.inPort < currentTunnelPortRange.min ||\n+ form.inPort > currentTunnelPortRange.max\n+ ) {\n newErrors.inPort = `端口 ${currentTunnelPortRange.min}-${currentTunnelPortRange.max} 超出允许范围`;\n }\n }\n@@ -1485,17 +1498,21 @@ export default function ForwardPage() {\n toast.success(\"删除成功\");\n setDeleteModalOpen(false);\n setForwardToDelete(null);\n- setForwards((prev) => prev.filter((forward) => forward.id !== forwardToDelete.id));\n+ setForwards((prev) =>\n+ prev.filter((forward) => forward.id !== forwardToDelete.id),\n+ );\n setForwardOrder((prev) => {\n const next = prev.filter((id) => id !== forwardToDelete.id);\n \n saveOrder(FORWARD_ORDER_KEY, next);\n+\n return next;\n });\n setSelectedIds((prev) => {\n const next = new Set(prev);\n \n next.delete(forwardToDelete.id);\n+\n return next;\n });\n } else {\n@@ -1518,12 +1535,14 @@ export default function ForwardPage() {\n const next = prev.filter((id) => id !== forwardToDelete.id);\n \n saveOrder(FORWARD_ORDER_KEY, next);\n+\n return next;\n });\n setSelectedIds((prev) => {\n const next = new Set(prev);\n \n next.delete(forwardToDelete.id);\n+\n return next;\n });\n } else {\n@@ -1597,6 +1616,7 @@ export default function ForwardPage() {\n strategy: addressCount > 1 ? form.strategy : \"fifo\",\n speedId: normalizedSpeedId,\n };\n+\n res = await createForward(createData);\n }\n \n@@ -2787,7 +2807,8 @@ export default function ForwardPage() {\n }\n \n if (\n- normalizeTunnelTrafficRatio(existingTunnelGroup.tunnelTrafficRatio) === 1 &&\n+ normalizeTunnelTrafficRatio(existingTunnelGroup.tunnelTrafficRatio) ===\n+ 1 &&\n normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio) !== 1\n ) {\n existingTunnelGroup.tunnelTrafficRatio = normalizeTunnelTrafficRatio(\n@@ -4032,7 +4053,16 @@ export default function ForwardPage() {\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n-
\n+ <>\n+
\n+ \n+ 全部规则\n+ \n+ \n+ {sortedForwards.length} 条规则\n+ \n+
\n+
\n \n \n
\n+ \n ) : (\n \n \n", "title": "Check diff", "metadata": { "output": "diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx\nindex aa897fd..9c815f1 100644\n--- a/vite-frontend/src/pages/forward.tsx\n+++ b/vite-frontend/src/pages/forward.tsx\n@@ -538,9 +538,7 @@ const mapForwardApiItems = (items: ForwardApiItem[]): Forward[] => {\n name: forward.name,\n tunnelId: forward.tunnelId ?? 0,\n tunnelName: forward.tunnelName || \"\",\n- tunnelTrafficRatio: normalizeTunnelTrafficRatio(\n- forward.tunnelTrafficRatio,\n- ),\n+ tunnelTrafficRatio: normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio),\n inIp: forward.inIp || \"\",\n inPort: forward.inPort ?? 0,\n remoteAddr: forward.remoteAddr || \"\",\n@@ -776,7 +774,10 @@ export default function ForwardPage() {\n currentTunnel.portRangeMin > 0 &&\n currentTunnel.portRangeMax > 0\n ) {\n- return { min: currentTunnel.portRangeMin, max: currentTunnel.portRangeMax };\n+ return {\n+ min: currentTunnel.portRangeMin,\n+ max: currentTunnel.portRangeMax,\n+ };\n }\n \n return null;\n@@ -1036,183 +1037,184 @@ export default function ForwardPage() {\n return Number.isFinite(shareId) && shareId > 0 ? shareId : null;\n };\n \n- const mergeFederationShareFlow = useCallback(async (\n- forwardsData: Forward[],\n- ): Promise => {\n- if (forwardsData.length === 0) {\n- return forwardsData;\n- }\n-\n- try {\n- const [usageRes, localShareRes] = await Promise.all([\n- getPeerRemoteUsageList(),\n- getPeerShareList(),\n- ]);\n+ const mergeFederationShareFlow = useCallback(\n+ async (forwardsData: Forward[]): Promise => {\n+ if (forwardsData.length === 0) {\n+ return forwardsData;\n+ }\n \n- const flowByShare = new Map();\n- const shareIdsByTunnel = new Map>();\n+ try {\n+ const [usageRes, localShareRes] = await Promise.all([\n+ getPeerRemoteUsageList(),\n+ getPeerShareList(),\n+ ]);\n+\n+ const flowByShare = new Map();\n+ const shareIdsByTunnel = new Map>();\n+\n+ if (usageRes.code === 0 && Array.isArray(usageRes.data)) {\n+ usageRes.data.forEach((item: Record) => {\n+ const shareId = Number(item.shareId || 0);\n+ const currentFlow = Number(item.currentFlow || 0);\n+\n+ if (\n+ Number.isFinite(shareId) &&\n+ shareId > 0 &&\n+ Number.isFinite(currentFlow) &&\n+ currentFlow > 0\n+ ) {\n+ const prev = flowByShare.get(shareId) || 0;\n+\n+ flowByShare.set(shareId, Math.max(prev, currentFlow));\n+ }\n \n- if (usageRes.code === 0 && Array.isArray(usageRes.data)) {\n- usageRes.data.forEach((item: Record) => {\n- const shareId = Number(item.shareId || 0);\n- const currentFlow = Number(item.currentFlow || 0);\n+ if (Number.isFinite(shareId) && shareId > 0) {\n+ const bindings = Array.isArray(item.bindings)\n+ ? (item.bindings as Array>)\n+ : [];\n \n- if (\n- Number.isFinite(shareId) &&\n- shareId > 0 &&\n- Number.isFinite(currentFlow) &&\n- currentFlow > 0\n- ) {\n- const prev = flowByShare.get(shareId) || 0;\n+ bindings.forEach((binding) => {\n+ const tunnelId = Number(binding.tunnelId || 0);\n+ const chainType = Number(binding.chainType || 0);\n \n- flowByShare.set(shareId, Math.max(prev, currentFlow));\n- }\n+ if (!Number.isFinite(tunnelId) || tunnelId <= 0) {\n+ return;\n+ }\n \n- if (Number.isFinite(shareId) && shareId > 0) {\n- const bindings = Array.isArray(item.bindings)\n- ? (item.bindings as Array>)\n- : [];\n+ if (Number.isFinite(chainType) && chainType !== 1) {\n+ return;\n+ }\n \n- bindings.forEach((binding) => {\n- const tunnelId = Number(binding.tunnelId || 0);\n- const chainType = Number(binding.chainType || 0);\n+ let shareSet = shareIdsByTunnel.get(tunnelId);\n \n- if (!Number.isFinite(tunnelId) || tunnelId <= 0) {\n- return;\n- }\n+ if (!shareSet) {\n+ shareSet = new Set();\n+ shareIdsByTunnel.set(tunnelId, shareSet);\n+ }\n \n- if (Number.isFinite(chainType) && chainType !== 1) {\n- return;\n- }\n+ shareSet.add(shareId);\n+ });\n+ }\n+ });\n+ }\n \n- let shareSet = shareIdsByTunnel.get(tunnelId);\n+ if (localShareRes.code === 0 && Array.isArray(localShareRes.data)) {\n+ localShareRes.data.forEach((item: Record) => {\n+ const shareId = Number(item.id || 0);\n+ const currentFlow = Number(item.currentFlow || 0);\n \n- if (!shareSet) {\n- shareSet = new Set();\n- shareIdsByTunnel.set(tunnelId, shareSet);\n- }\n+ if (\n+ Number.isFinite(shareId) &&\n+ shareId > 0 &&\n+ Number.isFinite(currentFlow) &&\n+ currentFlow > 0\n+ ) {\n+ const prev = flowByShare.get(shareId) || 0;\n \n- shareSet.add(shareId);\n- });\n- }\n- });\n- }\n+ flowByShare.set(shareId, Math.max(prev, currentFlow));\n+ }\n+ });\n+ }\n \n- if (localShareRes.code === 0 && Array.isArray(localShareRes.data)) {\n- localShareRes.data.forEach((item: Record) => {\n- const shareId = Number(item.id || 0);\n- const currentFlow = Number(item.currentFlow || 0);\n+ if (flowByShare.size === 0) {\n+ return forwardsData;\n+ }\n \n- if (\n- Number.isFinite(shareId) &&\n- shareId > 0 &&\n- Number.isFinite(currentFlow) &&\n- currentFlow > 0\n- ) {\n- const prev = flowByShare.get(shareId) || 0;\n+ const resolveShareIdForForward = (forward: Forward): number | null => {\n+ const candidates = new Set();\n+ const shareIdFromName = parseShareIdFromTunnelName(\n+ forward.tunnelName || \"\",\n+ );\n \n- flowByShare.set(shareId, Math.max(prev, currentFlow));\n+ if (shareIdFromName) {\n+ candidates.add(shareIdFromName);\n }\n- });\n- }\n \n- if (flowByShare.size === 0) {\n- return forwardsData;\n- }\n+ const tunnelId = Number(forward.tunnelId || 0);\n+ const shareSetByTunnel = shareIdsByTunnel.get(tunnelId);\n \n- const resolveShareIdForForward = (forward: Forward): number | null => {\n- const candidates = new Set();\n- const shareIdFromName = parseShareIdFromTunnelName(\n- forward.tunnelName || \"\",\n- );\n+ if (shareSetByTunnel && shareSetByTunnel.size > 0) {\n+ shareSetByTunnel.forEach((shareId) => {\n+ if (Number.isFinite(shareId) && shareId > 0) {\n+ candidates.add(shareId);\n+ }\n+ });\n+ }\n \n- if (shareIdFromName) {\n- candidates.add(shareIdFromName);\n- }\n+ if (candidates.size === 0) {\n+ return null;\n+ }\n \n- const tunnelId = Number(forward.tunnelId || 0);\n- const shareSetByTunnel = shareIdsByTunnel.get(tunnelId);\n+ let bestShareId: number | null = null;\n+ let bestFlow = 0;\n \n- if (shareSetByTunnel && shareSetByTunnel.size > 0) {\n- shareSetByTunnel.forEach((shareId) => {\n- if (Number.isFinite(shareId) && shareId > 0) {\n- candidates.add(shareId);\n+ candidates.forEach((shareId) => {\n+ const shareFlow = flowByShare.get(shareId) || 0;\n+\n+ if (shareFlow > bestFlow) {\n+ bestFlow = shareFlow;\n+ bestShareId = shareId;\n }\n });\n- }\n \n- if (candidates.size === 0) {\n- return null;\n- }\n+ return bestShareId;\n+ };\n \n- let bestShareId: number | null = null;\n- let bestFlow = 0;\n+ const resolvedShareByForwardId = new Map();\n \n- candidates.forEach((shareId) => {\n- const shareFlow = flowByShare.get(shareId) || 0;\n+ forwardsData.forEach((forward) => {\n+ const shareId = resolveShareIdForForward(forward);\n \n- if (shareFlow > bestFlow) {\n- bestFlow = shareFlow;\n- bestShareId = shareId;\n+ if (shareId) {\n+ resolvedShareByForwardId.set(forward.id, shareId);\n }\n });\n \n- return bestShareId;\n- };\n-\n- const resolvedShareByForwardId = new Map();\n+ const forwardCountByShare = new Map();\n \n- forwardsData.forEach((forward) => {\n- const shareId = resolveShareIdForForward(forward);\n+ forwardsData.forEach((forward) => {\n+ const shareId = resolvedShareByForwardId.get(forward.id) || null;\n \n- if (shareId) {\n- resolvedShareByForwardId.set(forward.id, shareId);\n- }\n- });\n-\n- const forwardCountByShare = new Map();\n-\n- forwardsData.forEach((forward) => {\n- const shareId = resolvedShareByForwardId.get(forward.id) || null;\n-\n- if (!shareId || !flowByShare.has(shareId)) {\n- return;\n- }\n+ if (!shareId || !flowByShare.has(shareId)) {\n+ return;\n+ }\n \n- forwardCountByShare.set(\n- shareId,\n- (forwardCountByShare.get(shareId) || 0) + 1,\n- );\n- });\n+ forwardCountByShare.set(\n+ shareId,\n+ (forwardCountByShare.get(shareId) || 0) + 1,\n+ );\n+ });\n \n- return forwardsData.map((forward) => {\n- const shareId = resolvedShareByForwardId.get(forward.id) || null;\n+ return forwardsData.map((forward) => {\n+ const shareId = resolvedShareByForwardId.get(forward.id) || null;\n \n- if (!shareId) {\n- return { ...forward, federationShareFlow: undefined };\n- }\n+ if (!shareId) {\n+ return { ...forward, federationShareFlow: undefined };\n+ }\n \n- const shareFlow = flowByShare.get(shareId) || 0;\n+ const shareFlow = flowByShare.get(shareId) || 0;\n \n- if (shareFlow <= 0) {\n- return { ...forward, federationShareFlow: undefined };\n- }\n+ if (shareFlow <= 0) {\n+ return { ...forward, federationShareFlow: undefined };\n+ }\n \n- const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);\n+ const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);\n \n- if (directFlow > 0) {\n- return { ...forward, federationShareFlow: undefined };\n- }\n+ if (directFlow > 0) {\n+ return { ...forward, federationShareFlow: undefined };\n+ }\n \n- const count = forwardCountByShare.get(shareId) || 1;\n- const estimated = Math.max(1, Math.floor(shareFlow / count));\n+ const count = forwardCountByShare.get(shareId) || 1;\n+ const estimated = Math.max(1, Math.floor(shareFlow / count));\n \n- return { ...forward, federationShareFlow: estimated };\n- });\n- } catch {\n- return forwardsData;\n- }\n- }, []);\n+ return { ...forward, federationShareFlow: estimated };\n+ });\n+ } catch {\n+ return forwardsData;\n+ }\n+ },\n+ [],\n+ );\n \n const getForwardDisplayFlow = (forward: Forward): number => {\n const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);\n@@ -1283,45 +1285,48 @@ export default function ForwardPage() {\n );\n \n // 加载所有数据\n- const loadData = useCallback(async (lod = true) => {\n- setLoading(lod);\n- try {\n- const [tunnelsRes, speedLimitsRes] = await Promise.all([\n- userTunnel(),\n- getSpeedLimitList(),\n- ]);\n- const [allTunnelsRes, nodesRes] = await Promise.allSettled([\n- getTunnelList(),\n- getNodeList(),\n- ]);\n+ const loadData = useCallback(\n+ async (lod = true) => {\n+ setLoading(lod);\n+ try {\n+ const [tunnelsRes, speedLimitsRes] = await Promise.all([\n+ userTunnel(),\n+ getSpeedLimitList(),\n+ ]);\n+ const [allTunnelsRes, nodesRes] = await Promise.allSettled([\n+ getTunnelList(),\n+ getNodeList(),\n+ ]);\n \n- await refreshForwardList(false);\n+ await refreshForwardList(false);\n \n- if (tunnelsRes.code === 0) {\n- setTunnels(tunnelsRes.data || []);\n- } else {\n- }\n+ if (tunnelsRes.code === 0) {\n+ setTunnels(tunnelsRes.data || []);\n+ } else {\n+ }\n \n- if (\n- allTunnelsRes.status === \"fulfilled\" &&\n- allTunnelsRes.value.code === 0\n- ) {\n- setAllTunnels((allTunnelsRes.value.data || []) as Tunnel[]);\n- }\n+ if (\n+ allTunnelsRes.status === \"fulfilled\" &&\n+ allTunnelsRes.value.code === 0\n+ ) {\n+ setAllTunnels((allTunnelsRes.value.data || []) as Tunnel[]);\n+ }\n \n- if (nodesRes.status === \"fulfilled\" && nodesRes.value.code === 0) {\n- setNodes((nodesRes.value.data || []) as Node[]);\n- }\n+ if (nodesRes.status === \"fulfilled\" && nodesRes.value.code === 0) {\n+ setNodes((nodesRes.value.data || []) as Node[]);\n+ }\n \n- if (speedLimitsRes.code === 0) {\n- setSpeedLimits(speedLimitsRes.data || []);\n+ if (speedLimitsRes.code === 0) {\n+ setSpeedLimits(speedLimitsRes.data || []);\n+ }\n+ } catch {\n+ toast.error(\"加载数据失败\");\n+ } finally {\n+ setLoading(false);\n }\n- } catch {\n- toast.error(\"加载数据失败\");\n- } finally {\n- setLoading(false);\n- }\n- }, [refreshForwardList]);\n+ },\n+ [refreshForwardList],\n+ );\n \n useEffect(() => {\n loadData();\n@@ -1389,8 +1394,16 @@ export default function ForwardPage() {\n newErrors.tunnelId = \"请选择关联隧道\";\n }\n \n- if (form.inPort !== null && form.inPort !== undefined && form.inPort > 0 && currentTunnelPortRange) {\n- if (form.inPort < currentTunnelPortRange.min || form.inPort > currentTunnelPortRange.max) {\n+ if (\n+ form.inPort !== null &&\n+ form.inPort !== undefined &&\n+ form.inPort > 0 &&\n+ currentTunnelPortRange\n+ ) {\n+ if (\n+ form.inPort < currentTunnelPortRange.min ||\n+ form.inPort > currentTunnelPortRange.max\n+ ) {\n newErrors.inPort = `端口 ${currentTunnelPortRange.min}-${currentTunnelPortRange.max} 超出允许范围`;\n }\n }\n@@ -1485,17 +1498,21 @@ export default function ForwardPage() {\n toast.success(\"删除成功\");\n setDeleteModalOpen(false);\n setForwardToDelete(null);\n- setForwards((prev) => prev.filter((forward) => forward.id !== forwardToDelete.id));\n+ setForwards((prev) =>\n+ prev.filter((forward) => forward.id !== forwardToDelete.id),\n+ );\n setForwardOrder((prev) => {\n const next = prev.filter((id) => id !== forwardToDelete.id);\n \n saveOrder(FORWARD_ORDER_KEY, next);\n+\n return next;\n });\n setSelectedIds((prev) => {\n const next = new Set(prev);\n \n next.delete(forwardToDelete.id);\n+\n return next;\n });\n } else {\n@@ -1518,12 +1535,14 @@ export default function ForwardPage() {\n const next = prev.filter((id) => id !== forwardToDelete.id);\n \n saveOrder(FORWARD_ORDER_KEY, next);\n+\n return next;\n });\n setSelectedIds((prev) => {\n const next = new Set(prev);\n \n next.delete(forwardToDelete.id);\n+\n return next;\n });\n } else {\n@@ -1597,6 +1616,7 @@ export default function ForwardPage() {\n strategy: addressCount > 1 ? form.strategy : \"fifo\",\n speedId: normalizedSpeedId,\n };\n+\n res = await createForward(createData);\n }\n \n@@ -2787,7 +2807,8 @@ export default function ForwardPage() {\n }\n \n if (\n- normalizeTunnelTrafficRatio(existingTunnelGroup.tunnelTrafficRatio) === 1 &&\n+ normalizeTunnelTrafficRatio(existingTunnelGroup.tunnelTrafficRatio) ===\n+ 1 &&\n normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio) !== 1\n ) {\n existingTunnelGroup.tunnelTrafficRatio = normalizeTunnelTrafficRatio(\n@@ -4032,7 +4053,16 @@ export default function ForwardPage() {\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n-
\n+ <>\n+
\n+ \n+ 全部规则\n+ \n+ \n+ {sortedForwards.length} 条规则\n+ \n+
\n+
\n \n \n
\n+ \n ) : (\n \n \n", "exit": 0, "description": "Check diff", "truncated": false }, "time": { "start": 1773020864718, "end": 1773020864719 } }, "id": "prt_cd04760ce001b2KxPUd7xn1YcQ", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0475335001X5uGyk4b8JVd5F" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "a423a59505d51bb889cf21d58195fb41f385e77b", "cost": 0, "tokens": { "total": 36367, "input": 3832, "output": 33, "reasoning": 0, "cache": { "read": 32502, "write": 0 } }, "id": "prt_cd04760d0001auFuBGA5zCAY7y", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0475335001X5uGyk4b8JVd5F" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020864895, "completed": 1773020869192 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 41987, "input": 9415, "output": 39, "reasoning": 0, "cache": { "read": 32533, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd047617f0018mKzFbvF3fg5rw", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "a423a59505d51bb889cf21d58195fb41f385e77b", "id": "prt_cd047719e001O5uIsyWASIysuC", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd047617f0018mKzFbvF3fg5rw" }, { "type": "tool", "callID": "bash-1773020868870540562-229", "tool": "bash", "state": { "status": "completed", "input": { "description": "Clean up any orig files", "command": "rm -f vite-frontend/src/pages/forward.tsx.orig" }, "output": "", "title": "Clean up any orig files", "metadata": { "output": "", "exit": 0, "description": "Clean up any orig files", "truncated": false }, "time": { "start": 1773020869023, "end": 1773020869023 } }, "id": "prt_cd047719f001Cd6rChlVjL8MyM", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd047617f0018mKzFbvF3fg5rw" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "a423a59505d51bb889cf21d58195fb41f385e77b", "cost": 0, "tokens": { "total": 41987, "input": 9415, "output": 39, "reasoning": 0, "cache": { "read": 32533, "write": 0 } }, "id": "prt_cd04771a0001fWh3918M7e9SAi", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd047617f0018mKzFbvF3fg5rw" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020869195, "completed": 1773020875548 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 42030, "input": 1339, "output": 31, "reasoning": 0, "cache": { "read": 40660, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd047724b001pNKP6KIzhwFWaY", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "a423a59505d51bb889cf21d58195fb41f385e77b", "id": "prt_cd0477f5d001oyGPuarRk3wV5e", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd047724b001pNKP6KIzhwFWaY" }, { "type": "tool", "callID": "bash-1773020872411193200-230", "tool": "bash", "state": { "status": "completed", "input": { "description": "Lint code to ensure valid", "command": "npm run lint --prefix vite-frontend" }, "output": "\n> flvx@0.0.0 lint\n> eslint --fix\n\n", "title": "Lint code to ensure valid", "metadata": { "output": "\n> flvx@0.0.0 lint\n> eslint --fix\n\n", "exit": 0, "description": "Lint code to ensure valid", "truncated": false }, "time": { "start": 1773020872542, "end": 1773020875334 } }, "id": "prt_cd0477f5e001NPqz2boFM16FNg", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd047724b001pNKP6KIzhwFWaY" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "cost": 0, "tokens": { "total": 42030, "input": 1339, "output": 31, "reasoning": 0, "cache": { "read": 40660, "write": 0 } }, "id": "prt_cd0478a47001UYbkUbtQOAvTg7", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd047724b001pNKP6KIzhwFWaY" }, { "type": "patch", "hash": "a423a59505d51bb889cf21d58195fb41f385e77b", "files": [ "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/forward.tsx" ], "id": "prt_cd0478b1a001F6INsjWnY0h5DJ", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd047724b001pNKP6KIzhwFWaY" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020875551, "completed": 1773020881053 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 42097, "input": 1409, "output": 33, "reasoning": 0, "cache": { "read": 40655, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd0478b1f001zBuNiXlz7e4YkM", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "id": "prt_cd0479ff1001YypQcxn9Z9cQnS", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0478b1f001zBuNiXlz7e4YkM" }, { "type": "tool", "callID": "bash-1773020880724551958-231", "tool": "bash", "state": { "status": "completed", "input": { "command": "git diff vite-frontend/src/pages/forward.tsx", "description": "Check diff" }, "output": "diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx\nindex aa897fd..72fab20 100644\n--- a/vite-frontend/src/pages/forward.tsx\n+++ b/vite-frontend/src/pages/forward.tsx\n@@ -538,9 +538,7 @@ const mapForwardApiItems = (items: ForwardApiItem[]): Forward[] => {\n name: forward.name,\n tunnelId: forward.tunnelId ?? 0,\n tunnelName: forward.tunnelName || \"\",\n- tunnelTrafficRatio: normalizeTunnelTrafficRatio(\n- forward.tunnelTrafficRatio,\n- ),\n+ tunnelTrafficRatio: normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio),\n inIp: forward.inIp || \"\",\n inPort: forward.inPort ?? 0,\n remoteAddr: forward.remoteAddr || \"\",\n@@ -776,7 +774,10 @@ export default function ForwardPage() {\n currentTunnel.portRangeMin > 0 &&\n currentTunnel.portRangeMax > 0\n ) {\n- return { min: currentTunnel.portRangeMin, max: currentTunnel.portRangeMax };\n+ return {\n+ min: currentTunnel.portRangeMin,\n+ max: currentTunnel.portRangeMax,\n+ };\n }\n \n return null;\n@@ -1036,183 +1037,184 @@ export default function ForwardPage() {\n return Number.isFinite(shareId) && shareId > 0 ? shareId : null;\n };\n \n- const mergeFederationShareFlow = useCallback(async (\n- forwardsData: Forward[],\n- ): Promise => {\n- if (forwardsData.length === 0) {\n- return forwardsData;\n- }\n-\n- try {\n- const [usageRes, localShareRes] = await Promise.all([\n- getPeerRemoteUsageList(),\n- getPeerShareList(),\n- ]);\n+ const mergeFederationShareFlow = useCallback(\n+ async (forwardsData: Forward[]): Promise => {\n+ if (forwardsData.length === 0) {\n+ return forwardsData;\n+ }\n \n- const flowByShare = new Map();\n- const shareIdsByTunnel = new Map>();\n+ try {\n+ const [usageRes, localShareRes] = await Promise.all([\n+ getPeerRemoteUsageList(),\n+ getPeerShareList(),\n+ ]);\n+\n+ const flowByShare = new Map();\n+ const shareIdsByTunnel = new Map>();\n+\n+ if (usageRes.code === 0 && Array.isArray(usageRes.data)) {\n+ usageRes.data.forEach((item: Record) => {\n+ const shareId = Number(item.shareId || 0);\n+ const currentFlow = Number(item.currentFlow || 0);\n+\n+ if (\n+ Number.isFinite(shareId) &&\n+ shareId > 0 &&\n+ Number.isFinite(currentFlow) &&\n+ currentFlow > 0\n+ ) {\n+ const prev = flowByShare.get(shareId) || 0;\n+\n+ flowByShare.set(shareId, Math.max(prev, currentFlow));\n+ }\n \n- if (usageRes.code === 0 && Array.isArray(usageRes.data)) {\n- usageRes.data.forEach((item: Record) => {\n- const shareId = Number(item.shareId || 0);\n- const currentFlow = Number(item.currentFlow || 0);\n+ if (Number.isFinite(shareId) && shareId > 0) {\n+ const bindings = Array.isArray(item.bindings)\n+ ? (item.bindings as Array>)\n+ : [];\n \n- if (\n- Number.isFinite(shareId) &&\n- shareId > 0 &&\n- Number.isFinite(currentFlow) &&\n- currentFlow > 0\n- ) {\n- const prev = flowByShare.get(shareId) || 0;\n+ bindings.forEach((binding) => {\n+ const tunnelId = Number(binding.tunnelId || 0);\n+ const chainType = Number(binding.chainType || 0);\n \n- flowByShare.set(shareId, Math.max(prev, currentFlow));\n- }\n+ if (!Number.isFinite(tunnelId) || tunnelId <= 0) {\n+ return;\n+ }\n \n- if (Number.isFinite(shareId) && shareId > 0) {\n- const bindings = Array.isArray(item.bindings)\n- ? (item.bindings as Array>)\n- : [];\n+ if (Number.isFinite(chainType) && chainType !== 1) {\n+ return;\n+ }\n \n- bindings.forEach((binding) => {\n- const tunnelId = Number(binding.tunnelId || 0);\n- const chainType = Number(binding.chainType || 0);\n+ let shareSet = shareIdsByTunnel.get(tunnelId);\n \n- if (!Number.isFinite(tunnelId) || tunnelId <= 0) {\n- return;\n- }\n+ if (!shareSet) {\n+ shareSet = new Set();\n+ shareIdsByTunnel.set(tunnelId, shareSet);\n+ }\n \n- if (Number.isFinite(chainType) && chainType !== 1) {\n- return;\n- }\n+ shareSet.add(shareId);\n+ });\n+ }\n+ });\n+ }\n \n- let shareSet = shareIdsByTunnel.get(tunnelId);\n+ if (localShareRes.code === 0 && Array.isArray(localShareRes.data)) {\n+ localShareRes.data.forEach((item: Record) => {\n+ const shareId = Number(item.id || 0);\n+ const currentFlow = Number(item.currentFlow || 0);\n \n- if (!shareSet) {\n- shareSet = new Set();\n- shareIdsByTunnel.set(tunnelId, shareSet);\n- }\n+ if (\n+ Number.isFinite(shareId) &&\n+ shareId > 0 &&\n+ Number.isFinite(currentFlow) &&\n+ currentFlow > 0\n+ ) {\n+ const prev = flowByShare.get(shareId) || 0;\n \n- shareSet.add(shareId);\n- });\n- }\n- });\n- }\n+ flowByShare.set(shareId, Math.max(prev, currentFlow));\n+ }\n+ });\n+ }\n \n- if (localShareRes.code === 0 && Array.isArray(localShareRes.data)) {\n- localShareRes.data.forEach((item: Record) => {\n- const shareId = Number(item.id || 0);\n- const currentFlow = Number(item.currentFlow || 0);\n+ if (flowByShare.size === 0) {\n+ return forwardsData;\n+ }\n \n- if (\n- Number.isFinite(shareId) &&\n- shareId > 0 &&\n- Number.isFinite(currentFlow) &&\n- currentFlow > 0\n- ) {\n- const prev = flowByShare.get(shareId) || 0;\n+ const resolveShareIdForForward = (forward: Forward): number | null => {\n+ const candidates = new Set();\n+ const shareIdFromName = parseShareIdFromTunnelName(\n+ forward.tunnelName || \"\",\n+ );\n \n- flowByShare.set(shareId, Math.max(prev, currentFlow));\n+ if (shareIdFromName) {\n+ candidates.add(shareIdFromName);\n }\n- });\n- }\n \n- if (flowByShare.size === 0) {\n- return forwardsData;\n- }\n+ const tunnelId = Number(forward.tunnelId || 0);\n+ const shareSetByTunnel = shareIdsByTunnel.get(tunnelId);\n \n- const resolveShareIdForForward = (forward: Forward): number | null => {\n- const candidates = new Set();\n- const shareIdFromName = parseShareIdFromTunnelName(\n- forward.tunnelName || \"\",\n- );\n+ if (shareSetByTunnel && shareSetByTunnel.size > 0) {\n+ shareSetByTunnel.forEach((shareId) => {\n+ if (Number.isFinite(shareId) && shareId > 0) {\n+ candidates.add(shareId);\n+ }\n+ });\n+ }\n \n- if (shareIdFromName) {\n- candidates.add(shareIdFromName);\n- }\n+ if (candidates.size === 0) {\n+ return null;\n+ }\n \n- const tunnelId = Number(forward.tunnelId || 0);\n- const shareSetByTunnel = shareIdsByTunnel.get(tunnelId);\n+ let bestShareId: number | null = null;\n+ let bestFlow = 0;\n \n- if (shareSetByTunnel && shareSetByTunnel.size > 0) {\n- shareSetByTunnel.forEach((shareId) => {\n- if (Number.isFinite(shareId) && shareId > 0) {\n- candidates.add(shareId);\n+ candidates.forEach((shareId) => {\n+ const shareFlow = flowByShare.get(shareId) || 0;\n+\n+ if (shareFlow > bestFlow) {\n+ bestFlow = shareFlow;\n+ bestShareId = shareId;\n }\n });\n- }\n \n- if (candidates.size === 0) {\n- return null;\n- }\n+ return bestShareId;\n+ };\n \n- let bestShareId: number | null = null;\n- let bestFlow = 0;\n+ const resolvedShareByForwardId = new Map();\n \n- candidates.forEach((shareId) => {\n- const shareFlow = flowByShare.get(shareId) || 0;\n+ forwardsData.forEach((forward) => {\n+ const shareId = resolveShareIdForForward(forward);\n \n- if (shareFlow > bestFlow) {\n- bestFlow = shareFlow;\n- bestShareId = shareId;\n+ if (shareId) {\n+ resolvedShareByForwardId.set(forward.id, shareId);\n }\n });\n \n- return bestShareId;\n- };\n+ const forwardCountByShare = new Map();\n \n- const resolvedShareByForwardId = new Map();\n+ forwardsData.forEach((forward) => {\n+ const shareId = resolvedShareByForwardId.get(forward.id) || null;\n \n- forwardsData.forEach((forward) => {\n- const shareId = resolveShareIdForForward(forward);\n-\n- if (shareId) {\n- resolvedShareByForwardId.set(forward.id, shareId);\n- }\n- });\n-\n- const forwardCountByShare = new Map();\n-\n- forwardsData.forEach((forward) => {\n- const shareId = resolvedShareByForwardId.get(forward.id) || null;\n-\n- if (!shareId || !flowByShare.has(shareId)) {\n- return;\n- }\n+ if (!shareId || !flowByShare.has(shareId)) {\n+ return;\n+ }\n \n- forwardCountByShare.set(\n- shareId,\n- (forwardCountByShare.get(shareId) || 0) + 1,\n- );\n- });\n+ forwardCountByShare.set(\n+ shareId,\n+ (forwardCountByShare.get(shareId) || 0) + 1,\n+ );\n+ });\n \n- return forwardsData.map((forward) => {\n- const shareId = resolvedShareByForwardId.get(forward.id) || null;\n+ return forwardsData.map((forward) => {\n+ const shareId = resolvedShareByForwardId.get(forward.id) || null;\n \n- if (!shareId) {\n- return { ...forward, federationShareFlow: undefined };\n- }\n+ if (!shareId) {\n+ return { ...forward, federationShareFlow: undefined };\n+ }\n \n- const shareFlow = flowByShare.get(shareId) || 0;\n+ const shareFlow = flowByShare.get(shareId) || 0;\n \n- if (shareFlow <= 0) {\n- return { ...forward, federationShareFlow: undefined };\n- }\n+ if (shareFlow <= 0) {\n+ return { ...forward, federationShareFlow: undefined };\n+ }\n \n- const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);\n+ const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);\n \n- if (directFlow > 0) {\n- return { ...forward, federationShareFlow: undefined };\n- }\n+ if (directFlow > 0) {\n+ return { ...forward, federationShareFlow: undefined };\n+ }\n \n- const count = forwardCountByShare.get(shareId) || 1;\n- const estimated = Math.max(1, Math.floor(shareFlow / count));\n+ const count = forwardCountByShare.get(shareId) || 1;\n+ const estimated = Math.max(1, Math.floor(shareFlow / count));\n \n- return { ...forward, federationShareFlow: estimated };\n- });\n- } catch {\n- return forwardsData;\n- }\n- }, []);\n+ return { ...forward, federationShareFlow: estimated };\n+ });\n+ } catch {\n+ return forwardsData;\n+ }\n+ },\n+ [],\n+ );\n \n const getForwardDisplayFlow = (forward: Forward): number => {\n const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);\n@@ -1283,45 +1285,48 @@ export default function ForwardPage() {\n );\n \n // 加载所有数据\n- const loadData = useCallback(async (lod = true) => {\n- setLoading(lod);\n- try {\n- const [tunnelsRes, speedLimitsRes] = await Promise.all([\n- userTunnel(),\n- getSpeedLimitList(),\n- ]);\n- const [allTunnelsRes, nodesRes] = await Promise.allSettled([\n- getTunnelList(),\n- getNodeList(),\n- ]);\n+ const loadData = useCallback(\n+ async (lod = true) => {\n+ setLoading(lod);\n+ try {\n+ const [tunnelsRes, speedLimitsRes] = await Promise.all([\n+ userTunnel(),\n+ getSpeedLimitList(),\n+ ]);\n+ const [allTunnelsRes, nodesRes] = await Promise.allSettled([\n+ getTunnelList(),\n+ getNodeList(),\n+ ]);\n \n- await refreshForwardList(false);\n+ await refreshForwardList(false);\n \n- if (tunnelsRes.code === 0) {\n- setTunnels(tunnelsRes.data || []);\n- } else {\n- }\n+ if (tunnelsRes.code === 0) {\n+ setTunnels(tunnelsRes.data || []);\n+ } else {\n+ }\n \n- if (\n- allTunnelsRes.status === \"fulfilled\" &&\n- allTunnelsRes.value.code === 0\n- ) {\n- setAllTunnels((allTunnelsRes.value.data || []) as Tunnel[]);\n- }\n+ if (\n+ allTunnelsRes.status === \"fulfilled\" &&\n+ allTunnelsRes.value.code === 0\n+ ) {\n+ setAllTunnels((allTunnelsRes.value.data || []) as Tunnel[]);\n+ }\n \n- if (nodesRes.status === \"fulfilled\" && nodesRes.value.code === 0) {\n- setNodes((nodesRes.value.data || []) as Node[]);\n- }\n+ if (nodesRes.status === \"fulfilled\" && nodesRes.value.code === 0) {\n+ setNodes((nodesRes.value.data || []) as Node[]);\n+ }\n \n- if (speedLimitsRes.code === 0) {\n- setSpeedLimits(speedLimitsRes.data || []);\n+ if (speedLimitsRes.code === 0) {\n+ setSpeedLimits(speedLimitsRes.data || []);\n+ }\n+ } catch {\n+ toast.error(\"加载数据失败\");\n+ } finally {\n+ setLoading(false);\n }\n- } catch {\n- toast.error(\"加载数据失败\");\n- } finally {\n- setLoading(false);\n- }\n- }, [refreshForwardList]);\n+ },\n+ [refreshForwardList],\n+ );\n \n useEffect(() => {\n loadData();\n@@ -1389,8 +1394,16 @@ export default function ForwardPage() {\n newErrors.tunnelId = \"请选择关联隧道\";\n }\n \n- if (form.inPort !== null && form.inPort !== undefined && form.inPort > 0 && currentTunnelPortRange) {\n- if (form.inPort < currentTunnelPortRange.min || form.inPort > currentTunnelPortRange.max) {\n+ if (\n+ form.inPort !== null &&\n+ form.inPort !== undefined &&\n+ form.inPort > 0 &&\n+ currentTunnelPortRange\n+ ) {\n+ if (\n+ form.inPort < currentTunnelPortRange.min ||\n+ form.inPort > currentTunnelPortRange.max\n+ ) {\n newErrors.inPort = `端口 ${currentTunnelPortRange.min}-${currentTunnelPortRange.max} 超出允许范围`;\n }\n }\n@@ -1485,17 +1498,21 @@ export default function ForwardPage() {\n toast.success(\"删除成功\");\n setDeleteModalOpen(false);\n setForwardToDelete(null);\n- setForwards((prev) => prev.filter((forward) => forward.id !== forwardToDelete.id));\n+ setForwards((prev) =>\n+ prev.filter((forward) => forward.id !== forwardToDelete.id),\n+ );\n setForwardOrder((prev) => {\n const next = prev.filter((id) => id !== forwardToDelete.id);\n \n saveOrder(FORWARD_ORDER_KEY, next);\n+\n return next;\n });\n setSelectedIds((prev) => {\n const next = new Set(prev);\n \n next.delete(forwardToDelete.id);\n+\n return next;\n });\n } else {\n@@ -1518,12 +1535,14 @@ export default function ForwardPage() {\n const next = prev.filter((id) => id !== forwardToDelete.id);\n \n saveOrder(FORWARD_ORDER_KEY, next);\n+\n return next;\n });\n setSelectedIds((prev) => {\n const next = new Set(prev);\n \n next.delete(forwardToDelete.id);\n+\n return next;\n });\n } else {\n@@ -1597,6 +1616,7 @@ export default function ForwardPage() {\n strategy: addressCount > 1 ? form.strategy : \"fifo\",\n speedId: normalizedSpeedId,\n };\n+\n res = await createForward(createData);\n }\n \n@@ -2787,7 +2807,8 @@ export default function ForwardPage() {\n }\n \n if (\n- normalizeTunnelTrafficRatio(existingTunnelGroup.tunnelTrafficRatio) === 1 &&\n+ normalizeTunnelTrafficRatio(existingTunnelGroup.tunnelTrafficRatio) ===\n+ 1 &&\n normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio) !== 1\n ) {\n existingTunnelGroup.tunnelTrafficRatio = normalizeTunnelTrafficRatio(\n@@ -4032,66 +4053,76 @@ export default function ForwardPage() {\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n-
\n- \n- \n+
\n+ \n+ 全部规则\n+ \n+ \n+ {sortedForwards.length} 条规则\n+ \n+
\n+
\n+ \n- \n- \n- {selectMode && (\n- 选择\n- )}\n- \n- 用户\n- 名称\n- 隧道\n- 入口\n- 目标\n- 策略\n- 总流量\n- 状态\n- 操作\n- \n- \n- {(forward) => (\n- \n- )}\n- \n- \n- \n- \n-
\n+ \n+ {selectMode && (\n+ 选择\n+ )}\n+ \n+ 用户\n+ 名称\n+ 隧道\n+ 入口\n+ 目标\n+ 策略\n+ 总流量\n+ 状态\n+ 操作\n+ \n+ \n+ {(forward) => (\n+ \n+ )}\n+ \n+ \n+ \n+ \n+
\n+ \n ) : (\n \n \n", "title": "Check diff", "metadata": { "output": "diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx\nindex aa897fd..72fab20 100644\n--- a/vite-frontend/src/pages/forward.tsx\n+++ b/vite-frontend/src/pages/forward.tsx\n@@ -538,9 +538,7 @@ const mapForwardApiItems = (items: ForwardApiItem[]): Forward[] => {\n name: forward.name,\n tunnelId: forward.tunnelId ?? 0,\n tunnelName: forward.tunnelName || \"\",\n- tunnelTrafficRatio: normalizeTunnelTrafficRatio(\n- forward.tunnelTrafficRatio,\n- ),\n+ tunnelTrafficRatio: normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio),\n inIp: forward.inIp || \"\",\n inPort: forward.inPort ?? 0,\n remoteAddr: forward.remoteAddr || \"\",\n@@ -776,7 +774,10 @@ export default function ForwardPage() {\n currentTunnel.portRangeMin > 0 &&\n currentTunnel.portRangeMax > 0\n ) {\n- return { min: currentTunnel.portRangeMin, max: currentTunnel.portRangeMax };\n+ return {\n+ min: currentTunnel.portRangeMin,\n+ max: currentTunnel.portRangeMax,\n+ };\n }\n \n return null;\n@@ -1036,183 +1037,184 @@ export default function ForwardPage() {\n return Number.isFinite(shareId) && shareId > 0 ? shareId : null;\n };\n \n- const mergeFederationShareFlow = useCallback(async (\n- forwardsData: Forward[],\n- ): Promise => {\n- if (forwardsData.length === 0) {\n- return forwardsData;\n- }\n-\n- try {\n- const [usageRes, localShareRes] = await Promise.all([\n- getPeerRemoteUsageList(),\n- getPeerShareList(),\n- ]);\n+ const mergeFederationShareFlow = useCallback(\n+ async (forwardsData: Forward[]): Promise => {\n+ if (forwardsData.length === 0) {\n+ return forwardsData;\n+ }\n \n- const flowByShare = new Map();\n- const shareIdsByTunnel = new Map>();\n+ try {\n+ const [usageRes, localShareRes] = await Promise.all([\n+ getPeerRemoteUsageList(),\n+ getPeerShareList(),\n+ ]);\n+\n+ const flowByShare = new Map();\n+ const shareIdsByTunnel = new Map>();\n+\n+ if (usageRes.code === 0 && Array.isArray(usageRes.data)) {\n+ usageRes.data.forEach((item: Record) => {\n+ const shareId = Number(item.shareId || 0);\n+ const currentFlow = Number(item.currentFlow || 0);\n+\n+ if (\n+ Number.isFinite(shareId) &&\n+ shareId > 0 &&\n+ Number.isFinite(currentFlow) &&\n+ currentFlow > 0\n+ ) {\n+ const prev = flowByShare.get(shareId) || 0;\n+\n+ flowByShare.set(shareId, Math.max(prev, currentFlow));\n+ }\n \n- if (usageRes.code === 0 && Array.isArray(usageRes.data)) {\n- usageRes.data.forEach((item: Record) => {\n- const shareId = Number(item.shareId || 0);\n- const currentFlow = Number(item.currentFlow || 0);\n+ if (Number.isFinite(shareId) && shareId > 0) {\n+ const bindings = Array.isArray(item.bindings)\n+ ? (item.bindings as Array>)\n+ : [];\n \n- if (\n- Number.isFinite(shareId) &&\n- shareId > 0 &&\n- Number.isFinite(currentFlow) &&\n- currentFlow > 0\n- ) {\n- const prev = flowByShare.get(shareId) || 0;\n+ bindings.forEach((binding) => {\n+ const tunnelId = Number(binding.tunnelId || 0);\n+ const chainType = Number(binding.chainType || 0);\n \n- flowByShare.set(shareId, Math.max(prev, currentFlow));\n- }\n+ if (!Number.isFinite(tunnelId) || tunnelId <= 0) {\n+ return;\n+ }\n \n- if (Number.isFinite(shareId) && shareId > 0) {\n- const bindings = Array.isArray(item.bindings)\n- ? (item.bindings as Array>)\n- : [];\n+ if (Number.isFinite(chainType) && chainType !== 1) {\n+ return;\n+ }\n \n- bindings.forEach((binding) => {\n- const tunnelId = Number(binding.tunnelId || 0);\n- const chainType = Number(binding.chainType || 0);\n+ let shareSet = shareIdsByTunnel.get(tunnelId);\n \n- if (!Number.isFinite(tunnelId) || tunnelId <= 0) {\n- return;\n- }\n+ if (!shareSet) {\n+ shareSet = new Set();\n+ shareIdsByTunnel.set(tunnelId, shareSet);\n+ }\n \n- if (Number.isFinite(chainType) && chainType !== 1) {\n- return;\n- }\n+ shareSet.add(shareId);\n+ });\n+ }\n+ });\n+ }\n \n- let shareSet = shareIdsByTunnel.get(tunnelId);\n+ if (localShareRes.code === 0 && Array.isArray(localShareRes.data)) {\n+ localShareRes.data.forEach((item: Record) => {\n+ const shareId = Number(item.id || 0);\n+ const currentFlow = Number(item.currentFlow || 0);\n \n- if (!shareSet) {\n- shareSet = new Set();\n- shareIdsByTunnel.set(tunnelId, shareSet);\n- }\n+ if (\n+ Number.isFinite(shareId) &&\n+ shareId > 0 &&\n+ Number.isFinite(currentFlow) &&\n+ currentFlow > 0\n+ ) {\n+ const prev = flowByShare.get(shareId) || 0;\n \n- shareSet.add(shareId);\n- });\n- }\n- });\n- }\n+ flowByShare.set(shareId, Math.max(prev, currentFlow));\n+ }\n+ });\n+ }\n \n- if (localShareRes.code === 0 && Array.isArray(localShareRes.data)) {\n- localShareRes.data.forEach((item: Record) => {\n- const shareId = Number(item.id || 0);\n- const currentFlow = Number(item.currentFlow || 0);\n+ if (flowByShare.size === 0) {\n+ return forwardsData;\n+ }\n \n- if (\n- Number.isFinite(shareId) &&\n- shareId > 0 &&\n- Number.isFinite(currentFlow) &&\n- currentFlow > 0\n- ) {\n- const prev = flowByShare.get(shareId) || 0;\n+ const resolveShareIdForForward = (forward: Forward): number | null => {\n+ const candidates = new Set();\n+ const shareIdFromName = parseShareIdFromTunnelName(\n+ forward.tunnelName || \"\",\n+ );\n \n- flowByShare.set(shareId, Math.max(prev, currentFlow));\n+ if (shareIdFromName) {\n+ candidates.add(shareIdFromName);\n }\n- });\n- }\n \n- if (flowByShare.size === 0) {\n- return forwardsData;\n- }\n+ const tunnelId = Number(forward.tunnelId || 0);\n+ const shareSetByTunnel = shareIdsByTunnel.get(tunnelId);\n \n- const resolveShareIdForForward = (forward: Forward): number | null => {\n- const candidates = new Set();\n- const shareIdFromName = parseShareIdFromTunnelName(\n- forward.tunnelName || \"\",\n- );\n+ if (shareSetByTunnel && shareSetByTunnel.size > 0) {\n+ shareSetByTunnel.forEach((shareId) => {\n+ if (Number.isFinite(shareId) && shareId > 0) {\n+ candidates.add(shareId);\n+ }\n+ });\n+ }\n \n- if (shareIdFromName) {\n- candidates.add(shareIdFromName);\n- }\n+ if (candidates.size === 0) {\n+ return null;\n+ }\n \n- const tunnelId = Number(forward.tunnelId || 0);\n- const shareSetByTunnel = shareIdsByTunnel.get(tunnelId);\n+ let bestShareId: number | null = null;\n+ let bestFlow = 0;\n \n- if (shareSetByTunnel && shareSetByTunnel.size > 0) {\n- shareSetByTunnel.forEach((shareId) => {\n- if (Number.isFinite(shareId) && shareId > 0) {\n- candidates.add(shareId);\n+ candidates.forEach((shareId) => {\n+ const shareFlow = flowByShare.get(shareId) || 0;\n+\n+ if (shareFlow > bestFlow) {\n+ bestFlow = shareFlow;\n+ bestShareId = shareId;\n }\n });\n- }\n \n- if (candidates.size === 0) {\n- return null;\n- }\n+ return bestShareId;\n+ };\n \n- let bestShareId: number | null = null;\n- let bestFlow = 0;\n+ const resolvedShareByForwardId = new Map();\n \n- candidates.forEach((shareId) => {\n- const shareFlow = flowByShare.get(shareId) || 0;\n+ forwardsData.forEach((forward) => {\n+ const shareId = resolveShareIdForForward(forward);\n \n- if (shareFlow > bestFlow) {\n- bestFlow = shareFlow;\n- bestShareId = shareId;\n+ if (shareId) {\n+ resolvedShareByForwardId.set(forward.id, shareId);\n }\n });\n \n- return bestShareId;\n- };\n+ const forwardCountByShare = new Map();\n \n- const resolvedShareByForwardId = new Map();\n+ forwardsData.forEach((forward) => {\n+ const shareId = resolvedShareByForwardId.get(forward.id) || null;\n \n- forwardsData.forEach((forward) => {\n- const shareId = resolveShareIdForForward(forward);\n-\n- if (shareId) {\n- resolvedShareByForwardId.set(forward.id, shareId);\n- }\n- });\n-\n- const forwardCountByShare = new Map();\n-\n- forwardsData.forEach((forward) => {\n- const shareId = resolvedShareByForwardId.get(forward.id) || null;\n-\n- if (!shareId || !flowByShare.has(shareId)) {\n- return;\n- }\n+ if (!shareId || !flowByShare.has(shareId)) {\n+ return;\n+ }\n \n- forwardCountByShare.set(\n- shareId,\n- (forwardCountByShare.get(shareId) || 0) + 1,\n- );\n- });\n+ forwardCountByShare.set(\n+ shareId,\n+ (forwardCountByShare.get(shareId) || 0) + 1,\n+ );\n+ });\n \n- return forwardsData.map((forward) => {\n- const shareId = resolvedShareByForwardId.get(forward.id) || null;\n+ return forwardsData.map((forward) => {\n+ const shareId = resolvedShareByForwardId.get(forward.id) || null;\n \n- if (!shareId) {\n- return { ...forward, federationShareFlow: undefined };\n- }\n+ if (!shareId) {\n+ return { ...forward, federationShareFlow: undefined };\n+ }\n \n- const shareFlow = flowByShare.get(shareId) || 0;\n+ const shareFlow = flowByShare.get(shareId) || 0;\n \n- if (shareFlow <= 0) {\n- return { ...forward, federationShareFlow: undefined };\n- }\n+ if (shareFlow <= 0) {\n+ return { ...forward, federationShareFlow: undefined };\n+ }\n \n- const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);\n+ const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);\n \n- if (directFlow > 0) {\n- return { ...forward, federationShareFlow: undefined };\n- }\n+ if (directFlow > 0) {\n+ return { ...forward, federationShareFlow: undefined };\n+ }\n \n- const count = forwardCountByShare.get(shareId) || 1;\n- const estimated = Math.max(1, Math.floor(shareFlow / count));\n+ const count = forwardCountByShare.get(shareId) || 1;\n+ const estimated = Math.max(1, Math.floor(shareFlow / count));\n \n- return { ...forward, federationShareFlow: estimated };\n- });\n- } catch {\n- return forwardsData;\n- }\n- }, []);\n+ return { ...forward, federationShareFlow: estimated };\n+ });\n+ } catch {\n+ return forwardsData;\n+ }\n+ },\n+ [],\n+ );\n \n const getForwardDisplayFlow = (forward: Forward): number => {\n const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);\n@@ -1283,45 +1285,48 @@ export default function ForwardPage() {\n );\n \n // 加载所有数据\n- const loadData = useCallback(async (lod = true) => {\n- setLoading(lod);\n- try {\n- const [tunnelsRes, speedLimitsRes] = await Promise.all([\n- userTunnel(),\n- getSpeedLimitList(),\n- ]);\n- const [allTunnelsRes, nodesRes] = await Promise.allSettled([\n- getTunnelList(),\n- getNodeList(),\n- ]);\n+ const loadData = useCallback(\n+ async (lod = true) => {\n+ setLoading(lod);\n+ try {\n+ const [tunnelsRes, speedLimitsRes] = await Promise.all([\n+ userTunnel(),\n+ getSpeedLimitList(),\n+ ]);\n+ const [allTunnelsRes, nodesRes] = await Promise.allSettled([\n+ getTunnelList(),\n+ getNodeList(),\n+ ]);\n \n- await refreshForwardList(false);\n+ await refreshForwardList(false);\n \n- if (tunnelsRes.code === 0) {\n- setTunnels(tunnelsRes.data || []);\n- } else {\n- }\n+ if (tunnelsRes.code === 0) {\n+ setTunnels(tunnelsRes.data || []);\n+ } else {\n+ }\n \n- if (\n- allTunnelsRes.status === \"fulfilled\" &&\n- allTunnelsRes.value.code === 0\n- ) {\n- setAllTunnels((allTunnelsRes.value.data || []) as Tunnel[]);\n- }\n+ if (\n+ allTunnelsRes.status === \"fulfilled\" &&\n+ allTunnelsRes.value.code === 0\n+ ) {\n+ setAllTunnels((allTunnelsRes.value.data || []) as Tunnel[]);\n+ }\n \n- if (nodesRes.status === \"fulfilled\" && nodesRes.value.code === 0) {\n- setNodes((nodesRes.value.data || []) as Node[]);\n- }\n+ if (nodesRes.status === \"fulfilled\" && nodesRes.value.code === 0) {\n+ setNodes((nodesRes.value.data || []) as Node[]);\n+ }\n \n- if (speedLimitsRes.code === 0) {\n- setSpeedLimits(speedLimitsRes.data || []);\n+ if (speedLimitsRes.code === 0) {\n+ setSpeedLimits(speedLimitsRes.data || []);\n+ }\n+ } catch {\n+ toast.error(\"加载数据失败\");\n+ } finally {\n+ setLoading(false);\n }\n- } catch {\n- toast.error(\"加载数据失败\");\n- } finally {\n- setLoading(false);\n- }\n- }, [refreshForwardList]);\n+ },\n+ [refreshForwardList],\n+ );\n \n useEffect(() => {\n loadData();\n@@ -1389,8 +1394,16 @@ export default function ForwardPage() {\n newErrors.tunnelId = \"请选择关联隧道\";\n }\n \n- if (form.inPort !== null && form.inPort !== undefined && form.inPort > 0 && currentTunnelPortRange) {\n- if (form.inPort < currentTunnelPortRange.min || form.inPort > currentTunnelPortRange.max) {\n+ if (\n+ form.inPort !== null &&\n+ form.inPort !== undefined &&\n+ form.inPort > 0 &&\n+ currentTunnelPortRange\n+ ) {\n+ if (\n+ form.inPort < currentTunnelPortRange.min ||\n+ form.inPort > currentTunnelPortRange.max\n+ ) {\n newErrors.inPort = `端口 ${currentTunnelPortRange.min}-${currentTunnelPortRange.max} 超出允许范围`;\n }\n }\n@@ -1485,17 +1498,21 @@ export default function ForwardPage() {\n toast.success(\"删除成功\");\n setDeleteModalOpen(false);\n setForwardToDelete(null);\n- setForwards((prev) => prev.filter((forward) => forward.id !== forwardToDelete.id));\n+ setForwards((prev) =>\n+ prev.filter((forward) => forward.id !== forwardToDelete.id),\n+ );\n setForwardOrder((prev) => {\n const next = prev.filter((id) => id !== forwardToDelete.id);\n \n saveOrder(FORWARD_ORDER_KEY, next);\n+\n return next;\n });\n setSelectedIds((prev) => {\n const next = new Set(prev);\n \n next.delete(forwardToDelete.id);\n+\n return next;\n });\n } else {\n@@ -1518,12 +1535,14 @@ export default function ForwardPage() {\n const next = prev.filter((id) => id !== forwardToDelete.id);\n \n saveOrder(FORWARD_ORDER_KEY, next);\n+\n return next;\n });\n setSelectedIds((prev) => {\n const next = new Set(prev);\n \n next.delete(forwardToDelete.id);\n+\n return next;\n });\n } else {\n@@ -1597,6 +1616,7 @@ export default function ForwardPage() {\n strategy: addressCount > 1 ? form.strategy : \"fifo\",\n speedId: normalizedSpeedId,\n };\n+\n res = await createForward(createData);\n }\n \n@@ -2787,7 +2807,8 @@ export default function ForwardPage() {\n }\n \n if (\n- normalizeTunnelTrafficRatio(existingTunnelGroup.tunnelTrafficRatio) === 1 &&\n+ normalizeTunnelTrafficRatio(existingTunnelGroup.tunnelTrafficRatio) ===\n+ 1 &&\n normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio) !== 1\n ) {\n existingTunnelGroup.tunnelTrafficRatio = normalizeTunnelTrafficRatio(\n@@ -4032,66 +4053,76 @@ export default function ForwardPage() {\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n-
\n- \n- \n+
\n+ \n+ 全部规则\n+ \n+ \n+ {sortedForwards.length} 条规则\n+ \n+
\n+
\n+ \n- \n- \n- {selectMode && (\n- 选择\n- )}\n- \n- 用户\n- 名称\n- 隧道\n- 入口\n- 目标\n- 策略\n- 总流量\n- 状态\n- 操作\n- \n- \n- {(forward) => (\n- \n- )}\n- \n- \n- \n- \n-
\n+ \n+ {selectMode && (\n+ 选择\n+ )}\n+ \n+ 用户\n+ 名称\n+ 隧道\n+ 入口\n+ 目标\n+ 策略\n+ 总流量\n+ 状态\n+ 操作\n+ \n+ \n+ {(forward) => (\n+ \n+ )}\n+ \n+ \n+ \n+ \n+
\n+ \n ) : (\n \n \n", "exit": 0, "description": "Check diff", "truncated": false }, "time": { "start": 1773020880882, "end": 1773020880882 } }, "id": "prt_cd0479ff10026Ms1JlIb2WS5Me", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0478b1f001zBuNiXlz7e4YkM" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "cost": 0, "tokens": { "total": 42097, "input": 1409, "output": 33, "reasoning": 0, "cache": { "read": 40655, "write": 0 } }, "id": "prt_cd0479ff3001867qoGn3rYELbE", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0478b1f001zBuNiXlz7e4YkM" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020881057, "completed": 1773020889255 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 48842, "input": 16256, "output": 33, "reasoning": 0, "cache": { "read": 32553, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd047a0a1001W30fh6vENpK2Wo", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "id": "prt_cd047af53001XIMIWL4SgjhsIF", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd047a0a1001W30fh6vENpK2Wo" }, { "type": "tool", "callID": "bash-1773020884685085439-232", "tool": "bash", "state": { "status": "completed", "input": { "command": "npm run build --prefix vite-frontend", "description": "Double check the build and verify logic" }, "output": "\n> flvx@0.0.0 build\n> tsc && vite build\n\nrolldown-vite v7.3.1 building client environment for production...\n\u001b[2K\rtransforming...✓ 3193 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/manifest.webmanifest 0.43 kB\ndist/index.html 7.51 kB │ gzip: 2.50 kB\ndist/assets/index-CAG6-7s5.css 115.55 kB │ gzip: 17.83 kB\ndist/assets/workbox-window.prod.es5-Bm4xbD7U.js 8.29 kB │ gzip: 2.81 kB\ndist/assets/index-0r9ArjZC.js 4,010.23 kB │ gzip: 827.74 kB\n✓ built in 746ms\n\nPWA v1.2.0\nmode generateSW\nprecache 9 entries (4044.54 KiB)\nfiles generated\n dist/sw.js\n dist/workbox-8c29f6e4.js\n", "title": "Double check the build and verify logic", "metadata": { "output": "\n> flvx@0.0.0 build\n> tsc && vite build\n\nrolldown-vite v7.3.1 building client environment for production...\n\u001b[2K\rtransforming...✓ 3193 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/manifest.webmanifest 0.43 kB\ndist/index.html 7.51 kB │ gzip: 2.50 kB\ndist/assets/index-CAG6-7s5.css 115.55 kB │ gzip: 17.83 kB\ndist/assets/workbox-window.prod.es5-Bm4xbD7U.js 8.29 kB │ gzip: 2.81 kB\ndist/assets/index-0r9ArjZC.js 4,010.23 kB │ gzip: 827.74 kB\n✓ built in 746ms\n\nPWA v1.2.0\nmode generateSW\nprecache 9 entries (4044.54 KiB)\nfiles generated\n dist/sw.js\n dist/workbox-8c29f6e4.js\n", "exit": 0, "description": "Double check the build and verify logic", "truncated": false }, "time": { "start": 1773020884820, "end": 1773020889030 } }, "id": "prt_cd047af540010DiZbwi9e6Kdbc", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd047a0a1001W30fh6vENpK2Wo" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "cost": 0, "tokens": { "total": 48842, "input": 16256, "output": 33, "reasoning": 0, "cache": { "read": 32553, "write": 0 } }, "id": "prt_cd047bfc7001RUME18tot3Ipnt", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd047a0a1001W30fh6vENpK2Wo" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020889262, "completed": 1773020893128 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 49201, "input": 8461, "output": 51, "reasoning": 0, "cache": { "read": 40689, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd047c0ae001iI3DLsxMmSMQkz", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "id": "prt_cd047cf1c00117PKkus8HvjLb3", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd047c0ae001iI3DLsxMmSMQkz" }, { "type": "tool", "callID": "bash-1773020892808029129-233", "tool": "bash", "state": { "status": "completed", "input": { "description": "Check if compact grouped mode renders correctly", "command": "sed -n '4100,4300p' vite-frontend/src/pages/forward.tsx" }, "output": " items={sortedForwards}\n >\n {(forward) => (\n \n )}\n \n \n \n \n
\n \n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : sortedForwards.length > 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n {}}\n >\n \n
\n {sortedForwards.map((forward) =>\n forward && forward.id ? (\n \n ) : null,\n )}\n
\n \n \n \n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n
\n {groupedForwards.map((group) => {\n const isSelfGroup =\n isAdmin && tokenUserId !== null && group.userId === tokenUserId;\n const groupForwardCount = group.tunnels.reduce(\n (total, tunnel) => total + tunnel.items.length,\n 0,\n );\n\n return (\n \n
\n
\n \n {group.userName}\n \n {isSelfGroup && (\n \n 管理员本人\n \n )}\n
\n \n {groupForwardCount} 条规则\n \n
\n\n
\n \n \n buildTunnelGroupSortableId(\n group.userId,\n tunnel.tunnelKey,\n ),\n )}\n strategy={verticalListSortingStrategy}\n >\n {group.tunnels.map((tunnel) => {\n const tunnelSortableForwardIds = tunnel.items\n .map((item) => item.id)\n .filter((id) => id > 0);\n const collapsed =\n sanitizedCollapsedTunnelGroups[\n buildTunnelGroupCollapseKey(\n group.userId,\n tunnel.tunnelKey,\n )\n ] === true;\n\n return (\n \n toggleTunnelGroupCollapsed(\n group.userId,\n tunnel.tunnelKey,\n )\n }\n >\n \n \n \n {selectMode && (\n \n 选择\n \n )}\n \n \n 名称\n \n \n 入口\n \n \n {(forward) => (\n \n )}\n \n \n \n \n
\n \n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : sortedForwards.length > 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n {}}\n >\n \n
\n {sortedForwards.map((forward) =>\n forward && forward.id ? (\n \n ) : null,\n )}\n
\n \n \n \n ) : (\n \n \n

\n 暂无规则配置\n

\n

\n 还没有创建任何规则配置,点击上方按钮开始创建\n

\n
\n
\n )\n ) : viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n
\n {groupedForwards.map((group) => {\n const isSelfGroup =\n isAdmin && tokenUserId !== null && group.userId === tokenUserId;\n const groupForwardCount = group.tunnels.reduce(\n (total, tunnel) => total + tunnel.items.length,\n 0,\n );\n\n return (\n \n
\n
\n \n {group.userName}\n \n {isSelfGroup && (\n \n 管理员本人\n \n )}\n
\n \n {groupForwardCount} 条规则\n \n
\n\n
\n \n \n buildTunnelGroupSortableId(\n group.userId,\n tunnel.tunnelKey,\n ),\n )}\n strategy={verticalListSortingStrategy}\n >\n {group.tunnels.map((tunnel) => {\n const tunnelSortableForwardIds = tunnel.items\n .map((item) => item.id)\n .filter((id) => id > 0);\n const collapsed =\n sanitizedCollapsedTunnelGroups[\n buildTunnelGroupCollapseKey(\n group.userId,\n tunnel.tunnelKey,\n )\n ] === true;\n\n return (\n \n toggleTunnelGroupCollapsed(\n group.userId,\n tunnel.tunnelKey,\n )\n }\n >\n \n \n \n {selectMode && (\n \n 选择\n \n )}\n \n \n 名称\n \n \n 入口\n \n {tunnel.items.length} 条规则\nvite-frontend/src/pages/forward.tsx:4062: {sortedForwards.length} 条规则\nvite-frontend/src/pages/forward.tsx:4145: {sortedForwards.length} 条规则\nvite-frontend/src/pages/forward.tsx:4208: {groupForwardCount} 条规则\nvite-frontend/src/pages/forward.tsx:4424: {groupForwardCount} 条规则\n", "title": "Check other possible files", "metadata": { "output": "vite-frontend/src/pages/forward.tsx:3004: {tunnel.items.length} 条规则\nvite-frontend/src/pages/forward.tsx:4062: {sortedForwards.length} 条规则\nvite-frontend/src/pages/forward.tsx:4145: {sortedForwards.length} 条规则\nvite-frontend/src/pages/forward.tsx:4208: {groupForwardCount} 条规则\nvite-frontend/src/pages/forward.tsx:4424: {groupForwardCount} 条规则\n", "exit": 0, "description": "Check other possible files", "truncated": false }, "time": { "start": 1773020906629, "end": 1773020906631 } }, "id": "prt_cd0480484001RMXQG9eF1mYQiB", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd047f132001rUBiw1D698Jl4C" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "cost": 0, "tokens": { "total": 51403, "input": 2545, "output": 34, "reasoning": 0, "cache": { "read": 48824, "write": 0 } }, "id": "prt_cd0480489001PQxx28I8iXrXev", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd047f132001rUBiw1D698Jl4C" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020906925, "completed": 1773020912636 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 51636, "input": 2745, "output": 72, "reasoning": 0, "cache": { "read": 48819, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd04805ad0011xMKEcdohT6zAp", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "id": "prt_cd0481b4d0015DjAIqtcXkV5WB", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd04805ad0011xMKEcdohT6zAp" }, { "type": "tool", "callID": "bash-1773020912298874008-237", "tool": "bash", "state": { "status": "completed", "input": { "description": "Make sure there are no other places that need the same fix", "command": "sed -n '4050,4200p' vite-frontend/src/pages/forward.tsx | grep -B 10 -A 10 \"条规则\"" }, "output": " {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n
\n \n \n \n )\n ) : sortedForwards.length > 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n {}}\n >\n 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n
\n \n \n \n )\n ) : sortedForwards.length > 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n {}}\n >\n \n {isSelfGroup && (\n \n 管理员本人\n \n )}\n
\n \n {groupForwardCount} 条规则\n \n
\n\n
\n \n \n {isSelfGroup && (\n \n 管理员本人\n \n )}\n
\n \n {groupForwardCount} 条规则\n \n
\n\n
\n \n \n {group.userName}\n \n {isSelfGroup && (\n \n 管理员本人\n \n )}\n
\n \n {groupForwardCount} 条规则\n \n
\n\n
\n \n \n {group.userName}\n \n {isSelfGroup && (\n \n 管理员本人\n \n )}\n
\n \n {groupForwardCount} 条规则\n \n
\n\n
\n \n 规则数量\nvite-frontend/src/pages/user.tsx:1143: label=\"规则数量\"\nvite-frontend/src/pages/user.tsx:1280: 流量限制、规则数量、到期时间、流量重置时间将自动继承用户设置\nvite-frontend/src/pages/user.tsx:1438: 规则数量\nvite-frontend/src/pages/user.tsx:1595: label=\"规则数量\"\n", "title": "Check other places mentioning rules count", "metadata": { "output": "vite-frontend/src/pages/user.tsx:987: 规则数量\nvite-frontend/src/pages/user.tsx:1143: label=\"规则数量\"\nvite-frontend/src/pages/user.tsx:1280: 流量限制、规则数量、到期时间、流量重置时间将自动继承用户设置\nvite-frontend/src/pages/user.tsx:1438: 规则数量\nvite-frontend/src/pages/user.tsx:1595: label=\"规则数量\"\n", "exit": 0, "description": "Check other places mentioning rules count", "truncated": false }, "time": { "start": 1773020925027, "end": 1773020925027 } }, "id": "prt_cd0484c620023TAD0PoLOlBkrp", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0483c4c001u3211rLe3TQbsI" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "cost": 0, "tokens": { "total": 52512, "input": 3668, "output": 36, "reasoning": 0, "cache": { "read": 48808, "write": 0 } }, "id": "prt_cd0484c63001pV3Ur8GDQlH75B", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0483c4c001u3211rLe3TQbsI" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020925207, "completed": 1773020929381 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 52720, "input": 3871, "output": 45, "reasoning": 0, "cache": { "read": 48804, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd0484d17001i4EhxWoXD8hjw1", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "id": "prt_cd0485cb7001umwG1u11xT6JFP", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0484d17001i4EhxWoXD8hjw1" }, { "type": "tool", "callID": "bash-1773020929053681149-241", "tool": "bash", "state": { "status": "completed", "input": { "command": "grep -C 5 \"全部规则\" vite-frontend/src/pages/forward.tsx", "description": "Ensure there are no other missing sections" }, "output": " viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n--\n \n 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n", "title": "Ensure there are no other missing sections", "metadata": { "output": " viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n--\n \n 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n", "exit": 0, "description": "Ensure there are no other missing sections", "truncated": false }, "time": { "start": 1773020929207, "end": 1773020929208 } }, "id": "prt_cd0485cb7002KEin5HYKShbZx9", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0484d17001i4EhxWoXD8hjw1" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "cost": 0, "tokens": { "total": 52720, "input": 3871, "output": 45, "reasoning": 0, "cache": { "read": 48804, "write": 0 } }, "id": "prt_cd0485cb80014cvjPjD06AZZ32", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0484d17001i4EhxWoXD8hjw1" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020929385, "completed": 1773020934004 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 53135, "input": 4297, "output": 37, "reasoning": 0, "cache": { "read": 48801, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd0485d69001DKP3bNJKa3voYG", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "id": "prt_cd0486ecd0010zDf6gtp4jDeMi", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0485d69001DKP3bNJKa3voYG" }, { "type": "tool", "callID": "bash-1773020933705360975-242", "tool": "bash", "state": { "status": "completed", "input": { "command": "npm run dev --prefix vite-frontend &", "description": "Run the local server so I can see it working" }, "output": "", "title": "Run the local server so I can see it working", "metadata": { "output": "", "exit": 0, "description": "Run the local server so I can see it working", "truncated": false }, "time": { "start": 1773020933838, "end": 1773020933838 } }, "id": "prt_cd0486ecd002PArlz4N3mKfkFO", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0485d69001DKP3bNJKa3voYG" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "cost": 0, "tokens": { "total": 53135, "input": 4297, "output": 37, "reasoning": 0, "cache": { "read": 48801, "write": 0 } }, "id": "prt_cd0486ece001ScTgdpw12uAluN", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0485d69001DKP3bNJKa3voYG" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020934006, "completed": 1773020943756 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 53171, "input": 284, "output": 24, "reasoning": 0, "cache": { "read": 52863, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd0486f76001ahLVbolbLWyDMw", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "id": "prt_cd0488177001VonEIgQorkrNtn", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0486f76001ahLVbolbLWyDMw" }, { "type": "tool", "callID": "bash-1773020938458969115-243", "tool": "bash", "state": { "status": "completed", "input": { "description": "Wait for server", "command": "sleep 5" }, "output": "", "title": "Wait for server", "metadata": { "output": "", "exit": 0, "description": "Wait for server", "truncated": false }, "time": { "start": 1773020938616, "end": 1773020943492 } }, "id": "prt_cd0488177002r2TIjdYhRywIZ5", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0486f76001ahLVbolbLWyDMw" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "cost": 0, "tokens": { "total": 53171, "input": 284, "output": 24, "reasoning": 0, "cache": { "read": 52863, "write": 0 } }, "id": "prt_cd0489487001YElIsy2gk0trK3", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0486f76001ahLVbolbLWyDMw" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020943760, "completed": 1773020947876 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 53215, "input": 326, "output": 32, "reasoning": 0, "cache": { "read": 52857, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd0489590001ERg5DVQoJM85mb", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "id": "prt_cd048a4fc001LkKw8Jv7Ter03S", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0489590001ERg5DVQoJM85mb" }, { "type": "tool", "callID": "bash-1773020947563947319-244", "tool": "bash", "state": { "status": "completed", "input": { "description": "Check if port is open", "command": "lsof -i :8080" }, "output": "", "title": "Check if port is open", "metadata": { "output": "", "exit": 1, "description": "Check if port is open", "truncated": false }, "time": { "start": 1773020947709, "end": 1773020947709 } }, "id": "prt_cd048a4fd0017jDI2mINNTg6MU", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0489590001ERg5DVQoJM85mb" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "cost": 0, "tokens": { "total": 53215, "input": 326, "output": 32, "reasoning": 0, "cache": { "read": 52857, "write": 0 } }, "id": "prt_cd048a4fe0011xPmVn8znxXWZT", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0489590001ERg5DVQoJM85mb" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020947879, "completed": 1773020951767 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 53260, "input": 4441, "output": 33, "reasoning": 0, "cache": { "read": 48786, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd048a5a7001BJBNAMlOrgsMfO", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "id": "prt_cd048b42b0015ON1QeiqI8CwdF", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd048a5a7001BJBNAMlOrgsMfO" }, { "type": "tool", "callID": "bash-1773020951444214674-245", "tool": "bash", "state": { "status": "completed", "input": { "description": "Check if Vite port is open", "command": "lsof -i :5173" }, "output": "", "title": "Check if Vite port is open", "metadata": { "output": "", "exit": 1, "description": "Check if Vite port is open", "truncated": false }, "time": { "start": 1773020951596, "end": 1773020951596 } }, "id": "prt_cd048b42b002OEMY72BeOu95m3", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd048a5a7001BJBNAMlOrgsMfO" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "cost": 0, "tokens": { "total": 53260, "input": 4441, "output": 33, "reasoning": 0, "cache": { "read": 48786, "write": 0 } }, "id": "prt_cd048b42c001NV0I208241s9P5", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd048a5a7001BJBNAMlOrgsMfO" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020951771, "completed": 1773020956781 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 53300, "input": 426, "output": 28, "reasoning": 0, "cache": { "read": 52846, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd048b4db001HhiT7ZTcXn2ORP", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "id": "prt_cd048c7d5001A34huVEW150BAp", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd048b4db001HhiT7ZTcXn2ORP" }, { "type": "tool", "callID": "bash-1773020956499242366-246", "tool": "bash", "state": { "status": "completed", "input": { "command": "ps aux | grep vite", "description": "Check if vite is running" }, "output": "sagit 49371 3.8 3.0 468042208 496160 ?? SN 9:48上午 0:02.31 node /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/node_modules/.bin/vite\nsagit 49470 0.0 0.0 435301504 1408 ?? S 9:49上午 0:00.00 grep vite\nsagit 49467 0.0 0.0 435310048 2288 ?? Ss 9:49上午 0:00.01 /bin/zsh -c ps aux | grep vite\n", "title": "Check if vite is running", "metadata": { "output": "sagit 49371 3.8 3.0 468042208 496160 ?? SN 9:48上午 0:02.31 node /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/node_modules/.bin/vite\nsagit 49470 0.0 0.0 435301504 1408 ?? S 9:49上午 0:00.00 grep vite\nsagit 49467 0.0 0.0 435310048 2288 ?? Ss 9:49上午 0:00.01 /bin/zsh -c ps aux | grep vite\n", "exit": 0, "description": "Check if vite is running", "truncated": false }, "time": { "start": 1773020956630, "end": 1773020956630 } }, "id": "prt_cd048c7d6001NcoAEmgknI9cua", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd048b4db001HhiT7ZTcXn2ORP" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "cost": 0, "tokens": { "total": 53300, "input": 426, "output": 28, "reasoning": 0, "cache": { "read": 52846, "write": 0 } }, "id": "prt_cd048c7d6002bWL22ElTORhBQ1", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd048b4db001HhiT7ZTcXn2ORP" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020956784, "completed": 1773020960899 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 53578, "input": 708, "output": 28, "reasoning": 0, "cache": { "read": 52842, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd048c870001znEbn393OoKflE", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "id": "prt_cd048d7db001Z3InzA25G3c2Nm", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd048c870001znEbn393OoKflE" }, { "type": "tool", "callID": "bash-1773020960576820753-247", "tool": "bash", "state": { "status": "completed", "input": { "description": "Check scripts", "command": "cat vite-frontend/package.json" }, "output": "{\n \"name\": \"flvx\",\n \"private\": true,\n \"version\": \"0.0.0\",\n \"type\": \"module\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"tsc && vite build\",\n \"lint\": \"eslint --fix\",\n \"preview\": \"vite preview\"\n },\n \"dependencies\": {\n \"@dnd-kit/core\": \"^6.3.1\",\n \"@dnd-kit/sortable\": \"^10.0.0\",\n \"@dnd-kit/utilities\": \"^3.2.2\",\n \"@internationalized/date\": \"^3.10.0\",\n \"@marsidev/react-turnstile\": \"^1.1.0\",\n \"@radix-ui/react-accordion\": \"^1.2.0\",\n \"@radix-ui/react-checkbox\": \"^1.1.0\",\n \"@radix-ui/react-dialog\": \"^1.1.0\",\n \"@radix-ui/react-dropdown-menu\": \"^2.1.0\",\n \"@radix-ui/react-label\": \"^2.1.0\",\n \"@radix-ui/react-progress\": \"^1.1.0\",\n \"@radix-ui/react-radio-group\": \"^1.2.0\",\n \"@radix-ui/react-select\": \"^2.1.0\",\n \"@radix-ui/react-separator\": \"^1.1.0\",\n \"@radix-ui/react-slot\": \"^1.1.0\",\n \"@radix-ui/react-switch\": \"^1.1.0\",\n \"@radix-ui/react-tabs\": \"^1.1.0\",\n \"@react-aria/i18n\": \"^3.12.12\",\n \"@react-aria/visually-hidden\": \"3.8.25\",\n \"@react-types/shared\": \"3.30.0\",\n \"@tailwindcss/postcss\": \"4.1.11\",\n \"@tailwindcss/vite\": \"^4.1.18\",\n \"@types/react-beautiful-dnd\": \"^13.1.8\",\n \"axios\": \"^1.11.0\",\n \"class-variance-authority\": \"^0.7.1\",\n \"clsx\": \"2.1.1\",\n \"framer-motion\": \"11.18.2\",\n \"lucide-react\": \"^0.542.0\",\n \"react\": \"18.3.1\",\n \"react-beautiful-dnd\": \"^13.1.1\",\n \"react-dom\": \"18.3.1\",\n \"react-hot-toast\": \"^2.5.2\",\n \"react-is\": \"^19.2.4\",\n \"react-markdown\": \"^10.1.0\",\n \"react-router-dom\": \"6.30.3\",\n \"recharts\": \"^3.1.1\",\n \"rehype-sanitize\": \"^6.0.0\",\n \"remark-gfm\": \"^4.0.1\",\n \"sonner\": \"^2.0.6\",\n \"tailwind-merge\": \"^2.5.5\",\n \"tailwind-variants\": \"1.0.0\",\n \"tailwindcss\": \"4.1.11\",\n \"tw-animate-css\": \"^1.3.0\"\n },\n \"devDependencies\": {\n \"@eslint/compat\": \"1.2.8\",\n \"@eslint/eslintrc\": \"3.3.1\",\n \"@eslint/js\": \"9.39.2\",\n \"@types/node\": \"^24.3.0\",\n \"@types/react\": \"18.3.3\",\n \"@types/react-dom\": \"18.3.0\",\n \"@typescript-eslint/eslint-plugin\": \"8.31.1\",\n \"@typescript-eslint/parser\": \"8.31.1\",\n \"@vitejs/plugin-react\": \"^5.1.3\",\n \"eslint\": \"9.39.2\",\n \"eslint-config-prettier\": \"9.1.0\",\n \"eslint-plugin-import\": \"2.31.0\",\n \"eslint-plugin-jsx-a11y\": \"6.10.2\",\n \"eslint-plugin-node\": \"11.1.0\",\n \"eslint-plugin-prettier\": \"5.2.1\",\n \"eslint-plugin-react\": \"7.37.5\",\n \"eslint-plugin-react-hooks\": \"5.2.0\",\n \"eslint-plugin-unused-imports\": \"4.1.4\",\n \"globals\": \"16.0.0\",\n \"postcss\": \"8.5.6\",\n \"prettier\": \"3.5.3\",\n \"typescript\": \"5.6.3\",\n \"vite\": \"npm:rolldown-vite@^7.3.1\",\n \"vite-plugin-pwa\": \"^1.1.0\",\n \"vite-tsconfig-paths\": \"^6.0.5\"\n },\n \"overrides\": {\n \"serialize-javascript\": \"7.0.3\"\n }\n}\n", "title": "Check scripts", "metadata": { "output": "{\n \"name\": \"flvx\",\n \"private\": true,\n \"version\": \"0.0.0\",\n \"type\": \"module\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"tsc && vite build\",\n \"lint\": \"eslint --fix\",\n \"preview\": \"vite preview\"\n },\n \"dependencies\": {\n \"@dnd-kit/core\": \"^6.3.1\",\n \"@dnd-kit/sortable\": \"^10.0.0\",\n \"@dnd-kit/utilities\": \"^3.2.2\",\n \"@internationalized/date\": \"^3.10.0\",\n \"@marsidev/react-turnstile\": \"^1.1.0\",\n \"@radix-ui/react-accordion\": \"^1.2.0\",\n \"@radix-ui/react-checkbox\": \"^1.1.0\",\n \"@radix-ui/react-dialog\": \"^1.1.0\",\n \"@radix-ui/react-dropdown-menu\": \"^2.1.0\",\n \"@radix-ui/react-label\": \"^2.1.0\",\n \"@radix-ui/react-progress\": \"^1.1.0\",\n \"@radix-ui/react-radio-group\": \"^1.2.0\",\n \"@radix-ui/react-select\": \"^2.1.0\",\n \"@radix-ui/react-separator\": \"^1.1.0\",\n \"@radix-ui/react-slot\": \"^1.1.0\",\n \"@radix-ui/react-switch\": \"^1.1.0\",\n \"@radix-ui/react-tabs\": \"^1.1.0\",\n \"@react-aria/i18n\": \"^3.12.12\",\n \"@react-aria/visually-hidden\": \"3.8.25\",\n \"@react-types/shared\": \"3.30.0\",\n \"@tailwindcss/postcss\": \"4.1.11\",\n \"@tailwindcss/vite\": \"^4.1.18\",\n \"@types/react-beautiful-dnd\": \"^13.1.8\",\n \"axios\": \"^1.11.0\",\n \"class-variance-authority\": \"^0.7.1\",\n \"clsx\": \"2.1.1\",\n \"framer-motion\": \"11.18.2\",\n \"lucide-react\": \"^0.542.0\",\n \"react\": \"18.3.1\",\n \"react-beautiful-dnd\": \"^13.1.1\",\n \"react-dom\": \"18.3.1\",\n \"react-hot-toast\": \"^2.5.2\",\n \"react-is\": \"^19.2.4\",\n \"react-markdown\": \"^10.1.0\",\n \"react-router-dom\": \"6.30.3\",\n \"recharts\": \"^3.1.1\",\n \"rehype-sanitize\": \"^6.0.0\",\n \"remark-gfm\": \"^4.0.1\",\n \"sonner\": \"^2.0.6\",\n \"tailwind-merge\": \"^2.5.5\",\n \"tailwind-variants\": \"1.0.0\",\n \"tailwindcss\": \"4.1.11\",\n \"tw-animate-css\": \"^1.3.0\"\n },\n \"devDependencies\": {\n \"@eslint/compat\": \"1.2.8\",\n \"@eslint/eslintrc\": \"3.3.1\",\n \"@eslint/js\": \"9.39.2\",\n \"@types/node\": \"^24.3.0\",\n \"@types/react\": \"18.3.3\",\n \"@types/react-dom\": \"18.3.0\",\n \"@typescript-eslint/eslint-plugin\": \"8.31.1\",\n \"@typescript-eslint/parser\": \"8.31.1\",\n \"@vitejs/plugin-react\": \"^5.1.3\",\n \"eslint\": \"9.39.2\",\n \"eslint-config-prettier\": \"9.1.0\",\n \"eslint-plugin-import\": \"2.31.0\",\n \"eslint-plugin-jsx-a11y\": \"6.10.2\",\n \"eslint-plugin-node\": \"11.1.0\",\n \"eslint-plugin-prettier\": \"5.2.1\",\n \"eslint-plugin-react\": \"7.37.5\",\n \"eslint-plugin-react-hooks\": \"5.2.0\",\n \"eslint-plugin-unused-imports\": \"4.1.4\",\n \"globals\": \"16.0.0\",\n \"postcss\": \"8.5.6\",\n \"prettier\": \"3.5.3\",\n \"typescript\": \"5.6.3\",\n \"vite\": \"npm:rolldown-vite@^7.3.1\",\n \"vite-plugin-pwa\": \"^1.1.0\",\n \"vite-tsconfig-paths\": \"^6.0.5\"\n },\n \"overrides\": {\n \"serialize-javascript\": \"7.0.3\"\n }\n}\n", "exit": 0, "description": "Check scripts", "truncated": false }, "time": { "start": 1773020960732, "end": 1773020960733 } }, "id": "prt_cd048d7dc001MGHqXhWQKmZiGY", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd048c870001znEbn393OoKflE" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "cost": 0, "tokens": { "total": 53578, "input": 708, "output": 28, "reasoning": 0, "cache": { "read": 52842, "write": 0 } }, "id": "prt_cd048d7dd001UNdTARQAi5ETnh", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd048c870001znEbn393OoKflE" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020960903, "completed": 1773020964856 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 55018, "input": 2138, "output": 33, "reasoning": 0, "cache": { "read": 52847, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd048d887001JJba56mJJFEvR4", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "id": "prt_cd048e74c001KVc4E2Cnuj3Q3S", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd048d887001JJba56mJJFEvR4" }, { "type": "tool", "callID": "bash-1773020964522346402-248", "tool": "bash", "state": { "status": "completed", "input": { "description": "Check vite running status", "command": "curl -s http://localhost:5173" }, "output": "", "title": "Check vite running status", "metadata": { "output": "", "exit": 7, "description": "Check vite running status", "truncated": false }, "time": { "start": 1773020964684, "end": 1773020964685 } }, "id": "prt_cd048e74c002Wx13LXHaOw1fnE", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd048d887001JJba56mJJFEvR4" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "cost": 0, "tokens": { "total": 55018, "input": 2138, "output": 33, "reasoning": 0, "cache": { "read": 52847, "write": 0 } }, "id": "prt_cd048e74d001bDK9mq57IiDRx7", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd048d887001JJba56mJJFEvR4" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020964860, "completed": 1773020969757 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 55060, "input": 2189, "output": 30, "reasoning": 0, "cache": { "read": 52841, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd048e7fc00163CBcu6egP44bm", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "id": "prt_cd048fa69001xHxNU5xFFFTdpx", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd048e7fc00163CBcu6egP44bm" }, { "type": "tool", "callID": "bash-1773020969421400330-249", "tool": "bash", "state": { "status": "completed", "input": { "command": "cat vite-frontend/vite.config.ts", "description": "Check port" }, "output": "import path from \"path\";\n\nimport { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\nimport { VitePWA } from \"vite-plugin-pwa\";\n\nexport default defineConfig({\n plugins: [\n react(),\n VitePWA({\n registerType: \"autoUpdate\",\n injectRegister: \"auto\",\n includeAssets: [\"favicon.ico\", \"apple-touch-icon.png\"],\n manifest: {\n name: \"FLVX\",\n short_name: \"FLVX\",\n description: \"FLVX forwarding management panel\",\n theme_color: \"#2563eb\",\n background_color: \"#f6f7fb\",\n display: \"standalone\",\n start_url: \"/\",\n scope: \"/\",\n icons: [\n {\n src: \"pwa-192x192.png\",\n sizes: \"192x192\",\n type: \"image/png\",\n },\n {\n src: \"pwa-512x512.png\",\n sizes: \"512x512\",\n type: \"image/png\",\n },\n {\n src: \"pwa-maskable-512x512.png\",\n sizes: \"512x512\",\n type: \"image/png\",\n purpose: \"maskable\",\n },\n ],\n },\n workbox: {\n navigateFallback: \"/index.html\",\n cleanupOutdatedCaches: true,\n maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,\n },\n }),\n ],\n base: \"/\",\n resolve: {\n alias: {\n \"@\": path.resolve(__dirname, \"./src\"),\n },\n },\n server: {\n port: 3000,\n host: \"0.0.0.0\",\n },\n build: {\n outDir: \"dist\",\n sourcemap: false,\n minify: false,\n rollupOptions: {\n treeshake: false,\n },\n },\n});\n", "title": "Check port", "metadata": { "output": "import path from \"path\";\n\nimport { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\nimport { VitePWA } from \"vite-plugin-pwa\";\n\nexport default defineConfig({\n plugins: [\n react(),\n VitePWA({\n registerType: \"autoUpdate\",\n injectRegister: \"auto\",\n includeAssets: [\"favicon.ico\", \"apple-touch-icon.png\"],\n manifest: {\n name: \"FLVX\",\n short_name: \"FLVX\",\n description: \"FLVX forwarding management panel\",\n theme_color: \"#2563eb\",\n background_color: \"#f6f7fb\",\n display: \"standalone\",\n start_url: \"/\",\n scope: \"/\",\n icons: [\n {\n src: \"pwa-192x192.png\",\n sizes: \"192x192\",\n type: \"image/png\",\n },\n {\n src: \"pwa-512x512.png\",\n sizes: \"512x512\",\n type: \"image/png\",\n },\n {\n src: \"pwa-maskable-512x512.png\",\n sizes: \"512x512\",\n type: \"image/png\",\n purpose: \"maskable\",\n },\n ],\n },\n workbox: {\n navigateFallback: \"/index.html\",\n cleanupOutdatedCaches: true,\n maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,\n },\n }),\n ],\n base: \"/\",\n resolve: {\n alias: {\n \"@\": path.resolve(__dirname, \"./src\"),\n },\n },\n server: {\n port: 3000,\n host: \"0.0.0.0\",\n },\n build: {\n outDir: \"dist\",\n sourcemap: false,\n minify: false,\n rollupOptions: {\n treeshake: false,\n },\n },\n});\n", "exit": 0, "description": "Check port", "truncated": false }, "time": { "start": 1773020969578, "end": 1773020969578 } }, "id": "prt_cd048fa69002p2wbiC8xKbY6a7", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd048e7fc00163CBcu6egP44bm" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "cost": 0, "tokens": { "total": 55060, "input": 2189, "output": 30, "reasoning": 0, "cache": { "read": 52841, "write": 0 } }, "id": "prt_cd048fa6a0013vbpN4UU1XnWkj", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd048e7fc00163CBcu6egP44bm" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020969761, "completed": 1773020973701 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 55673, "input": 2797, "output": 36, "reasoning": 0, "cache": { "read": 52840, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd048fb21001x56NnyuFra1ndl", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "id": "prt_cd04909ca001oDoP4itz7ku3AT", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd048fb21001x56NnyuFra1ndl" }, { "type": "tool", "callID": "bash-1773020973380794465-250", "tool": "bash", "state": { "status": "completed", "input": { "description": "Check port 3000", "command": "curl -s http://localhost:3000" }, "output": "\n\n \n \n\n \n\n \n \n \n \n \n \n \n FLVX\n \n \n \n \n \n \n \n \n
\n \n \n\n", "title": "Check port 3000", "metadata": { "output": "\n\n \n \n\n \n\n \n \n \n \n \n \n \n FLVX\n \n \n \n \n \n \n \n \n
\n \n \n\n", "exit": 0, "description": "Check port 3000", "truncated": false }, "time": { "start": 1773020973515, "end": 1773020973515 } }, "id": "prt_cd04909cb001iRqFFbHUyGnlGh", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd048fb21001x56NnyuFra1ndl" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "cost": 0, "tokens": { "total": 55673, "input": 2797, "output": 36, "reasoning": 0, "cache": { "read": 52840, "write": 0 } }, "id": "prt_cd04909cc001FG78FqAVHbdaYN", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd048fb21001x56NnyuFra1ndl" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020973703, "completed": 1773020977969 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 57952, "input": 5071, "output": 30, "reasoning": 0, "cache": { "read": 52851, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd0490a87001fAshJNFMC5iSdX", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "id": "prt_cd0491a83001NpitOcRMHcBB5Q", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0490a87001fAshJNFMC5iSdX" }, { "type": "tool", "callID": "bash-1773020977635366203-251", "tool": "bash", "state": { "status": "completed", "input": { "description": "Kill dev server", "command": "kill -9 49371" }, "output": "", "title": "Kill dev server", "metadata": { "output": "", "exit": 0, "description": "Kill dev server", "truncated": false }, "time": { "start": 1773020977796, "end": 1773020977797 } }, "id": "prt_cd0491a84001cL2t1SbGJaLjCH", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0490a87001fAshJNFMC5iSdX" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "cost": 0, "tokens": { "total": 57952, "input": 5071, "output": 30, "reasoning": 0, "cache": { "read": 52851, "write": 0 } }, "id": "prt_cd0491a85001UOeiEO5t1YYFm6", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0490a87001fAshJNFMC5iSdX" } ] }, { "info": { "role": "assistant", "time": { "created": 1773020977973, "completed": 1773020982998 }, "parentID": "msg_cd046222b001Z6S2zXX4DUpg5y", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 58071, "input": 5119, "output": 107, "reasoning": 0, "cache": { "read": 52845, "write": 0 } }, "finish": "stop", "id": "msg_cd0491b35001Gyu7B4fNEHivH2", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "id": "prt_cd0492b3a001LaTukq2owLnVHR", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0491b35001Gyu7B4fNEHivH2" }, { "type": "text", "text": "I have successfully modified `vite-frontend/src/pages/forward.tsx` to include the rule count (\"{sortedForwards.length} 条规则\") when the forward page is in the `compactMode` (精简模式) and `viewMode === \"grouped\"`. This ensures consistency with the regular view modes where the total rule count is already displayed.\n\nThe fix was committed and pushed to the repository in the recent step:\n`fix(frontend): show rule count in compact view mode for forward pages`", "time": { "start": 1773020982751, "end": 1773020982751 }, "id": "prt_cd0492b3b001pQExqc4zRMmzxf", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0491b35001Gyu7B4fNEHivH2" }, { "type": "step-finish", "reason": "stop", "snapshot": "c3da2cd3290a2d18c8102e156db6e40efd9039c4", "cost": 0, "tokens": { "total": 58071, "input": 5119, "output": 107, "reasoning": 0, "cache": { "read": 52845, "write": 0 } }, "id": "prt_cd0492de0001L08xjLeMfJnCzo", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd0491b35001Gyu7B4fNEHivH2" } ] }, { "info": { "role": "user", "time": { "created": 1773022792451 }, "summary": { "diffs": [] }, "agent": "build", "model": { "providerID": "api", "modelID": "gemini-3.1-pro-high" }, "id": "msg_cd064ca87001mKUQE2EyR8PezH", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "text", "text": "这个没改对,页面没变化,请检查", "id": "prt_cd064ca87002we266x5BvrANpB", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd064ca87001mKUQE2EyR8PezH" } ] }, { "info": { "role": "assistant", "time": { "created": 1773022792467, "completed": 1773022801627 }, "parentID": "msg_cd064ca87001mKUQE2EyR8PezH", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 58585, "input": 58084, "output": 59, "reasoning": 442, "cache": { "read": 0, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd064cb13001TWoWh6nBESUPZl", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "97ed982edddf0655a654981a72da08c33b9e467d", "id": "prt_cd064ee23001PcfPcy835V87tU", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd064cb13001TWoWh6nBESUPZl" }, { "type": "tool", "callID": "bash-1773022801288808061-252", "tool": "bash", "state": { "status": "completed", "input": { "command": "git show HEAD:vite-frontend/src/pages/forward.tsx | grep -B 5 -A 20 \"根据显示模式渲染不同内容\"", "description": "Check original code structure for rendering modes" }, "output": " value={batchProgress.percent}\n />\n
\n )}\n\n {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n
\n \n \n
\n )}\n\n {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n
\n \n \nDate: Mon Mar 9 09:59:59 2026 +0800\n\n fix: restore renewal.ts from main to resolve build errors\n", "title": "Check latest commit", "metadata": { "output": "commit 78af992b6fde82eb3af4d09daa02a43866210636\nAuthor: sagitchu \nDate: Mon Mar 9 09:59:59 2026 +0800\n\n fix: restore renewal.ts from main to resolve build errors\n", "exit": 0, "description": "Check latest commit", "truncated": false }, "time": { "start": 1773022810506, "end": 1773022810506 } }, "id": "prt_cd065118a0014hx1B9opNcSfJl", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd065028100100U8RbhbWKxrf3" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "97ed982edddf0655a654981a72da08c33b9e467d", "cost": 0, "tokens": { "total": 58460, "input": 1540, "output": 25, "reasoning": 0, "cache": { "read": 56895, "write": 0 } }, "id": "prt_cd065118b001SISeN0O2IRDH5C", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd065028100100U8RbhbWKxrf3" } ] }, { "info": { "role": "assistant", "time": { "created": 1773022810683, "completed": 1773022814810 }, "parentID": "msg_cd064ca87001mKUQE2EyR8PezH", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 58595, "input": 1681, "output": 24, "reasoning": 0, "cache": { "read": 56890, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd065123a001uXPsKZwVH7RDJb", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "97ed982edddf0655a654981a72da08c33b9e467d", "id": "prt_cd06521a8001yiaSoVddfbN1tj", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd065123a001uXPsKZwVH7RDJb" }, { "type": "tool", "callID": "bash-1773022814469937121-255", "tool": "bash", "state": { "status": "completed", "input": { "description": "Check history", "command": "git log -3" }, "output": "commit 78af992b6fde82eb3af4d09daa02a43866210636\nAuthor: sagitchu \nDate: Mon Mar 9 09:59:59 2026 +0800\n\n fix: restore renewal.ts from main to resolve build errors\n\ncommit f790becda8ead611b847637daafad6e7d40d8115\nMerge: e7548e7 413081f\nAuthor: sagitchu \nDate: Mon Mar 9 09:58:02 2026 +0800\n\n merge: resolve conflicts with main branch\n\ncommit e7548e7b30ecd1c73e4ef98f5d36e6b6394c209a\nAuthor: sagitchu \nDate: Mon Mar 9 09:51:28 2026 +0800\n\n style(frontend): format code and improve node card layout\n \n Entire-Checkpoint: 08b760b76538\n", "title": "Check history", "metadata": { "output": "commit 78af992b6fde82eb3af4d09daa02a43866210636\nAuthor: sagitchu \nDate: Mon Mar 9 09:59:59 2026 +0800\n\n fix: restore renewal.ts from main to resolve build errors\n\ncommit f790becda8ead611b847637daafad6e7d40d8115\nMerge: e7548e7 413081f\nAuthor: sagitchu \nDate: Mon Mar 9 09:58:02 2026 +0800\n\n merge: resolve conflicts with main branch\n\ncommit e7548e7b30ecd1c73e4ef98f5d36e6b6394c209a\nAuthor: sagitchu \nDate: Mon Mar 9 09:51:28 2026 +0800\n\n style(frontend): format code and improve node card layout\n \n Entire-Checkpoint: 08b760b76538\n", "exit": 0, "description": "Check history", "truncated": false }, "time": { "start": 1773022814633, "end": 1773022814633 } }, "id": "prt_cd06521a8002I2rlXCrLkmKM1d", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd065123a001uXPsKZwVH7RDJb" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "97ed982edddf0655a654981a72da08c33b9e467d", "cost": 0, "tokens": { "total": 58595, "input": 1681, "output": 24, "reasoning": 0, "cache": { "read": 56890, "write": 0 } }, "id": "prt_cd06521a9001CzTlcvvzi6vd8Y", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd065123a001uXPsKZwVH7RDJb" } ] }, { "info": { "role": "assistant", "time": { "created": 1773022814814, "completed": 1773022818942 }, "parentID": "msg_cd064ca87001mKUQE2EyR8PezH", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "total": 58974, "input": 2054, "output": 33, "reasoning": 0, "cache": { "read": 56887, "write": 0 } }, "finish": "tool-calls", "id": "msg_cd065225e0010wlkl67le6h46g", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [ { "type": "step-start", "snapshot": "97ed982edddf0655a654981a72da08c33b9e467d", "id": "prt_cd06531d8001vtXeqUZ9z4fx14", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd065225e0010wlkl67le6h46g" }, { "type": "tool", "callID": "bash-1773022818624349202-256", "tool": "bash", "state": { "status": "completed", "input": { "command": "git log --oneline | head -n 10", "description": "Check recent commits" }, "output": "78af992 fix: restore renewal.ts from main to resolve build errors\nf790bec merge: resolve conflicts with main branch\ne7548e7 style(frontend): format code and improve node card layout\n50e4107 fix(frontend): show rule count in compact view mode for forward pages\n413081f feat(nodes): add renewal cycle and auto-advance scheduling (#296)\nbfac640 feat(nodes): add renewal cycle and auto-advance scheduling\n508a37a feat(nodes): add node metadata and expiry reminders (#293)\nd606550 feat(forward): display tunnel traffic ratio on forward page\n31ef861 feat(nodes): add node metadata and expiry reminders (#246)\nf1bdb2e feat(frontend): implement AJAX no-refresh UX improvements (#276)\n", "title": "Check recent commits", "metadata": { "output": "78af992 fix: restore renewal.ts from main to resolve build errors\nf790bec merge: resolve conflicts with main branch\ne7548e7 style(frontend): format code and improve node card layout\n50e4107 fix(frontend): show rule count in compact view mode for forward pages\n413081f feat(nodes): add renewal cycle and auto-advance scheduling (#296)\nbfac640 feat(nodes): add renewal cycle and auto-advance scheduling\n508a37a feat(nodes): add node metadata and expiry reminders (#293)\nd606550 feat(forward): display tunnel traffic ratio on forward page\n31ef861 feat(nodes): add node metadata and expiry reminders (#246)\nf1bdb2e feat(frontend): implement AJAX no-refresh UX improvements (#276)\n", "exit": 0, "description": "Check recent commits", "truncated": false }, "time": { "start": 1773022818776, "end": 1773022818776 } }, "id": "prt_cd06531d8002HjsfEaDHfZzBM7", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd065225e0010wlkl67le6h46g" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "97ed982edddf0655a654981a72da08c33b9e467d", "cost": 0, "tokens": { "total": 58974, "input": 2054, "output": 33, "reasoning": 0, "cache": { "read": 56887, "write": 0 } }, "id": "prt_cd06531d9001eYxQ5nQBoU1rsk", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", "messageID": "msg_cd065225e0010wlkl67le6h46g" } ] }, { "info": { "role": "assistant", "time": { "created": 1773022818946, "completed": 1773022824318 }, "error": { "name": "MessageAbortedError", "data": { "message": "The operation was aborted." } }, "parentID": "msg_cd064ca87001mKUQE2EyR8PezH", "modelID": "gemini-3.1-pro-high", "providerID": "api", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" }, "cost": 0, "tokens": { "input": 0, "output": 0, "reasoning": 0, "cache": { "read": 0, "write": 0 } }, "id": "msg_cd0653282001hDYLdWTwLVss9i", "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" }, "parts": [] } ] }