{ "info": { "id": "ses_34f0fe1d2ffeH3PJRyokYati7T", "slug": "clever-wizard", "projectID": "0d4685396cc353e0f47ffbe712b4e8d0545f5bad", "directory": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula", "title": "Main branch updates to this branch (Chinese)", "version": "1.2.15", "summary": { "additions": 1367, "deletions": 199, "files": 3 }, "time": { "created": 1772495052333, "updated": 1772496031103 } }, "messages": [ { "info": { "role": "user", "time": { "created": 1772495052427 }, "summary": { "diffs": [ { "file": "vite-frontend/src/pages/config.tsx", "before": "import { useState, useEffect, useRef } from \"react\";\nimport { useNavigate } from \"react-router-dom\";\nimport toast from \"react-hot-toast\";\n\nimport { Button } from \"@/shadcn-bridge/heroui/button\";\nimport { Card, CardBody, CardHeader } from \"@/shadcn-bridge/heroui/card\";\nimport { Input } from \"@/shadcn-bridge/heroui/input\";\nimport { Textarea } from \"@/shadcn-bridge/heroui/input\";\nimport { Spinner } from \"@/shadcn-bridge/heroui/spinner\";\nimport { Divider } from \"@/shadcn-bridge/heroui/divider\";\nimport { Switch } from \"@/shadcn-bridge/heroui/switch\";\nimport { Select, SelectItem } from \"@/shadcn-bridge/heroui/select\";\nimport { Checkbox } from \"@/shadcn-bridge/heroui/checkbox\";\nimport {\n Modal,\n ModalBody,\n ModalContent,\n ModalFooter,\n ModalHeader,\n} from \"@/shadcn-bridge/heroui/modal\";\nimport {\n updateConfigs,\n exportBackup,\n importBackup,\n getAnnouncement,\n updateAnnouncement,\n type AnnouncementData,\n} from \"@/api\";\nimport { SettingsIcon } from \"@/components/icons\";\nimport { isAdmin } from \"@/utils/auth\";\nimport { getCachedConfigs, configCache, updateSiteConfig } from \"@/config/site\";\nimport {\n type UpdateReleaseChannel,\n getUpdateReleaseChannel,\n setUpdateReleaseChannel,\n} from \"@/utils/version-update\";\nimport {\n convertBrandAssetToPngDataURL,\n isPngDataURL,\n type BrandAssetKind,\n} from \"@/utils/brand-asset\";\n\n// 简单的保存图标组件\nconst SaveIcon = ({ className }: { className?: string }) => (\n \n \n \n \n \n);\n\ninterface ConfigItem {\n key: string;\n label: string;\n placeholder?: string;\n description?: string;\n type: \"input\" | \"switch\" | \"select\";\n options?: { label: string; value: string; description?: string }[];\n dependsOn?: string; // 依赖的配置项key\n dependsValue?: string; // 依赖的配置项值\n}\n\nconst BRAND_PREVIEW_KEYS = [\"app_logo\", \"app_favicon\"] as const;\n\ntype BrandPreviewKey = (typeof BRAND_PREVIEW_KEYS)[number];\n\nconst isBrandPreviewKey = (key: string): key is BrandPreviewKey =>\n BRAND_PREVIEW_KEYS.includes(key as BrandPreviewKey);\n\nconst BRAND_FILE_ACCEPT = \"image/png,image/jpeg,image/webp,image/svg+xml\";\n\nconst toBrandAssetKind = (key: BrandPreviewKey): BrandAssetKind => {\n return key === \"app_logo\" ? \"logo\" : \"favicon\";\n};\n\n// 网站配置项定义\nconst CONFIG_ITEMS: ConfigItem[] = [\n {\n key: \"ip\",\n label: \"面板后端地址\",\n placeholder: \"请输入面板后端IP:PORT\",\n description:\n \"格式“ip:port”,用于对接节点时使用,ip是你安装面板服务器的公网ip,端口是安装脚本内输入的后端端口。不要套CDN,不支持https,通讯数据有加密\",\n type: \"input\",\n },\n {\n key: \"panel_domain\",\n label: \"面板域名\",\n placeholder: \"请输入面板域名\",\n description: \"当前面板的域名,用于与其他面板进行联邦共享时验证身份\",\n type: \"input\",\n },\n {\n key: \"app_name\",\n label: \"应用名称\",\n placeholder: \"请输入应用名称\",\n description: \"在浏览器标签页和导航栏显示的应用名称\",\n type: \"input\",\n },\n {\n key: \"app_logo\",\n label: \"网页角标 Logo\",\n description: \"用于页面左上角导航角标,上传后会自动转换为 PNG 并持久化保存\",\n type: \"input\",\n },\n {\n key: \"app_favicon\",\n label: \"浏览器缩略图标\",\n description: \"用于浏览器标签页图标,上传后会自动转换为 PNG 并持久化保存\",\n type: \"input\",\n },\n {\n key: \"captcha_enabled\",\n label: \"启用验证码\",\n description: \"开启后,用户登录时需要完成验证码验证\",\n type: \"switch\",\n },\n {\n key: \"cloudflare_site_key\",\n label: \"Cloudflare Site Key\",\n placeholder: \"请输入 Cloudflare Site Key\",\n description: \"Cloudflare Turnstile 站点密钥\",\n type: \"input\",\n dependsOn: \"captcha_enabled\",\n dependsValue: \"true\",\n },\n {\n key: \"cloudflare_secret_key\",\n label: \"Cloudflare Secret Key\",\n placeholder: \"请输入 Cloudflare Secret Key\",\n description: \"Cloudflare Turnstile 密钥\",\n type: \"input\",\n dependsOn: \"captcha_enabled\",\n dependsValue: \"true\",\n },\n];\n\nconst BACKUP_TYPE_OPTIONS = [\n { value: \"users\", label: \"用户\" },\n { value: \"nodes\", label: \"节点\" },\n { value: \"tunnels\", label: \"隧道\" },\n { value: \"forwards\", label: \"转发\" },\n { value: \"userTunnels\", label: \"用户隧道权限\" },\n { value: \"speedLimits\", label: \"限速规则\" },\n { value: \"tunnelGroups\", label: \"隧道分组\" },\n { value: \"userGroups\", label: \"用户分组\" },\n { value: \"permissions\", label: \"分组权限\" },\n { value: \"configs\", label: \"系统配置\" },\n] as const;\n\nconst BACKUP_TYPE_VALUES = BACKUP_TYPE_OPTIONS.map((option) => option.value);\n\n// 初始化时从缓存读取配置,避免闪烁\nconst getInitialConfigs = (): Record => {\n if (typeof window === \"undefined\") return {};\n\n const configKeys = [\n \"app_name\",\n \"captcha_enabled\",\n \"cloudflare_site_key\",\n \"cloudflare_secret_key\",\n \"ip\",\n \"panel_domain\",\n \"app_logo\",\n \"app_favicon\",\n ];\n const initialConfigs: Record = {};\n\n try {\n configKeys.forEach((key) => {\n const cachedValue = localStorage.getItem(\"vite_config_\" + key);\n\n if (cachedValue) {\n initialConfigs[key] = cachedValue;\n }\n });\n } catch {}\n\n return initialConfigs;\n};\n\nexport default function ConfigPage() {\n const navigate = useNavigate();\n const initialConfigs = getInitialConfigs();\n const [configs, setConfigs] =\n useState>(initialConfigs);\n const [loading, setLoading] = useState(\n Object.keys(initialConfigs).length === 0,\n );\n const [saving, setSaving] = useState(false);\n const [hasChanges, setHasChanges] = useState(false);\n const [originalConfigs, setOriginalConfigs] =\n useState>(initialConfigs);\n\n const [exportTypes, setExportTypes] = useState([]);\n const [importTypes, setImportTypes] = useState([]);\n const [exporting, setExporting] = useState(false);\n const [importing, setImporting] = useState(false);\n const [exportSelectorOpen, setExportSelectorOpen] = useState(false);\n const [importSelectorOpen, setImportSelectorOpen] = useState(false);\n const [importFileName, setImportFileName] = useState(\"\");\n const backupFileInputRef = useRef(null);\n const logoFileInputRef = useRef(null);\n const faviconFileInputRef = useRef(null);\n\n const [announcement, setAnnouncement] = useState({\n content: \"\",\n enabled: 0,\n });\n const [announcementLoading, setAnnouncementLoading] = useState(true);\n const [announcementSaving, setAnnouncementSaving] = useState(false);\n const [updateChannel, setUpdateChannel] = useState(\n getUpdateReleaseChannel(),\n );\n const [previewLoadFailed, setPreviewLoadFailed] = useState<\n Partial>\n >({});\n const [brandUploading, setBrandUploading] = useState<\n Partial>\n >({});\n\n // 权限检查\n useEffect(() => {\n if (!isAdmin()) {\n toast.error(\"权限不足,只有管理员可以访问此页面\");\n navigate(\"/dashboard\", { replace: true });\n\n return;\n }\n }, [navigate]);\n\n // 加载配置数据(优先从缓存)\n const loadConfigs = async (currentConfigs?: Record) => {\n const configsToCompare = currentConfigs || configs;\n const hasInitialData = Object.keys(configsToCompare).length > 0;\n\n // 如果已有缓存数据,不显示loading,静默更新\n if (!hasInitialData) {\n setLoading(true);\n }\n\n try {\n const configData = await getCachedConfigs();\n\n // 只有在数据有变化时才更新\n const hasDataChanged =\n JSON.stringify(configData) !== JSON.stringify(configsToCompare);\n\n if (hasDataChanged) {\n setConfigs(configData);\n setOriginalConfigs({ ...configData });\n setHasChanges(false);\n } else {\n }\n } catch {\n // 只有在没有缓存数据时才显示错误\n if (!hasInitialData) {\n toast.error(\"加载配置出错,请重试\");\n }\n } finally {\n setLoading(false);\n }\n };\n\n useEffect(() => {\n const timer = setTimeout(() => {\n loadConfigs(initialConfigs);\n loadAnnouncement();\n }, 100);\n\n return () => clearTimeout(timer);\n }, []);\n\n const loadAnnouncement = async () => {\n setAnnouncementLoading(true);\n try {\n const res = await getAnnouncement();\n\n if (res.code === 0 && res.data) {\n setAnnouncement(res.data);\n }\n } catch {\n } finally {\n setAnnouncementLoading(false);\n }\n };\n\n const saveAnnouncement = async () => {\n setAnnouncementSaving(true);\n try {\n const res = await updateAnnouncement(announcement);\n\n if (res.code === 0) {\n toast.success(\"公告保存成功\");\n } else {\n toast.error(res.msg || \"保存失败\");\n }\n } catch {\n toast.error(\"保存公告失败,请重试\");\n } finally {\n setAnnouncementSaving(false);\n }\n };\n\n const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {\n setUpdateChannel(channel);\n setUpdateReleaseChannel(channel);\n toast.success(\n `更新通道已切换为${channel === \"stable\" ? \"稳定版\" : \"开发版\"}`,\n );\n };\n\n const handleConfigChange = (key: string, value: string) => {\n const newConfigs = { ...configs, [key]: value };\n\n setConfigs(newConfigs);\n\n if (isBrandPreviewKey(key)) {\n setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }));\n }\n\n const hasChangesNow =\n Object.keys(newConfigs).some(\n (k) => newConfigs[k] !== originalConfigs[k],\n ) ||\n Object.keys(originalConfigs).some(\n (k) => originalConfigs[k] !== newConfigs[k],\n );\n\n setHasChanges(hasChangesNow);\n };\n\n // 保存配置\n const handleSave = async () => {\n setSaving(true);\n try {\n const changedKeys = Object.keys(configs).filter(\n (key) => configs[key] !== originalConfigs[key],\n );\n\n if (changedKeys.length === 0) {\n setHasChanges(false);\n\n return;\n }\n\n const changedPayload: Record = {};\n\n changedKeys.forEach((key) => {\n changedPayload[key] = configs[key] || \"\";\n });\n\n const response = await updateConfigs(changedPayload);\n\n if (response.code === 0) {\n toast.success(\"配置保存成功\");\n\n Object.entries(configs).forEach(([key, value]) => {\n configCache.set(key, value);\n });\n\n setOriginalConfigs({ ...configs });\n setHasChanges(false);\n\n if (\n changedKeys.some((key) =>\n [\"app_name\", \"app_logo\", \"app_favicon\"].includes(key),\n )\n ) {\n await updateSiteConfig(configs);\n }\n\n // 触发配置更新事件,通知其他组件\n window.dispatchEvent(\n new CustomEvent(\"configUpdated\", {\n detail: { changedKeys },\n }),\n );\n } else {\n toast.error(\"保存配置失败: \" + response.msg);\n }\n } catch {\n toast.error(\"保存配置出错,请重试\");\n } finally {\n setSaving(false);\n }\n };\n\n // 检查配置项是否应该显示(依赖检查)\n const shouldShowItem = (item: ConfigItem): boolean => {\n if (!item.dependsOn || !item.dependsValue) {\n return true;\n }\n\n return configs[item.dependsOn] === item.dependsValue;\n };\n\n const getBrandInputRef = (key: BrandPreviewKey) => {\n return key === \"app_logo\" ? logoFileInputRef : faviconFileInputRef;\n };\n\n const triggerBrandFilePicker = (key: BrandPreviewKey) => {\n if (brandUploading[key]) {\n return;\n }\n\n getBrandInputRef(key).current?.click();\n };\n\n const clearBrandAsset = (key: BrandPreviewKey) => {\n handleConfigChange(key, \"\");\n setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }));\n };\n\n const handleBrandFileChange = async (\n key: BrandPreviewKey,\n event: React.ChangeEvent,\n ) => {\n const file = event.target.files?.[0];\n\n if (!file) {\n return;\n }\n\n setBrandUploading((prev) => ({ ...prev, [key]: true }));\n\n try {\n const pngDataURL = await convertBrandAssetToPngDataURL(\n file,\n toBrandAssetKind(key),\n );\n\n handleConfigChange(key, pngDataURL);\n toast.success(key === \"app_logo\" ? \"Logo 上传成功\" : \"Favicon 上传成功\");\n } catch (error) {\n const message =\n error instanceof Error ? error.message : \"图片处理失败,请重试\";\n\n toast.error(message);\n } finally {\n setBrandUploading((prev) => ({ ...prev, [key]: false }));\n event.target.value = \"\";\n }\n };\n\n const renderBrandPreview = (key: BrandPreviewKey) => {\n const previewUrl = (configs[key] || \"\").trim();\n const appNamePreview = (configs.app_name || \"\").trim() || \"应用名称\";\n const failed = previewLoadFailed[key] === true;\n const showImage = previewUrl.length > 0 && !failed;\n\n return (\n
\n

实时预览

\n
\n {key === \"app_logo\" ? (\n
\n {showImage ? (\n \n setPreviewLoadFailed((prev) => ({ ...prev, [key]: true }))\n }\n onLoad={() =>\n setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }))\n }\n />\n ) : (\n
\n LOGO\n
\n )}\n \n {appNamePreview}\n \n
\n ) : (\n
\n {showImage ? (\n \n setPreviewLoadFailed((prev) => ({ ...prev, [key]: true }))\n }\n onLoad={() =>\n setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }))\n }\n />\n ) : (\n
\n )}\n \n {appNamePreview}\n \n
\n )}\n
\n\n {previewUrl.length === 0 ? (\n

\n 上传图片后会实时显示预览\n

\n ) : null}\n\n {previewUrl.length > 0 && failed ? (\n

图片加载失败,请重新上传

\n ) : null}\n\n {previewUrl.length > 0 && !isPngDataURL(previewUrl) ? (\n

\n 当前是旧版 URL 配置,建议重新上传图片以启用无闪烁加载\n

\n ) : null}\n
\n );\n };\n\n const renderBrandAssetUploader = (\n key: BrandPreviewKey,\n isChanged: boolean,\n ) => {\n const value = (configs[key] || \"\").trim();\n const uploading = brandUploading[key] === true;\n const isLogo = key === \"app_logo\";\n\n return (\n \n {\n void handleBrandFileChange(key, event);\n }}\n />\n\n
\n triggerBrandFilePicker(key)}\n >\n {value.length > 0\n ? isLogo\n ? \"替换 Logo\"\n : \"替换 Favicon\"\n : isLogo\n ? \"上传 Logo\"\n : \"上传 Favicon\"}\n \n clearBrandAsset(key)}\n >\n 清除\n \n \n 仅支持图片文件,自动转换为 PNG\n \n
\n\n

\n {isLogo\n ? \"建议上传方形图片,系统会统一转换为 96x96 PNG\"\n : \"建议上传方形图片,系统会统一转换为 64x64 PNG\"}\n

\n\n {renderBrandPreview(key)}\n
\n );\n };\n\n // 渲染不同类型的配置项\n const renderConfigItem = (item: ConfigItem) => {\n const isChanged =\n hasChanges && configs[item.key] !== originalConfigs[item.key];\n\n switch (item.type) {\n case \"input\":\n if (isBrandPreviewKey(item.key)) {\n return renderBrandAssetUploader(item.key, isChanged);\n }\n\n return (\n handleConfigChange(item.key, e.target.value)}\n />\n );\n\n case \"switch\":\n return (\n \n handleConfigChange(item.key, checked ? \"true\" : \"false\")\n }\n >\n \n {configs[item.key] === \"true\" ? \"已启用\" : \"已禁用\"}\n \n \n );\n\n case \"select\":\n return (\n {\n const selectedKey = Array.from(keys)[0] as string;\n\n if (selectedKey) {\n handleConfigChange(item.key, selectedKey);\n }\n }}\n >\n {item.options?.map((option) => (\n \n {option.label}\n \n )) || []}\n \n );\n\n default:\n return null;\n }\n };\n\n const handleExport = async () => {\n if (exportTypes.length === 0) {\n toast.error(\"请至少选择一种数据类型\");\n\n return;\n }\n setExporting(true);\n try {\n await exportBackup(exportTypes);\n toast.success(\"导出成功\");\n setExportSelectorOpen(false);\n } catch {\n toast.error(\"导出失败,请重试\");\n } finally {\n setExporting(false);\n }\n };\n\n const triggerImportFilePicker = () => {\n if (importTypes.length === 0) {\n toast.error(\"请先选择要导入的数据类型\");\n\n return;\n }\n\n setImportSelectorOpen(false);\n requestAnimationFrame(() => backupFileInputRef.current?.click());\n };\n\n const handleFileChange = async (e: React.ChangeEvent) => {\n const file = e.target.files?.[0];\n\n if (!file) return;\n\n if (importTypes.length === 0) {\n toast.error(\"请先选择要导入的数据类型\");\n\n return;\n }\n\n setImportFileName(file.name);\n setImporting(true);\n\n try {\n const text = await file.text();\n const data = JSON.parse(text);\n\n const response = await importBackup({\n types: importTypes,\n ...data,\n });\n\n if (response.code === 0) {\n toast.success(`导入成功: ${JSON.stringify(response.data)}`);\n setImportTypes([]);\n setImportFileName(\"\");\n } else {\n toast.error(\"导入失败: \" + response.msg);\n }\n } catch {\n toast.error(\"导入失败,请检查文件格式\");\n } finally {\n setImporting(false);\n if (backupFileInputRef.current) {\n backupFileInputRef.current.value = \"\";\n }\n }\n };\n\n const toggleTypeSelection = (\n type: string,\n setTypes: React.Dispatch>,\n ) => {\n setTypes((prev) =>\n prev.includes(type)\n ? prev.filter((item) => item !== type)\n : [...prev, type],\n );\n };\n\n const isAllTypesSelected = (types: string[]) =>\n BACKUP_TYPE_VALUES.every((type) => types.includes(type));\n\n const renderTypeSelection = (\n label: string,\n selectedTypes: string[],\n setTypes: React.Dispatch>,\n ) => {\n const allSelected = isAllTypesSelected(selectedTypes);\n\n return (\n
\n
\n \n {label}\n \n
\n \n setTypes(allSelected ? [] : [...BACKUP_TYPE_VALUES])\n }\n >\n {allSelected ? \"取消全选\" : \"全选\"}\n \n \n
\n
\n\n
\n {BACKUP_TYPE_OPTIONS.map((option) => {\n const isSelected = selectedTypes.includes(option.value);\n\n return (\n toggleTypeSelection(option.value, setTypes)}\n >\n
\n \n \n {option.label}\n \n
\n \n );\n })}\n
\n
\n );\n };\n\n if (loading) {\n return (\n
\n \n
\n );\n }\n\n return (\n
\n {/* 页面标题 */}\n
\n \n
\n

网站配置

\n

\n 管理网站的基本信息和显示设置\n

\n
\n
\n\n \n \n
\n
\n

基本设置

\n

\n 配置网站的基本信息,这些设置会影响网站的显示效果\n

\n
\n
\n }\n onPress={handleSave}\n >\n {saving ? \"保存中...\" : \"保存配置\"}\n \n
\n
\n
\n\n \n\n \n {CONFIG_ITEMS.map((item, index) => {\n // 检查配置项是否应该显示\n if (!shouldShowItem(item)) {\n return null;\n }\n\n // 计算是否是最后一个显示的项目(用于决定是否显示分隔线)\n const remainingItems = CONFIG_ITEMS.slice(index + 1).filter(\n shouldShowItem,\n );\n const isLastItem = remainingItems.length === 0;\n\n return (\n
\n
\n \n {item.description && (\n

\n {item.description}\n

\n )}\n
\n\n {/* 渲染配置项 */}\n {renderConfigItem(item)}\n\n {/* 分隔线 */}\n {!isLastItem && }\n
\n );\n })}\n\n \n\n
\n
\n

