mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-12 04:06:37 +08:00
888 lines
476 KiB
JSON
888 lines
476 KiB
JSON
{
|
||
"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=\"请输入远程地址,多个地址用换行分隔 例如: 192.168.1.100:8080 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 "{forwardToDelete?.name}"\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=\"请输入远程地址,多个地址用换行分隔 例如: 192.168.1.100:8080 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 "{forwardToDelete?.name}"\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"
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|