Files
flvx/.entire/metadata/ses_34f0fe1d2ffeH3PJRyokYati7T/full.jsonl
T
2026-03-03 14:19:18 +08:00

888 lines
476 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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 <svg\n className={className}\n fill=\"none\"\n stroke=\"currentColor\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n >\n <path d=\"M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z\" />\n <polyline points=\"17,21 17,13 7,13 7,21\" />\n <polyline points=\"7,3 7,8 15,8\" />\n </svg>\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<string, string> => {\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<string, string> = {};\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<Record<string, string>>(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<Record<string, string>>(initialConfigs);\n\n const [exportTypes, setExportTypes] = useState<string[]>([]);\n const [importTypes, setImportTypes] = useState<string[]>([]);\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<HTMLInputElement>(null);\n const logoFileInputRef = useRef<HTMLInputElement>(null);\n const faviconFileInputRef = useRef<HTMLInputElement>(null);\n\n const [announcement, setAnnouncement] = useState<AnnouncementData>({\n content: \"\",\n enabled: 0,\n });\n const [announcementLoading, setAnnouncementLoading] = useState(true);\n const [announcementSaving, setAnnouncementSaving] = useState(false);\n const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(\n getUpdateReleaseChannel(),\n );\n const [previewLoadFailed, setPreviewLoadFailed] = useState<\n Partial<Record<BrandPreviewKey, boolean>>\n >({});\n const [brandUploading, setBrandUploading] = useState<\n Partial<Record<BrandPreviewKey, boolean>>\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<string, string>) => {\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<string, string> = {};\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<HTMLInputElement>,\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 <div className=\"mt-3 rounded-lg border border-default-200 dark:border-default-100/30 bg-default-50/60 dark:bg-default-100/10 p-3\">\n <p className=\"text-xs text-default-500\">实时预览</p>\n <div className=\"mt-2 rounded-md border border-default-200 dark:border-default-100/30 bg-white dark:bg-black px-3 py-2\">\n {key === \"app_logo\" ? (\n <div className=\"flex h-10 items-center gap-2\">\n {showImage ? (\n <img\n alt=\"logo preview\"\n className=\"h-7 w-7 rounded-sm border border-default-200 object-cover dark:border-default-100/30\"\n src={previewUrl}\n onError={() =>\n setPreviewLoadFailed((prev) => ({ ...prev, [key]: true }))\n }\n onLoad={() =>\n setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }))\n }\n />\n ) : (\n <div className=\"flex h-7 w-7 items-center justify-center rounded-sm bg-default-200 text-[10px] font-semibold text-default-600 dark:bg-default-700 dark:text-default-200\">\n LOGO\n </div>\n )}\n <span className=\"truncate text-sm font-semibold text-foreground\">\n {appNamePreview}\n </span>\n </div>\n ) : (\n <div className=\"flex h-7 max-w-[260px] items-center gap-2 rounded border border-default-200 bg-default-100/70 px-2 dark:border-default-100/30 dark:bg-default-100/20\">\n {showImage ? (\n <img\n alt=\"favicon preview\"\n className=\"h-4 w-4 rounded-sm object-contain\"\n src={previewUrl}\n onError={() =>\n setPreviewLoadFailed((prev) => ({ ...prev, [key]: true }))\n }\n onLoad={() =>\n setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }))\n }\n />\n ) : (\n <div className=\"h-4 w-4 rounded-sm bg-default-300 dark:bg-default-600\" />\n )}\n <span className=\"truncate text-xs text-default-700 dark:text-default-300\">\n {appNamePreview}\n </span>\n </div>\n )}\n </div>\n\n {previewUrl.length === 0 ? (\n <p className=\"mt-2 text-xs text-default-500\">\n 上传图片后会实时显示预览\n </p>\n ) : null}\n\n {previewUrl.length > 0 && failed ? (\n <p className=\"mt-2 text-xs text-danger\">图片加载失败,请重新上传</p>\n ) : null}\n\n {previewUrl.length > 0 && !isPngDataURL(previewUrl) ? (\n <p className=\"mt-2 text-xs text-warning-600 dark:text-warning-400\">\n 当前是旧版 URL 配置,建议重新上传图片以启用无闪烁加载\n </p>\n ) : null}\n </div>\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 <div\n className={`rounded-lg border p-3 ${\n isChanged\n ? \"border-warning-300\"\n : \"border-default-200 dark:border-default-100/30\"\n }`}\n >\n <input\n ref={getBrandInputRef(key)}\n accept={BRAND_FILE_ACCEPT}\n className=\"hidden\"\n type=\"file\"\n onChange={(event) => {\n void handleBrandFileChange(key, event);\n }}\n />\n\n <div className=\"flex flex-wrap items-center gap-2\">\n <Button\n color=\"primary\"\n isLoading={uploading}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => triggerBrandFilePicker(key)}\n >\n {value.length > 0\n ? isLogo\n ? \"替换 Logo\"\n : \"替换 Favicon\"\n : isLogo\n ? \"上传 Logo\"\n : \"上传 Favicon\"}\n </Button>\n <Button\n isDisabled={value.length === 0 || uploading}\n size=\"sm\"\n variant=\"light\"\n onPress={() => clearBrandAsset(key)}\n >\n 清除\n </Button>\n <span className=\"text-xs text-default-500\">\n 仅支持图片文件,自动转换为 PNG\n </span>\n </div>\n\n <p className=\"mt-2 text-xs text-default-500\">\n {isLogo\n ? \"建议上传方形图片,系统会统一转换为 96x96 PNG\"\n : \"建议上传方形图片,系统会统一转换为 64x64 PNG\"}\n </p>\n\n {renderBrandPreview(key)}\n </div>\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 <Input\n classNames={{\n input: \"text-sm\",\n inputWrapper: isChanged\n ? \"border-warning-300 data-[hover=true]:border-warning-400\"\n : \"\",\n }}\n placeholder={item.placeholder}\n size=\"md\"\n value={configs[item.key] || \"\"}\n variant=\"bordered\"\n onChange={(e) => handleConfigChange(item.key, e.target.value)}\n />\n );\n\n case \"switch\":\n return (\n <Switch\n classNames={{\n wrapper: isChanged ? \"border-warning-300\" : \"\",\n }}\n color=\"primary\"\n isSelected={configs[item.key] === \"true\"}\n size=\"md\"\n onValueChange={(checked) =>\n handleConfigChange(item.key, checked ? \"true\" : \"false\")\n }\n >\n <span className=\"text-sm text-gray-700 dark:text-gray-300\">\n {configs[item.key] === \"true\" ? \"已启用\" : \"已禁用\"}\n </span>\n </Switch>\n );\n\n case \"select\":\n return (\n <Select\n classNames={{\n trigger: isChanged\n ? \"border-warning-300 data-[hover=true]:border-warning-400\"\n : \"\",\n }}\n placeholder=\"请选择验证码类型\"\n selectedKeys={configs[item.key] ? [configs[item.key]] : []}\n size=\"md\"\n variant=\"bordered\"\n onSelectionChange={(keys) => {\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 <SelectItem key={option.value} description={option.description}>\n {option.label}\n </SelectItem>\n )) || []}\n </Select>\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<HTMLInputElement>) => {\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<React.SetStateAction<string[]>>,\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<React.SetStateAction<string[]>>,\n ) => {\n const allSelected = isAllTypesSelected(selectedTypes);\n\n return (\n <div className=\"space-y-3\">\n <div className=\"flex flex-wrap items-center justify-between gap-2\">\n <span className=\"text-sm font-medium text-default-700 dark:text-default-300\">\n {label}\n </span>\n <div className=\"flex items-center gap-2\">\n <Button\n size=\"sm\"\n variant=\"flat\"\n onPress={() =>\n setTypes(allSelected ? [] : [...BACKUP_TYPE_VALUES])\n }\n >\n {allSelected ? \"取消全选\" : \"全选\"}\n </Button>\n <Button size=\"sm\" variant=\"light\" onPress={() => setTypes([])}>\n 清空\n </Button>\n </div>\n </div>\n\n <div className=\"grid grid-cols-1 sm:grid-cols-2 gap-2\">\n {BACKUP_TYPE_OPTIONS.map((option) => {\n const isSelected = selectedTypes.includes(option.value);\n\n return (\n <button\n key={option.value}\n aria-pressed={isSelected}\n className={`w-full px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer text-left ${\n isSelected\n ? \"bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-500/50 shadow-sm\"\n : \"bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm\"\n }`}\n type=\"button\"\n onClick={() => toggleTypeSelection(option.value, setTypes)}\n >\n <div className=\"flex items-center gap-3\">\n <Checkbox\n classNames={{\n base: \"pointer-events-none\",\n }}\n color=\"primary\"\n isSelected={isSelected}\n size=\"md\"\n />\n <span\n className={`font-medium ${\n isSelected\n ? \"text-default-900 dark:text-default-100\"\n : \"text-default-700 dark:text-default-500\"\n }`}\n >\n {option.label}\n </span>\n </div>\n </button>\n );\n })}\n </div>\n </div>\n );\n };\n\n if (loading) {\n return (\n <div className=\"flex items-center justify-center min-h-[400px]\">\n <Spinner label=\"加载配置中...\" size=\"lg\" />\n </div>\n );\n }\n\n return (\n <div className=\"p-6 max-w-4xl mx-auto\">\n {/* 页面标题 */}\n <div className=\"flex items-center gap-3 mb-6\">\n <SettingsIcon className=\"w-8 h-8 text-primary\" />\n <div>\n <h1 className=\"text-2xl font-bold\">网站配置</h1>\n <p className=\"text-gray-600 dark:text-gray-400\">\n 管理网站的基本信息和显示设置\n </p>\n </div>\n </div>\n\n <Card className=\"shadow-md\">\n <CardHeader className=\"pb-6\">\n <div className=\"flex justify-between items-center w-full\">\n <div>\n <h2 className=\"text-xl font-semibold\">基本设置</h2>\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\n 配置网站的基本信息,这些设置会影响网站的显示效果\n </p>\n </div>\n <div className=\"flex gap-2\">\n <Button\n color=\"primary\"\n disabled={!hasChanges}\n isLoading={saving}\n startContent={<SaveIcon className=\"w-4 h-4\" />}\n onPress={handleSave}\n >\n {saving ? \"保存中...\" : \"保存配置\"}\n </Button>\n </div>\n </div>\n </CardHeader>\n\n <Divider />\n\n <CardBody className=\"space-y-6 pt-8 md:pt-8\">\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 <div key={item.key} className=\"space-y-3\">\n <div className=\"flex flex-col gap-1\">\n <label className=\"text-sm font-medium text-gray-700 dark:text-gray-300\">\n {item.label}\n </label>\n {item.description && (\n <p className=\"text-xs text-gray-500 dark:text-gray-400\">\n {item.description}\n </p>\n )}\n </div>\n\n {/* 渲染配置项 */}\n {renderConfigItem(item)}\n\n {/* 分隔线 */}\n {!isLastItem && <Divider className=\"mt-6\" />}\n </div>\n );\n })}\n\n <Divider className=\"my-2\" />\n\n <div className=\"space-y-3\">\n <div className=\"flex flex-col gap-1\">\n <p className=\"text-sm font-medium text-gray-700 dark:text-gray-300\">\n 更新通道\n </p>\n <p className=\"text-xs text-gray-500 dark:text-gray-400\">\n 稳定版仅匹配纯数字版本;开发版仅匹配包含 alpha / beta / rc\n 的版本。\n </p>\n </div>\n\n <Select\n selectedKeys={[updateChannel]}\n size=\"md\"\n variant=\"bordered\"\n onSelectionChange={(keys) => {\n const selected =\n (Array.from(keys)[0] as UpdateReleaseChannel) || \"stable\";\n\n handleUpdateChannelChange(selected);\n }}\n >\n <SelectItem key=\"stable\" description=\"仅纯数字版本,如 2.1.4\">\n 稳定版\n </SelectItem>\n <SelectItem\n key=\"dev\"\n description=\"仅 alpha / beta / rc 关键字版本\"\n >\n 开发版\n </SelectItem>\n </Select>\n </div>\n </CardBody>\n </Card>\n\n {hasChanges && (\n <Card className=\"mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800\">\n <CardBody className=\"py-3\">\n <div className=\"w-full flex items-center justify-center gap-2 text-warning-700 dark:text-warning-300\">\n <div className=\"w-2 h-2 bg-warning-500 rounded-full animate-pulse\" />\n <span className=\"text-sm font-medium\">\n 检测到配置变更,请记得保存您的修改\n </span>\n </div>\n </CardBody>\n </Card>\n )}\n\n <Card className=\"mt-6 shadow-md\">\n <CardHeader className=\"pb-6\">\n <div className=\"flex justify-between items-center w-full\">\n <div>\n <h2 className=\"text-xl font-semibold\">公告管理</h2>\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\n 设置首页显示的公告内容\n </p>\n </div>\n </div>\n </CardHeader>\n\n <Divider />\n\n <CardBody className=\"space-y-4 pt-8 md:pt-8\">\n {announcementLoading ? (\n <div className=\"flex justify-center py-8\">\n <Spinner size=\"lg\" />\n </div>\n ) : (\n <>\n <div className=\"space-y-2\">\n <Switch\n isSelected={announcement.enabled === 1}\n onValueChange={(checked) =>\n setAnnouncement({\n ...announcement,\n enabled: checked ? 1 : 0,\n })\n }\n >\n <span className=\"text-sm text-gray-700 dark:text-gray-300\">\n {announcement.enabled === 1 ? \"已启用\" : \"已禁用\"}\n </span>\n </Switch>\n <p className=\"text-xs text-gray-500 dark:text-gray-400\">\n 启用后,公告将在首页顶部显示\n </p>\n </div>\n\n <Textarea\n label=\"公告内容\"\n minRows={4}\n placeholder=\"支持 Markdown,例如:**加粗**、[链接](https://example.com)、- 列表\"\n value={announcement.content}\n variant=\"bordered\"\n onChange={(e) =>\n setAnnouncement({ ...announcement, content: e.target.value })\n }\n />\n <p className=\"text-xs text-gray-500 dark:text-gray-400\">\n 公告支持 Markdown 语法,链接会在新标签页打开\n </p>\n\n <div className=\"flex justify-end\">\n <Button\n color=\"primary\"\n isLoading={announcementSaving}\n startContent={<SaveIcon className=\"w-4 h-4\" />}\n onPress={saveAnnouncement}\n >\n 保存公告\n </Button>\n </div>\n </>\n )}\n </CardBody>\n </Card>\n\n {/* 备份与恢复 */}\n <Card className=\"mt-6 shadow-md\">\n <CardHeader className=\"pb-6\">\n <div className=\"flex justify-between items-center w-full\">\n <div>\n <h2 className=\"text-xl font-semibold\">数据备份与恢复</h2>\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\n 导出或导入系统数据,支持选择特定数据类型\n </p>\n </div>\n </div>\n </CardHeader>\n\n <Divider />\n\n <CardBody className=\"space-y-6 pt-8 md:pt-8\">\n {/* 导出部分 */}\n <div className=\"space-y-4\">\n <h3 className=\"text-lg font-medium\">导出数据</h3>\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\n 选择要导出的数据类型,导出为 JSON 格式文件\n </p>\n <p className=\"text-xs text-default-500\">\n 当前已选 {exportTypes.length} / {BACKUP_TYPE_VALUES.length}\n </p>\n\n <div className=\"flex gap-3\">\n <Button\n color=\"primary\"\n isLoading={exporting}\n onPress={() => setExportSelectorOpen(true)}\n >\n {exporting ? \"导出中...\" : \"选择并导出\"}\n </Button>\n </div>\n </div>\n\n <Divider />\n\n {/* 导入部分 */}\n <div className=\"space-y-4\">\n <h3 className=\"text-lg font-medium\">导入数据</h3>\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\n 选择要导入的数据类型,支持从备份文件恢复数据\n </p>\n <p className=\"text-xs text-default-500\">\n 当前已选 {importTypes.length} / {BACKUP_TYPE_VALUES.length}\n </p>\n\n <input\n ref={backupFileInputRef}\n accept=\".json\"\n className=\"hidden\"\n type=\"file\"\n onChange={handleFileChange}\n />\n\n <div className=\"flex gap-3\">\n <Button\n color=\"primary\"\n isLoading={importing}\n variant=\"flat\"\n onPress={() => setImportSelectorOpen(true)}\n >\n {importing ? \"导入中...\" : \"选择并导入\"}\n </Button>\n {importFileName && (\n <span className=\"self-center text-sm text-gray-600 dark:text-gray-400\">\n 已选择: {importFileName}\n </span>\n )}\n </div>\n </div>\n </CardBody>\n </Card>\n\n <Modal isOpen={exportSelectorOpen} onOpenChange={setExportSelectorOpen}>\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader>选择导出内容</ModalHeader>\n <ModalBody>\n {renderTypeSelection(\"导出内容\", exportTypes, setExportTypes)}\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"primary\"\n isLoading={exporting}\n onPress={handleExport}\n >\n {exporting ? \"导出中...\" : \"确认导出\"}\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n <Modal isOpen={importSelectorOpen} onOpenChange={setImportSelectorOpen}>\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader>选择导入内容</ModalHeader>\n <ModalBody>\n {renderTypeSelection(\"导入内容\", importTypes, setImportTypes)}\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"primary\"\n isDisabled={importTypes.length === 0}\n onPress={triggerImportFilePicker}\n >\n 下一步选择文件\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n </div>\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 <svg\n className={className}\n fill=\"none\"\n stroke=\"currentColor\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n >\n <path d=\"M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z\" />\n <polyline points=\"17,21 17,13 7,13 7,21\" />\n <polyline points=\"7,3 7,8 15,8\" />\n </svg>\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<string, string> => {\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<string, string> = {};\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<Record<string, string>>(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<Record<string, string>>(initialConfigs);\n\n const [exportTypes, setExportTypes] = useState<string[]>([]);\n const [importTypes, setImportTypes] = useState<string[]>([]);\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<HTMLInputElement>(null);\n const logoFileInputRef = useRef<HTMLInputElement>(null);\n const faviconFileInputRef = useRef<HTMLInputElement>(null);\n\n const [announcement, setAnnouncement] = useState<AnnouncementData>({\n content: \"\",\n enabled: 0,\n });\n const [announcementLoading, setAnnouncementLoading] = useState(true);\n const [announcementSaving, setAnnouncementSaving] = useState(false);\n const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(\n getUpdateReleaseChannel(),\n );\n const [previewLoadFailed, setPreviewLoadFailed] = useState<\n Partial<Record<BrandPreviewKey, boolean>>\n >({});\n const [brandUploading, setBrandUploading] = useState<\n Partial<Record<BrandPreviewKey, boolean>>\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<string, string>) => {\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<string, string> = {};\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<HTMLInputElement>,\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 <div className=\"mt-3 rounded-lg border border-default-200 dark:border-default-100/30 bg-default-50/60 dark:bg-default-100/10 p-3\">\n <p className=\"text-xs text-default-500\">实时预览</p>\n <div className=\"mt-2 rounded-md border border-default-200 dark:border-default-100/30 bg-white dark:bg-black px-3 py-2\">\n {key === \"app_logo\" ? (\n <div className=\"flex h-10 items-center gap-2\">\n {showImage ? (\n <img\n alt=\"logo preview\"\n className=\"h-7 w-7 rounded-sm border border-default-200 object-cover dark:border-default-100/30\"\n src={previewUrl}\n onError={() =>\n setPreviewLoadFailed((prev) => ({ ...prev, [key]: true }))\n }\n onLoad={() =>\n setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }))\n }\n />\n ) : (\n <div className=\"flex h-7 w-7 items-center justify-center rounded-sm bg-default-200 text-[10px] font-semibold text-default-600 dark:bg-default-700 dark:text-default-200\">\n LOGO\n </div>\n )}\n <span className=\"truncate text-sm font-semibold text-foreground\">\n {appNamePreview}\n </span>\n </div>\n ) : (\n <div className=\"flex h-7 max-w-[260px] items-center gap-2 rounded border border-default-200 bg-default-100/70 px-2 dark:border-default-100/30 dark:bg-default-100/20\">\n {showImage ? (\n <img\n alt=\"favicon preview\"\n className=\"h-4 w-4 rounded-sm object-contain\"\n src={previewUrl}\n onError={() =>\n setPreviewLoadFailed((prev) => ({ ...prev, [key]: true }))\n }\n onLoad={() =>\n setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }))\n }\n />\n ) : (\n <div className=\"h-4 w-4 rounded-sm bg-default-300 dark:bg-default-600\" />\n )}\n <span className=\"truncate text-xs text-default-700 dark:text-default-300\">\n {appNamePreview}\n </span>\n </div>\n )}\n </div>\n\n {previewUrl.length === 0 ? (\n <p className=\"mt-2 text-xs text-default-500\">\n 上传图片后会实时显示预览\n </p>\n ) : null}\n\n {previewUrl.length > 0 && failed ? (\n <p className=\"mt-2 text-xs text-danger\">图片加载失败,请重新上传</p>\n ) : null}\n\n {previewUrl.length > 0 && !isPngDataURL(previewUrl) ? (\n <p className=\"mt-2 text-xs text-warning-600 dark:text-warning-400\">\n 当前是旧版 URL 配置,建议重新上传图片以启用无闪烁加载\n </p>\n ) : null}\n </div>\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 <div\n className={`rounded-lg border p-3 ${\n isChanged\n ? \"border-warning-300\"\n : \"border-default-200 dark:border-default-100/30\"\n }`}\n >\n <input\n ref={getBrandInputRef(key)}\n accept={BRAND_FILE_ACCEPT}\n className=\"hidden\"\n type=\"file\"\n onChange={(event) => {\n void handleBrandFileChange(key, event);\n }}\n />\n\n <div className=\"flex flex-wrap items-center gap-2\">\n <Button\n color=\"primary\"\n isLoading={uploading}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => triggerBrandFilePicker(key)}\n >\n {value.length > 0\n ? isLogo\n ? \"替换 Logo\"\n : \"替换 Favicon\"\n : isLogo\n ? \"上传 Logo\"\n : \"上传 Favicon\"}\n </Button>\n <Button\n isDisabled={value.length === 0 || uploading}\n size=\"sm\"\n variant=\"light\"\n onPress={() => clearBrandAsset(key)}\n >\n 清除\n </Button>\n <span className=\"text-xs text-default-500\">\n 仅支持图片文件,自动转换为 PNG\n </span>\n </div>\n\n <p className=\"mt-2 text-xs text-default-500\">\n {isLogo\n ? \"建议上传方形图片,系统会统一转换为 96x96 PNG\"\n : \"建议上传方形图片,系统会统一转换为 64x64 PNG\"}\n </p>\n\n {renderBrandPreview(key)}\n </div>\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 <Input\n classNames={{\n input: \"text-sm\",\n inputWrapper: isChanged\n ? \"border-warning-300 data-[hover=true]:border-warning-400\"\n : \"\",\n }}\n placeholder={item.placeholder}\n size=\"md\"\n value={configs[item.key] || \"\"}\n variant=\"bordered\"\n onChange={(e) => handleConfigChange(item.key, e.target.value)}\n />\n );\n\n case \"switch\":\n return (\n <Switch\n classNames={{\n wrapper: isChanged ? \"border-warning-300\" : \"\",\n }}\n color=\"primary\"\n isSelected={configs[item.key] === \"true\"}\n size=\"md\"\n onValueChange={(checked) =>\n handleConfigChange(item.key, checked ? \"true\" : \"false\")\n }\n >\n <span className=\"text-sm text-gray-700 dark:text-gray-300\">\n {configs[item.key] === \"true\" ? \"已启用\" : \"已禁用\"}\n </span>\n </Switch>\n );\n\n case \"select\":\n return (\n <Select\n classNames={{\n trigger: isChanged\n ? \"border-warning-300 data-[hover=true]:border-warning-400\"\n : \"\",\n }}\n placeholder=\"请选择验证码类型\"\n selectedKeys={configs[item.key] ? [configs[item.key]] : []}\n size=\"md\"\n variant=\"bordered\"\n onSelectionChange={(keys) => {\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 <SelectItem key={option.value} description={option.description}>\n {option.label}\n </SelectItem>\n )) || []}\n </Select>\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<HTMLInputElement>) => {\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<React.SetStateAction<string[]>>,\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<React.SetStateAction<string[]>>,\n ) => {\n const allSelected = isAllTypesSelected(selectedTypes);\n\n return (\n <div className=\"space-y-3\">\n <div className=\"flex flex-wrap items-center justify-between gap-2\">\n <span className=\"text-sm font-medium text-default-700 dark:text-default-300\">\n {label}\n </span>\n <div className=\"flex items-center gap-2\">\n <Button\n size=\"sm\"\n variant=\"flat\"\n onPress={() =>\n setTypes(allSelected ? [] : [...BACKUP_TYPE_VALUES])\n }\n >\n {allSelected ? \"取消全选\" : \"全选\"}\n </Button>\n <Button size=\"sm\" variant=\"light\" onPress={() => setTypes([])}>\n 清空\n </Button>\n </div>\n </div>\n\n <div className=\"grid grid-cols-1 sm:grid-cols-2 gap-2\">\n {BACKUP_TYPE_OPTIONS.map((option) => {\n const isSelected = selectedTypes.includes(option.value);\n\n return (\n <button\n key={option.value}\n aria-pressed={isSelected}\n className={`w-full px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer text-left ${\n isSelected\n ? \"bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-500/50 shadow-sm\"\n : \"bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm\"\n }`}\n type=\"button\"\n onClick={() => toggleTypeSelection(option.value, setTypes)}\n >\n <div className=\"flex items-center gap-3\">\n <Checkbox\n classNames={{\n base: \"pointer-events-none\",\n }}\n color=\"primary\"\n isSelected={isSelected}\n size=\"md\"\n />\n <span\n className={`font-medium ${\n isSelected\n ? \"text-default-900 dark:text-default-100\"\n : \"text-default-700 dark:text-default-500\"\n }`}\n >\n {option.label}\n </span>\n </div>\n </button>\n );\n })}\n </div>\n </div>\n );\n };\n\n if (loading) {\n return (\n <div className=\"flex items-center justify-center min-h-[400px]\">\n <Spinner label=\"加载配置中...\" size=\"lg\" />\n </div>\n );\n }\n\n return (\n <div className=\"p-6 max-w-4xl mx-auto\">\n {/* 页面标题 */}\n <div className=\"flex items-center gap-3 mb-6\">\n <SettingsIcon className=\"w-8 h-8 text-primary\" />\n <div>\n <h1 className=\"text-2xl font-bold\">网站配置</h1>\n <p className=\"text-gray-600 dark:text-gray-400\">\n 管理网站的基本信息和显示设置\n </p>\n </div>\n </div>\n\n <Card className=\"shadow-md\">\n <CardHeader className=\"pb-6\">\n <div className=\"flex justify-between items-center w-full\">\n <div>\n <h2 className=\"text-xl font-semibold\">基本设置</h2>\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\n 配置网站的基本信息,这些设置会影响网站的显示效果\n </p>\n </div>\n <div className=\"flex gap-2\">\n <Button\n color=\"primary\"\n disabled={!hasChanges}\n isLoading={saving}\n startContent={<SaveIcon className=\"w-4 h-4\" />}\n onPress={handleSave}\n >\n {saving ? \"保存中...\" : \"保存配置\"}\n </Button>\n </div>\n </div>\n </CardHeader>\n\n <Divider />\n\n <CardBody className=\"space-y-6 pt-8 md:pt-8\">\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 <div key={item.key} className=\"space-y-3\">\n <div className=\"flex flex-col gap-1\">\n <label className=\"text-sm font-medium text-gray-700 dark:text-gray-300\">\n {item.label}\n </label>\n {item.description && (\n <p className=\"text-xs text-gray-500 dark:text-gray-400\">\n {item.description}\n </p>\n )}\n </div>\n\n {/* 渲染配置项 */}\n {renderConfigItem(item)}\n\n {/* 分隔线 */}\n {!isLastItem && <Divider className=\"mt-6\" />}\n </div>\n );\n })}\n\n <Divider className=\"my-2\" />\n\n <div className=\"space-y-3\">\n <div className=\"flex flex-col gap-1\">\n <p className=\"text-sm font-medium text-gray-700 dark:text-gray-300\">\n 更新通道\n </p>\n <p className=\"text-xs text-gray-500 dark:text-gray-400\">\n 稳定版仅匹配纯数字版本;开发版仅匹配包含 alpha / beta / rc\n 的版本。\n </p>\n </div>\n\n <Select\n selectedKeys={[updateChannel]}\n size=\"md\"\n variant=\"bordered\"\n onSelectionChange={(keys) => {\n const selected =\n (Array.from(keys)[0] as UpdateReleaseChannel) || \"stable\";\n\n handleUpdateChannelChange(selected);\n }}\n >\n <SelectItem key=\"stable\" description=\"仅纯数字版本,如 2.1.4\">\n 稳定版\n </SelectItem>\n <SelectItem\n key=\"dev\"\n description=\"仅 alpha / beta / rc 关键字版本\"\n >\n 开发版\n </SelectItem>\n </Select>\n </div>\n </CardBody>\n </Card>\n\n {hasChanges && (\n <Card className=\"mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800\">\n <CardBody className=\"py-3\">\n <div className=\"w-full flex items-center justify-center gap-2 text-warning-700 dark:text-warning-300\">\n <div className=\"w-2 h-2 bg-warning-500 rounded-full animate-pulse\" />\n <span className=\"text-sm font-medium\">\n 检测到配置变更,请记得保存您的修改\n </span>\n </div>\n </CardBody>\n </Card>\n )}\n\n <Card className=\"mt-6 shadow-md\">\n <CardHeader className=\"pb-6\">\n <div className=\"flex justify-between items-center w-full\">\n <div>\n <h2 className=\"text-xl font-semibold\">公告管理</h2>\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\n 设置首页显示的公告内容\n </p>\n </div>\n </div>\n </CardHeader>\n\n <Divider />\n\n <CardBody className=\"space-y-4 pt-8 md:pt-8\">\n {announcementLoading ? (\n <div className=\"flex justify-center py-8\">\n <Spinner size=\"lg\" />\n </div>\n ) : (\n <>\n <div className=\"space-y-2\">\n <Switch\n isSelected={announcement.enabled === 1}\n onValueChange={(checked) =>\n setAnnouncement({\n ...announcement,\n enabled: checked ? 1 : 0,\n })\n }\n >\n <span className=\"text-sm text-gray-700 dark:text-gray-300\">\n {announcement.enabled === 1 ? \"已启用\" : \"已禁用\"}\n </span>\n </Switch>\n <p className=\"text-xs text-gray-500 dark:text-gray-400\">\n 启用后,公告将在首页顶部显示\n </p>\n </div>\n\n <Textarea\n label=\"公告内容\"\n minRows={4}\n placeholder=\"支持 Markdown,例如:**加粗**、[链接](https://example.com)、- 列表\"\n value={announcement.content}\n variant=\"bordered\"\n onChange={(e) =>\n setAnnouncement({ ...announcement, content: e.target.value })\n }\n />\n <p className=\"text-xs text-gray-500 dark:text-gray-400\">\n 公告支持 Markdown 语法,链接会在新标签页打开\n </p>\n\n <div className=\"flex justify-end\">\n <Button\n color=\"primary\"\n isLoading={announcementSaving}\n startContent={<SaveIcon className=\"w-4 h-4\" />}\n onPress={saveAnnouncement}\n >\n 保存公告\n </Button>\n </div>\n </>\n )}\n </CardBody>\n </Card>\n\n {/* 备份与恢复 */}\n <Card className=\"mt-6 shadow-md\">\n <CardHeader className=\"pb-6\">\n <div className=\"flex justify-between items-center w-full\">\n <div>\n <h2 className=\"text-xl font-semibold\">数据备份与恢复</h2>\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\n 导出或导入系统数据,支持选择特定数据类型\n </p>\n </div>\n </div>\n </CardHeader>\n\n <Divider />\n\n <CardBody className=\"space-y-6 pt-8 md:pt-8\">\n {/* 导出部分 */}\n <div className=\"space-y-4\">\n <h3 className=\"text-lg font-medium\">导出数据</h3>\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\n 选择要导出的数据类型,导出为 JSON 格式文件\n </p>\n <p className=\"text-xs text-default-500\">\n 当前已选 {exportTypes.length} / {BACKUP_TYPE_VALUES.length}\n </p>\n\n <div className=\"flex gap-3\">\n <Button\n color=\"primary\"\n isLoading={exporting}\n onPress={() => setExportSelectorOpen(true)}\n >\n {exporting ? \"导出中...\" : \"选择并导出\"}\n </Button>\n </div>\n </div>\n\n <Divider />\n\n {/* 导入部分 */}\n <div className=\"space-y-4\">\n <h3 className=\"text-lg font-medium\">导入数据</h3>\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\n 选择要导入的数据类型,支持从备份文件恢复数据\n </p>\n <p className=\"text-xs text-default-500\">\n 当前已选 {importTypes.length} / {BACKUP_TYPE_VALUES.length}\n </p>\n\n <input\n ref={backupFileInputRef}\n accept=\".json\"\n className=\"hidden\"\n type=\"file\"\n onChange={handleFileChange}\n />\n\n <div className=\"flex gap-3\">\n <Button\n color=\"primary\"\n isLoading={importing}\n variant=\"flat\"\n onPress={() => setImportSelectorOpen(true)}\n >\n {importing ? \"导入中...\" : \"选择并导入\"}\n </Button>\n {importFileName && (\n <span className=\"self-center text-sm text-gray-600 dark:text-gray-400\">\n 已选择: {importFileName}\n </span>\n )}\n </div>\n </div>\n </CardBody>\n </Card>\n\n <Modal isOpen={exportSelectorOpen} onOpenChange={setExportSelectorOpen}>\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader>选择导出内容</ModalHeader>\n <ModalBody>\n {renderTypeSelection(\"导出内容\", exportTypes, setExportTypes)}\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"primary\"\n isLoading={exporting}\n onPress={handleExport}\n >\n {exporting ? \"导出中...\" : \"确认导出\"}\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n <Modal isOpen={importSelectorOpen} onOpenChange={setImportSelectorOpen}>\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader>选择导入内容</ModalHeader>\n <ModalBody>\n {renderTypeSelection(\"导入内容\", importTypes, setImportTypes)}\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"primary\"\n isDisabled={importTypes.length === 0}\n onPress={triggerImportFilePicker}\n >\n 下一步选择文件\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n </div>\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<Forward[]>([]);\n const [tunnels, setTunnels] = useState<Tunnel[]>([]);\n const [speedLimits, setSpeedLimits] = useState<SpeedLimitApiItem[]>([]);\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<string>(\"forward-filter-user-id\", \"all\");\n const [filterTunnelId, setFilterTunnelId, resetFilterTunnelId] =\n useLocalStorageState<string>(\"forward-filter-tunnel-id\", \"all\");\n\n // 拖拽排序相关状态\n const [forwardOrder, setForwardOrder] = useState<number[]>([]);\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<Forward | null>(null);\n const [currentDiagnosisForward, setCurrentDiagnosisForward] =\n useState<Forward | null>(null);\n const [diagnosisResult, setDiagnosisResult] =\n useState<ForwardDiagnosisResult | null>(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<AbortController | null>(null);\n const [addressModalTitle, setAddressModalTitle] = useState(\"\");\n const [addressList, setAddressList] = useState<ForwardAddressItem[]>([]);\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<ForwardForm>({\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<Set<number>>(new Set());\n const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);\n const [batchChangeTunnelModalOpen, setBatchChangeTunnelModalOpen] =\n useState(false);\n const [batchTargetTunnelId, setBatchTargetTunnelId] = useState<number | null>(\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<Forward[]> => {\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<number, number>();\n const shareIdsByTunnel = new Map<number, Set<number>>();\n\n if (usageRes.code === 0 && Array.isArray(usageRes.data)) {\n usageRes.data.forEach((item: Record<string, unknown>) => {\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<Record<string, unknown>>)\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<number>();\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<string, unknown>) => {\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<number>();\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<number, number>();\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<number, number>();\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<string, ForwardTunnelGroup>;\n };\n\n const userGroupMap = new Map<number, MutableForwardUserGroup>();\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<string, ForwardTunnelGroup>(),\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 <div ref={setNodeRef} className=\"h-full\" style={style} {...attributes}>\n {renderForwardCard(forward, listeners)}\n </div>\n );\n };\n\n // 生成用作筛选项的用户和隧道列表\n const uniqueUsers = useMemo(() => {\n const userMap = new Map<number, { id: number; name: string }>();\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 <TableRow key={forward.id} ref={setNodeRef} style={style}>\n {selectMode && (\n <TableCell className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.select}>\n <Checkbox\n isSelected={selectedIds.has(forward.id)}\n onValueChange={() => toggleSelect(forward.id)}\n />\n </TableCell>\n )}\n <TableCell className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.drag}>\n <div\n className=\"cursor-grab active:cursor-grabbing p-1 text-default-400 hover:text-default-600 transition-colors\"\n {...attributes}\n {...listeners}\n title=\"拖拽排序\"\n >\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path d=\"M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z\" />\n </svg>\n </div>\n </TableCell>\n <TableCell\n className={`${FORWARD_GROUPED_TABLE_COLUMN_CLASS.name} whitespace-nowrap font-semibold text-foreground`}\n >\n {forward.name}\n </TableCell>\n <TableCell\n className={`${FORWARD_GROUPED_TABLE_COLUMN_CLASS.inbound} max-w-[280px]`}\n >\n <button\n className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${\n hasMultipleAddresses(forward.inIp)\n ? \"hover:bg-default-200 hover:shadow-sm\"\n : \"\"\n }`}\n title={formatInAddress(forward.inIp, forward.inPort)}\n type=\"button\"\n onClick={() =>\n showAddressModal(forward.inIp, forward.inPort, \"入口端口\")\n }\n >\n {formatInAddress(forward.inIp, forward.inPort)}\n </button>\n </TableCell>\n <TableCell\n className={`${FORWARD_GROUPED_TABLE_COLUMN_CLASS.target} max-w-[280px]`}\n >\n <button\n className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${\n hasMultipleAddresses(forward.remoteAddr)\n ? \"hover:bg-default-200 hover:shadow-sm\"\n : \"\"\n }`}\n title={formatRemoteAddress(forward.remoteAddr)}\n type=\"button\"\n onClick={() =>\n showAddressModal(forward.remoteAddr, null, \"目标地址\")\n }\n >\n {formatRemoteAddress(forward.remoteAddr)}\n </button>\n </TableCell>\n <TableCell className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.strategy}>\n <Chip\n className=\"text-xs font-medium\"\n color={strategyDisplay.color as any}\n size=\"sm\"\n variant=\"flat\"\n >\n {strategyDisplay.text}\n </Chip>\n </TableCell>\n <TableCell\n className={`${FORWARD_GROUPED_TABLE_COLUMN_CLASS.totalFlow} whitespace-nowrap`}\n >\n <span className=\"text-sm font-medium text-default-600 font-mono\">\n {formatFlow(getForwardDisplayFlow(forward))}\n </span>\n </TableCell>\n <TableCell className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.status}>\n <div className=\"flex items-center gap-2.5 whitespace-nowrap\">\n <Switch\n color=\"success\"\n isDisabled={forward.status !== 1 && forward.status !== 0}\n isSelected={forward.serviceRunning}\n size=\"sm\"\n onValueChange={() => handleServiceToggle(forward)}\n />\n </div>\n </TableCell>\n <TableCell className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.actions}>\n <div className=\"flex justify-end gap-2\">\n <Button\n isIconOnly\n className=\"bg-primary/10 text-primary hover:bg-primary/20\"\n size=\"sm\"\n title=\"编辑\"\n onPress={() => handleEdit(forward)}\n >\n <svg\n className=\"h-4 w-4\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n d=\"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n />\n </svg>\n </Button>\n <Button\n isIconOnly\n className=\"bg-warning/10 text-warning hover:bg-warning/20\"\n size=\"sm\"\n title=\"诊断\"\n onPress={() => handleDiagnose(forward)}\n >\n <svg\n className=\"h-4 w-4\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n />\n </svg>\n </Button>\n <Button\n isIconOnly\n className=\"bg-danger/10 text-danger hover:bg-danger/20\"\n size=\"sm\"\n title=\"删除\"\n onPress={() => handleDelete(forward)}\n >\n <svg\n className=\"h-4 w-4\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n d=\"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n />\n </svg>\n </Button>\n </div>\n </TableCell>\n </TableRow>\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 <Card\n key={forward.id}\n className=\"group h-full flex flex-col shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden\"\n >\n <CardHeader className=\"pb-2 md:pb-2\">\n <div className=\"flex justify-between items-start w-full\">\n {selectMode && (\n <Checkbox\n className=\"mr-2\"\n isSelected={selectedIds.has(forward.id)}\n onValueChange={() => toggleSelect(forward.id)}\n />\n )}\n <div className=\"flex-1 min-w-0\">\n <h3 className=\"font-semibold text-foreground truncate text-sm\">\n {forward.name}\n </h3>\n <p className=\"text-xs text-default-500 truncate\">\n {normalizeForwardTunnelName(forward.tunnelName)}\n </p>\n </div>\n <div className=\"flex items-center gap-1.5 ml-2\">\n {viewMode === \"direct\" && (\n <div\n className={`cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation ${\n isMobile\n ? \"opacity-100\" // 移动端始终显示\n : \"opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100\"\n }`}\n {...listeners}\n style={{ touchAction: \"none\" }}\n title={isMobile ? \"长按拖拽排序\" : \"拖拽排序\"}\n >\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path d=\"M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z\" />\n </svg>\n </div>\n )}\n <Switch\n isDisabled={forward.status !== 1 && forward.status !== 0}\n isSelected={forward.serviceRunning}\n size=\"sm\"\n onValueChange={() => handleServiceToggle(forward)}\n />\n <Chip\n className=\"text-xs\"\n color={statusDisplay.color as any}\n size=\"sm\"\n variant=\"flat\"\n >\n {statusDisplay.text}\n </Chip>\n </div>\n </div>\n </CardHeader>\n\n <CardBody className=\"flex flex-1 flex-col pt-0 pb-3 md:pt-0 md:pb-3\">\n <div className=\"space-y-2 flex-1\">\n {/* 地址信息 */}\n <div className=\"space-y-1\">\n <button\n className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${\n hasMultipleAddresses(forward.inIp)\n ? \"hover:bg-default-100 dark:hover:bg-default-200/50\"\n : \"\"\n }`}\n title={formatInAddress(forward.inIp, forward.inPort)}\n type=\"button\"\n onClick={() =>\n showAddressModal(forward.inIp, forward.inPort, \"入口端口\")\n }\n >\n <div className=\"flex items-center justify-between\">\n <div className=\"flex items-center gap-1.5 min-w-0 flex-1\">\n <span className=\"text-xs font-medium text-default-600 flex-shrink-0\">\n 入口:\n </span>\n <code className=\"text-xs font-mono text-foreground truncate min-w-0\">\n {formatInAddress(forward.inIp, forward.inPort)}\n </code>\n </div>\n {hasMultipleAddresses(forward.inIp) && (\n <svg\n aria-hidden=\"true\"\n className=\"w-3 h-3 text-default-400 flex-shrink-0\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n d=\"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n />\n </svg>\n )}\n </div>\n </button>\n\n <button\n className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${\n hasMultipleAddresses(forward.remoteAddr)\n ? \"hover:bg-default-100 dark:hover:bg-default-200/50\"\n : \"\"\n }`}\n title={formatRemoteAddress(forward.remoteAddr)}\n type=\"button\"\n onClick={() =>\n showAddressModal(forward.remoteAddr, null, \"目标地址\")\n }\n >\n <div className=\"flex items-center justify-between\">\n <div className=\"flex items-center gap-1.5 min-w-0 flex-1\">\n <span className=\"text-xs font-medium text-default-600 flex-shrink-0\">\n 目标:\n </span>\n <code className=\"text-xs font-mono text-foreground truncate min-w-0\">\n {formatRemoteAddress(forward.remoteAddr)}\n </code>\n </div>\n {hasMultipleAddresses(forward.remoteAddr) && (\n <svg\n aria-hidden=\"true\"\n className=\"w-3 h-3 text-default-400 flex-shrink-0\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n d=\"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n />\n </svg>\n )}\n </div>\n </button>\n </div>\n\n {/* 统计信息 */}\n <div className=\"flex flex-wrap items-center justify-between pt-2 border-t border-divider gap-1\">\n <Chip\n className=\"text-xs whitespace-nowrap\"\n color={strategyDisplay.color as any}\n size=\"sm\"\n variant=\"flat\"\n >\n {strategyDisplay.text}\n </Chip>\n {(forward.inFlow || 0) + (forward.outFlow || 0) > 0 ? (\n <>\n <div className=\"flex items-center gap-1\">\n <Chip\n className=\"text-xs whitespace-nowrap\"\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n >\n ↑{formatFlow(forward.inFlow || 0)}\n </Chip>\n </div>\n <Chip\n className=\"text-xs whitespace-nowrap\"\n color=\"success\"\n size=\"sm\"\n variant=\"flat\"\n >\n ↓{formatFlow(forward.outFlow || 0)}\n </Chip>\n </>\n ) : (forward.federationShareFlow || 0) > 0 ? (\n <Chip\n className=\"text-xs whitespace-nowrap\"\n color=\"secondary\"\n size=\"sm\"\n variant=\"flat\"\n >\n 共享 {formatFlow(forward.federationShareFlow || 0)}\n </Chip>\n ) : (\n <Chip\n className=\"text-xs whitespace-nowrap\"\n color=\"default\"\n size=\"sm\"\n variant=\"flat\"\n >\n 总流量 {formatFlow(0)}\n </Chip>\n )}\n </div>\n </div>\n\n <div className=\"flex gap-1.5 mt-3\">\n <Button\n className=\"flex-1 min-h-8\"\n color=\"primary\"\n size=\"sm\"\n startContent={\n <svg\n aria-hidden=\"true\"\n className=\"w-3 h-3\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path d=\"M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z\" />\n </svg>\n }\n variant=\"flat\"\n onPress={() => handleEdit(forward)}\n >\n 编辑\n </Button>\n <Button\n className=\"flex-1 min-h-8\"\n color=\"warning\"\n size=\"sm\"\n startContent={\n <svg\n aria-hidden=\"true\"\n className=\"w-3 h-3\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path\n clipRule=\"evenodd\"\n d=\"M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z\"\n fillRule=\"evenodd\"\n />\n </svg>\n }\n variant=\"flat\"\n onPress={() => handleDiagnose(forward)}\n >\n 诊断\n </Button>\n <Button\n className=\"flex-1 min-h-8\"\n color=\"danger\"\n size=\"sm\"\n startContent={\n <svg\n aria-hidden=\"true\"\n className=\"w-3 h-3\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path\n clipRule=\"evenodd\"\n d=\"M9 2a1 1 0 000 2h2a1 1 0 100-2H9z\"\n fillRule=\"evenodd\"\n />\n <path\n clipRule=\"evenodd\"\n d=\"M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z\"\n fillRule=\"evenodd\"\n />\n </svg>\n }\n variant=\"flat\"\n onPress={() => handleDelete(forward)}\n >\n 删除\n </Button>\n </div>\n </CardBody>\n </Card>\n );\n };\n\n if (loading) {\n return <PageLoadingState message=\"正在加载...\" />;\n }\n\n return (\n <AnimatedPage className=\"px-3 lg:px-6 py-8\">\n {/* 页面头部 */}\n <div className=\"flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3\">\n <div className=\"flex-1 max-w-sm flex items-center gap-2\">\n <SearchBar\n isVisible={isSearchVisible}\n placeholder=\"搜索转发名称、地址或用户名\"\n value={searchKeyword}\n onChange={setSearchKeyword}\n onClose={() => setIsSearchVisible(false)}\n onOpen={() => setIsSearchVisible(true)}\n />\n </div>\n <div className=\"min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x\">\n <div className=\"flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap sm:gap-3 [&>*]:shrink-0\">\n {selectMode ? (\n <>\n <span className=\"text-sm text-default-600 shrink-0\">\n 已选择 {selectedIds.size} 项\n </span>\n <Button\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={selectAll}\n >\n 全选\n </Button>\n <Button\n color=\"secondary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={deselectAll}\n >\n 清空\n </Button>\n <Button\n color=\"danger\"\n isDisabled={selectedIds.size === 0}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => setBatchDeleteModalOpen(true)}\n >\n 删除\n </Button>\n <Button\n color=\"warning\"\n isDisabled={selectedIds.size === 0}\n isLoading={batchLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => handleBatchToggleService(false)}\n >\n 停用\n </Button>\n <Button\n color=\"success\"\n isDisabled={selectedIds.size === 0}\n isLoading={batchLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => handleBatchToggleService(true)}\n >\n 启用\n </Button>\n <Button\n color=\"primary\"\n isDisabled={selectedIds.size === 0}\n isLoading={batchLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={handleBatchRedeploy}\n >\n 下发\n </Button>\n <Button\n color=\"secondary\"\n isDisabled={selectedIds.size === 0}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => setBatchChangeTunnelModalOpen(true)}\n >\n 隧道\n </Button>\n <Button\n color=\"secondary\"\n size=\"sm\"\n variant=\"solid\"\n onPress={toggleSelectMode}\n >\n 退出\n </Button>\n </>\n ) : (\n <>\n {/* 筛选按钮 */}\n <Button\n isIconOnly\n aria-label=\"筛选条件\"\n className={\n filterUserId !== \"all\" || filterTunnelId !== \"all\"\n ? \"bg-primary/20 text-primary relative\"\n : \"text-default-600 relative\"\n }\n color={\n filterUserId !== \"all\" || filterTunnelId !== \"all\"\n ? \"primary\"\n : \"default\"\n }\n size=\"sm\"\n title=\"筛选条件\"\n variant=\"flat\"\n onPress={() => setIsFilterModalOpen(true)}\n >\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n d=\"M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n />\n </svg>\n {(filterUserId !== \"all\" || filterTunnelId !== \"all\") && (\n <span className=\"absolute top-1.5 right-1.5 flex h-1.5 w-1.5 rounded-full bg-primary\" />\n )}\n </Button>\n {/* 显示模式切换按钮 */}\n <Button\n isIconOnly\n aria-label={\n viewMode === \"grouped\" ? \"切换到直接显示\" : \"切换到分类显示\"\n }\n className=\"text-sm\"\n color=\"default\"\n size=\"sm\"\n title={\n viewMode === \"grouped\" ? \"切换到直接显示\" : \"切换到分类显示\"\n }\n variant=\"flat\"\n onPress={handleViewModeChange}\n >\n {viewMode === \"grouped\" ? (\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path\n clipRule=\"evenodd\"\n d=\"M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2zM3 16a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2z\"\n fillRule=\"evenodd\"\n />\n </svg>\n ) : (\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path d=\"M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z\" />\n </svg>\n )}\n </Button>\n\n {/* 导入按钮 */}\n <Button\n color=\"warning\"\n size=\"sm\"\n variant=\"flat\"\n onPress={handleImport}\n >\n 导入\n </Button>\n\n {/* 导出按钮 */}\n <Button\n color=\"success\"\n isLoading={exportLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={handleExport}\n >\n 导出\n </Button>\n\n <Button\n className=\"bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45\"\n color=\"default\"\n size=\"sm\"\n variant=\"flat\"\n onPress={toggleSelectMode}\n >\n 批量\n </Button>\n\n <Button\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={handleAdd}\n >\n 新增\n </Button>\n </>\n )}\n </div>\n </div>\n </div>\n\n {/* 根据显示模式渲染不同内容 */}\n {viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n <div className=\"space-y-4\">\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 <div\n key={`grouped-table-${group.userId}-${group.userName}`}\n className=\"overflow-hidden rounded-xl border border-divider bg-content1 shadow-md\"\n >\n <div className=\"flex items-center justify-between border-b border-divider bg-default-100/40 px-4 py-3\">\n <div className=\"flex items-center gap-2\">\n <span className=\"text-sm font-semibold text-foreground\">\n {group.userName}\n </span>\n {isSelfGroup && (\n <Chip color=\"primary\" size=\"sm\" variant=\"flat\">\n 管理员本人\n </Chip>\n )}\n </div>\n <span className=\"text-xs text-default-600\">\n {groupForwardCount} 条转发\n </span>\n </div>\n\n <div className=\"space-y-4 p-4\">\n {group.tunnels.map((tunnel) => {\n const tunnelSortableForwardIds = tunnel.items\n .map((item) => item.id)\n .filter((id) => id > 0);\n\n return (\n <div\n key={`grouped-table-${group.userId}-${tunnel.tunnelKey}`}\n className=\"overflow-hidden rounded-lg border border-secondary/20 bg-secondary/5\"\n >\n <div className=\"flex items-center justify-between border-b border-secondary/20 bg-secondary/10 px-4 py-2.5\">\n <span className=\"text-sm font-semibold text-secondary-700\">\n {tunnel.tunnelName}\n </span>\n <span className=\"text-xs text-secondary-700\">\n {tunnel.items.length} 条转发\n </span>\n </div>\n\n <DndContext\n collisionDetection={closestCenter}\n sensors={sensors}\n onDragEnd={handleDragEnd}\n >\n <Table\n aria-label={`${group.userName}-${tunnel.tunnelName}转发列表`}\n className={`table-fixed ${FORWARD_GROUPED_TABLE_MIN_WIDTH_CLASS}`}\n classNames={{\n th: \"bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider\",\n td: \"py-3 border-b border-divider/50 group-data-[last=true]:border-b-0\",\n tr: \"hover:bg-default-50/50 transition-colors\",\n }}\n >\n <TableHeader>\n {selectMode && (\n <TableColumn\n className={\n FORWARD_GROUPED_TABLE_COLUMN_CLASS.select\n }\n >\n 选择\n </TableColumn>\n )}\n <TableColumn\n className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.drag}\n />\n <TableColumn\n className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.name}\n >\n 名称\n </TableColumn>\n <TableColumn\n className={\n FORWARD_GROUPED_TABLE_COLUMN_CLASS.inbound\n }\n >\n 入口\n </TableColumn>\n <TableColumn\n className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.target}\n >\n 目标\n </TableColumn>\n <TableColumn\n className={\n FORWARD_GROUPED_TABLE_COLUMN_CLASS.strategy\n }\n >\n 策略\n </TableColumn>\n <TableColumn\n className={\n FORWARD_GROUPED_TABLE_COLUMN_CLASS.totalFlow\n }\n >\n 总流量\n </TableColumn>\n <TableColumn\n className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.status}\n >\n 状态\n </TableColumn>\n <TableColumn\n className={\n FORWARD_GROUPED_TABLE_COLUMN_CLASS.actions\n }\n >\n 操作\n </TableColumn>\n </TableHeader>\n <TableBody\n emptyContent=\"暂无转发配置\"\n items={tunnel.items}\n >\n {(forward) => (\n <SortableContext\n key={forward.id}\n items={tunnelSortableForwardIds}\n strategy={verticalListSortingStrategy}\n >\n <SortableTableRow\n formatFlow={formatFlow}\n formatInAddress={formatInAddress}\n formatRemoteAddress={formatRemoteAddress}\n forward={forward}\n getStrategyDisplay={getStrategyDisplay}\n handleDelete={handleDelete}\n handleDiagnose={handleDiagnose}\n handleEdit={handleEdit}\n handleServiceToggle={handleServiceToggle}\n hasMultipleAddresses={hasMultipleAddresses}\n selectMode={selectMode}\n selectedIds={selectedIds}\n showAddressModal={showAddressModal}\n toggleSelect={toggleSelect}\n />\n </SortableContext>\n )}\n </TableBody>\n </Table>\n </DndContext>\n </div>\n );\n })}\n </div>\n </div>\n );\n })}\n </div>\n ) : (\n /* 空状态 */\n <Card className=\"shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50\">\n <CardBody className=\"text-center py-20 flex flex-col items-center justify-center min-h-[240px]\">\n <h3 className=\"text-xl font-medium text-foreground tracking-tight mb-2\">\n 暂无转发配置\n </h3>\n <p className=\"text-default-500 text-sm max-w-xs mx-auto leading-relaxed\">\n 还没有创建任何转发配置,点击上方按钮开始创建\n </p>\n </CardBody>\n </Card>\n )\n ) : /* 直接显示模式 */\n sortedForwards.length > 0 ? (\n <div className=\"space-y-5\">\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 <div\n key={`direct-group-${group.userId}-${group.userName}`}\n className=\"space-y-3\"\n >\n <div className=\"flex items-center justify-between px-1\">\n <div className=\"flex items-center gap-2\">\n <span className=\"text-sm font-semibold text-foreground\">\n {group.userName}\n </span>\n {isSelfGroup && (\n <Chip color=\"primary\" size=\"sm\" variant=\"flat\">\n 管理员本人\n </Chip>\n )}\n </div>\n <span className=\"text-xs text-default-600\">\n {groupForwardCount} 条转发\n </span>\n </div>\n\n <div className=\"space-y-4\">\n {group.tunnels.map((tunnel) => {\n const tunnelSortableForwardIds = tunnel.items\n .map((item) => item.id)\n .filter((id) => id > 0);\n\n return (\n <div\n key={`direct-group-${group.userId}-${tunnel.tunnelKey}`}\n className=\"rounded-xl border border-secondary/20 bg-secondary/5 p-3 space-y-3\"\n >\n <div className=\"flex items-center justify-between rounded-lg bg-secondary/10 px-3 py-2\">\n <span className=\"text-sm font-semibold text-secondary-700\">\n {tunnel.tunnelName}\n </span>\n <span className=\"text-xs text-secondary-700\">\n {tunnel.items.length} 条转发\n </span>\n </div>\n\n <DndContext\n collisionDetection={closestCenter}\n sensors={sensors}\n onDragEnd={handleDragEnd}\n onDragStart={() => {}} // 添加空的 onDragStart 处理器\n >\n <SortableContext\n items={tunnelSortableForwardIds}\n strategy={rectSortingStrategy}\n >\n <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4\">\n {tunnel.items.map((forward) =>\n forward && forward.id ? (\n <SortableForwardCard\n key={forward.id}\n forward={forward}\n />\n ) : null,\n )}\n </div>\n </SortableContext>\n </DndContext>\n </div>\n );\n })}\n </div>\n </div>\n );\n })}\n </div>\n ) : (\n /* 空状态 */\n <Card className=\"shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50\">\n <CardBody className=\"text-center py-20 flex flex-col items-center justify-center min-h-[240px]\">\n <h3 className=\"text-xl font-medium text-foreground tracking-tight mb-2\">\n 暂无转发配置\n </h3>\n <p className=\"text-default-500 text-sm max-w-xs mx-auto leading-relaxed\">\n 还没有创建任何转发配置,点击上方按钮开始创建\n </p>\n </CardBody>\n </Card>\n )}\n\n {/* 新增/编辑模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={modalOpen}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"2xl\"\n onOpenChange={setModalOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-xl font-bold\">\n {isEdit ? \"编辑转发\" : \"新增转发\"}\n </h2>\n <p className=\"text-small text-default-500\">\n {isEdit ? \"修改现有转发配置的信息\" : \"创建新的转发配置\"}\n </p>\n </ModalHeader>\n <ModalBody>\n <div className=\"space-y-4 pb-4\">\n <Input\n errorMessage={errors.name}\n isInvalid={!!errors.name}\n label=\"转发名称\"\n placeholder=\"请输入转发名称\"\n value={form.name}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, name: e.target.value }))\n }\n />\n\n <Select\n label=\"限速规则\"\n placeholder=\"不限速\"\n selectedKeys={\n selectedSpeedId !== null\n ? [selectedSpeedId.toString()]\n : []\n }\n variant=\"bordered\"\n onSelectionChange={(keys) => {\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 <SelectItem\n key={speedLimit.id.toString()}\n textValue={speedLimit.name}\n >\n {speedLimit.name}\n </SelectItem>\n ))}\n </Select>\n\n <Select\n description={\n isEdit\n ? \"更改隧道将释放原端口并在新隧道分配端口\"\n : undefined\n }\n errorMessage={errors.tunnelId}\n isInvalid={!!errors.tunnelId}\n label=\"选择隧道\"\n placeholder=\"请选择关联的隧道\"\n selectedKeys={\n form.tunnelId ? [form.tunnelId.toString()] : []\n }\n variant=\"bordered\"\n onSelectionChange={(keys) => {\n const selectedKey = Array.from(keys)[0] as string;\n\n if (selectedKey) {\n handleTunnelChange(selectedKey);\n }\n }}\n >\n {tunnels.map((tunnel) => (\n <SelectItem key={tunnel.id}>{tunnel.name}</SelectItem>\n ))}\n </Select>\n\n <Input\n description=\"指定入口端口,留空则从节点可用端口中自动分配\"\n errorMessage={errors.inPort}\n isInvalid={!!errors.inPort}\n label=\"入口端口\"\n placeholder=\"留空则自动分配可用端口\"\n type=\"number\"\n value={form.inPort !== null ? form.inPort.toString() : \"\"}\n variant=\"bordered\"\n onChange={(e) => {\n const value = e.target.value;\n\n setForm((prev) => ({\n ...prev,\n inPort: value ? parseInt(value) : null,\n }));\n }}\n />\n\n <Input\n description=\"留空使用默认入口IP,多IP节点可指定监听地址\"\n label=\"监听IP\"\n placeholder=\"留空使用默认\"\n value={form.inIp}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, inIp: e.target.value }))\n }\n />\n\n <Textarea\n description=\"格式: IP:端口 或 域名:端口,支持多个地址(每行一个)\"\n errorMessage={errors.remoteAddr}\n isInvalid={!!errors.remoteAddr}\n label=\"远程地址\"\n maxRows={6}\n minRows={3}\n placeholder=\"请输入远程地址,多个地址用换行分隔&#10;例如:&#10;192.168.1.100:8080&#10;example.com:3000\"\n value={form.remoteAddr}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n remoteAddr: e.target.value,\n }))\n }\n />\n\n {getAddressCount(form.remoteAddr) > 1 && (\n <Select\n description=\"多个目标地址的负载均衡策略\"\n label=\"负载策略\"\n placeholder=\"请选择负载均衡策略\"\n selectedKeys={[form.strategy]}\n variant=\"bordered\"\n onSelectionChange={(keys) => {\n const selectedKey = Array.from(keys)[0] as string;\n\n setForm((prev) => ({ ...prev, strategy: selectedKey }));\n }}\n >\n <SelectItem key=\"fifo\">主备模式 - 自上而下</SelectItem>\n <SelectItem key=\"round\">轮询模式 - 依次轮换</SelectItem>\n <SelectItem key=\"rand\">随机模式 - 随机选择</SelectItem>\n <SelectItem key=\"hash\">哈希模式 - IP哈希</SelectItem>\n </Select>\n )}\n </div>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"primary\"\n isLoading={submitLoading}\n onPress={handleSubmit}\n >\n {isEdit ? \"保存修改\" : \"创建转发\"}\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n {/* 删除确认模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={deleteModalOpen}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"2xl\"\n onOpenChange={setDeleteModalOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-lg font-bold text-danger\">确认删除</h2>\n </ModalHeader>\n <ModalBody>\n <p className=\"text-default-600\">\n 确定要删除转发{\" \"}\n <span className=\"font-semibold text-foreground\">\n &quot;{forwardToDelete?.name}&quot;\n </span>{\" \"}\n 吗?\n </p>\n <p className=\"text-small text-default-500 mt-2\">\n 此操作无法撤销,删除后该转发将永久消失。\n </p>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"danger\"\n isLoading={deleteLoading}\n onPress={confirmDelete}\n >\n 确认删除\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n {/* 地址列表弹窗 */}\n <Modal\n isOpen={addressModalOpen}\n scrollBehavior=\"outside\"\n size=\"lg\"\n onClose={() => setAddressModalOpen(false)}\n >\n <ModalContent>\n <ModalHeader className=\"text-base\">{addressModalTitle}</ModalHeader>\n <ModalBody className=\"pb-6\">\n <div className=\"mb-4 text-right\">\n <Button size=\"sm\" onPress={copyAllAddresses}>\n 复制\n </Button>\n </div>\n\n <div className=\"space-y-2 max-h-60 overflow-y-auto\">\n {addressList.map((item) => (\n <div\n key={item.id}\n className=\"flex justify-between items-center p-3 border border-default-200 dark:border-default-100 rounded-lg\"\n >\n <code className=\"text-sm flex-1 mr-3 text-foreground\">\n {item.address}\n </code>\n <Button\n isLoading={item.copying}\n size=\"sm\"\n variant=\"light\"\n onPress={() => copyAddress(item)}\n >\n 复制\n </Button>\n </div>\n ))}\n </div>\n </ModalBody>\n </ModalContent>\n </Modal>\n\n {/* 导出数据模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={exportModalOpen}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"2xl\"\n onClose={() => {\n setExportModalOpen(false);\n setSelectedTunnelForExport(null);\n setExportData(\"\");\n }}\n >\n <ModalContent>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-xl font-bold\">导出转发数据</h2>\n <p className=\"text-small text-default-500\">\n 格式:目标地址|转发名称|入口端口\n </p>\n </ModalHeader>\n <ModalBody className=\"pb-6\">\n <div className=\"space-y-4\">\n {/* 隧道选择 */}\n <div>\n <Select\n isRequired\n label=\"选择导出隧道\"\n placeholder=\"请选择要导出的隧道\"\n selectedKeys={\n selectedTunnelForExport\n ? [selectedTunnelForExport.toString()]\n : []\n }\n variant=\"bordered\"\n onSelectionChange={(keys) => {\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 <SelectItem\n key={tunnel.id.toString()}\n textValue={tunnel.name}\n >\n {tunnel.name}\n </SelectItem>\n ))}\n </Select>\n </div>\n\n {/* 导出按钮和数据 */}\n {exportData && (\n <div className=\"flex justify-between items-center\">\n <Button\n color=\"primary\"\n isDisabled={!selectedTunnelForExport}\n isLoading={exportLoading}\n size=\"sm\"\n startContent={\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path\n clipRule=\"evenodd\"\n d=\"M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM6.293 6.707a1 1 0 010-1.414l3-3a1 1 0 011.414 0l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 6.707a1 1 0 01-1.414 0z\"\n fillRule=\"evenodd\"\n />\n </svg>\n }\n onPress={executeExport}\n >\n 重新生成\n </Button>\n <Button\n color=\"secondary\"\n size=\"sm\"\n startContent={\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path d=\"M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z\" />\n <path d=\"M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z\" />\n </svg>\n }\n onPress={copyExportData}\n >\n 复制\n </Button>\n </div>\n )}\n\n {/* 初始导出按钮 */}\n {!exportData && (\n <div className=\"text-right\">\n <Button\n color=\"primary\"\n isDisabled={!selectedTunnelForExport}\n isLoading={exportLoading}\n size=\"sm\"\n startContent={\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path\n clipRule=\"evenodd\"\n d=\"M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM6.293 6.707a1 1 0 010-1.414l3-3a1 1 0 011.414 0l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 6.707a1 1 0 01-1.414 0z\"\n fillRule=\"evenodd\"\n />\n </svg>\n }\n onPress={executeExport}\n >\n 生成导出数据\n </Button>\n </div>\n )}\n\n {/* 导出数据显示 */}\n {exportData && (\n <div className=\"relative\">\n <Textarea\n readOnly\n className=\"font-mono text-sm\"\n classNames={{\n input: \"font-mono text-sm\",\n }}\n maxRows={20}\n minRows={10}\n placeholder=\"暂无数据\"\n value={exportData}\n variant=\"bordered\"\n />\n </div>\n )}\n </div>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={() => setExportModalOpen(false)}>\n 关闭\n </Button>\n </ModalFooter>\n </ModalContent>\n </Modal>\n\n {/* 导入数据模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={importModalOpen}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"2xl\"\n onClose={() => setImportModalOpen(false)}\n >\n <ModalContent>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-xl font-bold\">导入转发数据</h2>\n <p className=\"text-small text-default-500\">\n 格式:目标地址|转发名称|入口端口,每行一个,入口端口留空将自动分配可用端口\n </p>\n <p className=\"text-small text-default-400\">\n 目标地址支持单个地址(如:example.com:8080)或多个地址用逗号分隔(如:3.3.3.3:3,4.4.4.4:4)\n </p>\n </ModalHeader>\n <ModalBody className=\"pb-6\">\n <div className=\"space-y-4\">\n {/* 隧道选择 */}\n <div>\n <Select\n isRequired\n label=\"选择导入隧道\"\n placeholder=\"请选择要导入的隧道\"\n selectedKeys={\n selectedTunnelForImport\n ? [selectedTunnelForImport.toString()]\n : []\n }\n variant=\"bordered\"\n onSelectionChange={(keys) => {\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 <SelectItem\n key={tunnel.id.toString()}\n textValue={tunnel.name}\n >\n {tunnel.name}\n </SelectItem>\n ))}\n </Select>\n </div>\n\n {/* 输入区域 */}\n <div>\n <Textarea\n classNames={{\n input: \"font-mono text-sm\",\n }}\n label=\"导入数据\"\n maxRows={12}\n minRows={8}\n placeholder=\"请输入要导入的转发数据,格式:目标地址|转发名称|入口端口\"\n value={importData}\n variant=\"flat\"\n onChange={(e) => setImportData(e.target.value)}\n />\n </div>\n\n {/* 导入结果 */}\n {importResults.length > 0 && (\n <div>\n <div className=\"flex items-center justify-between mb-2\">\n <h3 className=\"text-base font-semibold\">导入结果</h3>\n <div className=\"flex items-center gap-2\">\n <span className=\"text-xs text-default-500\">\n 成功:{importResults.filter((r) => r.success).length} /\n 总计:{importResults.length}\n </span>\n </div>\n </div>\n\n <div\n className=\"max-h-40 overflow-y-auto space-y-1\"\n style={{\n scrollbarWidth: \"thin\",\n scrollbarColor: \"rgb(156 163 175) transparent\",\n }}\n >\n {importResults.map((result, index) => (\n <div\n key={index}\n className={`p-2 rounded border ${\n result.success\n ? \"bg-success-50 dark:bg-success-100/10 border-success-200 dark:border-success-300/20\"\n : \"bg-danger-50 dark:bg-danger-100/10 border-danger-200 dark:border-danger-300/20\"\n }`}\n >\n <div className=\"flex items-center gap-2\">\n {result.success ? (\n <svg\n aria-hidden=\"true\"\n className=\"w-3 h-3 text-success-600 flex-shrink-0\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path\n clipRule=\"evenodd\"\n d=\"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z\"\n fillRule=\"evenodd\"\n />\n </svg>\n ) : (\n <svg\n aria-hidden=\"true\"\n className=\"w-3 h-3 text-danger-600 flex-shrink-0\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path\n clipRule=\"evenodd\"\n d=\"M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z\"\n fillRule=\"evenodd\"\n />\n </svg>\n )}\n <div className=\"flex-1 min-w-0\">\n <div className=\"flex items-center gap-2 mb-0.5\">\n <span\n className={`text-xs font-medium ${\n result.success\n ? \"text-success-700 dark:text-success-300\"\n : \"text-danger-700 dark:text-danger-300\"\n }`}\n >\n {result.success ? \"成功\" : \"失败\"}\n </span>\n <span className=\"text-xs text-default-500\">\n |\n </span>\n <code className=\"text-xs font-mono text-default-600 truncate\">\n {result.line}\n </code>\n </div>\n <div\n className={`text-xs ${\n result.success\n ? \"text-success-600 dark:text-success-400\"\n : \"text-danger-600 dark:text-danger-400\"\n }`}\n >\n {result.message}\n </div>\n </div>\n </div>\n </div>\n ))}\n </div>\n </div>\n )}\n </div>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={() => setImportModalOpen(false)}>\n 关闭\n </Button>\n <Button\n color=\"warning\"\n isDisabled={!importData.trim() || !selectedTunnelForImport}\n isLoading={importLoading}\n onPress={executeImport}\n >\n 开始导入\n </Button>\n </ModalFooter>\n </ModalContent>\n </Modal>\n\n {/* 诊断结果模态框 */}\n <Modal\n backdrop=\"blur\"\n classNames={{\n base: \"rounded-2xl\",\n header: \"rounded-t-2xl\",\n body: \"rounded-none\",\n footer: \"rounded-b-2xl\",\n }}\n isOpen={diagnosisModalOpen}\n placement=\"center\"\n scrollBehavior=\"inside\"\n size=\"4xl\"\n onOpenChange={(open) => {\n setDiagnosisModalOpen(open);\n if (!open) {\n diagnosisAbortRef.current?.abort();\n diagnosisAbortRef.current = null;\n setDiagnosisLoading(false);\n }\n }}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1 bg-content1 border-b border-divider\">\n <h2 className=\"text-xl font-bold\">转发诊断结果</h2>\n {currentDiagnosisForward && (\n <div className=\"flex items-center gap-2 min-w-0\">\n <span className=\"text-small text-default-500 truncate flex-1 min-w-0\">\n {currentDiagnosisForward.name}\n </span>\n <Chip\n className=\"flex-shrink-0\"\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n >\n 转发服务\n </Chip>\n </div>\n )}\n </ModalHeader>\n <ModalBody className=\"bg-content1\">\n {diagnosisResult ? (\n <div className=\"space-y-4\">\n {diagnosisLoading && (\n <div className=\"flex items-center justify-between rounded-lg border border-primary/20 bg-primary/5 px-3 py-2\">\n <div className=\"flex items-center gap-2 text-sm text-primary\">\n <Spinner size=\"sm\" />\n <span>\n 正在诊断 {diagnosisProgress.completed}/\n {diagnosisProgress.total > 0\n ? diagnosisProgress.total\n : \"?\"}\n </span>\n </div>\n <Chip color=\"primary\" size=\"sm\" variant=\"flat\">\n 流式更新中\n </Chip>\n </div>\n )}\n\n {diagnosisProgress.timedOut && (\n <Alert\n color=\"warning\"\n description=\"诊断超时(单条30秒 / 整体2分钟),以下为当前已完成结果。\"\n title=\"诊断超时\"\n variant=\"flat\"\n />\n )}\n\n {/* 统计摘要 */}\n <div className=\"grid grid-cols-3 gap-3\">\n <div className=\"text-center p-3 bg-default-100 dark:bg-gray-800 rounded-lg border border-divider\">\n <div className=\"text-2xl font-bold text-foreground\">\n {diagnosisProgress.total > 0\n ? diagnosisProgress.total\n : diagnosisResult.results.length}\n </div>\n <div className=\"text-xs text-default-500 mt-1\">\n 总测试数\n </div>\n </div>\n <div className=\"text-center p-3 bg-success-50 dark:bg-success-900/20 rounded-lg border border-success-200 dark:border-success-700\">\n <div className=\"text-2xl font-bold text-success-600 dark:text-success-400\">\n {diagnosisProgress.completed > 0 ||\n diagnosisProgress.total > 0\n ? diagnosisProgress.success\n : diagnosisResult.results.filter((r) => r.success)\n .length}\n </div>\n <div className=\"text-xs text-success-600 dark:text-success-400/80 mt-1\">\n 成功\n </div>\n </div>\n <div className=\"text-center p-3 bg-danger-50 dark:bg-danger-900/20 rounded-lg border border-danger-200 dark:border-danger-700\">\n <div className=\"text-2xl font-bold text-danger-600 dark:text-danger-400\">\n {diagnosisProgress.completed > 0 ||\n diagnosisProgress.total > 0\n ? diagnosisProgress.failed\n : diagnosisResult.results.filter((r) => !r.success)\n .length}\n </div>\n <div className=\"text-xs text-danger-600 dark:text-danger-400/80 mt-1\">\n 失败\n </div>\n </div>\n </div>\n\n {/* 桌面端表格展示 */}\n <div className=\"hidden md:block space-y-3\">\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 <div\n key={title}\n className=\"border border-divider rounded-lg overflow-hidden bg-white dark:bg-gray-800\"\n >\n <div className=\"bg-primary/10 dark:bg-primary/20 px-3 py-2 border-b border-divider\">\n <h3 className=\"text-sm font-semibold text-primary\">\n {title}\n </h3>\n </div>\n <table className=\"w-full text-sm\">\n <thead className=\"bg-default-100 dark:bg-gray-700\">\n <tr>\n <th className=\"px-3 py-2 text-left font-semibold text-xs\">\n 路径\n </th>\n <th className=\"px-3 py-2 text-center font-semibold text-xs w-20\">\n 状态\n </th>\n <th className=\"px-3 py-2 text-center font-semibold text-xs w-24\">\n 延迟(ms)\n </th>\n <th className=\"px-3 py-2 text-center font-semibold text-xs w-24\">\n 丢包率\n </th>\n <th className=\"px-3 py-2 text-center font-semibold text-xs w-20\">\n 质量\n </th>\n </tr>\n </thead>\n <tbody className=\"divide-y divide-divider bg-white dark:bg-gray-800\">\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 <tr\n key={index}\n className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${\n isDiagnosing\n ? \"bg-warning-50 dark:bg-warning-900/20\"\n : isSuccess\n ? \"bg-white dark:bg-gray-800\"\n : \"bg-danger-50 dark:bg-danger-900/30\"\n }`}\n >\n <td className=\"px-3 py-2\">\n <div className=\"flex items-center gap-2\">\n {isDiagnosing ? (\n <Spinner size=\"sm\" />\n ) : (\n <span\n className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${\n isSuccess\n ? \"bg-success text-white\"\n : \"bg-danger text-white\"\n }`}\n >\n {isSuccess ? \"✓\" : \"✗\"}\n </span>\n )}\n <div className=\"flex-1 min-w-0\">\n <div className=\"font-medium text-foreground truncate\">\n {result.description}\n </div>\n <div className=\"text-xs text-default-500 truncate\">\n {result.targetIp}:\n {result.targetPort}\n </div>\n </div>\n </div>\n </td>\n <td className=\"px-3 py-2 text-center\">\n <Chip\n color={\n isDiagnosing\n ? \"warning\"\n : isSuccess\n ? \"success\"\n : \"danger\"\n }\n size=\"sm\"\n variant=\"flat\"\n >\n {isDiagnosing\n ? \"诊断中\"\n : isSuccess\n ? \"成功\"\n : \"失败\"}\n </Chip>\n </td>\n <td className=\"px-3 py-2 text-center\">\n {isSuccess ? (\n <span className=\"font-semibold text-primary\">\n {result.averageTime?.toFixed(0)}\n </span>\n ) : (\n <span className=\"text-default-400\">\n -\n </span>\n )}\n </td>\n <td className=\"px-3 py-2 text-center\">\n {isSuccess ? (\n <span\n className={`font-semibold ${\n (result.packetLoss || 0) > 0\n ? \"text-warning\"\n : \"text-success\"\n }`}\n >\n {result.packetLoss?.toFixed(1)}%\n </span>\n ) : (\n <span className=\"text-default-400\">\n -\n </span>\n )}\n </td>\n <td className=\"px-3 py-2 text-center\">\n {isSuccess && quality ? (\n <Chip\n className=\"text-xs whitespace-nowrap\"\n color={quality.color as any}\n size=\"sm\"\n variant=\"flat\"\n >\n {quality.text}\n </Chip>\n ) : (\n <span className=\"text-default-400\">\n -\n </span>\n )}\n </td>\n </tr>\n );\n })}\n </tbody>\n </table>\n </div>\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 </div>\n\n {/* 移动端卡片展示 */}\n <div className=\"md:hidden space-y-3\">\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 <div key={title} className=\"space-y-2\">\n <div className=\"px-2 py-1.5 bg-primary/10 dark:bg-primary/20 rounded-lg border border-primary/30\">\n <h3 className=\"text-sm font-semibold text-primary\">\n {title}\n </h3>\n </div>\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 <div\n key={index}\n className={`border rounded-lg p-3 ${\n isDiagnosing\n ? \"border-warning-200 dark:border-warning-300/30 bg-warning-50 dark:bg-warning-900/20\"\n : isSuccess\n ? \"border-divider bg-white dark:bg-gray-800\"\n : \"border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30\"\n }`}\n >\n <div className=\"flex items-start gap-2 mb-2\">\n {isDiagnosing ? (\n <Spinner size=\"sm\" />\n ) : (\n <span\n className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${\n isSuccess\n ? \"bg-success text-white\"\n : \"bg-danger text-white\"\n }`}\n >\n {isSuccess ? \"✓\" : \"✗\"}\n </span>\n )}\n <div className=\"flex-1 min-w-0\">\n <div className=\"font-semibold text-sm text-foreground break-words\">\n {result.description}\n </div>\n <div className=\"text-xs text-default-500 mt-0.5 break-all\">\n {result.targetIp}:{result.targetPort}\n </div>\n </div>\n <Chip\n className=\"flex-shrink-0\"\n color={\n isDiagnosing\n ? \"warning\"\n : isSuccess\n ? \"success\"\n : \"danger\"\n }\n size=\"sm\"\n variant=\"flat\"\n >\n {isDiagnosing\n ? \"诊断中\"\n : isSuccess\n ? \"成功\"\n : \"失败\"}\n </Chip>\n </div>\n\n {isSuccess ? (\n <div className=\"grid grid-cols-3 gap-2 mt-2 pt-2 border-t border-divider\">\n <div className=\"text-center\">\n <div className=\"text-lg font-bold text-primary\">\n {result.averageTime?.toFixed(0)}\n </div>\n <div className=\"text-xs text-default-500\">\n 延迟(ms)\n </div>\n </div>\n <div className=\"text-center\">\n <div\n className={`text-lg font-bold ${\n (result.packetLoss || 0) > 0\n ? \"text-warning\"\n : \"text-success\"\n }`}\n >\n {result.packetLoss?.toFixed(1)}%\n </div>\n <div className=\"text-xs text-default-500\">\n 丢包率\n </div>\n </div>\n <div className=\"text-center\">\n {quality && (\n <>\n <Chip\n className=\"text-xs whitespace-nowrap\"\n color={quality.color as any}\n size=\"sm\"\n variant=\"flat\"\n >\n {quality.text}\n </Chip>\n <div className=\"text-xs text-default-500 mt-0.5\">\n 质量\n </div>\n </>\n )}\n </div>\n </div>\n ) : (\n <div className=\"mt-2 pt-2 border-t border-divider\">\n <div\n className={`text-xs ${\n isDiagnosing\n ? \"text-warning\"\n : \"text-danger\"\n }`}\n >\n {isDiagnosing\n ? result.message || \"诊断中...\"\n : result.message || \"连接失败\"}\n </div>\n </div>\n )}\n </div>\n );\n })}\n </div>\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 </div>\n\n {/* 失败详情(仅桌面端显示,移动端已在卡片中显示) */}\n {diagnosisResult.results.some(\n (r) => r.success === false && !r.diagnosing,\n ) && (\n <div className=\"space-y-2 hidden md:block\">\n <h4 className=\"text-sm font-semibold text-danger\">\n 失败详情\n </h4>\n <div className=\"space-y-2\">\n {diagnosisResult.results\n .filter((r) => r.success === false && !r.diagnosing)\n .map((result, index) => (\n <Alert\n key={index}\n className=\"text-xs\"\n color=\"danger\"\n description={result.message || \"连接失败\"}\n title={result.description}\n variant=\"flat\"\n />\n ))}\n </div>\n </div>\n )}\n </div>\n ) : (\n <div className=\"text-center py-16\">\n <div className=\"w-16 h-16 bg-default-100 rounded-full flex items-center justify-center mx-auto mb-4\">\n <svg\n aria-hidden=\"true\"\n className=\"w-8 h-8 text-default-400\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n d=\"M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={1.5}\n />\n </svg>\n </div>\n <h3 className=\"text-lg font-semibold text-foreground\">\n 暂无诊断数据\n </h3>\n </div>\n )}\n </ModalBody>\n <ModalFooter className=\"bg-content1 border-t border-divider\">\n <Button variant=\"light\" onPress={onClose}>\n 关闭\n </Button>\n {currentDiagnosisForward && (\n <Button\n color=\"primary\"\n isLoading={diagnosisLoading}\n onPress={() => handleDiagnose(currentDiagnosisForward)}\n >\n 重新诊断\n </Button>\n )}\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n {/* 批量删除确认模态框 */}\n <Modal\n isOpen={batchDeleteModalOpen}\n onOpenChange={setBatchDeleteModalOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader>确认删除</ModalHeader>\n <ModalBody>\n <p>\n 确定要删除选中的 {selectedIds.size} 项转发吗?此操作不可撤销。\n </p>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"danger\"\n isLoading={batchLoading}\n onPress={handleBatchDelete}\n >\n 确认删除\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n {/* 批量换隧道模态框 */}\n <Modal\n isOpen={batchChangeTunnelModalOpen}\n onOpenChange={setBatchChangeTunnelModalOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader>隧道</ModalHeader>\n <ModalBody>\n <p className=\"mb-4\">\n 将选中的 {selectedIds.size} 项转发迁移到新隧道:\n </p>\n <Select\n label=\"目标隧道\"\n placeholder=\"请选择目标隧道\"\n selectedKeys={\n batchTargetTunnelId ? [String(batchTargetTunnelId)] : []\n }\n onSelectionChange={(keys) => {\n const selected = Array.from(keys)[0];\n\n setBatchTargetTunnelId(selected ? Number(selected) : null);\n }}\n >\n {tunnels.map((tunnel) => (\n <SelectItem key={String(tunnel.id)}>\n {tunnel.name}\n </SelectItem>\n ))}\n </Select>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"primary\"\n isDisabled={!batchTargetTunnelId}\n isLoading={batchLoading}\n onPress={handleBatchChangeTunnel}\n >\n 确认换隧道\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n {/* 筛选模态框 */}\n <Modal\n isOpen={isFilterModalOpen}\n placement=\"center\"\n size=\"md\"\n onOpenChange={setIsFilterModalOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1\">\n 筛选条件\n </ModalHeader>\n <ModalBody>\n <div className=\"flex flex-col gap-4 py-2\">\n <div className=\"flex flex-col gap-2\">\n <p className=\"text-sm font-medium\">按用户筛选</p>\n <Select\n aria-label=\"筛选用户\"\n className=\"w-full\"\n selectedKeys={[filterUserId]}\n variant=\"bordered\"\n onSelectionChange={(keys) => {\n const key = Array.from(keys)[0] as string;\n\n setFilterUserId(key || \"all\");\n }}\n >\n <SelectItem key=\"all\">全部用户</SelectItem>\n {uniqueUsers.map((user) => (\n <SelectItem key={user.id.toString()}>\n {user.name}\n </SelectItem>\n ))}\n </Select>\n </div>\n <div className=\"flex flex-col gap-2\">\n <p className=\"text-sm font-medium\">按隧道筛选</p>\n <Select\n aria-label=\"筛选隧道\"\n className=\"w-full\"\n selectedKeys={[filterTunnelId]}\n variant=\"bordered\"\n onSelectionChange={(keys) => {\n const key = Array.from(keys)[0] as string;\n\n setFilterTunnelId(key || \"all\");\n }}\n >\n <SelectItem key=\"all\">全部隧道</SelectItem>\n {tunnels.map((tunnel) => (\n <SelectItem key={tunnel.id.toString()}>\n {tunnel.name}\n </SelectItem>\n ))}\n </Select>\n </div>\n </div>\n </ModalBody>\n <ModalFooter>\n <Button\n color=\"default\"\n variant=\"flat\"\n onPress={() => {\n resetFilterUserId();\n resetFilterTunnelId();\n }}\n >\n 重置\n </Button>\n <Button color=\"primary\" onPress={onClose}>\n 完成\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n </AnimatedPage>\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<string, string[]>;\ntype ForwardGroupCollapsedMap = Record<string, boolean>;\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 = <T,>(raw: string | null): Record<string, T> | 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<string, T>;\n } catch {\n return null;\n }\n};\n\nconst parseGroupOrderMap = (raw: string | null): ForwardGroupOrderMap => {\n const parsed = parsePreferenceMap<unknown>(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<unknown>(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<number, Set<string>>,\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<string>();\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<string>,\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<number, Set<string>>;\n availableCollapseKeys: Set<string>;\n} => {\n const availableTunnelKeysByUser = new Map<number, Set<string>>();\n const availableCollapseKeys = new Set<string>();\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<string>();\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<Forward[]>([]);\n const [tunnels, setTunnels] = useState<Tunnel[]>([]);\n const [speedLimits, setSpeedLimits] = useState<SpeedLimitApiItem[]>([]);\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<string>(\"forward-filter-user-id\", \"all\");\n const [filterTunnelId, setFilterTunnelId, resetFilterTunnelId] =\n useLocalStorageState<string>(\"forward-filter-tunnel-id\", \"all\");\n\n // 拖拽排序相关状态\n const [forwardOrder, setForwardOrder] = useState<number[]>([]);\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<Forward | null>(null);\n const [currentDiagnosisForward, setCurrentDiagnosisForward] =\n useState<Forward | null>(null);\n const [diagnosisResult, setDiagnosisResult] =\n useState<ForwardDiagnosisResult | null>(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<AbortController | null>(null);\n const [addressModalTitle, setAddressModalTitle] = useState(\"\");\n const [addressList, setAddressList] = useState<ForwardAddressItem[]>([]);\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<ForwardForm>({\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<Set<number>>(new Set());\n const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);\n const [batchChangeTunnelModalOpen, setBatchChangeTunnelModalOpen] =\n useState(false);\n const [batchTargetTunnelId, setBatchTargetTunnelId] = useState<number | null>(\n null,\n );\n const [batchLoading, setBatchLoading] = useState(false);\n const [groupOrderMap, setGroupOrderMap] = useState<ForwardGroupOrderMap>({});\n const [collapsedTunnelGroups, setCollapsedTunnelGroups] =\n useState<ForwardGroupCollapsedMap>({});\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<void> => {\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<ForwardGroupOrderMap>(\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<void> => {\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<ForwardGroupCollapsedMap>(\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<ForwardGroupOrderMap>(\n globalOrderRes.code === 0 &&\n typeof globalOrderRes.data?.value === \"string\"\n ? globalOrderRes.data.value\n : null,\n );\n const globalCollapsedMap = parsePreferenceMap<ForwardGroupCollapsedMap>(\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<Forward[]> => {\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<number, number>();\n const shareIdsByTunnel = new Map<number, Set<number>>();\n\n if (usageRes.code === 0 && Array.isArray(usageRes.data)) {\n usageRes.data.forEach((item: Record<string, unknown>) => {\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<Record<string, unknown>>)\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<number>();\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<string, unknown>) => {\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<number>();\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<number, number>();\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<number, number>();\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<string, ForwardTunnelGroup>;\n };\n\n const userGroupMap = new Map<number, MutableForwardUserGroup>();\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<string, ForwardTunnelGroup>(),\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<string, number>();\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 <div ref={setNodeRef} className={wrapperClassName} style={style}>\n <div className={headerClassName}>\n <div className=\"flex items-center gap-2 min-w-0\">\n <Button\n isIconOnly\n aria-label={collapsed ? \"展开分组\" : \"折叠分组\"}\n className=\"h-7 w-7 min-w-7\"\n size=\"sm\"\n variant=\"light\"\n onPress={onToggleCollapsed}\n >\n <svg\n aria-hidden=\"true\"\n className={`h-4 w-4 transition-transform ${collapsed ? \"-rotate-90\" : \"rotate-0\"}`}\n fill=\"none\"\n stroke=\"currentColor\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n >\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n </Button>\n <span className={titleClassName}>{tunnel.tunnelName}</span>\n </div>\n <div className=\"flex items-center gap-2\">\n <span className={countClassName}>{tunnel.items.length} 条转发</span>\n <div\n className=\"cursor-grab active:cursor-grabbing p-1 text-default-400 hover:text-default-600 transition-colors\"\n title=\"拖拽分组排序\"\n {...attributes}\n {...listeners}\n >\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path d=\"M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z\" />\n </svg>\n </div>\n </div>\n </div>\n {!collapsed && <div className={bodyClassName}>{children}</div>}\n </div>\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 <div ref={setNodeRef} className=\"h-full\" style={style} {...attributes}>\n {renderForwardCard(forward, listeners)}\n </div>\n );\n };\n\n // 生成用作筛选项的用户和隧道列表\n const uniqueUsers = useMemo(() => {\n const userMap = new Map<number, { id: number; name: string }>();\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 <TableRow key={forward.id} ref={setNodeRef} style={style}>\n {selectMode && (\n <TableCell className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.select}>\n <Checkbox\n isSelected={selectedIds.has(forward.id)}\n onValueChange={() => toggleSelect(forward.id)}\n />\n </TableCell>\n )}\n <TableCell className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.drag}>\n <div\n className=\"cursor-grab active:cursor-grabbing p-1 text-default-400 hover:text-default-600 transition-colors\"\n {...attributes}\n {...listeners}\n title=\"拖拽排序\"\n >\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path d=\"M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z\" />\n </svg>\n </div>\n </TableCell>\n <TableCell\n className={`${FORWARD_GROUPED_TABLE_COLUMN_CLASS.name} whitespace-nowrap font-semibold text-foreground`}\n >\n {forward.name}\n </TableCell>\n <TableCell\n className={`${FORWARD_GROUPED_TABLE_COLUMN_CLASS.inbound} max-w-[280px]`}\n >\n <button\n className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${\n hasMultipleAddresses(forward.inIp)\n ? \"hover:bg-default-200 hover:shadow-sm\"\n : \"\"\n }`}\n title={formatInAddress(forward.inIp, forward.inPort)}\n type=\"button\"\n onClick={() =>\n showAddressModal(forward.inIp, forward.inPort, \"入口端口\")\n }\n >\n {formatInAddress(forward.inIp, forward.inPort)}\n </button>\n </TableCell>\n <TableCell\n className={`${FORWARD_GROUPED_TABLE_COLUMN_CLASS.target} max-w-[280px]`}\n >\n <button\n className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${\n hasMultipleAddresses(forward.remoteAddr)\n ? \"hover:bg-default-200 hover:shadow-sm\"\n : \"\"\n }`}\n title={formatRemoteAddress(forward.remoteAddr)}\n type=\"button\"\n onClick={() =>\n showAddressModal(forward.remoteAddr, null, \"目标地址\")\n }\n >\n {formatRemoteAddress(forward.remoteAddr)}\n </button>\n </TableCell>\n <TableCell className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.strategy}>\n <Chip\n className=\"text-xs font-medium\"\n color={strategyDisplay.color as any}\n size=\"sm\"\n variant=\"flat\"\n >\n {strategyDisplay.text}\n </Chip>\n </TableCell>\n <TableCell\n className={`${FORWARD_GROUPED_TABLE_COLUMN_CLASS.totalFlow} whitespace-nowrap`}\n >\n <span className=\"text-sm font-medium text-default-600 font-mono\">\n {formatFlow(getForwardDisplayFlow(forward))}\n </span>\n </TableCell>\n <TableCell className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.status}>\n <div className=\"flex items-center gap-2.5 whitespace-nowrap\">\n <Switch\n color=\"success\"\n isDisabled={forward.status !== 1 && forward.status !== 0}\n isSelected={forward.serviceRunning}\n size=\"sm\"\n onValueChange={() => handleServiceToggle(forward)}\n />\n </div>\n </TableCell>\n <TableCell className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.actions}>\n <div className=\"flex justify-end gap-2\">\n <Button\n isIconOnly\n className=\"bg-primary/10 text-primary hover:bg-primary/20\"\n size=\"sm\"\n title=\"编辑\"\n onPress={() => handleEdit(forward)}\n >\n <svg\n className=\"h-4 w-4\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n d=\"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n />\n </svg>\n </Button>\n <Button\n isIconOnly\n className=\"bg-warning/10 text-warning hover:bg-warning/20\"\n size=\"sm\"\n title=\"诊断\"\n onPress={() => handleDiagnose(forward)}\n >\n <svg\n className=\"h-4 w-4\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n />\n </svg>\n </Button>\n <Button\n isIconOnly\n className=\"bg-danger/10 text-danger hover:bg-danger/20\"\n size=\"sm\"\n title=\"删除\"\n onPress={() => handleDelete(forward)}\n >\n <svg\n className=\"h-4 w-4\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n d=\"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n />\n </svg>\n </Button>\n </div>\n </TableCell>\n </TableRow>\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 <TableRow key={forward.id} ref={setNodeRef} style={style}>\n {selectMode && (\n <TableCell>\n <Checkbox\n isSelected={selectedIds.has(forward.id)}\n onValueChange={() => toggleSelect(forward.id)}\n />\n </TableCell>\n )}\n <TableCell>\n <div\n className=\"cursor-grab active:cursor-grabbing p-1 text-default-400 hover:text-default-600 transition-colors\"\n {...attributes}\n {...listeners}\n title=\"拖拽排序\"\n >\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path d=\"M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z\" />\n </svg>\n </div>\n </TableCell>\n <TableCell className=\"whitespace-nowrap\">\n <span className=\"text-sm font-medium text-default-700\">\n {forward.userName || \"未知用户\"}\n </span>\n </TableCell>\n <TableCell className=\"whitespace-nowrap font-semibold text-foreground\">\n {forward.name}\n </TableCell>\n <TableCell className=\"whitespace-nowrap\">\n <Chip\n className=\"border-none bg-secondary/10 px-2\"\n color=\"secondary\"\n size=\"sm\"\n >\n <span className=\"font-medium text-secondary-700\">\n {forward.tunnelName}\n </span>\n </Chip>\n </TableCell>\n <TableCell className=\"max-w-[220px]\">\n <button\n className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${\n hasMultipleAddresses(forward.inIp)\n ? \"hover:bg-default-200 hover:shadow-sm\"\n : \"\"\n }`}\n title={formatInAddress(forward.inIp, forward.inPort)}\n type=\"button\"\n onClick={() =>\n showAddressModal(forward.inIp, forward.inPort, \"入口端口\")\n }\n >\n {formatInAddress(forward.inIp, forward.inPort)}\n </button>\n </TableCell>\n <TableCell className=\"max-w-[240px]\">\n <button\n className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${\n hasMultipleAddresses(forward.remoteAddr)\n ? \"hover:bg-default-200 hover:shadow-sm\"\n : \"\"\n }`}\n title={formatRemoteAddress(forward.remoteAddr)}\n type=\"button\"\n onClick={() =>\n showAddressModal(forward.remoteAddr, null, \"目标地址\")\n }\n >\n {formatRemoteAddress(forward.remoteAddr)}\n </button>\n </TableCell>\n <TableCell>\n <Chip\n className=\"text-xs font-medium\"\n color={strategyDisplay.color as any}\n size=\"sm\"\n variant=\"flat\"\n >\n {strategyDisplay.text}\n </Chip>\n </TableCell>\n <TableCell className=\"whitespace-nowrap\">\n <span className=\"text-sm font-medium text-default-600 font-mono\">\n {formatFlow(getForwardDisplayFlow(forward))}\n </span>\n </TableCell>\n <TableCell>\n <div className=\"flex items-center gap-2.5 whitespace-nowrap\">\n <Switch\n color=\"success\"\n isDisabled={forward.status !== 1 && forward.status !== 0}\n isSelected={forward.serviceRunning}\n size=\"sm\"\n onValueChange={() => handleServiceToggle(forward)}\n />\n </div>\n </TableCell>\n <TableCell>\n <div className=\"flex justify-end gap-2\">\n <Button\n isIconOnly\n className=\"bg-primary/10 text-primary hover:bg-primary/20\"\n size=\"sm\"\n title=\"编辑\"\n onPress={() => handleEdit(forward)}\n >\n <svg\n className=\"h-4 w-4\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n d=\"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n />\n </svg>\n </Button>\n <Button\n isIconOnly\n className=\"bg-warning/10 text-warning hover:bg-warning/20\"\n size=\"sm\"\n title=\"诊断\"\n onPress={() => handleDiagnose(forward)}\n >\n <svg\n className=\"h-4 w-4\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n />\n </svg>\n </Button>\n <Button\n isIconOnly\n className=\"bg-danger/10 text-danger hover:bg-danger/20\"\n size=\"sm\"\n title=\"删除\"\n onPress={() => handleDelete(forward)}\n >\n <svg\n className=\"h-4 w-4\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n d=\"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n />\n </svg>\n </Button>\n </div>\n </TableCell>\n </TableRow>\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 <Card\n key={forward.id}\n className=\"group h-full flex flex-col shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden\"\n >\n <CardHeader className=\"pb-2 md:pb-2\">\n <div className=\"flex justify-between items-start w-full\">\n {selectMode && (\n <Checkbox\n className=\"mr-2\"\n isSelected={selectedIds.has(forward.id)}\n onValueChange={() => toggleSelect(forward.id)}\n />\n )}\n <div className=\"flex-1 min-w-0\">\n <h3 className=\"font-semibold text-foreground truncate text-sm\">\n {forward.name}\n </h3>\n <p className=\"text-xs text-default-500 truncate\">\n {normalizeForwardTunnelName(forward.tunnelName)}\n </p>\n </div>\n <div className=\"flex items-center gap-1.5 ml-2\">\n {viewMode === \"direct\" && (\n <div\n className={`cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation ${\n isMobile\n ? \"opacity-100\" // 移动端始终显示\n : \"opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100\"\n }`}\n {...listeners}\n style={{ touchAction: \"none\" }}\n title={isMobile ? \"长按拖拽排序\" : \"拖拽排序\"}\n >\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path d=\"M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z\" />\n </svg>\n </div>\n )}\n <Switch\n isDisabled={forward.status !== 1 && forward.status !== 0}\n isSelected={forward.serviceRunning}\n size=\"sm\"\n onValueChange={() => handleServiceToggle(forward)}\n />\n <Chip\n className=\"text-xs\"\n color={statusDisplay.color as any}\n size=\"sm\"\n variant=\"flat\"\n >\n {statusDisplay.text}\n </Chip>\n </div>\n </div>\n </CardHeader>\n\n <CardBody className=\"flex flex-1 flex-col pt-0 pb-3 md:pt-0 md:pb-3\">\n <div className=\"space-y-2 flex-1\">\n {/* 地址信息 */}\n <div className=\"space-y-1\">\n <button\n className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${\n hasMultipleAddresses(forward.inIp)\n ? \"hover:bg-default-100 dark:hover:bg-default-200/50\"\n : \"\"\n }`}\n title={formatInAddress(forward.inIp, forward.inPort)}\n type=\"button\"\n onClick={() =>\n showAddressModal(forward.inIp, forward.inPort, \"入口端口\")\n }\n >\n <div className=\"flex items-center justify-between\">\n <div className=\"flex items-center gap-1.5 min-w-0 flex-1\">\n <span className=\"text-xs font-medium text-default-600 flex-shrink-0\">\n 入口:\n </span>\n <code className=\"text-xs font-mono text-foreground truncate min-w-0\">\n {formatInAddress(forward.inIp, forward.inPort)}\n </code>\n </div>\n {hasMultipleAddresses(forward.inIp) && (\n <svg\n aria-hidden=\"true\"\n className=\"w-3 h-3 text-default-400 flex-shrink-0\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n d=\"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n />\n </svg>\n )}\n </div>\n </button>\n\n <button\n className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${\n hasMultipleAddresses(forward.remoteAddr)\n ? \"hover:bg-default-100 dark:hover:bg-default-200/50\"\n : \"\"\n }`}\n title={formatRemoteAddress(forward.remoteAddr)}\n type=\"button\"\n onClick={() =>\n showAddressModal(forward.remoteAddr, null, \"目标地址\")\n }\n >\n <div className=\"flex items-center justify-between\">\n <div className=\"flex items-center gap-1.5 min-w-0 flex-1\">\n <span className=\"text-xs font-medium text-default-600 flex-shrink-0\">\n 目标:\n </span>\n <code className=\"text-xs font-mono text-foreground truncate min-w-0\">\n {formatRemoteAddress(forward.remoteAddr)}\n </code>\n </div>\n {hasMultipleAddresses(forward.remoteAddr) && (\n <svg\n aria-hidden=\"true\"\n className=\"w-3 h-3 text-default-400 flex-shrink-0\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n d=\"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n />\n </svg>\n )}\n </div>\n </button>\n </div>\n\n {/* 统计信息 */}\n <div className=\"flex flex-wrap items-center justify-between pt-2 border-t border-divider gap-1\">\n <Chip\n className=\"text-xs whitespace-nowrap\"\n color={strategyDisplay.color as any}\n size=\"sm\"\n variant=\"flat\"\n >\n {strategyDisplay.text}\n </Chip>\n {(forward.inFlow || 0) + (forward.outFlow || 0) > 0 ? (\n <>\n <div className=\"flex items-center gap-1\">\n <Chip\n className=\"text-xs whitespace-nowrap\"\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n >\n ↑{formatFlow(forward.inFlow || 0)}\n </Chip>\n </div>\n <Chip\n className=\"text-xs whitespace-nowrap\"\n color=\"success\"\n size=\"sm\"\n variant=\"flat\"\n >\n ↓{formatFlow(forward.outFlow || 0)}\n </Chip>\n </>\n ) : (forward.federationShareFlow || 0) > 0 ? (\n <Chip\n className=\"text-xs whitespace-nowrap\"\n color=\"secondary\"\n size=\"sm\"\n variant=\"flat\"\n >\n 共享 {formatFlow(forward.federationShareFlow || 0)}\n </Chip>\n ) : (\n <Chip\n className=\"text-xs whitespace-nowrap\"\n color=\"default\"\n size=\"sm\"\n variant=\"flat\"\n >\n 总流量 {formatFlow(0)}\n </Chip>\n )}\n </div>\n </div>\n\n <div className=\"flex gap-1.5 mt-3\">\n <Button\n className=\"flex-1 min-h-8\"\n color=\"primary\"\n size=\"sm\"\n startContent={\n <svg\n aria-hidden=\"true\"\n className=\"w-3 h-3\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path d=\"M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z\" />\n </svg>\n }\n variant=\"flat\"\n onPress={() => handleEdit(forward)}\n >\n 编辑\n </Button>\n <Button\n className=\"flex-1 min-h-8\"\n color=\"warning\"\n size=\"sm\"\n startContent={\n <svg\n aria-hidden=\"true\"\n className=\"w-3 h-3\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path\n clipRule=\"evenodd\"\n d=\"M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z\"\n fillRule=\"evenodd\"\n />\n </svg>\n }\n variant=\"flat\"\n onPress={() => handleDiagnose(forward)}\n >\n 诊断\n </Button>\n <Button\n className=\"flex-1 min-h-8\"\n color=\"danger\"\n size=\"sm\"\n startContent={\n <svg\n aria-hidden=\"true\"\n className=\"w-3 h-3\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path\n clipRule=\"evenodd\"\n d=\"M9 2a1 1 0 000 2h2a1 1 0 100-2H9z\"\n fillRule=\"evenodd\"\n />\n <path\n clipRule=\"evenodd\"\n d=\"M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z\"\n fillRule=\"evenodd\"\n />\n </svg>\n }\n variant=\"flat\"\n onPress={() => handleDelete(forward)}\n >\n 删除\n </Button>\n </div>\n </CardBody>\n </Card>\n );\n };\n\n if (loading || !groupPreferenceHydrated) {\n return <PageLoadingState message=\"正在加载...\" />;\n }\n\n return (\n <AnimatedPage className=\"px-3 lg:px-6 py-8\">\n {/* 页面头部 */}\n <div className=\"flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3\">\n <div className=\"flex-1 max-w-sm flex items-center gap-2\">\n <SearchBar\n isVisible={isSearchVisible}\n placeholder=\"搜索转发名称、地址或用户名\"\n value={searchKeyword}\n onChange={setSearchKeyword}\n onClose={() => setIsSearchVisible(false)}\n onOpen={() => setIsSearchVisible(true)}\n />\n </div>\n <div className=\"min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x\">\n <div className=\"flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap sm:gap-3 [&>*]:shrink-0\">\n {selectMode ? (\n <>\n <span className=\"text-sm text-default-600 shrink-0\">\n 已选择 {selectedIds.size} 项\n </span>\n <Button\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={selectAll}\n >\n 全选\n </Button>\n <Button\n color=\"secondary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={deselectAll}\n >\n 清空\n </Button>\n <Button\n color=\"danger\"\n isDisabled={selectedIds.size === 0}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => setBatchDeleteModalOpen(true)}\n >\n 删除\n </Button>\n <Button\n color=\"warning\"\n isDisabled={selectedIds.size === 0}\n isLoading={batchLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => handleBatchToggleService(false)}\n >\n 停用\n </Button>\n <Button\n color=\"success\"\n isDisabled={selectedIds.size === 0}\n isLoading={batchLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => handleBatchToggleService(true)}\n >\n 启用\n </Button>\n <Button\n color=\"primary\"\n isDisabled={selectedIds.size === 0}\n isLoading={batchLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={handleBatchRedeploy}\n >\n 下发\n </Button>\n <Button\n color=\"secondary\"\n isDisabled={selectedIds.size === 0}\n size=\"sm\"\n variant=\"flat\"\n onPress={() => setBatchChangeTunnelModalOpen(true)}\n >\n 隧道\n </Button>\n <Button\n color=\"secondary\"\n size=\"sm\"\n variant=\"solid\"\n onPress={toggleSelectMode}\n >\n 退出\n </Button>\n </>\n ) : (\n <>\n {/* 筛选按钮 */}\n <Button\n isIconOnly\n aria-label=\"筛选条件\"\n className={\n filterUserId !== \"all\" || filterTunnelId !== \"all\"\n ? \"bg-primary/20 text-primary relative\"\n : \"text-default-600 relative\"\n }\n color={\n filterUserId !== \"all\" || filterTunnelId !== \"all\"\n ? \"primary\"\n : \"default\"\n }\n size=\"sm\"\n title=\"筛选条件\"\n variant=\"flat\"\n onPress={() => setIsFilterModalOpen(true)}\n >\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n d=\"M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n />\n </svg>\n {(filterUserId !== \"all\" || filterTunnelId !== \"all\") && (\n <span className=\"absolute top-1.5 right-1.5 flex h-1.5 w-1.5 rounded-full bg-primary\" />\n )}\n </Button>\n {/* 显示模式切换按钮 */}\n <Button\n isIconOnly\n aria-label={\n viewMode === \"grouped\" ? \"切换到直接显示\" : \"切换到分类显示\"\n }\n className=\"text-sm\"\n color=\"default\"\n size=\"sm\"\n title={\n viewMode === \"grouped\" ? \"切换到直接显示\" : \"切换到分类显示\"\n }\n variant=\"flat\"\n onPress={handleViewModeChange}\n >\n {viewMode === \"grouped\" ? (\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path\n clipRule=\"evenodd\"\n d=\"M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2zM3 16a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2z\"\n fillRule=\"evenodd\"\n />\n </svg>\n ) : (\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path d=\"M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z\" />\n </svg>\n )}\n </Button>\n\n {/* 导入按钮 */}\n <Button\n color=\"warning\"\n size=\"sm\"\n variant=\"flat\"\n onPress={handleImport}\n >\n 导入\n </Button>\n\n {/* 导出按钮 */}\n <Button\n color=\"success\"\n isLoading={exportLoading}\n size=\"sm\"\n variant=\"flat\"\n onPress={handleExport}\n >\n 导出\n </Button>\n\n <Button\n className=\"bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45\"\n color=\"default\"\n size=\"sm\"\n variant=\"flat\"\n onPress={toggleSelectMode}\n >\n 批量\n </Button>\n\n <Button\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={handleAdd}\n >\n 新增\n </Button>\n </>\n )}\n </div>\n </div>\n </div>\n\n {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n <div className=\"overflow-hidden rounded-xl border border-divider bg-content1 shadow-md\">\n <DndContext\n collisionDetection={closestCenter}\n sensors={sensors}\n onDragEnd={handleDragEnd}\n >\n <Table\n aria-label=\"全部转发列表\"\n classNames={{\n th: \"bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider\",\n td: \"py-3 border-b border-divider/50 group-data-[last=true]:border-b-0\",\n tr: \"hover:bg-default-50/50 transition-colors\",\n }}\n >\n <TableHeader>\n {selectMode && <TableColumn className=\"w-14\">选择</TableColumn>}\n <TableColumn className=\"w-10 pl-4\" />\n <TableColumn>用户</TableColumn>\n <TableColumn>名称</TableColumn>\n <TableColumn>隧道</TableColumn>\n <TableColumn>入口</TableColumn>\n <TableColumn>目标</TableColumn>\n <TableColumn>策略</TableColumn>\n <TableColumn>总流量</TableColumn>\n <TableColumn>状态</TableColumn>\n <TableColumn className=\"text-right\">操作</TableColumn>\n </TableHeader>\n <TableBody emptyContent=\"暂无转发配置\" items={sortedForwards}>\n {(forward) => (\n <SortableContext\n key={forward.id}\n items={sortableForwardIds}\n strategy={verticalListSortingStrategy}\n >\n <SortableCompactTableRow\n formatFlow={formatFlow}\n formatInAddress={formatInAddress}\n formatRemoteAddress={formatRemoteAddress}\n forward={forward}\n getStrategyDisplay={getStrategyDisplay}\n handleDelete={handleDelete}\n handleDiagnose={handleDiagnose}\n handleEdit={handleEdit}\n handleServiceToggle={handleServiceToggle}\n hasMultipleAddresses={hasMultipleAddresses}\n selectMode={selectMode}\n selectedIds={selectedIds}\n showAddressModal={showAddressModal}\n toggleSelect={toggleSelect}\n />\n </SortableContext>\n )}\n </TableBody>\n </Table>\n </DndContext>\n </div>\n ) : (\n <Card className=\"shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50\">\n <CardBody className=\"text-center py-20 flex flex-col items-center justify-center min-h-[240px]\">\n <h3 className=\"text-xl font-medium text-foreground tracking-tight mb-2\">\n 暂无转发配置\n </h3>\n <p className=\"text-default-500 text-sm max-w-xs mx-auto leading-relaxed\">\n 还没有创建任何转发配置,点击上方按钮开始创建\n </p>\n </CardBody>\n </Card>\n )\n ) : sortedForwards.length > 0 ? (\n <DndContext\n collisionDetection={closestCenter}\n sensors={sensors}\n onDragEnd={handleDragEnd}\n onDragStart={() => {}}\n >\n <SortableContext items={sortableForwardIds} strategy={rectSortingStrategy}>\n <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4\">\n {sortedForwards.map((forward) =>\n forward && forward.id ? (\n <SortableForwardCard key={forward.id} forward={forward} />\n ) : null,\n )}\n </div>\n </SortableContext>\n </DndContext>\n ) : (\n <Card className=\"shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50\">\n <CardBody className=\"text-center py-20 flex flex-col items-center justify-center min-h-[240px]\">\n <h3 className=\"text-xl font-medium text-foreground tracking-tight mb-2\">\n 暂无转发配置\n </h3>\n <p className=\"text-default-500 text-sm max-w-xs mx-auto leading-relaxed\">\n 还没有创建任何转发配置,点击上方按钮开始创建\n </p>\n </CardBody>\n </Card>\n )\n ) : viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n <div className=\"space-y-4\">\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 <div\n key={`grouped-table-${group.userId}-${group.userName}`}\n className=\"overflow-hidden rounded-xl border border-divider bg-content1 shadow-md\"\n >\n <div className=\"flex items-center justify-between border-b border-divider bg-default-100/40 px-4 py-3\">\n <div className=\"flex items-center gap-2\">\n <span className=\"text-sm font-semibold text-foreground\">\n {group.userName}\n </span>\n {isSelfGroup && (\n <Chip color=\"primary\" size=\"sm\" variant=\"flat\">\n 管理员本人\n </Chip>\n )}\n </div>\n <span className=\"text-xs text-default-600\">\n {groupForwardCount} 条转发\n </span>\n </div>\n\n <div className=\"space-y-4 p-4\">\n <DndContext\n collisionDetection={closestCenter}\n sensors={sensors}\n onDragEnd={handleDragEnd}\n >\n <SortableContext\n items={group.tunnels.map((tunnel) =>\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 <SortableTunnelGroupContainer\n key={`grouped-table-${group.userId}-${tunnel.tunnelKey}`}\n bodyClassName=\"\"\n collapsed={collapsed}\n countClassName=\"text-xs text-secondary-700\"\n groupUserId={group.userId}\n headerClassName=\"flex items-center justify-between border-b border-secondary/20 bg-secondary/10 px-4 py-2.5\"\n titleClassName=\"truncate text-sm font-semibold text-secondary-700\"\n tunnel={tunnel}\n wrapperClassName=\"overflow-hidden rounded-lg border border-secondary/20 bg-secondary/5\"\n onToggleCollapsed={() =>\n toggleTunnelGroupCollapsed(\n group.userId,\n tunnel.tunnelKey,\n )\n }\n >\n <DndContext\n collisionDetection={closestCenter}\n sensors={sensors}\n onDragEnd={handleDragEnd}\n >\n <Table\n aria-label={`${group.userName}-${tunnel.tunnelName}转发列表`}\n className={`table-fixed ${FORWARD_GROUPED_TABLE_MIN_WIDTH_CLASS}`}\n classNames={{\n th: \"bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider\",\n td: \"py-3 border-b border-divider/50 group-data-[last=true]:border-b-0\",\n tr: \"hover:bg-default-50/50 transition-colors\",\n }}\n >\n <TableHeader>\n {selectMode && (\n <TableColumn\n className={\n FORWARD_GROUPED_TABLE_COLUMN_CLASS.select\n }\n >\n 选择\n </TableColumn>\n )}\n <TableColumn\n className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.drag}\n />\n <TableColumn\n className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.name}\n >\n 名称\n </TableColumn>\n <TableColumn\n className={\n FORWARD_GROUPED_TABLE_COLUMN_CLASS.inbound\n }\n >\n 入口\n </TableColumn>\n <TableColumn\n className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.target}\n >\n 目标\n </TableColumn>\n <TableColumn\n className={\n FORWARD_GROUPED_TABLE_COLUMN_CLASS.strategy\n }\n >\n 策略\n </TableColumn>\n <TableColumn\n className={\n FORWARD_GROUPED_TABLE_COLUMN_CLASS.totalFlow\n }\n >\n 总流量\n </TableColumn>\n <TableColumn\n className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.status}\n >\n 状态\n </TableColumn>\n <TableColumn\n className={\n FORWARD_GROUPED_TABLE_COLUMN_CLASS.actions\n }\n >\n 操作\n </TableColumn>\n </TableHeader>\n <TableBody\n emptyContent=\"暂无转发配置\"\n items={tunnel.items}\n >\n {(forward) => (\n <SortableContext\n key={forward.id}\n items={tunnelSortableForwardIds}\n strategy={verticalListSortingStrategy}\n >\n <SortableTableRow\n formatFlow={formatFlow}\n formatInAddress={formatInAddress}\n formatRemoteAddress={formatRemoteAddress}\n forward={forward}\n getStrategyDisplay={getStrategyDisplay}\n handleDelete={handleDelete}\n handleDiagnose={handleDiagnose}\n handleEdit={handleEdit}\n handleServiceToggle={handleServiceToggle}\n hasMultipleAddresses={hasMultipleAddresses}\n selectMode={selectMode}\n selectedIds={selectedIds}\n showAddressModal={showAddressModal}\n toggleSelect={toggleSelect}\n />\n </SortableContext>\n )}\n </TableBody>\n </Table>\n </DndContext>\n </SortableTunnelGroupContainer>\n );\n })}\n </SortableContext>\n </DndContext>\n </div>\n </div>\n );\n })}\n </div>\n ) : (\n <Card className=\"shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50\">\n <CardBody className=\"text-center py-20 flex flex-col items-center justify-center min-h-[240px]\">\n <h3 className=\"text-xl font-medium text-foreground tracking-tight mb-2\">\n 暂无转发配置\n </h3>\n <p className=\"text-default-500 text-sm max-w-xs mx-auto leading-relaxed\">\n 还没有创建任何转发配置,点击上方按钮开始创建\n </p>\n </CardBody>\n </Card>\n )\n ) : sortedForwards.length > 0 ? (\n <div className=\"space-y-5\">\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 <div\n key={`direct-group-${group.userId}-${group.userName}`}\n className=\"space-y-3\"\n >\n <div className=\"flex items-center justify-between px-1\">\n <div className=\"flex items-center gap-2\">\n <span className=\"text-sm font-semibold text-foreground\">\n {group.userName}\n </span>\n {isSelfGroup && (\n <Chip color=\"primary\" size=\"sm\" variant=\"flat\">\n 管理员本人\n </Chip>\n )}\n </div>\n <span className=\"text-xs text-default-600\">\n {groupForwardCount} 条转发\n </span>\n </div>\n\n <div className=\"space-y-4\">\n <DndContext\n collisionDetection={closestCenter}\n sensors={sensors}\n onDragEnd={handleDragEnd}\n >\n <SortableContext\n items={group.tunnels.map((tunnel) =>\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 <SortableTunnelGroupContainer\n key={`direct-group-${group.userId}-${tunnel.tunnelKey}`}\n bodyClassName=\"p-3\"\n collapsed={collapsed}\n countClassName=\"text-xs text-secondary-700\"\n groupUserId={group.userId}\n headerClassName=\"flex items-center justify-between rounded-lg bg-secondary/10 px-3 py-2\"\n titleClassName=\"truncate text-sm font-semibold text-secondary-700\"\n tunnel={tunnel}\n wrapperClassName=\"rounded-xl border border-secondary/20 bg-secondary/5 space-y-3\"\n onToggleCollapsed={() =>\n toggleTunnelGroupCollapsed(group.userId, tunnel.tunnelKey)\n }\n >\n <DndContext\n collisionDetection={closestCenter}\n sensors={sensors}\n onDragEnd={handleDragEnd}\n onDragStart={() => {}}\n >\n <SortableContext\n items={tunnelSortableForwardIds}\n strategy={rectSortingStrategy}\n >\n <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4\">\n {tunnel.items.map((forward) =>\n forward && forward.id ? (\n <SortableForwardCard\n key={forward.id}\n forward={forward}\n />\n ) : null,\n )}\n </div>\n </SortableContext>\n </DndContext>\n </SortableTunnelGroupContainer>\n );\n })}\n </SortableContext>\n </DndContext>\n </div>\n </div>\n );\n })}\n </div>\n ) : (\n <Card className=\"shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50\">\n <CardBody className=\"text-center py-20 flex flex-col items-center justify-center min-h-[240px]\">\n <h3 className=\"text-xl font-medium text-foreground tracking-tight mb-2\">\n 暂无转发配置\n </h3>\n <p className=\"text-default-500 text-sm max-w-xs mx-auto leading-relaxed\">\n 还没有创建任何转发配置,点击上方按钮开始创建\n </p>\n </CardBody>\n </Card>\n )}\n\n {/* 新增/编辑模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={modalOpen}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"2xl\"\n onOpenChange={setModalOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-xl font-bold\">\n {isEdit ? \"编辑转发\" : \"新增转发\"}\n </h2>\n <p className=\"text-small text-default-500\">\n {isEdit ? \"修改现有转发配置的信息\" : \"创建新的转发配置\"}\n </p>\n </ModalHeader>\n <ModalBody>\n <div className=\"space-y-4 pb-4\">\n <Input\n errorMessage={errors.name}\n isInvalid={!!errors.name}\n label=\"转发名称\"\n placeholder=\"请输入转发名称\"\n value={form.name}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, name: e.target.value }))\n }\n />\n\n <Select\n label=\"限速规则\"\n placeholder=\"不限速\"\n selectedKeys={\n selectedSpeedId !== null\n ? [selectedSpeedId.toString()]\n : []\n }\n variant=\"bordered\"\n onSelectionChange={(keys) => {\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 <SelectItem\n key={speedLimit.id.toString()}\n textValue={speedLimit.name}\n >\n {speedLimit.name}\n </SelectItem>\n ))}\n </Select>\n\n <Select\n description={\n isEdit\n ? \"更改隧道将释放原端口并在新隧道分配端口\"\n : undefined\n }\n errorMessage={errors.tunnelId}\n isInvalid={!!errors.tunnelId}\n label=\"选择隧道\"\n placeholder=\"请选择关联的隧道\"\n selectedKeys={\n form.tunnelId ? [form.tunnelId.toString()] : []\n }\n variant=\"bordered\"\n onSelectionChange={(keys) => {\n const selectedKey = Array.from(keys)[0] as string;\n\n if (selectedKey) {\n handleTunnelChange(selectedKey);\n }\n }}\n >\n {tunnels.map((tunnel) => (\n <SelectItem key={tunnel.id}>{tunnel.name}</SelectItem>\n ))}\n </Select>\n\n <Input\n description=\"指定入口端口,留空则从节点可用端口中自动分配\"\n errorMessage={errors.inPort}\n isInvalid={!!errors.inPort}\n label=\"入口端口\"\n placeholder=\"留空则自动分配可用端口\"\n type=\"number\"\n value={form.inPort !== null ? form.inPort.toString() : \"\"}\n variant=\"bordered\"\n onChange={(e) => {\n const value = e.target.value;\n\n setForm((prev) => ({\n ...prev,\n inPort: value ? parseInt(value) : null,\n }));\n }}\n />\n\n <Input\n description=\"留空使用默认入口IP,多IP节点可指定监听地址\"\n label=\"监听IP\"\n placeholder=\"留空使用默认\"\n value={form.inIp}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({ ...prev, inIp: e.target.value }))\n }\n />\n\n <Textarea\n description=\"格式: IP:端口 或 域名:端口,支持多个地址(每行一个)\"\n errorMessage={errors.remoteAddr}\n isInvalid={!!errors.remoteAddr}\n label=\"远程地址\"\n maxRows={6}\n minRows={3}\n placeholder=\"请输入远程地址,多个地址用换行分隔&#10;例如:&#10;192.168.1.100:8080&#10;example.com:3000\"\n value={form.remoteAddr}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n remoteAddr: e.target.value,\n }))\n }\n />\n\n {getAddressCount(form.remoteAddr) > 1 && (\n <Select\n description=\"多个目标地址的负载均衡策略\"\n label=\"负载策略\"\n placeholder=\"请选择负载均衡策略\"\n selectedKeys={[form.strategy]}\n variant=\"bordered\"\n onSelectionChange={(keys) => {\n const selectedKey = Array.from(keys)[0] as string;\n\n setForm((prev) => ({ ...prev, strategy: selectedKey }));\n }}\n >\n <SelectItem key=\"fifo\">主备模式 - 自上而下</SelectItem>\n <SelectItem key=\"round\">轮询模式 - 依次轮换</SelectItem>\n <SelectItem key=\"rand\">随机模式 - 随机选择</SelectItem>\n <SelectItem key=\"hash\">哈希模式 - IP哈希</SelectItem>\n </Select>\n )}\n </div>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"primary\"\n isLoading={submitLoading}\n onPress={handleSubmit}\n >\n {isEdit ? \"保存修改\" : \"创建转发\"}\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n {/* 删除确认模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={deleteModalOpen}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"2xl\"\n onOpenChange={setDeleteModalOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-lg font-bold text-danger\">确认删除</h2>\n </ModalHeader>\n <ModalBody>\n <p className=\"text-default-600\">\n 确定要删除转发{\" \"}\n <span className=\"font-semibold text-foreground\">\n &quot;{forwardToDelete?.name}&quot;\n </span>{\" \"}\n 吗?\n </p>\n <p className=\"text-small text-default-500 mt-2\">\n 此操作无法撤销,删除后该转发将永久消失。\n </p>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"danger\"\n isLoading={deleteLoading}\n onPress={confirmDelete}\n >\n 确认删除\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n {/* 地址列表弹窗 */}\n <Modal\n isOpen={addressModalOpen}\n scrollBehavior=\"outside\"\n size=\"lg\"\n onClose={() => setAddressModalOpen(false)}\n >\n <ModalContent>\n <ModalHeader className=\"text-base\">{addressModalTitle}</ModalHeader>\n <ModalBody className=\"pb-6\">\n <div className=\"mb-4 text-right\">\n <Button size=\"sm\" onPress={copyAllAddresses}>\n 复制\n </Button>\n </div>\n\n <div className=\"space-y-2 max-h-60 overflow-y-auto\">\n {addressList.map((item) => (\n <div\n key={item.id}\n className=\"flex justify-between items-center p-3 border border-default-200 dark:border-default-100 rounded-lg\"\n >\n <code className=\"text-sm flex-1 mr-3 text-foreground\">\n {item.address}\n </code>\n <Button\n isLoading={item.copying}\n size=\"sm\"\n variant=\"light\"\n onPress={() => copyAddress(item)}\n >\n 复制\n </Button>\n </div>\n ))}\n </div>\n </ModalBody>\n </ModalContent>\n </Modal>\n\n {/* 导出数据模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={exportModalOpen}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"2xl\"\n onClose={() => {\n setExportModalOpen(false);\n setSelectedTunnelForExport(null);\n setExportData(\"\");\n }}\n >\n <ModalContent>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-xl font-bold\">导出转发数据</h2>\n <p className=\"text-small text-default-500\">\n 格式:目标地址|转发名称|入口端口\n </p>\n </ModalHeader>\n <ModalBody className=\"pb-6\">\n <div className=\"space-y-4\">\n {/* 隧道选择 */}\n <div>\n <Select\n isRequired\n label=\"选择导出隧道\"\n placeholder=\"请选择要导出的隧道\"\n selectedKeys={\n selectedTunnelForExport\n ? [selectedTunnelForExport.toString()]\n : []\n }\n variant=\"bordered\"\n onSelectionChange={(keys) => {\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 <SelectItem\n key={tunnel.id.toString()}\n textValue={tunnel.name}\n >\n {tunnel.name}\n </SelectItem>\n ))}\n </Select>\n </div>\n\n {/* 导出按钮和数据 */}\n {exportData && (\n <div className=\"flex justify-between items-center\">\n <Button\n color=\"primary\"\n isDisabled={!selectedTunnelForExport}\n isLoading={exportLoading}\n size=\"sm\"\n startContent={\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path\n clipRule=\"evenodd\"\n d=\"M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM6.293 6.707a1 1 0 010-1.414l3-3a1 1 0 011.414 0l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 6.707a1 1 0 01-1.414 0z\"\n fillRule=\"evenodd\"\n />\n </svg>\n }\n onPress={executeExport}\n >\n 重新生成\n </Button>\n <Button\n color=\"secondary\"\n size=\"sm\"\n startContent={\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path d=\"M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z\" />\n <path d=\"M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z\" />\n </svg>\n }\n onPress={copyExportData}\n >\n 复制\n </Button>\n </div>\n )}\n\n {/* 初始导出按钮 */}\n {!exportData && (\n <div className=\"text-right\">\n <Button\n color=\"primary\"\n isDisabled={!selectedTunnelForExport}\n isLoading={exportLoading}\n size=\"sm\"\n startContent={\n <svg\n aria-hidden=\"true\"\n className=\"w-4 h-4\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path\n clipRule=\"evenodd\"\n d=\"M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM6.293 6.707a1 1 0 010-1.414l3-3a1 1 0 011.414 0l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 6.707a1 1 0 01-1.414 0z\"\n fillRule=\"evenodd\"\n />\n </svg>\n }\n onPress={executeExport}\n >\n 生成导出数据\n </Button>\n </div>\n )}\n\n {/* 导出数据显示 */}\n {exportData && (\n <div className=\"relative\">\n <Textarea\n readOnly\n className=\"font-mono text-sm\"\n classNames={{\n input: \"font-mono text-sm\",\n }}\n maxRows={20}\n minRows={10}\n placeholder=\"暂无数据\"\n value={exportData}\n variant=\"bordered\"\n />\n </div>\n )}\n </div>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={() => setExportModalOpen(false)}>\n 关闭\n </Button>\n </ModalFooter>\n </ModalContent>\n </Modal>\n\n {/* 导入数据模态框 */}\n <Modal\n backdrop=\"blur\"\n isOpen={importModalOpen}\n placement=\"center\"\n scrollBehavior=\"outside\"\n size=\"2xl\"\n onClose={() => setImportModalOpen(false)}\n >\n <ModalContent>\n <ModalHeader className=\"flex flex-col gap-1\">\n <h2 className=\"text-xl font-bold\">导入转发数据</h2>\n <p className=\"text-small text-default-500\">\n 格式:目标地址|转发名称|入口端口,每行一个,入口端口留空将自动分配可用端口\n </p>\n <p className=\"text-small text-default-400\">\n 目标地址支持单个地址(如:example.com:8080)或多个地址用逗号分隔(如:3.3.3.3:3,4.4.4.4:4)\n </p>\n </ModalHeader>\n <ModalBody className=\"pb-6\">\n <div className=\"space-y-4\">\n {/* 隧道选择 */}\n <div>\n <Select\n isRequired\n label=\"选择导入隧道\"\n placeholder=\"请选择要导入的隧道\"\n selectedKeys={\n selectedTunnelForImport\n ? [selectedTunnelForImport.toString()]\n : []\n }\n variant=\"bordered\"\n onSelectionChange={(keys) => {\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 <SelectItem\n key={tunnel.id.toString()}\n textValue={tunnel.name}\n >\n {tunnel.name}\n </SelectItem>\n ))}\n </Select>\n </div>\n\n {/* 输入区域 */}\n <div>\n <Textarea\n classNames={{\n input: \"font-mono text-sm\",\n }}\n label=\"导入数据\"\n maxRows={12}\n minRows={8}\n placeholder=\"请输入要导入的转发数据,格式:目标地址|转发名称|入口端口\"\n value={importData}\n variant=\"flat\"\n onChange={(e) => setImportData(e.target.value)}\n />\n </div>\n\n {/* 导入结果 */}\n {importResults.length > 0 && (\n <div>\n <div className=\"flex items-center justify-between mb-2\">\n <h3 className=\"text-base font-semibold\">导入结果</h3>\n <div className=\"flex items-center gap-2\">\n <span className=\"text-xs text-default-500\">\n 成功:{importResults.filter((r) => r.success).length} /\n 总计:{importResults.length}\n </span>\n </div>\n </div>\n\n <div\n className=\"max-h-40 overflow-y-auto space-y-1\"\n style={{\n scrollbarWidth: \"thin\",\n scrollbarColor: \"rgb(156 163 175) transparent\",\n }}\n >\n {importResults.map((result, index) => (\n <div\n key={index}\n className={`p-2 rounded border ${\n result.success\n ? \"bg-success-50 dark:bg-success-100/10 border-success-200 dark:border-success-300/20\"\n : \"bg-danger-50 dark:bg-danger-100/10 border-danger-200 dark:border-danger-300/20\"\n }`}\n >\n <div className=\"flex items-center gap-2\">\n {result.success ? (\n <svg\n aria-hidden=\"true\"\n className=\"w-3 h-3 text-success-600 flex-shrink-0\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path\n clipRule=\"evenodd\"\n d=\"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z\"\n fillRule=\"evenodd\"\n />\n </svg>\n ) : (\n <svg\n aria-hidden=\"true\"\n className=\"w-3 h-3 text-danger-600 flex-shrink-0\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path\n clipRule=\"evenodd\"\n d=\"M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z\"\n fillRule=\"evenodd\"\n />\n </svg>\n )}\n <div className=\"flex-1 min-w-0\">\n <div className=\"flex items-center gap-2 mb-0.5\">\n <span\n className={`text-xs font-medium ${\n result.success\n ? \"text-success-700 dark:text-success-300\"\n : \"text-danger-700 dark:text-danger-300\"\n }`}\n >\n {result.success ? \"成功\" : \"失败\"}\n </span>\n <span className=\"text-xs text-default-500\">\n |\n </span>\n <code className=\"text-xs font-mono text-default-600 truncate\">\n {result.line}\n </code>\n </div>\n <div\n className={`text-xs ${\n result.success\n ? \"text-success-600 dark:text-success-400\"\n : \"text-danger-600 dark:text-danger-400\"\n }`}\n >\n {result.message}\n </div>\n </div>\n </div>\n </div>\n ))}\n </div>\n </div>\n )}\n </div>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={() => setImportModalOpen(false)}>\n 关闭\n </Button>\n <Button\n color=\"warning\"\n isDisabled={!importData.trim() || !selectedTunnelForImport}\n isLoading={importLoading}\n onPress={executeImport}\n >\n 开始导入\n </Button>\n </ModalFooter>\n </ModalContent>\n </Modal>\n\n {/* 诊断结果模态框 */}\n <Modal\n backdrop=\"blur\"\n classNames={{\n base: \"rounded-2xl\",\n header: \"rounded-t-2xl\",\n body: \"rounded-none\",\n footer: \"rounded-b-2xl\",\n }}\n isOpen={diagnosisModalOpen}\n placement=\"center\"\n scrollBehavior=\"inside\"\n size=\"4xl\"\n onOpenChange={(open) => {\n setDiagnosisModalOpen(open);\n if (!open) {\n diagnosisAbortRef.current?.abort();\n diagnosisAbortRef.current = null;\n setDiagnosisLoading(false);\n }\n }}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1 bg-content1 border-b border-divider\">\n <h2 className=\"text-xl font-bold\">转发诊断结果</h2>\n {currentDiagnosisForward && (\n <div className=\"flex items-center gap-2 min-w-0\">\n <span className=\"text-small text-default-500 truncate flex-1 min-w-0\">\n {currentDiagnosisForward.name}\n </span>\n <Chip\n className=\"flex-shrink-0\"\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n >\n 转发服务\n </Chip>\n </div>\n )}\n </ModalHeader>\n <ModalBody className=\"bg-content1\">\n {diagnosisResult ? (\n <div className=\"space-y-4\">\n {diagnosisLoading && (\n <div className=\"flex items-center justify-between rounded-lg border border-primary/20 bg-primary/5 px-3 py-2\">\n <div className=\"flex items-center gap-2 text-sm text-primary\">\n <Spinner size=\"sm\" />\n <span>\n 正在诊断 {diagnosisProgress.completed}/\n {diagnosisProgress.total > 0\n ? diagnosisProgress.total\n : \"?\"}\n </span>\n </div>\n <Chip color=\"primary\" size=\"sm\" variant=\"flat\">\n 流式更新中\n </Chip>\n </div>\n )}\n\n {diagnosisProgress.timedOut && (\n <Alert\n color=\"warning\"\n description=\"诊断超时(单条30秒 / 整体2分钟),以下为当前已完成结果。\"\n title=\"诊断超时\"\n variant=\"flat\"\n />\n )}\n\n {/* 统计摘要 */}\n <div className=\"grid grid-cols-3 gap-3\">\n <div className=\"text-center p-3 bg-default-100 dark:bg-gray-800 rounded-lg border border-divider\">\n <div className=\"text-2xl font-bold text-foreground\">\n {diagnosisProgress.total > 0\n ? diagnosisProgress.total\n : diagnosisResult.results.length}\n </div>\n <div className=\"text-xs text-default-500 mt-1\">\n 总测试数\n </div>\n </div>\n <div className=\"text-center p-3 bg-success-50 dark:bg-success-900/20 rounded-lg border border-success-200 dark:border-success-700\">\n <div className=\"text-2xl font-bold text-success-600 dark:text-success-400\">\n {diagnosisProgress.completed > 0 ||\n diagnosisProgress.total > 0\n ? diagnosisProgress.success\n : diagnosisResult.results.filter((r) => r.success)\n .length}\n </div>\n <div className=\"text-xs text-success-600 dark:text-success-400/80 mt-1\">\n 成功\n </div>\n </div>\n <div className=\"text-center p-3 bg-danger-50 dark:bg-danger-900/20 rounded-lg border border-danger-200 dark:border-danger-700\">\n <div className=\"text-2xl font-bold text-danger-600 dark:text-danger-400\">\n {diagnosisProgress.completed > 0 ||\n diagnosisProgress.total > 0\n ? diagnosisProgress.failed\n : diagnosisResult.results.filter((r) => !r.success)\n .length}\n </div>\n <div className=\"text-xs text-danger-600 dark:text-danger-400/80 mt-1\">\n 失败\n </div>\n </div>\n </div>\n\n {/* 桌面端表格展示 */}\n <div className=\"hidden md:block space-y-3\">\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 <div\n key={title}\n className=\"border border-divider rounded-lg overflow-hidden bg-white dark:bg-gray-800\"\n >\n <div className=\"bg-primary/10 dark:bg-primary/20 px-3 py-2 border-b border-divider\">\n <h3 className=\"text-sm font-semibold text-primary\">\n {title}\n </h3>\n </div>\n <table className=\"w-full text-sm\">\n <thead className=\"bg-default-100 dark:bg-gray-700\">\n <tr>\n <th className=\"px-3 py-2 text-left font-semibold text-xs\">\n 路径\n </th>\n <th className=\"px-3 py-2 text-center font-semibold text-xs w-20\">\n 状态\n </th>\n <th className=\"px-3 py-2 text-center font-semibold text-xs w-24\">\n 延迟(ms)\n </th>\n <th className=\"px-3 py-2 text-center font-semibold text-xs w-24\">\n 丢包率\n </th>\n <th className=\"px-3 py-2 text-center font-semibold text-xs w-20\">\n 质量\n </th>\n </tr>\n </thead>\n <tbody className=\"divide-y divide-divider bg-white dark:bg-gray-800\">\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 <tr\n key={index}\n className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${\n isDiagnosing\n ? \"bg-warning-50 dark:bg-warning-900/20\"\n : isSuccess\n ? \"bg-white dark:bg-gray-800\"\n : \"bg-danger-50 dark:bg-danger-900/30\"\n }`}\n >\n <td className=\"px-3 py-2\">\n <div className=\"flex items-center gap-2\">\n {isDiagnosing ? (\n <Spinner size=\"sm\" />\n ) : (\n <span\n className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${\n isSuccess\n ? \"bg-success text-white\"\n : \"bg-danger text-white\"\n }`}\n >\n {isSuccess ? \"✓\" : \"✗\"}\n </span>\n )}\n <div className=\"flex-1 min-w-0\">\n <div className=\"font-medium text-foreground truncate\">\n {result.description}\n </div>\n <div className=\"text-xs text-default-500 truncate\">\n {result.targetIp}:\n {result.targetPort}\n </div>\n </div>\n </div>\n </td>\n <td className=\"px-3 py-2 text-center\">\n <Chip\n color={\n isDiagnosing\n ? \"warning\"\n : isSuccess\n ? \"success\"\n : \"danger\"\n }\n size=\"sm\"\n variant=\"flat\"\n >\n {isDiagnosing\n ? \"诊断中\"\n : isSuccess\n ? \"成功\"\n : \"失败\"}\n </Chip>\n </td>\n <td className=\"px-3 py-2 text-center\">\n {isSuccess ? (\n <span className=\"font-semibold text-primary\">\n {result.averageTime?.toFixed(0)}\n </span>\n ) : (\n <span className=\"text-default-400\">\n -\n </span>\n )}\n </td>\n <td className=\"px-3 py-2 text-center\">\n {isSuccess ? (\n <span\n className={`font-semibold ${\n (result.packetLoss || 0) > 0\n ? \"text-warning\"\n : \"text-success\"\n }`}\n >\n {result.packetLoss?.toFixed(1)}%\n </span>\n ) : (\n <span className=\"text-default-400\">\n -\n </span>\n )}\n </td>\n <td className=\"px-3 py-2 text-center\">\n {isSuccess && quality ? (\n <Chip\n className=\"text-xs whitespace-nowrap\"\n color={quality.color as any}\n size=\"sm\"\n variant=\"flat\"\n >\n {quality.text}\n </Chip>\n ) : (\n <span className=\"text-default-400\">\n -\n </span>\n )}\n </td>\n </tr>\n );\n })}\n </tbody>\n </table>\n </div>\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 </div>\n\n {/* 移动端卡片展示 */}\n <div className=\"md:hidden space-y-3\">\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 <div key={title} className=\"space-y-2\">\n <div className=\"px-2 py-1.5 bg-primary/10 dark:bg-primary/20 rounded-lg border border-primary/30\">\n <h3 className=\"text-sm font-semibold text-primary\">\n {title}\n </h3>\n </div>\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 <div\n key={index}\n className={`border rounded-lg p-3 ${\n isDiagnosing\n ? \"border-warning-200 dark:border-warning-300/30 bg-warning-50 dark:bg-warning-900/20\"\n : isSuccess\n ? \"border-divider bg-white dark:bg-gray-800\"\n : \"border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30\"\n }`}\n >\n <div className=\"flex items-start gap-2 mb-2\">\n {isDiagnosing ? (\n <Spinner size=\"sm\" />\n ) : (\n <span\n className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${\n isSuccess\n ? \"bg-success text-white\"\n : \"bg-danger text-white\"\n }`}\n >\n {isSuccess ? \"✓\" : \"✗\"}\n </span>\n )}\n <div className=\"flex-1 min-w-0\">\n <div className=\"font-semibold text-sm text-foreground break-words\">\n {result.description}\n </div>\n <div className=\"text-xs text-default-500 mt-0.5 break-all\">\n {result.targetIp}:{result.targetPort}\n </div>\n </div>\n <Chip\n className=\"flex-shrink-0\"\n color={\n isDiagnosing\n ? \"warning\"\n : isSuccess\n ? \"success\"\n : \"danger\"\n }\n size=\"sm\"\n variant=\"flat\"\n >\n {isDiagnosing\n ? \"诊断中\"\n : isSuccess\n ? \"成功\"\n : \"失败\"}\n </Chip>\n </div>\n\n {isSuccess ? (\n <div className=\"grid grid-cols-3 gap-2 mt-2 pt-2 border-t border-divider\">\n <div className=\"text-center\">\n <div className=\"text-lg font-bold text-primary\">\n {result.averageTime?.toFixed(0)}\n </div>\n <div className=\"text-xs text-default-500\">\n 延迟(ms)\n </div>\n </div>\n <div className=\"text-center\">\n <div\n className={`text-lg font-bold ${\n (result.packetLoss || 0) > 0\n ? \"text-warning\"\n : \"text-success\"\n }`}\n >\n {result.packetLoss?.toFixed(1)}%\n </div>\n <div className=\"text-xs text-default-500\">\n 丢包率\n </div>\n </div>\n <div className=\"text-center\">\n {quality && (\n <>\n <Chip\n className=\"text-xs whitespace-nowrap\"\n color={quality.color as any}\n size=\"sm\"\n variant=\"flat\"\n >\n {quality.text}\n </Chip>\n <div className=\"text-xs text-default-500 mt-0.5\">\n 质量\n </div>\n </>\n )}\n </div>\n </div>\n ) : (\n <div className=\"mt-2 pt-2 border-t border-divider\">\n <div\n className={`text-xs ${\n isDiagnosing\n ? \"text-warning\"\n : \"text-danger\"\n }`}\n >\n {isDiagnosing\n ? result.message || \"诊断中...\"\n : result.message || \"连接失败\"}\n </div>\n </div>\n )}\n </div>\n );\n })}\n </div>\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 </div>\n\n {/* 失败详情(仅桌面端显示,移动端已在卡片中显示) */}\n {diagnosisResult.results.some(\n (r) => r.success === false && !r.diagnosing,\n ) && (\n <div className=\"space-y-2 hidden md:block\">\n <h4 className=\"text-sm font-semibold text-danger\">\n 失败详情\n </h4>\n <div className=\"space-y-2\">\n {diagnosisResult.results\n .filter((r) => r.success === false && !r.diagnosing)\n .map((result, index) => (\n <Alert\n key={index}\n className=\"text-xs\"\n color=\"danger\"\n description={result.message || \"连接失败\"}\n title={result.description}\n variant=\"flat\"\n />\n ))}\n </div>\n </div>\n )}\n </div>\n ) : (\n <div className=\"text-center py-16\">\n <div className=\"w-16 h-16 bg-default-100 rounded-full flex items-center justify-center mx-auto mb-4\">\n <svg\n aria-hidden=\"true\"\n className=\"w-8 h-8 text-default-400\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n d=\"M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={1.5}\n />\n </svg>\n </div>\n <h3 className=\"text-lg font-semibold text-foreground\">\n 暂无诊断数据\n </h3>\n </div>\n )}\n </ModalBody>\n <ModalFooter className=\"bg-content1 border-t border-divider\">\n <Button variant=\"light\" onPress={onClose}>\n 关闭\n </Button>\n {currentDiagnosisForward && (\n <Button\n color=\"primary\"\n isLoading={diagnosisLoading}\n onPress={() => handleDiagnose(currentDiagnosisForward)}\n >\n 重新诊断\n </Button>\n )}\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n {/* 批量删除确认模态框 */}\n <Modal\n isOpen={batchDeleteModalOpen}\n onOpenChange={setBatchDeleteModalOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader>确认删除</ModalHeader>\n <ModalBody>\n <p>\n 确定要删除选中的 {selectedIds.size} 项转发吗?此操作不可撤销。\n </p>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"danger\"\n isLoading={batchLoading}\n onPress={handleBatchDelete}\n >\n 确认删除\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n {/* 批量换隧道模态框 */}\n <Modal\n isOpen={batchChangeTunnelModalOpen}\n onOpenChange={setBatchChangeTunnelModalOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader>隧道</ModalHeader>\n <ModalBody>\n <p className=\"mb-4\">\n 将选中的 {selectedIds.size} 项转发迁移到新隧道:\n </p>\n <Select\n label=\"目标隧道\"\n placeholder=\"请选择目标隧道\"\n selectedKeys={\n batchTargetTunnelId ? [String(batchTargetTunnelId)] : []\n }\n onSelectionChange={(keys) => {\n const selected = Array.from(keys)[0];\n\n setBatchTargetTunnelId(selected ? Number(selected) : null);\n }}\n >\n {tunnels.map((tunnel) => (\n <SelectItem key={String(tunnel.id)}>\n {tunnel.name}\n </SelectItem>\n ))}\n </Select>\n </ModalBody>\n <ModalFooter>\n <Button variant=\"light\" onPress={onClose}>\n 取消\n </Button>\n <Button\n color=\"primary\"\n isDisabled={!batchTargetTunnelId}\n isLoading={batchLoading}\n onPress={handleBatchChangeTunnel}\n >\n 确认换隧道\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n\n {/* 筛选模态框 */}\n <Modal\n isOpen={isFilterModalOpen}\n placement=\"center\"\n size=\"md\"\n onOpenChange={setIsFilterModalOpen}\n >\n <ModalContent>\n {(onClose) => (\n <>\n <ModalHeader className=\"flex flex-col gap-1\">\n 筛选条件\n </ModalHeader>\n <ModalBody>\n <div className=\"flex flex-col gap-4 py-2\">\n <div className=\"flex flex-col gap-2\">\n <p className=\"text-sm font-medium\">按用户筛选</p>\n <Select\n aria-label=\"筛选用户\"\n className=\"w-full\"\n selectedKeys={[filterUserId]}\n variant=\"bordered\"\n onSelectionChange={(keys) => {\n const key = Array.from(keys)[0] as string;\n\n setFilterUserId(key || \"all\");\n }}\n >\n <SelectItem key=\"all\">全部用户</SelectItem>\n {uniqueUsers.map((user) => (\n <SelectItem key={user.id.toString()}>\n {user.name}\n </SelectItem>\n ))}\n </Select>\n </div>\n <div className=\"flex flex-col gap-2\">\n <p className=\"text-sm font-medium\">按隧道筛选</p>\n <Select\n aria-label=\"筛选隧道\"\n className=\"w-full\"\n selectedKeys={[filterTunnelId]}\n variant=\"bordered\"\n onSelectionChange={(keys) => {\n const key = Array.from(keys)[0] as string;\n\n setFilterTunnelId(key || \"all\");\n }}\n >\n <SelectItem key=\"all\">全部隧道</SelectItem>\n {tunnels.map((tunnel) => (\n <SelectItem key={tunnel.id.toString()}>\n {tunnel.name}\n </SelectItem>\n ))}\n </Select>\n </div>\n </div>\n </ModalBody>\n <ModalFooter>\n <Button\n color=\"default\"\n variant=\"flat\"\n onPress={() => {\n resetFilterUserId();\n resetFilterTunnelId();\n }}\n >\n 重置\n </Button>\n <Button color=\"primary\" onPress={onClose}>\n 完成\n </Button>\n </ModalFooter>\n </>\n )}\n </ModalContent>\n </Modal>\n </AnimatedPage>\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<PanelAddress[]>([]);\n const [newName, setNewName] = useState(\"\");\n const [newAddress, setNewAddress] = useState(\"\");\n const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(\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 <div className=\"min-h-screen bg-gray-50 dark:bg-black\">\n {/* 顶部导航 */}\n <div className=\"bg-white dark:bg-black border-b border-gray-200 dark:border-gray-700\">\n <div className=\"max-w-4xl mx-auto px-4 py-4\">\n <div className=\"flex items-center gap-3\">\n <Button\n isIconOnly\n aria-label=\"返回上一页\"\n className=\"text-gray-600 dark:text-gray-300\"\n variant=\"light\"\n onPress={() => navigate(-1)}\n >\n <svg\n aria-hidden=\"true\"\n className=\"w-5 h-5\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n d=\"M15 19l-7-7 7-7\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n />\n </svg>\n </Button>\n <h1 className=\"text-xl font-semibold text-gray-900 dark:text-white\">\n 面板设置\n </h1>\n </div>\n </div>\n </div>\n\n {/* 内容区域 */}\n <div className=\"max-w-4xl mx-auto px-4 py-6\">\n <div className=\"space-y-6\">\n <Card className=\"border border-gray-200 dark:border-gray-700\">\n <CardBody className=\"p-6\">\n <h2 className=\"text-lg font-medium text-gray-900 dark:text-white mb-4\">\n 更新设置\n </h2>\n <div className=\"space-y-2\">\n <Select\n label=\"更新通道\"\n selectedKeys={[updateChannel]}\n onSelectionChange={(keys) => {\n const selected =\n (Array.from(keys)[0] as UpdateReleaseChannel) || \"stable\";\n\n handleUpdateChannelChange(selected);\n }}\n >\n <SelectItem key=\"stable\" textValue=\"stable\">\n 稳定版(纯数字版本,如 2.1.4)\n </SelectItem>\n <SelectItem key=\"dev\" textValue=\"dev\">\n 开发版(含 alpha / beta / rc)\n </SelectItem>\n </Select>\n <p className=\"text-xs text-gray-500 dark:text-gray-400\">\n 版本提示会根据该通道检查最新版本。\n </p>\n </div>\n </CardBody>\n </Card>\n\n {/* 添加新地址 */}\n <Card className=\"border border-gray-200 dark:border-gray-700\">\n <CardBody className=\"p-6\">\n <h2 className=\"text-lg font-medium text-gray-900 dark:text-white mb-4\">\n 添加新面板地址\n </h2>\n <div className=\"space-y-4\">\n <div className=\"grid grid-cols-1 sm:grid-cols-2 gap-4\">\n <Input\n label=\"名称\"\n placeholder=\"请输入面板名称\"\n value={newName}\n onChange={(e) => setNewName(e.target.value)}\n />\n <Input\n label=\"地址\"\n placeholder=\"http://192.168.1.100:3000\"\n value={newAddress}\n onChange={(e) => setNewAddress(e.target.value)}\n />\n </div>\n <Button color=\"primary\" onPress={addPanelAddress}>\n 添加\n </Button>\n </div>\n </CardBody>\n </Card>\n\n {/* 地址列表 */}\n <Card className=\"border border-gray-200 dark:border-gray-700\">\n <CardBody className=\"p-6\">\n <h2 className=\"text-lg font-medium text-gray-900 dark:text-white mb-4\">\n 已保存的面板地址\n </h2>\n {panelAddresses.length === 0 ? (\n <p className=\"text-gray-500 dark:text-gray-400 text-center py-8\">\n 暂无保存的面板地址\n </p>\n ) : (\n <div className=\"space-y-3\">\n {panelAddresses.map((panel, index) => (\n <div\n key={index}\n className=\"border border-gray-200 dark:border-gray-600 rounded-lg p-4\"\n >\n <div className=\"flex items-center justify-between\">\n <div className=\"flex-1\">\n <div className=\"flex items-center gap-2\">\n <span className=\"font-medium text-gray-900 dark:text-white\">\n {panel.name}\n </span>\n {panel.inx && (\n <span className=\"px-2 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300 text-xs rounded\">\n 当前\n </span>\n )}\n </div>\n <p className=\"text-sm text-gray-500 dark:text-gray-400 mt-1\">\n {panel.address}\n </p>\n </div>\n <div className=\"flex items-center gap-2\">\n {!panel.inx && (\n <Button\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={() => setCurrentPanel(panel.name)}\n >\n 设为当前\n </Button>\n )}\n <Button\n color=\"danger\"\n size=\"sm\"\n variant=\"light\"\n onPress={() => handleDeletePanelAddress(panel.name)}\n >\n 删除\n </Button>\n </div>\n </div>\n </div>\n ))}\n </div>\n )}\n </CardBody>\n </Card>\n </div>\n </div>\n </div>\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<PanelAddress[]>([]);\n const [newName, setNewName] = useState(\"\");\n const [newAddress, setNewAddress] = useState(\"\");\n const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(\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 <div className=\"min-h-screen bg-gray-50 dark:bg-black\">\n {/* 顶部导航 */}\n <div className=\"bg-white dark:bg-black border-b border-gray-200 dark:border-gray-700\">\n <div className=\"max-w-4xl mx-auto px-4 py-4\">\n <div className=\"flex items-center gap-3\">\n <Button\n isIconOnly\n aria-label=\"返回上一页\"\n className=\"text-gray-600 dark:text-gray-300\"\n variant=\"light\"\n onPress={() => navigate(-1)}\n >\n <svg\n aria-hidden=\"true\"\n className=\"w-5 h-5\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n d=\"M15 19l-7-7 7-7\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n />\n </svg>\n </Button>\n <h1 className=\"text-xl font-semibold text-gray-900 dark:text-white\">\n 面板设置\n </h1>\n </div>\n </div>\n </div>\n\n {/* 内容区域 */}\n <div className=\"max-w-4xl mx-auto px-4 py-6\">\n <div className=\"space-y-6\">\n <Card className=\"border border-gray-200 dark:border-gray-700\">\n <CardBody className=\"p-6\">\n <h2 className=\"text-lg font-medium text-gray-900 dark:text-white mb-4\">\n 更新设置\n </h2>\n <div className=\"space-y-2\">\n <Select\n label=\"更新通道\"\n selectedKeys={[updateChannel]}\n onSelectionChange={(keys) => {\n const selected =\n (Array.from(keys)[0] as UpdateReleaseChannel) || \"stable\";\n\n handleUpdateChannelChange(selected);\n }}\n >\n <SelectItem key=\"stable\" textValue=\"stable\">\n 稳定版(纯数字版本,如 2.1.4)\n </SelectItem>\n <SelectItem key=\"dev\" textValue=\"dev\">\n 开发版(含 alpha / beta / rc)\n </SelectItem>\n </Select>\n <p className=\"text-xs text-gray-500 dark:text-gray-400\">\n 版本提示会根据该通道检查最新版本。\n </p>\n </div>\n </CardBody>\n </Card>\n\n <Card className=\"border border-gray-200 dark:border-gray-700\">\n <CardBody className=\"p-6\">\n <h2 className=\"text-lg font-medium text-gray-900 dark:text-white mb-4\">\n 显示设置\n </h2>\n <div className=\"rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3\">\n <div className=\"flex items-center justify-between gap-4\">\n <div>\n <p className=\"text-sm font-medium text-gray-900 dark:text-white\">\n 转发页面精简模式\n </p>\n <p className=\"mt-1 text-xs text-gray-500 dark:text-gray-400\">\n 开启后,转发页面列表使用 2.1.6-alpha8 样式。\n </p>\n </div>\n <Switch\n color=\"primary\"\n isDisabled={!admin || forwardCompactModeSaving}\n isSelected={forwardCompactMode}\n onValueChange={handleForwardCompactModeChange}\n />\n </div>\n {!admin && (\n <p className=\"mt-2 text-xs text-amber-600 dark:text-amber-400\">\n 仅管理员可修改该全局配置。\n </p>\n )}\n </div>\n </CardBody>\n </Card>\n\n {/* 添加新地址 */}\n <Card className=\"border border-gray-200 dark:border-gray-700\">\n <CardBody className=\"p-6\">\n <h2 className=\"text-lg font-medium text-gray-900 dark:text-white mb-4\">\n 添加新面板地址\n </h2>\n <div className=\"space-y-4\">\n <div className=\"grid grid-cols-1 sm:grid-cols-2 gap-4\">\n <Input\n label=\"名称\"\n placeholder=\"请输入面板名称\"\n value={newName}\n onChange={(e) => setNewName(e.target.value)}\n />\n <Input\n label=\"地址\"\n placeholder=\"http://192.168.1.100:3000\"\n value={newAddress}\n onChange={(e) => setNewAddress(e.target.value)}\n />\n </div>\n <Button color=\"primary\" onPress={addPanelAddress}>\n 添加\n </Button>\n </div>\n </CardBody>\n </Card>\n\n {/* 地址列表 */}\n <Card className=\"border border-gray-200 dark:border-gray-700\">\n <CardBody className=\"p-6\">\n <h2 className=\"text-lg font-medium text-gray-900 dark:text-white mb-4\">\n 已保存的面板地址\n </h2>\n {panelAddresses.length === 0 ? (\n <p className=\"text-gray-500 dark:text-gray-400 text-center py-8\">\n 暂无保存的面板地址\n </p>\n ) : (\n <div className=\"space-y-3\">\n {panelAddresses.map((panel, index) => (\n <div\n key={index}\n className=\"border border-gray-200 dark:border-gray-600 rounded-lg p-4\"\n >\n <div className=\"flex items-center justify-between\">\n <div className=\"flex-1\">\n <div className=\"flex items-center gap-2\">\n <span className=\"font-medium text-gray-900 dark:text-white\">\n {panel.name}\n </span>\n {panel.inx && (\n <span className=\"px-2 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300 text-xs rounded\">\n 当前\n </span>\n )}\n </div>\n <p className=\"text-sm text-gray-500 dark:text-gray-400 mt-1\">\n {panel.address}\n </p>\n </div>\n <div className=\"flex items-center gap-2\">\n {!panel.inx && (\n <Button\n color=\"primary\"\n size=\"sm\"\n variant=\"flat\"\n onPress={() => setCurrentPanel(panel.name)}\n >\n 设为当前\n </Button>\n )}\n <Button\n color=\"danger\"\n size=\"sm\"\n variant=\"light\"\n onPress={() => handleDeletePanelAddress(panel.name)}\n >\n 删除\n </Button>\n </div>\n </div>\n </div>\n ))}\n </div>\n )}\n </CardBody>\n </Card>\n </div>\n </div>\n </div>\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"
}
]
}
]
}