\n 更新通道\n

\n

\n 稳定版仅匹配纯数字版本;开发版仅匹配包含 alpha / beta / rc\n 的版本。\n

\n
\n\n {\n const selected =\n (Array.from(keys)[0] as UpdateReleaseChannel) || \"stable\";\n\n handleUpdateChannelChange(selected);\n }}\n >\n \n 稳定版\n \n \n 开发版\n \n \n
\n
\n
\n\n {hasChanges && (\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 {announcementLoading ? (\n
\n \n
\n ) : (\n <>\n
\n \n setAnnouncement({\n ...announcement,\n enabled: checked ? 1 : 0,\n })\n }\n >\n \n {announcement.enabled === 1 ? \"已启用\" : \"已禁用\"}\n \n \n

\n 启用后,公告将在首页顶部显示\n

\n
\n\n \n setAnnouncement({ ...announcement, content: e.target.value })\n }\n />\n

\n 公告支持 Markdown 语法,链接会在新标签页打开\n

\n\n
\n }\n onPress={saveAnnouncement}\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 选择要导出的数据类型,导出为 JSON 格式文件\n

\n

\n 当前已选 {exportTypes.length} / {BACKUP_TYPE_VALUES.length}\n

\n\n
\n setExportSelectorOpen(true)}\n >\n {exporting ? \"导出中...\" : \"选择并导出\"}\n \n
\n
\n\n \n\n {/* 导入部分 */}\n
\n

导入数据

\n

\n 选择要导入的数据类型,支持从备份文件恢复数据\n

\n

\n 当前已选 {importTypes.length} / {BACKUP_TYPE_VALUES.length}\n

\n\n \n\n
\n setImportSelectorOpen(true)}\n >\n {importing ? \"导入中...\" : \"选择并导入\"}\n \n {importFileName && (\n \n 已选择: {importFileName}\n \n )}\n
\n
\n
\n
\n\n \n \n {(onClose) => (\n <>\n 选择导出内容\n \n {renderTypeSelection(\"导出内容\", exportTypes, setExportTypes)}\n \n \n \n \n {exporting ? \"导出中...\" : \"确认导出\"}\n \n \n \n )}\n \n \n\n \n \n {(onClose) => (\n <>\n 选择导入内容\n \n {renderTypeSelection(\"导入内容\", importTypes, setImportTypes)}\n \n \n \n \n 下一步选择文件\n \n \n \n )}\n \n \n
\n );\n}\n", "after": "import { useState, useEffect, useRef } from \"react\";\nimport { useNavigate } from \"react-router-dom\";\nimport toast from \"react-hot-toast\";\n\nimport { Button } from \"@/shadcn-bridge/heroui/button\";\nimport { Card, CardBody, CardHeader } from \"@/shadcn-bridge/heroui/card\";\nimport { Input } from \"@/shadcn-bridge/heroui/input\";\nimport { Textarea } from \"@/shadcn-bridge/heroui/input\";\nimport { Spinner } from \"@/shadcn-bridge/heroui/spinner\";\nimport { Divider } from \"@/shadcn-bridge/heroui/divider\";\nimport { Switch } from \"@/shadcn-bridge/heroui/switch\";\nimport { Select, SelectItem } from \"@/shadcn-bridge/heroui/select\";\nimport { Checkbox } from \"@/shadcn-bridge/heroui/checkbox\";\nimport {\n Modal,\n ModalBody,\n ModalContent,\n ModalFooter,\n ModalHeader,\n} from \"@/shadcn-bridge/heroui/modal\";\nimport {\n updateConfigs,\n exportBackup,\n importBackup,\n getAnnouncement,\n updateAnnouncement,\n type AnnouncementData,\n} from \"@/api\";\nimport { SettingsIcon } from \"@/components/icons\";\nimport { isAdmin } from \"@/utils/auth\";\nimport { getCachedConfigs, configCache, updateSiteConfig } from \"@/config/site\";\nimport {\n type UpdateReleaseChannel,\n getUpdateReleaseChannel,\n setUpdateReleaseChannel,\n} from \"@/utils/version-update\";\nimport {\n convertBrandAssetToPngDataURL,\n isPngDataURL,\n type BrandAssetKind,\n} from \"@/utils/brand-asset\";\n\n// 简单的保存图标组件\nconst SaveIcon = ({ className }: { className?: string }) => (\n \n \n \n \n \n);\n\ninterface ConfigItem {\n key: string;\n label: string;\n placeholder?: string;\n description?: string;\n type: \"input\" | \"switch\" | \"select\";\n options?: { label: string; value: string; description?: string }[];\n dependsOn?: string; // 依赖的配置项key\n dependsValue?: string; // 依赖的配置项值\n}\n\nconst BRAND_PREVIEW_KEYS = [\"app_logo\", \"app_favicon\"] as const;\n\ntype BrandPreviewKey = (typeof BRAND_PREVIEW_KEYS)[number];\n\nconst isBrandPreviewKey = (key: string): key is BrandPreviewKey =>\n BRAND_PREVIEW_KEYS.includes(key as BrandPreviewKey);\n\nconst BRAND_FILE_ACCEPT = \"image/png,image/jpeg,image/webp,image/svg+xml\";\n\nconst toBrandAssetKind = (key: BrandPreviewKey): BrandAssetKind => {\n return key === \"app_logo\" ? \"logo\" : \"favicon\";\n};\n\n// 网站配置项定义\nconst CONFIG_ITEMS: ConfigItem[] = [\n {\n key: \"ip\",\n label: \"面板后端地址\",\n placeholder: \"请输入面板后端IP:PORT\",\n description:\n \"格式“ip:port”,用于对接节点时使用,ip是你安装面板服务器的公网ip,端口是安装脚本内输入的后端端口。不要套CDN,不支持https,通讯数据有加密\",\n type: \"input\",\n },\n {\n key: \"panel_domain\",\n label: \"面板域名\",\n placeholder: \"请输入面板域名\",\n description: \"当前面板的域名,用于与其他面板进行联邦共享时验证身份\",\n type: \"input\",\n },\n {\n key: \"app_name\",\n label: \"应用名称\",\n placeholder: \"请输入应用名称\",\n description: \"在浏览器标签页和导航栏显示的应用名称\",\n type: \"input\",\n },\n {\n key: \"app_logo\",\n label: \"网页角标 Logo\",\n description: \"用于页面左上角导航角标,上传后会自动转换为 PNG 并持久化保存\",\n type: \"input\",\n },\n {\n key: \"app_favicon\",\n label: \"浏览器缩略图标\",\n description: \"用于浏览器标签页图标,上传后会自动转换为 PNG 并持久化保存\",\n type: \"input\",\n },\n {\n key: \"forward_compact_mode\",\n label: \"转发页面精简模式\",\n description: \"开启后,转发页面列表使用 2.1.6-alpha8 样式(全局配置)\",\n type: \"switch\",\n },\n {\n key: \"captcha_enabled\",\n label: \"启用验证码\",\n description: \"开启后,用户登录时需要完成验证码验证\",\n type: \"switch\",\n },\n {\n key: \"cloudflare_site_key\",\n label: \"Cloudflare Site Key\",\n placeholder: \"请输入 Cloudflare Site Key\",\n description: \"Cloudflare Turnstile 站点密钥\",\n type: \"input\",\n dependsOn: \"captcha_enabled\",\n dependsValue: \"true\",\n },\n {\n key: \"cloudflare_secret_key\",\n label: \"Cloudflare Secret Key\",\n placeholder: \"请输入 Cloudflare Secret Key\",\n description: \"Cloudflare Turnstile 密钥\",\n type: \"input\",\n dependsOn: \"captcha_enabled\",\n dependsValue: \"true\",\n },\n];\n\nconst BACKUP_TYPE_OPTIONS = [\n { value: \"users\", label: \"用户\" },\n { value: \"nodes\", label: \"节点\" },\n { value: \"tunnels\", label: \"隧道\" },\n { value: \"forwards\", label: \"转发\" },\n { value: \"userTunnels\", label: \"用户隧道权限\" },\n { value: \"speedLimits\", label: \"限速规则\" },\n { value: \"tunnelGroups\", label: \"隧道分组\" },\n { value: \"userGroups\", label: \"用户分组\" },\n { value: \"permissions\", label: \"分组权限\" },\n { value: \"configs\", label: \"系统配置\" },\n] as const;\n\nconst BACKUP_TYPE_VALUES = BACKUP_TYPE_OPTIONS.map((option) => option.value);\n\n// 初始化时从缓存读取配置,避免闪烁\nconst getInitialConfigs = (): Record => {\n if (typeof window === \"undefined\") return {};\n\n const configKeys = [\n \"app_name\",\n \"captcha_enabled\",\n \"cloudflare_site_key\",\n \"cloudflare_secret_key\",\n \"forward_compact_mode\",\n \"ip\",\n \"panel_domain\",\n \"app_logo\",\n \"app_favicon\",\n ];\n const initialConfigs: Record = {};\n\n try {\n configKeys.forEach((key) => {\n const cachedValue = localStorage.getItem(\"vite_config_\" + key);\n\n if (cachedValue) {\n initialConfigs[key] = cachedValue;\n }\n });\n } catch {}\n\n return initialConfigs;\n};\n\nexport default function ConfigPage() {\n const navigate = useNavigate();\n const initialConfigs = getInitialConfigs();\n const [configs, setConfigs] =\n useState>(initialConfigs);\n const [loading, setLoading] = useState(\n Object.keys(initialConfigs).length === 0,\n );\n const [saving, setSaving] = useState(false);\n const [hasChanges, setHasChanges] = useState(false);\n const [originalConfigs, setOriginalConfigs] =\n useState>(initialConfigs);\n\n const [exportTypes, setExportTypes] = useState([]);\n const [importTypes, setImportTypes] = useState([]);\n const [exporting, setExporting] = useState(false);\n const [importing, setImporting] = useState(false);\n const [exportSelectorOpen, setExportSelectorOpen] = useState(false);\n const [importSelectorOpen, setImportSelectorOpen] = useState(false);\n const [importFileName, setImportFileName] = useState(\"\");\n const backupFileInputRef = useRef(null);\n const logoFileInputRef = useRef(null);\n const faviconFileInputRef = useRef(null);\n\n const [announcement, setAnnouncement] = useState({\n content: \"\",\n enabled: 0,\n });\n const [announcementLoading, setAnnouncementLoading] = useState(true);\n const [announcementSaving, setAnnouncementSaving] = useState(false);\n const [updateChannel, setUpdateChannel] = useState(\n getUpdateReleaseChannel(),\n );\n const [previewLoadFailed, setPreviewLoadFailed] = useState<\n Partial>\n >({});\n const [brandUploading, setBrandUploading] = useState<\n Partial>\n >({});\n\n // 权限检查\n useEffect(() => {\n if (!isAdmin()) {\n toast.error(\"权限不足,只有管理员可以访问此页面\");\n navigate(\"/dashboard\", { replace: true });\n\n return;\n }\n }, [navigate]);\n\n // 加载配置数据(优先从缓存)\n const loadConfigs = async (currentConfigs?: Record) => {\n const configsToCompare = currentConfigs || configs;\n const hasInitialData = Object.keys(configsToCompare).length > 0;\n\n // 如果已有缓存数据,不显示loading,静默更新\n if (!hasInitialData) {\n setLoading(true);\n }\n\n try {\n const configData = await getCachedConfigs();\n\n // 只有在数据有变化时才更新\n const hasDataChanged =\n JSON.stringify(configData) !== JSON.stringify(configsToCompare);\n\n if (hasDataChanged) {\n setConfigs(configData);\n setOriginalConfigs({ ...configData });\n setHasChanges(false);\n } else {\n }\n } catch {\n // 只有在没有缓存数据时才显示错误\n if (!hasInitialData) {\n toast.error(\"加载配置出错,请重试\");\n }\n } finally {\n setLoading(false);\n }\n };\n\n useEffect(() => {\n const timer = setTimeout(() => {\n loadConfigs(initialConfigs);\n loadAnnouncement();\n }, 100);\n\n return () => clearTimeout(timer);\n }, []);\n\n const loadAnnouncement = async () => {\n setAnnouncementLoading(true);\n try {\n const res = await getAnnouncement();\n\n if (res.code === 0 && res.data) {\n setAnnouncement(res.data);\n }\n } catch {\n } finally {\n setAnnouncementLoading(false);\n }\n };\n\n const saveAnnouncement = async () => {\n setAnnouncementSaving(true);\n try {\n const res = await updateAnnouncement(announcement);\n\n if (res.code === 0) {\n toast.success(\"公告保存成功\");\n } else {\n toast.error(res.msg || \"保存失败\");\n }\n } catch {\n toast.error(\"保存公告失败,请重试\");\n } finally {\n setAnnouncementSaving(false);\n }\n };\n\n const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {\n setUpdateChannel(channel);\n setUpdateReleaseChannel(channel);\n toast.success(\n `更新通道已切换为${channel === \"stable\" ? \"稳定版\" : \"开发版\"}`,\n );\n };\n\n const handleConfigChange = (key: string, value: string) => {\n const newConfigs = { ...configs, [key]: value };\n\n setConfigs(newConfigs);\n\n if (isBrandPreviewKey(key)) {\n setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }));\n }\n\n const hasChangesNow =\n Object.keys(newConfigs).some(\n (k) => newConfigs[k] !== originalConfigs[k],\n ) ||\n Object.keys(originalConfigs).some(\n (k) => originalConfigs[k] !== newConfigs[k],\n );\n\n setHasChanges(hasChangesNow);\n };\n\n // 保存配置\n const handleSave = async () => {\n setSaving(true);\n try {\n const changedKeys = Object.keys(configs).filter(\n (key) => configs[key] !== originalConfigs[key],\n );\n\n if (changedKeys.length === 0) {\n setHasChanges(false);\n\n return;\n }\n\n const changedPayload: Record = {};\n\n changedKeys.forEach((key) => {\n changedPayload[key] = configs[key] || \"\";\n });\n\n const response = await updateConfigs(changedPayload);\n\n if (response.code === 0) {\n toast.success(\"配置保存成功\");\n\n Object.entries(configs).forEach(([key, value]) => {\n configCache.set(key, value);\n });\n\n setOriginalConfigs({ ...configs });\n setHasChanges(false);\n\n if (\n changedKeys.some((key) =>\n [\"app_name\", \"app_logo\", \"app_favicon\"].includes(key),\n )\n ) {\n await updateSiteConfig(configs);\n }\n\n // 触发配置更新事件,通知其他组件\n window.dispatchEvent(\n new CustomEvent(\"configUpdated\", {\n detail: { changedKeys },\n }),\n );\n } else {\n toast.error(\"保存配置失败: \" + response.msg);\n }\n } catch {\n toast.error(\"保存配置出错,请重试\");\n } finally {\n setSaving(false);\n }\n };\n\n // 检查配置项是否应该显示(依赖检查)\n const shouldShowItem = (item: ConfigItem): boolean => {\n if (!item.dependsOn || !item.dependsValue) {\n return true;\n }\n\n return configs[item.dependsOn] === item.dependsValue;\n };\n\n const getBrandInputRef = (key: BrandPreviewKey) => {\n return key === \"app_logo\" ? logoFileInputRef : faviconFileInputRef;\n };\n\n const triggerBrandFilePicker = (key: BrandPreviewKey) => {\n if (brandUploading[key]) {\n return;\n }\n\n getBrandInputRef(key).current?.click();\n };\n\n const clearBrandAsset = (key: BrandPreviewKey) => {\n handleConfigChange(key, \"\");\n setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }));\n };\n\n const handleBrandFileChange = async (\n key: BrandPreviewKey,\n event: React.ChangeEvent,\n ) => {\n const file = event.target.files?.[0];\n\n if (!file) {\n return;\n }\n\n setBrandUploading((prev) => ({ ...prev, [key]: true }));\n\n try {\n const pngDataURL = await convertBrandAssetToPngDataURL(\n file,\n toBrandAssetKind(key),\n );\n\n handleConfigChange(key, pngDataURL);\n toast.success(key === \"app_logo\" ? \"Logo 上传成功\" : \"Favicon 上传成功\");\n } catch (error) {\n const message =\n error instanceof Error ? error.message : \"图片处理失败,请重试\";\n\n toast.error(message);\n } finally {\n setBrandUploading((prev) => ({ ...prev, [key]: false }));\n event.target.value = \"\";\n }\n };\n\n const renderBrandPreview = (key: BrandPreviewKey) => {\n const previewUrl = (configs[key] || \"\").trim();\n const appNamePreview = (configs.app_name || \"\").trim() || \"应用名称\";\n const failed = previewLoadFailed[key] === true;\n const showImage = previewUrl.length > 0 && !failed;\n\n return (\n
\n

实时预览

\n
\n {key === \"app_logo\" ? (\n
\n {showImage ? (\n \n setPreviewLoadFailed((prev) => ({ ...prev, [key]: true }))\n }\n onLoad={() =>\n setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }))\n }\n />\n ) : (\n
\n LOGO\n
\n )}\n \n {appNamePreview}\n \n
\n ) : (\n
\n {showImage ? (\n \n setPreviewLoadFailed((prev) => ({ ...prev, [key]: true }))\n }\n onLoad={() =>\n setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }))\n }\n />\n ) : (\n
\n )}\n \n {appNamePreview}\n \n
\n )}\n
\n\n {previewUrl.length === 0 ? (\n

\n 上传图片后会实时显示预览\n

\n ) : null}\n\n {previewUrl.length > 0 && failed ? (\n

图片加载失败,请重新上传

\n ) : null}\n\n {previewUrl.length > 0 && !isPngDataURL(previewUrl) ? (\n

\n 当前是旧版 URL 配置,建议重新上传图片以启用无闪烁加载\n

\n ) : null}\n
\n );\n };\n\n const renderBrandAssetUploader = (\n key: BrandPreviewKey,\n isChanged: boolean,\n ) => {\n const value = (configs[key] || \"\").trim();\n const uploading = brandUploading[key] === true;\n const isLogo = key === \"app_logo\";\n\n return (\n \n {\n void handleBrandFileChange(key, event);\n }}\n />\n\n
\n triggerBrandFilePicker(key)}\n >\n {value.length > 0\n ? isLogo\n ? \"替换 Logo\"\n : \"替换 Favicon\"\n : isLogo\n ? \"上传 Logo\"\n : \"上传 Favicon\"}\n \n clearBrandAsset(key)}\n >\n 清除\n \n \n 仅支持图片文件,自动转换为 PNG\n \n
\n\n

\n {isLogo\n ? \"建议上传方形图片,系统会统一转换为 96x96 PNG\"\n : \"建议上传方形图片,系统会统一转换为 64x64 PNG\"}\n

\n\n {renderBrandPreview(key)}\n
\n );\n };\n\n // 渲染不同类型的配置项\n const renderConfigItem = (item: ConfigItem) => {\n const isChanged =\n hasChanges && configs[item.key] !== originalConfigs[item.key];\n\n switch (item.type) {\n case \"input\":\n if (isBrandPreviewKey(item.key)) {\n return renderBrandAssetUploader(item.key, isChanged);\n }\n\n return (\n handleConfigChange(item.key, e.target.value)}\n />\n );\n\n case \"switch\":\n return (\n \n handleConfigChange(item.key, checked ? \"true\" : \"false\")\n }\n >\n \n {configs[item.key] === \"true\" ? \"已启用\" : \"已禁用\"}\n \n \n );\n\n case \"select\":\n return (\n {\n const selectedKey = Array.from(keys)[0] as string;\n\n if (selectedKey) {\n handleConfigChange(item.key, selectedKey);\n }\n }}\n >\n {item.options?.map((option) => (\n \n {option.label}\n \n )) || []}\n \n );\n\n default:\n return null;\n }\n };\n\n const handleExport = async () => {\n if (exportTypes.length === 0) {\n toast.error(\"请至少选择一种数据类型\");\n\n return;\n }\n setExporting(true);\n try {\n await exportBackup(exportTypes);\n toast.success(\"导出成功\");\n setExportSelectorOpen(false);\n } catch {\n toast.error(\"导出失败,请重试\");\n } finally {\n setExporting(false);\n }\n };\n\n const triggerImportFilePicker = () => {\n if (importTypes.length === 0) {\n toast.error(\"请先选择要导入的数据类型\");\n\n return;\n }\n\n setImportSelectorOpen(false);\n requestAnimationFrame(() => backupFileInputRef.current?.click());\n };\n\n const handleFileChange = async (e: React.ChangeEvent) => {\n const file = e.target.files?.[0];\n\n if (!file) return;\n\n if (importTypes.length === 0) {\n toast.error(\"请先选择要导入的数据类型\");\n\n return;\n }\n\n setImportFileName(file.name);\n setImporting(true);\n\n try {\n const text = await file.text();\n const data = JSON.parse(text);\n\n const response = await importBackup({\n types: importTypes,\n ...data,\n });\n\n if (response.code === 0) {\n toast.success(`导入成功: ${JSON.stringify(response.data)}`);\n setImportTypes([]);\n setImportFileName(\"\");\n } else {\n toast.error(\"导入失败: \" + response.msg);\n }\n } catch {\n toast.error(\"导入失败,请检查文件格式\");\n } finally {\n setImporting(false);\n if (backupFileInputRef.current) {\n backupFileInputRef.current.value = \"\";\n }\n }\n };\n\n const toggleTypeSelection = (\n type: string,\n setTypes: React.Dispatch>,\n ) => {\n setTypes((prev) =>\n prev.includes(type)\n ? prev.filter((item) => item !== type)\n : [...prev, type],\n );\n };\n\n const isAllTypesSelected = (types: string[]) =>\n BACKUP_TYPE_VALUES.every((type) => types.includes(type));\n\n const renderTypeSelection = (\n label: string,\n selectedTypes: string[],\n setTypes: React.Dispatch>,\n ) => {\n const allSelected = isAllTypesSelected(selectedTypes);\n\n return (\n
\n
\n \n {label}\n \n
\n \n setTypes(allSelected ? [] : [...BACKUP_TYPE_VALUES])\n }\n >\n {allSelected ? \"取消全选\" : \"全选\"}\n \n \n
\n
\n\n
\n {BACKUP_TYPE_OPTIONS.map((option) => {\n const isSelected = selectedTypes.includes(option.value);\n\n return (\n toggleTypeSelection(option.value, setTypes)}\n >\n
\n \n \n {option.label}\n \n
\n \n );\n })}\n
\n
\n );\n };\n\n if (loading) {\n return (\n
\n \n
\n );\n }\n\n return (\n
\n {/* 页面标题 */}\n
\n \n
\n

网站配置

\n

\n 管理网站的基本信息和显示设置\n

\n
\n
\n\n \n \n
\n
\n

基本设置

\n

\n 配置网站的基本信息,这些设置会影响网站的显示效果\n

\n
\n
\n }\n onPress={handleSave}\n >\n {saving ? \"保存中...\" : \"保存配置\"}\n \n
\n
\n
\n\n \n\n \n {CONFIG_ITEMS.map((item, index) => {\n // 检查配置项是否应该显示\n if (!shouldShowItem(item)) {\n return null;\n }\n\n // 计算是否是最后一个显示的项目(用于决定是否显示分隔线)\n const remainingItems = CONFIG_ITEMS.slice(index + 1).filter(\n shouldShowItem,\n );\n const isLastItem = remainingItems.length === 0;\n\n return (\n
\n
\n \n {item.description && (\n

\n {item.description}\n

\n )}\n
\n\n {/* 渲染配置项 */}\n {renderConfigItem(item)}\n\n {/* 分隔线 */}\n {!isLastItem && }\n
\n );\n })}\n\n \n\n
\n
\n

