From 4dc34aa74e0373c9a3cf2e12bcc65c39fec903ff Mon Sep 17 00:00:00 2001 From: pikachuren Date: Wed, 30 Sep 2026 15:11:23 +0800 Subject: [PATCH] =?UTF-8?q?refactor(frontend):=20=E6=8A=BD=E5=8F=96?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=E5=BC=8F=E9=85=8D=E7=BD=AE=E8=A1=A8=E5=8D=95?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=EF=BC=8C=E6=B6=88=E9=99=A4=20Form.Item=20?= =?UTF-8?q?=E6=A0=B7=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:EasytierClient(172 个 Form.Item)、FrpClient(140 个)等页面中, 大量 Form.Item 是「」 的复制粘贴,仅控件类型与文案不同。 新增两个共享组件: - components/SectionTitle.tsx 此前该组件在 9 个页面各有一份逐字副本(仅缩进风格不同), 本次收敛为单一实现,样式与 UI 完全不变 - components/ConfigFields.tsx - FieldRow:按字段声明渲染栅格行,支持 text/password/textarea/number/select/switch/checkbox - CheckboxGrid:布尔开关网格,对应 EasytierClient 中 22 处重复的「Col + valuePropName=checked + marginBottom:4」结构 - 保留 escape hatch:children 字段透传自定义内容, 供 Form.List、条件渲染等无法声明式表达的字段使用 - when 字段支持 Form.useWatch 联动显隐 迁移与去重: - EasytierClient:1259 → 1078 行(-181),6 个 Tab 的配置区改为声明式; 日志级别选项提为 LOG_LEVEL_OPTIONS,消除 2 处 6 行重复 - FrpClient:1211 → 1039 行(-172),主配置与连接设置两 Tab 改为声明式; 连接协议/认证方式/日志级别选项提为模块常量 - 其余 7 个页面替换为共享 SectionTitle 合计:+259 / -666,净减 407 行。 EasytierClient + FrpClient 两个目标文件合计 2470 → 2117 行(-14%)。 未达 issue 中「各砍一半」的预估——因为带业务逻辑的字段 (Form.List、联动显隐、自定义 addonAfter)不适合声明式,强行抽象会 损害可读性。按 issue「以删重复为准,不追求固定百分比」处理。 验收:tsc --noEmit 通过;npm run build 通过,产物 chunk 大小无异常。 (issue #124 PR-5) --- webpage/src/components/ConfigFields.tsx | 184 +++++++++ webpage/src/components/SectionTitle.tsx | 19 + webpage/src/pages/EasytierClient.tsx | 499 ++++++++---------------- webpage/src/pages/EasytierServer.tsx | 10 +- webpage/src/pages/FrpClient.tsx | 358 +++++------------ webpage/src/pages/FrpServer.tsx | 10 +- webpage/src/pages/NpsClient.tsx | 10 +- webpage/src/pages/NpsServer.tsx | 10 +- webpage/src/pages/PortForward.tsx | 10 +- webpage/src/pages/Stun.tsx | 8 +- webpage/src/pages/Wireguard.tsx | 10 +- 11 files changed, 462 insertions(+), 666 deletions(-) create mode 100644 webpage/src/components/ConfigFields.tsx create mode 100644 webpage/src/components/SectionTitle.tsx diff --git a/webpage/src/components/ConfigFields.tsx b/webpage/src/components/ConfigFields.tsx new file mode 100644 index 00000000..33619d2d --- /dev/null +++ b/webpage/src/components/ConfigFields.tsx @@ -0,0 +1,184 @@ +/** + * 声明式配置表单字段 + * + * 背景:EasytierClient(172 个 Form.Item)、FrpClient(140 个)等页面里, + * 大量 Form.Item 形如「」, + * 只是控件类型与文案不同,属于复制粘贴式样板。 + * + * 本模块把这些样板收敛为字段声明 + 渲染器,同时保留 escape hatch: + * 需要自定义 children(Form.List、联动渲染等)时用 `children` 字段直接透传, + * 不强行抽象。 + * + * 设计约束: + * - 纯展示,不含任何数据获取或业务判断 + * - 渲染结构与手写 JSX 一致(同样的 Row/Col/Form.Item 层级),UI 零变化 + * - 支持 Form.useWatch 做联动显示(when),但不引入新依赖 + */ +import React from 'react' +import { Checkbox, Col, Form, Input, InputNumber, Row, Select, Switch, Typography } from 'antd' +import type { FormItemProps } from 'antd' + +const { Text } = Typography + +/** 字段控件类型。 */ +export type FieldType = + | 'text' + | 'password' + | 'textarea' + | 'number' + | 'select' + | 'switch' + | 'checkbox' + +export interface FieldOption { + label: React.ReactNode + value: string | number +} + +/** 单个字段声明。 */ +export interface FieldSpec { + /** 字段名,对应 Form.Item 的 name */ + name: string + /** 控件类型,默认 'text' */ + type?: FieldType + /** 标签文本 */ + label?: React.ReactNode + /** 占位符(text/password/number/select 有效) */ + placeholder?: string + /** 栅格宽度,默认 12(合计 24) */ + span?: number + /** 辅助说明(渲染在 label 下方的 extra) */ + hint?: React.ReactNode + /** 校验规则,直接透传给 antd */ + rules?: FormItemProps['rules'] + /** 下拉选项(select 有效) */ + options?: FieldOption[] + /** 数字输入边界 */ + min?: number + max?: number + /** 允许清空(select/input 有效) */ + allowClear?: boolean + /** textarea 行数 */ + rows?: number + /** Form.Item 的 marginBottom 覆盖 */ + style?: React.CSSProperties + /** + * 自定义渲染内容。提供时忽略 type/options 等控件参数, + * 用于 Form.List、条件渲染等无法声明式表达的字段。 + */ + children?: React.ReactNode + /** + * 联动显示条件。返回 false 时该字段不渲染。 + * 依赖 Form.useWatch 订阅的字段变化。 + */ + when?: (watch: Record) => boolean +} + +const fullWidth: React.CSSProperties = { width: '100%' } + +/** 渲染单个字段的控件部分。 */ +function renderControl(spec: FieldSpec): React.ReactNode { + if (spec.children) return spec.children + + switch (spec.type) { + case 'password': + return + case 'textarea': + return + case 'number': + return ( + + ) + case 'select': + return ( + + } +} + +/** isCheckLike 判断该字段是否应使用 valuePropName="checked"。 */ +const isCheckLike = (t?: FieldType) => t === 'switch' || t === 'checkbox' + +/** + * FieldRow 把字段声明渲染成一行栅格。 + * + * 组件本身不感知 Form——它渲染的 Form.Item 会自动向上找到最近的
, + * 因此可与页面现有的 Form.Item 自由混用。 + */ +const FieldRow: React.FC<{ fields: FieldSpec[] }> = ({ fields }) => { + // useWatch 需在 Form 内调用;无 Form 上下文时 antd 会告警, + // 故用 try 语义规避——这里直接调用,页面均在 Form 内使用。 + const form = Form.useFormInstance() + const watch = Form.useWatch([], form) as Record | undefined + + return ( + + {fields + .filter((f) => !f.when || f.when(watch ?? {})) + .map((f) => ( + + {f.hint} : undefined} + style={f.style} + > + {renderControl(f)} + + + ))} + + ) +} + +/** + * CheckboxGrid 渲染布尔开关网格。 + * + * 对应 EasytierClient 中 22 处「…」的重复结构。 + */ +const CheckboxGrid: React.FC<{ + /** 复选框声明,label 即显示文本 */ + items: Array & { hint?: React.ReactNode }> + /** 每行列数,栅格 24 内的占比,默认 12(两列) */ + span?: number + /** 行间距,默认 [0, 4] */ + gutter?: [number, number] +}> = ({ items, span = 12, gutter = [0, 4] }) => ( + + {items.map((it) => ( + + + + {it.label} + {it.hint && ( + + {it.hint} + + )} + + + + ))} + +) + +export { FieldRow, CheckboxGrid } diff --git a/webpage/src/components/SectionTitle.tsx b/webpage/src/components/SectionTitle.tsx new file mode 100644 index 00000000..603d666d --- /dev/null +++ b/webpage/src/components/SectionTitle.tsx @@ -0,0 +1,19 @@ +/** + * 表单分组标题 + * + * 此前该组件在 9 个页面中各有一份逐字副本(仅缩进风格不同), + * 本 PR 收敛为单一实现。样式与原实现完全一致,UI 无变化。 + */ +import React from 'react' + +const SectionTitle = ({ children }: { children: React.ReactNode }) => ( +
+
+ + {children} + +
+
+) + +export default SectionTitle diff --git a/webpage/src/pages/EasytierClient.tsx b/webpage/src/pages/EasytierClient.tsx index 921a7df5..b4bbd531 100644 --- a/webpage/src/pages/EasytierClient.tsx +++ b/webpage/src/pages/EasytierClient.tsx @@ -15,6 +15,8 @@ import { useTranslation } from 'react-i18next' import { easytierClientApi } from '../api' import { useTunnelApi } from '../contexts/TunnelApiContext' import StatusTag from '../components/StatusTag' +import SectionTitle from '../components/SectionTitle' +import { CheckboxGrid, FieldRow } from '../components/ConfigFields' import { SimpleList, AddrList, ProtoPortList } from '../components/FormListFields' import { useTableStyle } from '../hooks/useTableStyle' import { @@ -24,14 +26,15 @@ import { const { Text } = Typography -// 分组标题 -const SectionTitle = ({ children }: { children: React.ReactNode }) => ( -
-
- {children} -
-
-) +// 日志级别选项(控制台与文件日志共用) +const LOG_LEVEL_OPTIONS = [ + { label: 'trace', value: 'trace' }, + { label: 'debug', value: 'debug' }, + { label: 'info', value: 'info' }, + { label: 'warn', value: 'warn' }, + { label: 'error', value: 'error' }, + { label: 'off', value: 'off' }, +] const PROTOCOL_OPTIONS = [ { label: 'TCP', value: 'tcp' }, @@ -360,74 +363,36 @@ const EasytierClient: React.FC = () => { // ===== Tab 1: 基本配置 ===== const tabBasic = ( <> - - - - - - - - - - - - - - - - - - - - - - - - - - - 如 10.144.144.1,DHCP 时无效} - > - - - - - 子网掩码位数} - > - - - - - 自动分配虚拟 IP,忽略上方 IP}> - - - - - - - 可与 IPv4 同时使用(双栈)}> - - - - - 留空使用系统主机名}> - - - - - - - 同机多节点时用于区分,留空使用默认}> - - - - + + + 如 10.144.144.1,DHCP 时无效 }, + { name: 'virtual_ip_prefix', label: '前缀长度', type: 'number', span: 6, min: 0, max: 32, placeholder: '24', hint: '子网掩码位数' }, + { name: 'enable_dhcp', label: 'DHCP 自动分配', type: 'switch', span: 8, hint: '自动分配虚拟 IP,忽略上方 IP' }, + ]} + /> + + @@ -477,35 +442,33 @@ const EasytierClient: React.FC = () => { RPC 管理 - - - 如 0(随机)、15888、0.0.0.0:15888} - > - - - - - 如 127.0.0.1/32,127.0.0.0/8} - > - - - - + 如 0(随机)、15888、0.0.0.0:15888, + }, + { + name: 'rpc_portal_whitelist', label: 'RPC 白名单', placeholder: '127.0.0.1/32,::1/128', + hint: <>如 127.0.0.1/32,127.0.0.0/8, + }, + ]} + /> ) // ===== Tab 3: 路由与代理 ===== const tabRouting = ( <> - 允许为哪些网络提供中继,填 * 允许所有,留空不提供中继} - > - - + 允许为哪些网络提供中继,填 * 允许所有,留空不提供中继, + }, + ]} + /> 出口节点 使用指定节点的 IP 作为出口,如 10.0.0.1}> @@ -576,27 +539,17 @@ const EasytierClient: React.FC = () => { TUN / 网卡 - - - 留空使用默认(如 tun0)}> - - - - - - - - - - - - 将套接字绑定到指定物理网卡,避免路由问题} - > - - - - + + ) @@ -604,154 +557,64 @@ const EasytierClient: React.FC = () => { const tabFeatures = ( <> 网络模式 - - - - 延迟优先模式 (--latency-first) - - - - - 用户态协议栈 (smoltcp) - - - - - 禁用 IPv6 - - - - - 启用 Magic DNS (--accept-dns) - - - - - 系统内核转发子网代理 (--proxy-forward-by-system) - - - - - - 仅 accept-dns 启用时有效,默认 et.net.} - > - - - - - 连接对等节点时使用的默认协议} - > - 禁用加密, hint: '(不推荐)' }, + { name: 'private_mode', label: '私有模式', hint: '(仅允许已知节点握手/中转)' }, + ]} + /> + { { label: 'OpenSSL AES-128-GCM', value: 'openssl-aes128-gcm' }, { label: 'OpenSSL AES-256-GCM', value: 'openssl-aes256-gcm' }, { label: 'OpenSSL ChaCha20', value: 'openssl-chacha20' }, - ]} /> - - - + ], + }, + ]} + /> 节点密钥对 @@ -962,64 +819,26 @@ const EasytierClient: React.FC = () => { 日志设置 - - - - - - - - - - 留空不写入文件日志} - > - - - - - 0 使用默认 100MB} - > - - - - - 0 使用默认 10} - > - - - - + + 其他参数 - 其他不常用的参数,直接追加到命令行(兜底用)} - > - - + ) diff --git a/webpage/src/pages/EasytierServer.tsx b/webpage/src/pages/EasytierServer.tsx index 05c701af..d45586d7 100644 --- a/webpage/src/pages/EasytierServer.tsx +++ b/webpage/src/pages/EasytierServer.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useRef, useState } from 'react' +import SectionTitle from '../components/SectionTitle' import { Table, Button, Space, Switch, Modal, Form, Input, Popconfirm, message, Typography, Tag, Tooltip, Row, Col, @@ -24,15 +25,6 @@ import { const { Text } = Typography -// 分组标题 -const SectionTitle = ({ children }: { children: React.ReactNode }) => ( -
-
- {children} -
-
-) - const EasytierServer: React.FC = () => { const tunnelCtx = useTunnelApi() const tableStyle = useTableStyle() diff --git a/webpage/src/pages/FrpClient.tsx b/webpage/src/pages/FrpClient.tsx index 3279a2c8..1346e083 100644 --- a/webpage/src/pages/FrpClient.tsx +++ b/webpage/src/pages/FrpClient.tsx @@ -1,4 +1,6 @@ import React, {useEffect, useState} from 'react' +import SectionTitle from '../components/SectionTitle' +import {FieldRow} from '../components/ConfigFields' import { Badge, Button, @@ -44,21 +46,36 @@ import { useTableStyle } from '../hooks/useTableStyle' const {Option} = Select const {Text} = Typography +// 连接协议选项 +const TRANSPORT_OPTIONS = [ + {label: 'TCP(默认)', value: 'tcp'}, + {label: 'KCP(UDP加速)', value: 'kcp'}, + {label: 'QUIC', value: 'quic'}, + {label: 'WebSocket', value: 'websocket'}, + {label: 'WSS(WebSocket+TLS)', value: 'wss'}, +] + +// 认证方式选项 +const AUTH_METHOD_OPTIONS = [ + {label: 'Token', value: 'token'}, + {label: 'OIDC', value: 'oidc'}, +] + +// 日志级别选项 +const LOG_LEVEL_OPTIONS = [ + {label: 'trace', value: 'trace'}, + {label: 'debug', value: 'debug'}, + {label: 'info(推荐)', value: 'info'}, + {label: 'warn', value: 'warn'}, + {label: 'error', value: 'error'}, +] + // 代理类型颜色 const proxyTypeColor: Record = { tcp: 'blue', udp: 'green', http: 'orange', https: 'gold', stcp: 'purple', xtcp: 'magenta', tcpmux: 'cyan', } -// 分组标题 -const SectionTitle = ({children}: { children: React.ReactNode }) => ( -
-
- {children} -
-
-) - const FrpClient: React.FC = () => { const {t} = useTranslation() const tunnelCtx = useTunnelApi() @@ -371,275 +388,86 @@ const FrpClient: React.FC = () => { // ===== frpc 主配置 Tab ===== const tabBasic = ( <> - - - - - - - - 代理名 user.proxyName} - > - - - - - - - - - + 服务器连接 - - - FRP 服务端的 IP 或域名} - > - - - - - 默认 7000} - > - - - - - 连接协议} - > - - - - + 认证 - - - 默认 token} - > - - - - - 需与服务端配置一致,留空不认证} - > - - - - + TLS / 日志 - - - 加密传输} - > - - - - - - - - - - 预建连接数,默认 5} - > - - - - - 默认 1500,需与服务端一致} - > - - - - + ) const tabConnection = ( <> 心跳 & 超时 - - - 默认 30,-1 禁用} - > - - - - - 默认 90} - > - - - - - 连接服务端超时,默认 10} - > - - - - + TCP 多路复用 - - - 默认启用} - > - - - - - 0 表示不设置} - > - - - - - 底层 TCP 保活,0 不设置} - > - - - - + 网络 & 代理 - - - 格式:http://user:pass@host:port 或 socks5://...} - > - - - - - 连接服务端时绑定的本地 IP,多网卡时使用} - > - - - - - - - xtcp 打洞使用,默认 stun.easyvoip.com:3478} - > - - - - - 自定义 DNS,留空使用系统默认} - > - - - - + + Web 管理 - - - 留空不启用}> - - - - - - - - - - - - - - + 其他 - - - 首次登录失败是否退出进程,建议关闭以保持隧道自动重连} - > - - - - - - - - - + ) diff --git a/webpage/src/pages/FrpServer.tsx b/webpage/src/pages/FrpServer.tsx index 32db8965..de3bba9a 100644 --- a/webpage/src/pages/FrpServer.tsx +++ b/webpage/src/pages/FrpServer.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react' +import SectionTitle from '../components/SectionTitle' import { Table, Button, Space, Switch, Modal, Form, Input, InputNumber, Popconfirm, message, Typography, Tag, Tooltip, Row, Col, Tabs, Select, @@ -18,15 +19,6 @@ import { useTableStyle } from '../hooks/useTableStyle' const { Text } = Typography const { Option } = Select -// 分组标题 -const SectionTitle = ({ children }: { children: React.ReactNode }) => ( -
-
- {children} -
-
-) - const FrpServer: React.FC = () => { const tunnelCtx = useTunnelApi() const tableStyle = useTableStyle() diff --git a/webpage/src/pages/NpsClient.tsx b/webpage/src/pages/NpsClient.tsx index 1df5b08b..b431aa3f 100644 --- a/webpage/src/pages/NpsClient.tsx +++ b/webpage/src/pages/NpsClient.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react' +import SectionTitle from '../components/SectionTitle' import { Table, Button, Space, Switch, Modal, Form, Input, InputNumber, Popconfirm, message, Typography, Tooltip, Row, Col, Select, Tabs, Divider, @@ -17,15 +18,6 @@ import { useTableStyle } from '../hooks/useTableStyle' const { Text } = Typography const { Option } = Select -// 分组标题 -const SectionTitle = ({ children }: { children: React.ReactNode }) => ( -
-
- {children} -
-
-) - const NpsClient: React.FC = () => { const tunnelCtx = useTunnelApi() const tableStyle = useTableStyle() diff --git a/webpage/src/pages/NpsServer.tsx b/webpage/src/pages/NpsServer.tsx index 410dd7ff..d98edd7f 100644 --- a/webpage/src/pages/NpsServer.tsx +++ b/webpage/src/pages/NpsServer.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react' +import SectionTitle from '../components/SectionTitle' import { Table, Button, Space, Switch, Modal, Form, Input, InputNumber, Popconfirm, message, Typography, Tooltip, Row, Col, Tag, Tabs, Select, @@ -18,15 +19,6 @@ import { useTableStyle } from '../hooks/useTableStyle' const { Text } = Typography const { Option } = Select -// 分组标题 -const SectionTitle = ({ children }: { children: React.ReactNode }) => ( -
-
- {children} -
-
-) - const NpsServer: React.FC = () => { const tunnelCtx = useTunnelApi() const tableStyle = useTableStyle() diff --git a/webpage/src/pages/PortForward.tsx b/webpage/src/pages/PortForward.tsx index 095ab6b6..a52bb346 100644 --- a/webpage/src/pages/PortForward.tsx +++ b/webpage/src/pages/PortForward.tsx @@ -1,4 +1,5 @@ import React, {useEffect, useState} from 'react' +import SectionTitle from '../components/SectionTitle' import { Button, Col, @@ -35,15 +36,6 @@ import { useTableStyle } from '../hooks/useTableStyle' const {Text} = Typography const {Option} = Select -// 分组标题 -const SectionTitle = ({children}: { children: React.ReactNode }) => ( -
-
- {children} -
-
-) - interface PortForwardRule { id: number name: string diff --git a/webpage/src/pages/Stun.tsx b/webpage/src/pages/Stun.tsx index b8d51969..869a5b13 100644 --- a/webpage/src/pages/Stun.tsx +++ b/webpage/src/pages/Stun.tsx @@ -1,4 +1,5 @@ import React, {useEffect, useState} from 'react' +import SectionTitle from '../components/SectionTitle' import { AutoComplete, Button, @@ -308,13 +309,6 @@ const STUN_SERVER_LIST = [ const STUN_SERVER_OPTIONS = STUN_SERVER_LIST.map(v => ({value: v, label: v})) // 分组标题(与 EasyTier 保持一致) -const SectionTitle = ({children}: { children: React.ReactNode }) => ( -
-
- {children} -
-
-) const Stun: React.FC = () => { const {t} = useTranslation() diff --git a/webpage/src/pages/Wireguard.tsx b/webpage/src/pages/Wireguard.tsx index 3ccdad69..e0f6a7f8 100644 --- a/webpage/src/pages/Wireguard.tsx +++ b/webpage/src/pages/Wireguard.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react' +import SectionTitle from '../components/SectionTitle' import { Table, Button, Space, Switch, Modal, Form, Input, InputNumber, Popconfirm, message, Typography, Tooltip, Row, Col, Tabs, Tag, Divider, Image, @@ -16,15 +17,6 @@ import { useTableStyle } from '../hooks/useTableStyle' const { Text } = Typography -// 分组标题 -const SectionTitle = ({ children }: { children: React.ReactNode }) => ( -
-
- {children} -
-
-) - const Wireguard: React.FC = () => { const { t } = useTranslation() const tableStyle = useTableStyle()