\n 更新通道\n

\n

\n 稳定版仅匹配纯数字版本;开发版仅匹配包含 alpha / beta / rc\n 的版本。\n

\n
\n\n {\n const selected =\n (Array.from(keys)[0] as UpdateReleaseChannel) || \"stable\";\n\n handleUpdateChannelChange(selected);\n }}\n >\n \n 稳定版\n \n \n 开发版\n \n \n
\n
\n
\n\n {hasChanges && (\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 {announcementLoading ? (\n
\n \n
\n ) : (\n <>\n
\n \n setAnnouncement({\n ...announcement,\n enabled: checked ? 1 : 0,\n })\n }\n >\n \n {announcement.enabled === 1 ? \"已启用\" : \"已禁用\"}\n \n \n

\n 启用后,公告将在首页顶部显示\n

\n
\n\n \n setAnnouncement({ ...announcement, content: e.target.value })\n }\n />\n

\n 公告支持 Markdown 语法,链接会在新标签页打开\n

\n\n
\n }\n onPress={saveAnnouncement}\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 选择要导出的数据类型,导出为 JSON 格式文件\n

\n

\n 当前已选 {exportTypes.length} / {BACKUP_TYPE_VALUES.length}\n

\n\n
\n setExportSelectorOpen(true)}\n >\n {exporting ? \"导出中...\" : \"选择并导出\"}\n \n
\n
\n\n \n\n {/* 导入部分 */}\n
\n

导入数据

\n

\n 选择要导入的数据类型,支持从备份文件恢复数据\n

\n

\n 当前已选 {importTypes.length} / {BACKUP_TYPE_VALUES.length}\n

\n\n \n\n
\n setImportSelectorOpen(true)}\n >\n {importing ? \"导入中...\" : \"选择并导入\"}\n \n {importFileName && (\n \n 已选择: {importFileName}\n \n )}\n
\n
\n
\n
\n\n \n \n {(onClose) => (\n <>\n 选择导出内容\n \n {renderTypeSelection(\"导出内容\", exportTypes, setExportTypes)}\n \n \n \n \n {exporting ? \"导出中...\" : \"确认导出\"}\n \n \n \n )}\n \n \n\n \n \n {(onClose) => (\n <>\n 选择导入内容\n \n {renderTypeSelection(\"导入内容\", importTypes, setImportTypes)}\n \n \n \n \n 下一步选择文件\n \n \n \n )}\n \n \n
\n );\n}\n", "additions": 7, "deletions": 0, "status": "modified" }, { "file": "vite-frontend/src/pages/forward.tsx", "before": "import type { SpeedLimitApiItem } from \"@/api/types\";\n\nimport { useState, useEffect, useMemo, useRef } 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 { 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 pauseForwardService,\n resumeForwardService,\n diagnoseForward,\n updateForwardOrder,\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 { 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 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 inNodePortSta?: number;\n inNodePortEnd?: number;\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 items: Forward[];\n}\n\nconst UNKNOWN_FORWARD_USER_NAME = \"未知用户\";\nconst UNCATEGORIZED_FORWARD_TUNNEL_NAME = \"未分类\";\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\nexport default function ForwardPage() {\n const [loading, setLoading] = useState(true);\n const [forwards, setForwards] = useState([]);\n const [tunnels, setTunnels] = 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\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 const [importModalOpen, setImportModalOpen] = useState(false);\n const [importData, setImportData] = useState(\"\");\n const [importLoading, setImportLoading] = useState(false);\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\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\n useEffect(() => {\n return () => {\n diagnosisAbortRef.current?.abort();\n diagnosisAbortRef.current = null;\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 = 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\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 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 useEffect(() => {\n loadData();\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 // 加载所有数据\n const loadData = async (lod = true) => {\n setLoading(lod);\n try {\n const [forwardsRes, tunnelsRes, speedLimitsRes] = await Promise.all([\n getForwardList(),\n userTunnel(),\n getSpeedLimitList(),\n ]);\n\n if (forwardsRes.code === 0) {\n const forwardsData =\n forwardsRes.data?.map((forward: any) => ({\n ...forward,\n serviceRunning: forward.status === 1,\n })) || [];\n\n const mergedForwards = await mergeFederationShareFlow(forwardsData);\n\n setForwards(mergedForwards);\n\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 } else {\n toast.error(forwardsRes.msg || \"获取转发列表失败\");\n }\n\n if (tunnelsRes.code === 0) {\n setTunnels(tunnelsRes.data || []);\n } else {\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\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 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 return noLimitSpeedLimitIds.has(speedId) ? null : 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 // 验证入口端口(可选,如果填写则验证)\n if (form.inPort !== null && form.inPort !== undefined) {\n const port = Number(form.inPort);\n\n if (isNaN(port) || port < 1 || port > 65535) {\n newErrors.inPort = \"端口必须在 1-65535 之间\";\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 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 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 loadData();\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 loadData();\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 setForm((prev) => ({ ...prev, tunnelId: parseInt(tunnelId) }));\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\n if (isEdit) {\n // 更新时确保包含必要字段\n const updateData = {\n id: form.id,\n userId: form.userId,\n name: form.name,\n tunnelId: form.tunnelId,\n inPort: form.inPort,\n inIp: form.inIp || null,\n remoteAddr: processedRemoteAddr,\n strategy: addressCount > 1 ? form.strategy : \"fifo\",\n speedId: normalizeSpeedId(form.speedId),\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 || null,\n remoteAddr: processedRemoteAddr,\n strategy: addressCount > 1 ? form.strategy : \"fifo\",\n speedId: normalizeSpeedId(form.speedId),\n };\n\n res = await createForward(createData);\n }\n\n if (res.code === 0) {\n toast.success(isEdit ? \"修改成功\" : \"创建成功\");\n setModalOpen(false);\n loadData();\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 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 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 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((r) => r.success).length;\n const failedCount = resultData.results.length - successCount;\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 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 // 验证远程地址格式 - 支持单个地址或多个地址用逗号分隔\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 // 处理入口端口\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 // 调用创建转发接口\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 toast.success(`导入执行完成`);\n\n // 导入完成后刷新转发列表\n await loadData(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 // 确保 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(overForward?.tunnelName);\n\n // 仅允许在同一用户+隧道分组内拖拽,避免混排\n if (activeUserId !== overUserId || activeTunnelGroupKey !== overTunnelGroupKey) {\n return;\n }\n\n const oldIndex = forwardOrder.indexOf(activeId);\n const newIndex = forwardOrder.indexOf(overId);\n\n if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {\n const newOrder = arrayMove(forwardOrder, oldIndex, newIndex);\n\n setForwardOrder(newOrder);\n\n saveOrder(FORWARD_ORDER_KEY, newOrder);\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 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 setSelectedIds(new Set());\n setSelectMode(false);\n if (outcome.closeDeleteModal) {\n setBatchDeleteModalOpen(false);\n }\n loadData(false);\n }\n } finally {\n setBatchLoading(false);\n }\n };\n\n const handleBatchToggleService = async (enable: boolean) => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\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 setSelectedIds(new Set());\n setSelectMode(false);\n loadData(false);\n }\n } finally {\n setBatchLoading(false);\n }\n };\n\n const handleBatchRedeploy = async () => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\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 setSelectedIds(new Set());\n setSelectMode(false);\n loadData(false);\n }\n } finally {\n setBatchLoading(false);\n }\n };\n\n const handleBatchChangeTunnel = async () => {\n if (selectedIds.size === 0 || !batchTargetTunnelId) return;\n setBatchLoading(true);\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 setSelectedIds(new Set());\n setSelectMode(false);\n if (outcome.closeChangeTunnelModal) {\n setBatchChangeTunnelModalOpen(false);\n }\n if (outcome.resetTargetTunnel) {\n setBatchTargetTunnelId(null);\n }\n loadData(false);\n }\n } finally {\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 const tokenUserId = JwtUtil.getUserIdFromToken();\n const tokenRoleId = JwtUtil.getRoleIdFromToken();\n const isAdmin = tokenRoleId === 0;\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 }, [\n forwards,\n forwardOrder,\n filterUserId,\n filterTunnelId,\n searchKeyword,\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 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\n const groups = Array.from(userGroupMap.values()).map((group) => {\n const tunnels = Array.from(group.tunnelMap.values());\n\n tunnels.sort((a, b) => {\n const aIsUncategorized =\n a.tunnelName === UNCATEGORIZED_FORWARD_TUNNEL_NAME;\n const bIsUncategorized =\n b.tunnelName === UNCATEGORIZED_FORWARD_TUNNEL_NAME;\n\n if (aIsUncategorized !== bIsUncategorized) {\n return aIsUncategorized ? 1 : -1;\n }\n\n const nameCompare = compareForwardTunnelNameAsc(a.tunnelName, b.tunnelName);\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]);\n\n const sortedForwards = useMemo(\n () =>\n groupedForwards.flatMap((group) =>\n group.tunnels.flatMap((tunnel) => tunnel.items),\n ),\n [groupedForwards],\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 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 // 渲染转发卡片\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)}\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) {\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 {/* 根据显示模式渲染不同内容 */}\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 {group.tunnels.map((tunnel) => {\n const tunnelSortableForwardIds = tunnel.items\n .map((item) => item.id)\n .filter((id) => id > 0);\n\n return (\n \n
\n \n {tunnel.tunnelName}\n \n \n {tunnel.items.length} 条转发\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 {group.tunnels.map((tunnel) => {\n const tunnelSortableForwardIds = tunnel.items\n .map((item) => item.id)\n .filter((id) => id > 0);\n\n return (\n \n
\n \n {tunnel.tunnelName}\n \n \n {tunnel.items.length} 条转发\n \n
\n\n {}} // 添加空的 onDragStart 处理器\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 {(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 {\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 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 \n setForm((prev) => ({ ...prev, inIp: e.target.value }))\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

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

\n

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

\n
\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 {/* 输入区域 */}\n
\n setImportData(e.target.value)}\n />\n
\n\n {/* 导入结果 */}\n {importResults.length > 0 && (\n
\n
\n

导入结果

\n
\n \n 成功:{importResults.filter((r) => r.success).length} /\n 总计:{importResults.length}\n \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 { SpeedLimitApiItem } from \"@/api/types\";\n\nimport { useState, useEffect, useMemo, useRef } 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 { 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 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 { 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 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 inNodePortSta?: number;\n inNodePortEnd?: number;\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 items: Forward[];\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 =\n \"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 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 = (raw: string | null): 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 = (raw: string | null): 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 = (userId: number, tunnelKey: string): string => {\n return `${userId}:${tunnelKey}`;\n};\n\nconst buildTunnelGroupSortableId = (userId: number, tunnelKey: string): 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 = (forwards: Forward[]): {\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\nexport default function ForwardPage() {\n const [loading, setLoading] = useState(true);\n const [forwards, setForwards] = useState([]);\n const [tunnels, setTunnels] = 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 const [importModalOpen, setImportModalOpen] = useState(false);\n const [importData, setImportData] = useState(\"\");\n const [importLoading, setImportLoading] = useState(false);\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\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 [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 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(FORWARD_GROUP_COLLAPSED_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()] = 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 = 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(JSON.stringify(globalOrderBucket));\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 = 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\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 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 useEffect(() => {\n loadData();\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 // 加载所有数据\n const loadData = async (lod = true) => {\n setLoading(lod);\n try {\n const [forwardsRes, tunnelsRes, speedLimitsRes] = await Promise.all([\n getForwardList(),\n userTunnel(),\n getSpeedLimitList(),\n ]);\n\n if (forwardsRes.code === 0) {\n const forwardsData =\n forwardsRes.data?.map((forward: any) => ({\n ...forward,\n serviceRunning: forward.status === 1,\n })) || [];\n\n const mergedForwards = await mergeFederationShareFlow(forwardsData);\n\n setForwards(mergedForwards);\n\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 } else {\n toast.error(forwardsRes.msg || \"获取转发列表失败\");\n }\n\n if (tunnelsRes.code === 0) {\n setTunnels(tunnelsRes.data || []);\n } else {\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\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 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 return noLimitSpeedLimitIds.has(speedId) ? null : 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 // 验证入口端口(可选,如果填写则验证)\n if (form.inPort !== null && form.inPort !== undefined) {\n const port = Number(form.inPort);\n\n if (isNaN(port) || port < 1 || port > 65535) {\n newErrors.inPort = \"端口必须在 1-65535 之间\";\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 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 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 loadData();\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 loadData();\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 setForm((prev) => ({ ...prev, tunnelId: parseInt(tunnelId) }));\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\n if (isEdit) {\n // 更新时确保包含必要字段\n const updateData = {\n id: form.id,\n userId: form.userId,\n name: form.name,\n tunnelId: form.tunnelId,\n inPort: form.inPort,\n inIp: form.inIp || null,\n remoteAddr: processedRemoteAddr,\n strategy: addressCount > 1 ? form.strategy : \"fifo\",\n speedId: normalizeSpeedId(form.speedId),\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 || null,\n remoteAddr: processedRemoteAddr,\n strategy: addressCount > 1 ? form.strategy : \"fifo\",\n speedId: normalizeSpeedId(form.speedId),\n };\n\n res = await createForward(createData);\n }\n\n if (res.code === 0) {\n toast.success(isEdit ? \"修改成功\" : \"创建成功\");\n setModalOpen(false);\n loadData();\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 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 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 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((r) => r.success).length;\n const failedCount = resultData.results.length - successCount;\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 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 // 验证远程地址格式 - 支持单个地址或多个地址用逗号分隔\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 // 处理入口端口\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 // 调用创建转发接口\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 toast.success(`导入执行完成`);\n\n // 导入完成后刷新转发列表\n await loadData(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(overForward?.tunnelName);\n\n // 非精简模式仅允许在同一用户+隧道分组内拖拽,避免混排\n if (!compactMode) {\n if (\n activeUserId !== overUserId ||\n activeTunnelGroupKey !== overTunnelGroupKey\n ) {\n return;\n }\n }\n\n const oldIndex = forwardOrder.indexOf(activeId);\n const newIndex = forwardOrder.indexOf(overId);\n\n if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {\n const newOrder = arrayMove(forwardOrder, oldIndex, newIndex);\n\n setForwardOrder(newOrder);\n\n saveOrder(FORWARD_ORDER_KEY, newOrder);\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 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 setSelectedIds(new Set());\n setSelectMode(false);\n if (outcome.closeDeleteModal) {\n setBatchDeleteModalOpen(false);\n }\n loadData(false);\n }\n } finally {\n setBatchLoading(false);\n }\n };\n\n const handleBatchToggleService = async (enable: boolean) => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\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 setSelectedIds(new Set());\n setSelectMode(false);\n loadData(false);\n }\n } finally {\n setBatchLoading(false);\n }\n };\n\n const handleBatchRedeploy = async () => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\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 setSelectedIds(new Set());\n setSelectMode(false);\n loadData(false);\n }\n } finally {\n setBatchLoading(false);\n }\n };\n\n const handleBatchChangeTunnel = async () => {\n if (selectedIds.size === 0 || !batchTargetTunnelId) return;\n setBatchLoading(true);\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 setSelectedIds(new Set());\n setSelectMode(false);\n if (outcome.closeChangeTunnelModal) {\n setBatchChangeTunnelModalOpen(false);\n }\n if (outcome.resetTargetTunnel) {\n setBatchTargetTunnelId(null);\n }\n loadData(false);\n }\n } finally {\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 }, [\n forwards,\n forwardOrder,\n filterUserId,\n filterTunnelId,\n searchKeyword,\n ]);\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 (!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 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 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\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(a.tunnelName, b.tunnelName);\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(groupUserId, tunnel.tunnelKey);\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
\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 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 \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)}\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 {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\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 {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 ) : 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(group.userId, tunnel.tunnelKey),\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(group.userId, tunnel.tunnelKey),\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(group.userId, tunnel.tunnelKey)\n ] === true;\n\n return (\n \n toggleTunnelGroupCollapsed(group.userId, tunnel.tunnelKey)\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 {\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 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 \n setForm((prev) => ({ ...prev, inIp: e.target.value }))\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

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

\n

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

\n
\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 {/* 输入区域 */}\n
\n setImportData(e.target.value)}\n />\n
\n\n {/* 导入结果 */}\n {importResults.length > 0 && (\n
\n
\n

导入结果

\n
\n \n 成功:{importResults.filter((r) => r.success).length} /\n 总计:{importResults.length}\n \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": 1270, "deletions": 199, "status": "modified" }, { "file": "vite-frontend/src/pages/settings.tsx", "before": "import { useState, useEffect } from \"react\";\nimport { useNavigate } from \"react-router-dom\";\nimport toast from \"react-hot-toast\";\n\nimport { Input } from \"@/shadcn-bridge/heroui/input\";\nimport { Button } from \"@/shadcn-bridge/heroui/button\";\nimport { Card, CardBody } from \"@/shadcn-bridge/heroui/card\";\nimport { Select, SelectItem } from \"@/shadcn-bridge/heroui/select\";\nimport { reinitializeBaseURL } from \"@/api/network\";\nimport {\n type UpdateReleaseChannel,\n getUpdateReleaseChannel,\n setUpdateReleaseChannel,\n} from \"@/utils/version-update\";\nimport {\n getPanelAddresses,\n savePanelAddress,\n setCurrentPanelAddress,\n deletePanelAddress,\n validatePanelAddress,\n} from \"@/utils/panel\";\n\ninterface PanelAddress {\n name: string;\n address: string;\n inx: boolean;\n}\n\nexport const SettingsPage = () => {\n const navigate = useNavigate();\n const [panelAddresses, setPanelAddresses] = useState([]);\n const [newName, setNewName] = useState(\"\");\n const [newAddress, setNewAddress] = useState(\"\");\n const [updateChannel, setUpdateChannel] = useState(\n getUpdateReleaseChannel(),\n );\n\n const setPanelAddressesFunc = (newAddress: PanelAddress[]) => {\n setPanelAddresses(newAddress);\n };\n\n // 加载面板地址列表\n const loadPanelAddresses = async () => {\n (window as any).setPanelAddresses = setPanelAddressesFunc;\n getPanelAddresses();\n };\n\n // 添加新面板地址\n const addPanelAddress = async () => {\n if (!newName.trim() || !newAddress.trim()) {\n toast.error(\"请输入名称和地址\");\n\n return;\n }\n\n // 验证地址格式\n if (!validatePanelAddress(newAddress.trim())) {\n toast.error(\n \"地址格式不正确,请检查:\\n• 必须是完整的URL格式\\n• 必须以 http:// 或 https:// 开头\\n• 支持域名、IPv4、IPv6 地址\\n• 端口号范围:1-65535\\n• 示例:http://192.168.1.100:3000\",\n );\n\n return;\n }\n (window as any).setPanelAddresses = setPanelAddressesFunc;\n savePanelAddress(newName.trim(), newAddress.trim());\n setNewName(\"\");\n setNewAddress(\"\");\n toast.success(\"添加成功\");\n };\n\n // 设置当前面板地址\n const setCurrentPanel = async (name: string) => {\n (window as any).setPanelAddresses = setPanelAddressesFunc;\n setCurrentPanelAddress(name);\n reinitializeBaseURL();\n };\n\n // 删除面板地址\n const handleDeletePanelAddress = async (name: string) => {\n (window as any).setPanelAddresses = setPanelAddressesFunc;\n deletePanelAddress(name);\n reinitializeBaseURL();\n toast.success(\"删除成功\");\n };\n\n // 页面加载时获取数据\n useEffect(() => {\n loadPanelAddresses();\n }, []);\n\n const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {\n setUpdateChannel(channel);\n setUpdateReleaseChannel(channel);\n toast.success(\n `更新通道已切换为${channel === \"stable\" ? \"稳定版\" : \"开发版\"}`,\n );\n };\n\n return (\n
\n {/* 顶部导航 */}\n
\n
\n
\n navigate(-1)}\n >\n \n \n \n \n

\n 面板设置\n

\n
\n
\n
\n\n {/* 内容区域 */}\n
\n
\n \n \n

\n 更新设置\n

\n
\n {\n const selected =\n (Array.from(keys)[0] as UpdateReleaseChannel) || \"stable\";\n\n handleUpdateChannelChange(selected);\n }}\n >\n \n 稳定版(纯数字版本,如 2.1.4)\n \n \n 开发版(含 alpha / beta / rc)\n \n \n

\n 版本提示会根据该通道检查最新版本。\n

\n
\n
\n
\n\n {/* 添加新地址 */}\n \n \n

\n 添加新面板地址\n

\n
\n
\n setNewName(e.target.value)}\n />\n setNewAddress(e.target.value)}\n />\n
\n \n
\n
\n
\n\n {/* 地址列表 */}\n \n \n

\n 已保存的面板地址\n

\n {panelAddresses.length === 0 ? (\n

\n 暂无保存的面板地址\n

\n ) : (\n
\n {panelAddresses.map((panel, index) => (\n \n
\n
\n
\n \n {panel.name}\n \n {panel.inx && (\n \n 当前\n \n )}\n
\n

\n {panel.address}\n

\n
\n
\n {!panel.inx && (\n setCurrentPanel(panel.name)}\n >\n 设为当前\n \n )}\n handleDeletePanelAddress(panel.name)}\n >\n 删除\n \n
\n
\n
\n ))}\n
\n )}\n \n \n
\n
\n \n );\n};\n", "after": "import { useState, useEffect } from \"react\";\nimport { useNavigate } from \"react-router-dom\";\nimport toast from \"react-hot-toast\";\n\nimport { Input } from \"@/shadcn-bridge/heroui/input\";\nimport { Button } from \"@/shadcn-bridge/heroui/button\";\nimport { Card, CardBody } from \"@/shadcn-bridge/heroui/card\";\nimport { Select, SelectItem } from \"@/shadcn-bridge/heroui/select\";\nimport { Switch } from \"@/shadcn-bridge/heroui/switch\";\nimport { reinitializeBaseURL } from \"@/api/network\";\nimport { getConfigByName, updateConfig } from \"@/api\";\nimport {\n type UpdateReleaseChannel,\n getUpdateReleaseChannel,\n setUpdateReleaseChannel,\n} from \"@/utils/version-update\";\nimport { isAdmin } from \"@/utils/auth\";\nimport {\n getPanelAddresses,\n savePanelAddress,\n setCurrentPanelAddress,\n deletePanelAddress,\n validatePanelAddress,\n} from \"@/utils/panel\";\n\ninterface PanelAddress {\n name: string;\n address: string;\n inx: boolean;\n}\n\nconst FORWARD_COMPACT_MODE_CONFIG_KEY = \"forward_compact_mode\";\n\nexport const SettingsPage = () => {\n const navigate = useNavigate();\n const [panelAddresses, setPanelAddresses] = useState([]);\n const [newName, setNewName] = useState(\"\");\n const [newAddress, setNewAddress] = useState(\"\");\n const [updateChannel, setUpdateChannel] = useState(\n getUpdateReleaseChannel(),\n );\n const [forwardCompactMode, setForwardCompactMode] = useState(false);\n const [forwardCompactModeSaving, setForwardCompactModeSaving] =\n useState(false);\n\n const admin = isAdmin();\n\n const setPanelAddressesFunc = (newAddress: PanelAddress[]) => {\n setPanelAddresses(newAddress);\n };\n\n // 加载面板地址列表\n const loadPanelAddresses = async () => {\n (window as any).setPanelAddresses = setPanelAddressesFunc;\n getPanelAddresses();\n };\n\n // 添加新面板地址\n const addPanelAddress = async () => {\n if (!newName.trim() || !newAddress.trim()) {\n toast.error(\"请输入名称和地址\");\n\n return;\n }\n\n // 验证地址格式\n if (!validatePanelAddress(newAddress.trim())) {\n toast.error(\n \"地址格式不正确,请检查:\\n• 必须是完整的URL格式\\n• 必须以 http:// 或 https:// 开头\\n• 支持域名、IPv4、IPv6 地址\\n• 端口号范围:1-65535\\n• 示例:http://192.168.1.100:3000\",\n );\n\n return;\n }\n (window as any).setPanelAddresses = setPanelAddressesFunc;\n savePanelAddress(newName.trim(), newAddress.trim());\n setNewName(\"\");\n setNewAddress(\"\");\n toast.success(\"添加成功\");\n };\n\n // 设置当前面板地址\n const setCurrentPanel = async (name: string) => {\n (window as any).setPanelAddresses = setPanelAddressesFunc;\n setCurrentPanelAddress(name);\n reinitializeBaseURL();\n };\n\n // 删除面板地址\n const handleDeletePanelAddress = async (name: string) => {\n (window as any).setPanelAddresses = setPanelAddressesFunc;\n deletePanelAddress(name);\n reinitializeBaseURL();\n toast.success(\"删除成功\");\n };\n\n // 页面加载时获取数据\n useEffect(() => {\n loadPanelAddresses();\n loadForwardCompactMode();\n }, []);\n\n const loadForwardCompactMode = async () => {\n try {\n const res = await getConfigByName(FORWARD_COMPACT_MODE_CONFIG_KEY);\n const enabled =\n res.code === 0 &&\n typeof res.data?.value === \"string\" &&\n res.data.value === \"true\";\n\n setForwardCompactMode(enabled);\n } catch {\n setForwardCompactMode(false);\n }\n };\n\n const handleForwardCompactModeChange = async (enabled: boolean) => {\n if (!admin || forwardCompactModeSaving) {\n return;\n }\n\n const previous = forwardCompactMode;\n\n setForwardCompactMode(enabled);\n setForwardCompactModeSaving(true);\n try {\n const response = await updateConfig(\n FORWARD_COMPACT_MODE_CONFIG_KEY,\n enabled ? \"true\" : \"false\",\n );\n\n if (response.code === 0) {\n toast.success(`转发页面精简模式已${enabled ? \"开启\" : \"关闭\"}`);\n window.dispatchEvent(\n new CustomEvent(\"forwardCompactModeChanged\", {\n detail: { enabled },\n }),\n );\n } else {\n setForwardCompactMode(previous);\n toast.error(response.msg || \"保存精简模式失败\");\n }\n } catch {\n setForwardCompactMode(previous);\n toast.error(\"保存精简模式失败\");\n } finally {\n setForwardCompactModeSaving(false);\n }\n };\n\n const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {\n setUpdateChannel(channel);\n setUpdateReleaseChannel(channel);\n toast.success(\n `更新通道已切换为${channel === \"stable\" ? \"稳定版\" : \"开发版\"}`,\n );\n };\n\n return (\n
\n {/* 顶部导航 */}\n
\n
\n
\n navigate(-1)}\n >\n \n \n \n \n

\n 面板设置\n

\n
\n
\n
\n\n {/* 内容区域 */}\n
\n
\n \n \n

\n 更新设置\n

\n
\n {\n const selected =\n (Array.from(keys)[0] as UpdateReleaseChannel) || \"stable\";\n\n handleUpdateChannelChange(selected);\n }}\n >\n \n 稳定版(纯数字版本,如 2.1.4)\n \n \n 开发版(含 alpha / beta / rc)\n \n \n

\n 版本提示会根据该通道检查最新版本。\n

\n
\n
\n
\n\n \n \n

\n 显示设置\n

\n
\n
\n
\n

\n 转发页面精简模式\n

\n

\n 开启后,转发页面列表使用 2.1.6-alpha8 样式。\n

\n
\n \n
\n {!admin && (\n

\n 仅管理员可修改该全局配置。\n

\n )}\n
\n
\n
\n\n {/* 添加新地址 */}\n \n \n

\n 添加新面板地址\n

\n
\n
\n setNewName(e.target.value)}\n />\n setNewAddress(e.target.value)}\n />\n
\n \n
\n
\n
\n\n {/* 地址列表 */}\n \n \n

\n 已保存的面板地址\n

\n {panelAddresses.length === 0 ? (\n

\n 暂无保存的面板地址\n

\n ) : (\n
\n {panelAddresses.map((panel, index) => (\n \n
\n
\n
\n \n {panel.name}\n \n {panel.inx && (\n \n 当前\n \n )}\n
\n

\n {panel.address}\n

\n
\n
\n {!panel.inx && (\n setCurrentPanel(panel.name)}\n >\n 设为当前\n \n )}\n handleDeletePanelAddress(panel.name)}\n >\n 删除\n \n
\n
\n
\n ))}\n
\n )}\n \n \n
\n
\n \n );\n};\n", "additions": 90, "deletions": 0, "status": "modified" } ] }, "agent": "build", "model": { "providerID": "bailian-coding-plan-test", "modelID": "glm-5" }, "id": "msg_cb0f01e45001bNkjBykCDSVUwZ", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T" }, "parts": [ { "type": "text", "text": "把main更新到本分支", "id": "prt_cb0f01e46001vHGAIx5FaUVAfJ", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0f01e45001bNkjBykCDSVUwZ" } ] }, { "info": { "role": "assistant", "time": { "created": 1772495052438, "completed": 1772495059405 }, "parentID": "msg_cb0f01e45001bNkjBykCDSVUwZ", "modelID": "glm-5", "providerID": "bailian-coding-plan-test", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula" }, "cost": 0, "tokens": { "total": 17113, "input": 16520, "output": 97, "reasoning": 0, "cache": { "read": 496, "write": 0 } }, "finish": "tool-calls", "id": "msg_cb0f01e96001M31PPxA4FL7hIw", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T" }, "parts": [ { "type": "step-start", "snapshot": "fcf51c53694578ccd8d6dd2d1874a5074b2c14f1", "id": "prt_cb0f030c1001DPixwtBKTA6sIX", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0f01e96001M31PPxA4FL7hIw" }, { "type": "reasoning", "text": "用户要求将 main 分支更新到当前分支。首先我需要了解当前的分支情况和状态。\n\n我需要:\n1. 查看当前分支\n2. 将 main 分支合并到当前分支(或者变基)\n\n让我先检查当前的 git 状态。", "metadata": { "anthropic": { "signature": "" } }, "time": { "start": 1772495057089, "end": 1772495058847 }, "id": "prt_cb0f030c1002f9KVejusJpTFu4", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0f01e96001M31PPxA4FL7hIw" }, { "type": "tool", "callID": "toolu_tool-4aa80d07ca5e49548d5d683ef4d1a912", "tool": "bash", "state": { "status": "completed", "input": { "command": "git branch --show-current", "description": "显示当前分支名" }, "output": "opencode/mighty-nebula\n", "title": "显示当前分支名", "metadata": { "output": "opencode/mighty-nebula\n", "exit": 0, "description": "显示当前分支名", "truncated": false }, "time": { "start": 1772495059261, "end": 1772495059300 } }, "id": "prt_cb0f037a1001ayybZArTe2gXhy", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0f01e96001M31PPxA4FL7hIw" }, { "type": "tool", "callID": "toolu_tool-8e82555d2a9b41ba92c13c46549c73ed", "tool": "bash", "state": { "status": "completed", "input": { "command": "git status", "description": "查看工作区状态" }, "output": "位于分支 opencode/mighty-nebula\n尚未暂存以备提交的变更:\n (使用 \"git add <文件>...\" 更新要提交的内容)\n (使用 \"git restore <文件>...\" 丢弃工作区的改动)\n\t修改: go-backend/internal/http/handler/control_plane.go\n\t修改: go-backend/internal/http/handler/dual_stack_test.go\n\t修改: go-backend/internal/http/handler/mutations.go\n\t修改: go-backend/internal/store/model/model.go\n\t修改: go-backend/internal/store/repo/repository.go\n\t修改: go-backend/internal/store/repo/repository_control.go\n\t修改: go-backend/internal/store/repo/repository_mutations.go\n\t修改: vite-frontend/src/pages/forward.tsx\n\t修改: vite-frontend/src/pages/node.tsx\n\t修改: vite-frontend/src/pages/tunnel.tsx\n\n未跟踪的文件:\n (使用 \"git add <文件>...\" 以包含要提交的内容)\n\t.entire/metadata/\n\t211-custom-ip-selection.md\n\n修改尚未加入提交(使用 \"git add\" 和/或 \"git commit -a\")\n", "title": "查看工作区状态", "metadata": { "output": "位于分支 opencode/mighty-nebula\n尚未暂存以备提交的变更:\n (使用 \"git add <文件>...\" 更新要提交的内容)\n (使用 \"git restore <文件>...\" 丢弃工作区的改动)\n\t修改: go-backend/internal/http/handler/control_plane.go\n\t修改: go-backend/internal/http/handler/dual_stack_test.go\n\t修改: go-backend/internal/http/handler/mutations.go\n\t修改: go-backend/internal/store/model/model.go\n\t修改: go-backend/internal/store/repo/repository.go\n\t修改: go-backend/internal/store/repo/repository_control.go\n\t修改: go-backend/internal/store/repo/repository_mutations.go\n\t修改: vite-frontend/src/pages/forward.tsx\n\t修改: vite-frontend/src/pages/node.tsx\n\t修改: vite-frontend/src/pages/tunnel.tsx\n\n未跟踪的文件:\n (使用 \"git add <文件>...\" 以包含要提交的内容)\n\t.entire/metadata/\n\t211-custom-ip-selection.md\n\n修改尚未加入提交(使用 \"git add\" 和/或 \"git commit -a\")\n", "exit": 0, "description": "查看工作区状态", "truncated": false }, "time": { "start": 1772495059269, "end": 1772495059359 } }, "id": "prt_cb0f038f1001Z5rq6N1z2uEhxw", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0f01e96001M31PPxA4FL7hIw" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "fcf51c53694578ccd8d6dd2d1874a5074b2c14f1", "cost": 0, "tokens": { "total": 17113, "input": 16520, "output": 97, "reasoning": 0, "cache": { "read": 496, "write": 0 } }, "id": "prt_cb0f039a0001V1fTvnNsdWM7Tr", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0f01e96001M31PPxA4FL7hIw" } ] }, { "info": { "role": "assistant", "time": { "created": 1772495059405, "completed": 1772495066927 }, "parentID": "msg_cb0f01e45001bNkjBykCDSVUwZ", "modelID": "glm-5", "providerID": "bailian-coding-plan-test", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula" }, "cost": 0, "tokens": { "total": 17393, "input": 303, "output": 76, "reasoning": 0, "cache": { "read": 17014, "write": 0 } }, "finish": "tool-calls", "id": "msg_cb0f039cd001oYAXKyNiWmUGjN", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T" }, "parts": [ { "type": "step-start", "snapshot": "fcf51c53694578ccd8d6dd2d1874a5074b2c14f1", "id": "prt_cb0f04140001cgvU8pa7gEOIy2", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0f039cd001oYAXKyNiWmUGjN" }, { "type": "reasoning", "text": "用户想把 main 分支的更新合并到当前分支 `opencode/mighty-nebula`。\n\n工作区有未提交的更改,直接 merge 可能会有冲突。我应该先 fetch main,然后尝试 merge。\n\n先看看远程状态:", "metadata": { "anthropic": { "signature": "" } }, "time": { "start": 1772495061312, "end": 1772495063020 }, "id": "prt_cb0f04140002uRpqqLcMQnHloo", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0f039cd001oYAXKyNiWmUGjN" }, { "type": "tool", "callID": "toolu_tool-1b930205e6154a65afe4331adc67f4b8", "tool": "bash", "state": { "status": "completed", "input": { "command": "git fetch origin main", "description": "获取远程 main 分支最新状态" }, "output": "来自 github.com:Sagit-chu/flvx\n * branch main -> FETCH_HEAD\n", "title": "获取远程 main 分支最新状态", "metadata": { "output": "来自 github.com:Sagit-chu/flvx\n * branch main -> FETCH_HEAD\n", "exit": 0, "description": "获取远程 main 分支最新状态", "truncated": false }, "time": { "start": 1772495063037, "end": 1772495066855 } }, "id": "prt_cb0f047ed0010pkNcYLsHA5y3A", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0f039cd001oYAXKyNiWmUGjN" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "fcf51c53694578ccd8d6dd2d1874a5074b2c14f1", "cost": 0, "tokens": { "total": 17393, "input": 303, "output": 76, "reasoning": 0, "cache": { "read": 17014, "write": 0 } }, "id": "prt_cb0f056e9001Vj91oiqs0fOJje", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0f039cd001oYAXKyNiWmUGjN" } ] }, { "info": { "role": "assistant", "time": { "created": 1772495066927, "completed": 1772496015034 }, "parentID": "msg_cb0f01e45001bNkjBykCDSVUwZ", "modelID": "glm-5", "providerID": "bailian-coding-plan-test", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula" }, "cost": 0, "tokens": { "total": 17662, "input": 49, "output": 298, "reasoning": 0, "cache": { "read": 17315, "write": 0 } }, "finish": "tool-calls", "id": "msg_cb0f0572f001BPNTPIRMFLQ3V8", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T" }, "parts": [ { "type": "step-start", "snapshot": "fcf51c53694578ccd8d6dd2d1874a5074b2c14f1", "id": "prt_cb0f06166001gGmyI8UlJJ5NYV", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0f0572f001BPNTPIRMFLQ3V8" }, { "type": "reasoning", "text": "好的,已经获取了远程 main 分支的最新状态。现在我需要将 main 分支更新到当前分支。有几种方式:\n\n1. `git merge origin/main` - 合并远程 main 到当前分支\n2. `git rebase origin/main` - 将当前分支变基到 main 分支上\n\n用户说的是\"把 main 更新到本分支\",这个表述有点模糊。可能的理解:\n1. 将 main 分支的最新更改合并到当前分支(git merge origin/main)\n2. 将当前分支更新为 main 分支的状态(git reset --hard origin/main)\n3. 将当前分支变基到 main(git rebase origin/main)\n\n最常见的需求应该是第一种:将 main 分支的最新更改合并到当前分支。我需要先问用户想要哪种方式。", "metadata": { "anthropic": { "signature": "" } }, "time": { "start": 1772495069543, "end": 1772495073369 }, "id": "prt_cb0f06167001DRbKzYJjuF6ois", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0f0572f001BPNTPIRMFLQ3V8" }, { "type": "text", "text": "有未提交的更改。需要先处理这些更改,然后选择如何更新:", "time": { "start": 1772495076266, "end": 1772495076266 }, "id": "prt_cb0f0705b001nPJ6FoCto9EpTd", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0f0572f001BPNTPIRMFLQ3V8" }, { "type": "tool", "callID": "toolu_tool-b9fca7f9ce1d44d6bb81be7d540bdaa5", "tool": "question", "state": { "status": "completed", "input": { "questions": [ { "header": "更新方式", "multiple": false, "options": [ { "description": "将远程 main 分支的更改合并到当前分支", "label": "合并 origin/main (推荐)" }, { "description": "将当前分支变基到 main 分支上", "label": "变基到 main" }, { "description": "丢弃当前分支更改,直接重置为 main", "label": "强制重置为 main" } ], "question": "如何将 main 更新到当前分支?" } ] }, "output": "User has answered your questions: \"如何将 main 更新到当前分支?\"=\"合并 origin/main (推荐)\". You can now continue with the user's answers in mind.", "title": "Asked 1 question", "metadata": { "answers": [ [ "合并 origin/main (推荐)" ] ], "truncated": false }, "time": { "start": 1772495076295, "end": 1772496014907 } }, "id": "prt_cb0f07bac0015NG4564hhlpau5", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0f0572f001BPNTPIRMFLQ3V8" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "fcf51c53694578ccd8d6dd2d1874a5074b2c14f1", "cost": 0, "tokens": { "total": 17662, "input": 49, "output": 298, "reasoning": 0, "cache": { "read": 17315, "write": 0 } }, "id": "prt_cb0fece42001yqnYHY69rnbh5W", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0f0572f001BPNTPIRMFLQ3V8" } ] }, { "info": { "role": "assistant", "time": { "created": 1772496015036, "completed": 1772496023001 }, "parentID": "msg_cb0f01e45001bNkjBykCDSVUwZ", "modelID": "glm-5", "providerID": "bailian-coding-plan-test", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula" }, "cost": 0, "tokens": { "total": 17594, "input": 17485, "output": 58, "reasoning": 0, "cache": { "read": 51, "write": 0 } }, "finish": "tool-calls", "id": "msg_cb0fecebc001k4W6yhkDBhs4I2", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T" }, "parts": [ { "type": "step-start", "snapshot": "fcf51c53694578ccd8d6dd2d1874a5074b2c14f1", "id": "prt_cb0fee6e3001TywhSXmgCmpeo9", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0fecebc001k4W6yhkDBhs4I2" }, { "type": "reasoning", "text": "用户选择合并 origin/main。我应该先暂存当前更改(stash),然后合并,再恢复暂存的更改。", "metadata": { "anthropic": { "signature": "" } }, "time": { "start": 1772496021219, "end": 1772496022766 }, "id": "prt_cb0fee6e3002I6tpx0u7whVwQP", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0fecebc001k4W6yhkDBhs4I2" }, { "type": "tool", "callID": "toolu_tool-42b33057538449809c38caec4ddb9001", "tool": "bash", "state": { "status": "completed", "input": { "command": "git stash push -m \"temp stash before merge main\"", "description": "暂存当前未提交的更改" }, "output": "保存工作目录和索引状态 On opencode/mighty-nebula: temp stash before merge main\n", "title": "暂存当前未提交的更改", "metadata": { "output": "保存工作目录和索引状态 On opencode/mighty-nebula: temp stash before merge main\n", "exit": 0, "description": "暂存当前未提交的更改", "truncated": false }, "time": { "start": 1772496022790, "end": 1772496022921 } }, "id": "prt_cb0feecf0001f6pT2gsjE0N7UT", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0fecebc001k4W6yhkDBhs4I2" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "13912b87b705358faaffbdecb5b3363f40c50ebf", "cost": 0, "tokens": { "total": 17594, "input": 17485, "output": 58, "reasoning": 0, "cache": { "read": 51, "write": 0 } }, "id": "prt_cb0feed8900165HKpqaPnbg7vD", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0fecebc001k4W6yhkDBhs4I2" }, { "type": "patch", "hash": "fcf51c53694578ccd8d6dd2d1874a5074b2c14f1", "files": [ "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go", "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/dual_stack_test.go", "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go", "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go", "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go", "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_control.go", "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_mutations.go", "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages/forward.tsx", "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages/node.tsx", "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages/tunnel.tsx" ], "id": "prt_cb0feedd7001v4b03QIsT8kX4P", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0fecebc001k4W6yhkDBhs4I2" } ] }, { "info": { "role": "assistant", "time": { "created": 1772496023002, "completed": 1772496026736 }, "parentID": "msg_cb0f01e45001bNkjBykCDSVUwZ", "modelID": "glm-5", "providerID": "bailian-coding-plan-test", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula" }, "cost": 0, "tokens": { "total": 17652, "input": 60, "output": 58, "reasoning": 0, "cache": { "read": 17534, "write": 0 } }, "finish": "tool-calls", "id": "msg_cb0feedda001QuVOdGhIP3uhbc", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T" }, "parts": [ { "type": "step-start", "snapshot": "13912b87b705358faaffbdecb5b3363f40c50ebf", "id": "prt_cb0fef5c2001w3RGmKSyW2UtOw", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0feedda001QuVOdGhIP3uhbc" }, { "type": "reasoning", "text": "好,现在暂存了未提交的更改。接下来执行合并。", "metadata": { "anthropic": { "signature": "" } }, "time": { "start": 1772496025027, "end": 1772496026519 }, "id": "prt_cb0fef5c30010ZkTu3IQu7Gxrm", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0feedda001QuVOdGhIP3uhbc" }, { "type": "tool", "callID": "toolu_tool-5192a70b842746acb5c09009e0e69cd0", "tool": "bash", "state": { "status": "completed", "input": { "command": "git merge origin/main -m \"Merge origin/main into opencode/mighty-nebula\"", "description": "合并远程 main 分支到当前分支" }, "output": "更新 7a40ddb..15e4508\nFast-forward (no commit created; -m option ignored)\n vite-frontend/src/pages/config.tsx | 7 +\n vite-frontend/src/pages/forward.tsx | 1469 +++++++++++++++++++++++++++++-----\n vite-frontend/src/pages/settings.tsx | 90 +++\n 3 files changed, 1367 insertions(+), 199 deletions(-)\n", "title": "合并远程 main 分支到当前分支", "metadata": { "output": "更新 7a40ddb..15e4508\nFast-forward (no commit created; -m option ignored)\n vite-frontend/src/pages/config.tsx | 7 +\n vite-frontend/src/pages/forward.tsx | 1469 +++++++++++++++++++++++++++++-----\n vite-frontend/src/pages/settings.tsx | 90 +++\n 3 files changed, 1367 insertions(+), 199 deletions(-)\n", "exit": 0, "description": "合并远程 main 分支到当前分支", "truncated": false }, "time": { "start": 1772496026618, "end": 1772496026676 } }, "id": "prt_cb0fefb990017Ggl5PZ6ifSCIF", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0feedda001QuVOdGhIP3uhbc" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "ce4b62ae3ce78f63927cf9f24b16a23658a76f11", "cost": 0, "tokens": { "total": 17652, "input": 60, "output": 58, "reasoning": 0, "cache": { "read": 17534, "write": 0 } }, "id": "prt_cb0fefc35001UDrg0UHlGLhjWv", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0feedda001QuVOdGhIP3uhbc" }, { "type": "patch", "hash": "13912b87b705358faaffbdecb5b3363f40c50ebf", "files": [ "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages/config.tsx", "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages/forward.tsx", "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages/settings.tsx" ], "id": "prt_cb0fefc6b00192LNTfmA2TqH83", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0feedda001QuVOdGhIP3uhbc" } ] }, { "info": { "role": "assistant", "time": { "created": 1772496026740, "completed": 1772496031042 }, "parentID": "msg_cb0f01e45001bNkjBykCDSVUwZ", "modelID": "glm-5", "providerID": "bailian-coding-plan-test", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula" }, "cost": 0, "tokens": { "total": 17764, "input": 136, "output": 36, "reasoning": 0, "cache": { "read": 17592, "write": 0 } }, "finish": "tool-calls", "id": "msg_cb0fefc740013dNl43VCaQiJ2Z", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T" }, "parts": [ { "type": "step-start", "snapshot": "ce4b62ae3ce78f63927cf9f24b16a23658a76f11", "id": "prt_cb0ff08e0001NycU4cU3gJGDPV", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0fefc740013dNl43VCaQiJ2Z" }, { "type": "reasoning", "text": "合并成功(Fast-forward)。现在恢复之前暂存的更改。", "metadata": { "anthropic": { "signature": "" } }, "time": { "start": 1772496029923, "end": 1772496030883 }, "id": "prt_cb0ff08e3001CQcrwHM4lIOX6b", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0fefc740013dNl43VCaQiJ2Z" }, { "type": "tool", "callID": "toolu_tool-24e145aebf91439aa27c78c5d260f676", "tool": "bash", "state": { "status": "completed", "input": { "command": "git stash pop", "description": "恢复之前暂存的更改" }, "output": "自动合并 vite-frontend/src/pages/forward.tsx\n位于分支 opencode/mighty-nebula\n尚未暂存以备提交的变更:\n (使用 \"git add <文件>...\" 更新要提交的内容)\n (使用 \"git restore <文件>...\" 丢弃工作区的改动)\n\t修改: go-backend/internal/http/handler/control_plane.go\n\t修改: go-backend/internal/http/handler/dual_stack_test.go\n\t修改: go-backend/internal/http/handler/mutations.go\n\t修改: go-backend/internal/store/model/model.go\n\t修改: go-backend/internal/store/repo/repository.go\n\t修改: go-backend/internal/store/repo/repository_control.go\n\t修改: go-backend/internal/store/repo/repository_mutations.go\n\t修改: vite-frontend/src/pages/forward.tsx\n\t修改: vite-frontend/src/pages/node.tsx\n\t修改: vite-frontend/src/pages/tunnel.tsx\n\n未跟踪的文件:\n (使用 \"git add <文件>...\" 以包含要提交的内容)\n\t.entire/metadata/\n\t211-custom-ip-selection.md\n\n修改尚未加入提交(使用 \"git add\" 和/或 \"git commit -a\")\n丢弃了 refs/stash@{0}(407301eb74fb2cb6758cc71d1cde4492179b7339)\n", "title": "恢复之前暂存的更改", "metadata": { "output": "自动合并 vite-frontend/src/pages/forward.tsx\n位于分支 opencode/mighty-nebula\n尚未暂存以备提交的变更:\n (使用 \"git add <文件>...\" 更新要提交的内容)\n (使用 \"git restore <文件>...\" 丢弃工作区的改动)\n\t修改: go-backend/internal/http/handler/control_plane.go\n\t修改: go-backend/internal/http/handler/dual_stack_test.go\n\t修改: go-backend/internal/http/handler/mutations.go\n\t修改: go-backend/internal/store/model/model.go\n\t修改: go-backend/internal/store/repo/repository.go\n\t修改: go-backend/internal/store/repo/repository_control.go\n\t修改: go-backend/internal/store/repo/repository_mutations.go\n\t修改: vite-frontend/src/pages/forward.tsx\n\t修改: vite-frontend/src/pages/node.tsx\n\t修改: vite-frontend/src/pages/tunnel.tsx\n\n未跟踪的文件:\n (使用 \"git add <文件>...\" 以包含要提交的内容)\n\t.entire/metadata/\n\t211-custom-ip-selection.md\n\n修改尚未加入提交(使用 \"git add\" 和/或 \"git commit -a\")\n丢弃了 refs/stash@{0}(407301eb74fb2cb6758cc71d1cde4492179b7339)\n", "exit": 0, "description": "恢复之前暂存的更改", "truncated": false }, "time": { "start": 1772496030903, "end": 1772496030982 } }, "id": "prt_cb0ff0ca4001s0GAfPOrd2ocPR", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0fefc740013dNl43VCaQiJ2Z" }, { "type": "step-finish", "reason": "tool-calls", "snapshot": "1d27c2cb724a04039242ccff316d79ef7e2f9bd5", "cost": 0, "tokens": { "total": 17764, "input": 136, "output": 36, "reasoning": 0, "cache": { "read": 17592, "write": 0 } }, "id": "prt_cb0ff0d06001a7WOfG5ng0fKT2", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0fefc740013dNl43VCaQiJ2Z" }, { "type": "patch", "hash": "ce4b62ae3ce78f63927cf9f24b16a23658a76f11", "files": [ "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go", "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/dual_stack_test.go", "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go", "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go", "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go", "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_control.go", "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_mutations.go", "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages/forward.tsx", "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages/node.tsx", "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages/tunnel.tsx" ], "id": "prt_cb0ff0d3f001Ndv93Rsg7pUwrn", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0fefc740013dNl43VCaQiJ2Z" } ] }, { "info": { "role": "assistant", "time": { "created": 1772496031046, "completed": 1772496035109 }, "parentID": "msg_cb0f01e45001bNkjBykCDSVUwZ", "modelID": "glm-5", "providerID": "bailian-coding-plan-test", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula" }, "cost": 0, "tokens": { "total": 18106, "input": 320, "output": 60, "reasoning": 0, "cache": { "read": 17726, "write": 0 } }, "finish": "stop", "id": "msg_cb0ff0d46001OQBhavfEGzAwCZ", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T" }, "parts": [ { "type": "step-start", "snapshot": "1d27c2cb724a04039242ccff316d79ef7e2f9bd5", "id": "prt_cb0ff14cf001U7p2ip9ims9kBN", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0ff0d46001OQBhavfEGzAwCZ" }, { "type": "reasoning", "text": "成功合并了 origin/main 到当前分支,并且之前暂存的更改已经恢复(包括 forward.tsx 的自动合并)。现在检查是否有冲突。", "metadata": { "anthropic": { "signature": "" } }, "time": { "start": 1772496032975, "end": 1772496034063 }, "id": "prt_cb0ff14cf0029izJ6AsTTt03g9", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0ff0d46001OQBhavfEGzAwCZ" }, { "type": "text", "text": "已完成 `origin/main` 合并到当前分支(Fast-forward)。之前的更改已恢复,`forward.tsx` 自动合并成功。", "time": { "start": 1772496035020, "end": 1772496035020 }, "id": "prt_cb0ff1910001qfdoe9eV5PhfPm", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0ff0d46001OQBhavfEGzAwCZ" }, { "type": "step-finish", "reason": "stop", "snapshot": "1d27c2cb724a04039242ccff316d79ef7e2f9bd5", "cost": 0, "tokens": { "total": 18106, "input": 320, "output": 60, "reasoning": 0, "cache": { "read": 17726, "write": 0 } }, "id": "prt_cb0ff1cce001nuwl166bZUhi3r", "sessionID": "ses_34f0fe1d2ffeH3PJRyokYati7T", "messageID": "msg_cb0ff0d46001OQBhavfEGzAwCZ" } ] } ] }