From 27a53f6f77262edc42950a1a893222cb6f49fdb1 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 30 May 2026 20:47:31 +0200 Subject: [PATCH] refactor(frontend): break down RoutingTab into sections Extract RoutingTab's presentational pieces into the routing/ folder: helpers.ts (arrJoin/csv/chipPreview/ruleCriteriaChips), types.ts (RuleRow), CriterionRow.tsx, RuleCardList.tsx (mobile card view), and useRoutingColumns.tsx (desktop table columns hook). RoutingTab stays the orchestrator holding rule state, mutate, tag-option memos and the pointer-drag reorder logic, and drops from 550 to 291 lines. No behavior change. --- .../src/pages/xray/routing/CriterionRow.tsx | 17 + .../src/pages/xray/routing/RoutingTab.tsx | 309 ++---------------- .../src/pages/xray/routing/RuleCardList.tsx | 118 +++++++ frontend/src/pages/xray/routing/helpers.ts | 33 ++ frontend/src/pages/xray/routing/types.ts | 16 + .../pages/xray/routing/useRoutingColumns.tsx | 170 ++++++++++ 6 files changed, 379 insertions(+), 284 deletions(-) create mode 100644 frontend/src/pages/xray/routing/CriterionRow.tsx create mode 100644 frontend/src/pages/xray/routing/RuleCardList.tsx create mode 100644 frontend/src/pages/xray/routing/helpers.ts create mode 100644 frontend/src/pages/xray/routing/types.ts create mode 100644 frontend/src/pages/xray/routing/useRoutingColumns.tsx diff --git a/frontend/src/pages/xray/routing/CriterionRow.tsx b/frontend/src/pages/xray/routing/CriterionRow.tsx new file mode 100644 index 00000000..1342d0ad --- /dev/null +++ b/frontend/src/pages/xray/routing/CriterionRow.tsx @@ -0,0 +1,17 @@ +import { Tooltip } from 'antd'; + +import { csv } from './helpers'; + +export default function CriterionRow({ label, value, title }: { label: string; value?: string; title: string }) { + const parts = csv(value); + if (parts.length === 0) return null; + return ( + + + {label} + {parts[0]} + {parts.length > 1 && +{parts.length - 1}} + + + ); +} diff --git a/frontend/src/pages/xray/routing/RoutingTab.tsx b/frontend/src/pages/xray/routing/RoutingTab.tsx index e426baf3..feeffece 100644 --- a/frontend/src/pages/xray/routing/RoutingTab.tsx +++ b/frontend/src/pages/xray/routing/RoutingTab.tsx @@ -1,21 +1,14 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Dropdown, Modal, Space, Table, Tag, Tooltip } from 'antd'; -import { - PlusOutlined, - MoreOutlined, - EditOutlined, - DeleteOutlined, - ExportOutlined, - ClusterOutlined, - ArrowUpOutlined, - ArrowDownOutlined, - HolderOutlined, -} from '@ant-design/icons'; -import type { ColumnsType } from 'antd/es/table'; +import { Button, Modal, Space, Table } from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; import RuleFormModal from './RuleFormModal'; import type { RoutingRule } from './RuleFormModal'; +import RuleCardList from './RuleCardList'; +import { useRoutingColumns } from './useRoutingColumns'; +import { arrJoin } from './helpers'; +import type { RuleRow } from './types'; import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting'; import type { RuleObject } from '@/schemas/routing'; import './RoutingTab.css'; @@ -28,41 +21,6 @@ interface RoutingTabProps { isMobile: boolean; } -interface RuleRow { - key: number; - domain?: string; - ip?: string; - port?: string; - sourcePort?: string; - vlessRoute?: string; - network?: string; - sourceIP?: string; - user?: string; - inboundTag?: string; - protocol?: string; - attrs?: string; - outboundTag?: string; - balancerTag?: string; -} - -function arrJoin(v: unknown): string | undefined { - if (v == null) return undefined; - if (Array.isArray(v)) return v.join(','); - return String(v); -} - -function csv(value?: string): string[] { - if (!value) return []; - return String(value).split(',').map((s) => s.trim()).filter(Boolean); -} - -function chipPreview(value?: string): string { - const parts = csv(value); - if (parts.length === 0) return ''; - if (parts.length === 1) return parts[0]; - return `${parts[0]} +${parts.length - 1}`; -} - export default function RoutingTab({ templateSettings, setTemplateSettings, @@ -268,151 +226,15 @@ export default function RoutingTab({ document.addEventListener('pointercancel', onUp); } - function ruleCriteriaChips(rule: RuleRow) { - const chips: { label: string; value?: string }[] = []; - if (rule.domain) chips.push({ label: 'Domain', value: rule.domain }); - if (rule.ip) chips.push({ label: 'IP', value: rule.ip }); - if (rule.port) chips.push({ label: 'Port', value: rule.port }); - if (rule.sourceIP) chips.push({ label: 'Src IP', value: rule.sourceIP }); - if (rule.sourcePort) chips.push({ label: 'Src Port', value: rule.sourcePort }); - if (rule.network) chips.push({ label: 'L4', value: rule.network }); - if (rule.protocol) chips.push({ label: 'Protocol', value: rule.protocol }); - if (rule.user) chips.push({ label: 'User', value: rule.user }); - if (rule.vlessRoute) chips.push({ label: 'VLESS', value: rule.vlessRoute }); - return chips; - } - - const desktopColumns: ColumnsType = useMemo( - () => [ - { - title: '#', - align: 'center', - width: 100, - key: 'action', - render: (_v, _r, index) => ( -
- onHandlePointerDown(index, ev)} - /> - {index + 1} -
- {!isMobile && ( -
-
- ), - }, - { - title: t('pages.xray.rules.source'), - align: 'left', - width: 180, - key: 'source', - render: (_v, record) => ( -
- {record.sourceIP && } - {record.sourcePort && } - {record.vlessRoute && } - {!record.sourceIP && !record.sourcePort && !record.vlessRoute && } -
- ), - }, - { - title: t('pages.inbounds.network'), - align: 'left', - width: 180, - key: 'network', - render: (_v, record) => ( -
- {record.network && } - {record.protocol && } - {record.attrs && } - {!record.network && !record.protocol && !record.attrs && } -
- ), - }, - { - title: t('pages.xray.rules.dest'), - align: 'left', - key: 'destination', - render: (_v, record) => ( -
- {record.ip && } - {record.domain && } - {record.port && } - {!record.ip && !record.domain && !record.port && } -
- ), - }, - { - title: t('pages.xray.Inbounds'), - align: 'left', - width: 180, - key: 'inbound', - render: (_v, record) => ( -
- {record.inboundTag && } - {record.user && } - {!record.inboundTag && !record.user && } -
- ), - }, - { - title: t('pages.xray.Outbounds'), - align: 'left', - width: 170, - key: 'outbound', - render: (_v, record) => - record.outboundTag ? ( -
- - {record.outboundTag} -
- ) : ( - - ), - }, - { - title: t('pages.xray.Balancers'), - align: 'left', - width: 150, - key: 'balancer', - render: (_v, record) => - record.balancerTag ? ( -
- - {record.balancerTag} -
- ) : ( - - ), - }, - ], - // eslint-disable-next-line react-hooks/exhaustive-deps - [t, isMobile, rows.length], - ); + const desktopColumns = useRoutingColumns({ + isMobile, + rowsLength: rows.length, + onHandlePointerDown, + openEdit, + moveUp, + moveDown, + confirmDelete, + }); return ( <> @@ -423,83 +245,16 @@ export default function RoutingTab({ {isMobile ? ( -
- {rows.length === 0 ? ( -
- ) : ( - rows.map((rule, index) => ( -
draggedIndex ? 'drop-after' : ''}`} - data-row-key={index} - > -
- onHandlePointerDown(index, ev)} - /> - #{index + 1} - {t('edit')}, onClick: () => openEdit(index) }, - { key: 'up', label: , disabled: index === 0, onClick: () => moveUp(index) }, - { key: 'down', label: , disabled: index === rows.length - 1, onClick: () => moveDown(index) }, - { key: 'del', danger: true, label: <> {t('delete')}, onClick: () => confirmDelete(index) }, - ], - }} - > -
- -
-
- {t('pages.xray.Inbounds')} - {rule.inboundTag ? ( - {chipPreview(rule.inboundTag)} - ) : ( - any - )} -
- -
- - {rule.balancerTag ? t('pages.xray.balancer') || 'Balancer' : t('pages.xray.Outbounds')} - - {rule.outboundTag ? ( - - {rule.outboundTag} - - ) : rule.balancerTag ? ( - - {rule.balancerTag} - - ) : ( - - )} -
-
- - {ruleCriteriaChips(rule).length > 0 && ( -
- {ruleCriteriaChips(rule).map((chip) => ( - - - {chip.label} - {chipPreview(chip.value)} - - - ))} -
- )} -
- )) - )} -
+ ) : ( ); } - -function CriterionRow({ label, value, title }: { label: string; value?: string; title: string }) { - const parts = csv(value); - if (parts.length === 0) return null; - return ( - - - {label} - {parts[0]} - {parts.length > 1 && +{parts.length - 1}} - - - ); -} diff --git a/frontend/src/pages/xray/routing/RuleCardList.tsx b/frontend/src/pages/xray/routing/RuleCardList.tsx new file mode 100644 index 00000000..f7bfa601 --- /dev/null +++ b/frontend/src/pages/xray/routing/RuleCardList.tsx @@ -0,0 +1,118 @@ +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown, Tag, Tooltip } from 'antd'; +import { + MoreOutlined, + EditOutlined, + DeleteOutlined, + ExportOutlined, + ClusterOutlined, + ArrowUpOutlined, + ArrowDownOutlined, + HolderOutlined, +} from '@ant-design/icons'; + +import { chipPreview, ruleCriteriaChips } from './helpers'; +import type { RuleRow } from './types'; + +interface RuleCardListProps { + rows: RuleRow[]; + draggedIndex: number | null; + dropTargetIndex: number | null; + onHandlePointerDown: (idx: number, ev: React.PointerEvent) => void; + openEdit: (idx: number) => void; + moveUp: (idx: number) => void; + moveDown: (idx: number) => void; + confirmDelete: (idx: number) => void; +} + +export default function RuleCardList({ + rows, + draggedIndex, + dropTargetIndex, + onHandlePointerDown, + openEdit, + moveUp, + moveDown, + confirmDelete, +}: RuleCardListProps) { + const { t } = useTranslation(); + return ( +
+ {rows.length === 0 ? ( +
+ ) : ( + rows.map((rule, index) => ( +
draggedIndex ? 'drop-after' : ''}`} + data-row-key={index} + > +
+ onHandlePointerDown(index, ev)} + /> + #{index + 1} + {t('edit')}, onClick: () => openEdit(index) }, + { key: 'up', label: , disabled: index === 0, onClick: () => moveUp(index) }, + { key: 'down', label: , disabled: index === rows.length - 1, onClick: () => moveDown(index) }, + { key: 'del', danger: true, label: <> {t('delete')}, onClick: () => confirmDelete(index) }, + ], + }} + > +
+ +
+
+ {t('pages.xray.Inbounds')} + {rule.inboundTag ? ( + {chipPreview(rule.inboundTag)} + ) : ( + any + )} +
+ +
+ + {rule.balancerTag ? t('pages.xray.balancer') || 'Balancer' : t('pages.xray.Outbounds')} + + {rule.outboundTag ? ( + + {rule.outboundTag} + + ) : rule.balancerTag ? ( + + {rule.balancerTag} + + ) : ( + + )} +
+
+ + {ruleCriteriaChips(rule).length > 0 && ( +
+ {ruleCriteriaChips(rule).map((chip) => ( + + + {chip.label} + {chipPreview(chip.value)} + + + ))} +
+ )} +
+ )) + )} +
+ ); +} diff --git a/frontend/src/pages/xray/routing/helpers.ts b/frontend/src/pages/xray/routing/helpers.ts new file mode 100644 index 00000000..56a5fe72 --- /dev/null +++ b/frontend/src/pages/xray/routing/helpers.ts @@ -0,0 +1,33 @@ +import type { RuleRow } from './types'; + +export function arrJoin(v: unknown): string | undefined { + if (v == null) return undefined; + if (Array.isArray(v)) return v.join(','); + return String(v); +} + +export function csv(value?: string): string[] { + if (!value) return []; + return String(value).split(',').map((s) => s.trim()).filter(Boolean); +} + +export function chipPreview(value?: string): string { + const parts = csv(value); + if (parts.length === 0) return ''; + if (parts.length === 1) return parts[0]; + return `${parts[0]} +${parts.length - 1}`; +} + +export function ruleCriteriaChips(rule: RuleRow) { + const chips: { label: string; value?: string }[] = []; + if (rule.domain) chips.push({ label: 'Domain', value: rule.domain }); + if (rule.ip) chips.push({ label: 'IP', value: rule.ip }); + if (rule.port) chips.push({ label: 'Port', value: rule.port }); + if (rule.sourceIP) chips.push({ label: 'Src IP', value: rule.sourceIP }); + if (rule.sourcePort) chips.push({ label: 'Src Port', value: rule.sourcePort }); + if (rule.network) chips.push({ label: 'L4', value: rule.network }); + if (rule.protocol) chips.push({ label: 'Protocol', value: rule.protocol }); + if (rule.user) chips.push({ label: 'User', value: rule.user }); + if (rule.vlessRoute) chips.push({ label: 'VLESS', value: rule.vlessRoute }); + return chips; +} diff --git a/frontend/src/pages/xray/routing/types.ts b/frontend/src/pages/xray/routing/types.ts new file mode 100644 index 00000000..fc10e8b8 --- /dev/null +++ b/frontend/src/pages/xray/routing/types.ts @@ -0,0 +1,16 @@ +export interface RuleRow { + key: number; + domain?: string; + ip?: string; + port?: string; + sourcePort?: string; + vlessRoute?: string; + network?: string; + sourceIP?: string; + user?: string; + inboundTag?: string; + protocol?: string; + attrs?: string; + outboundTag?: string; + balancerTag?: string; +} diff --git a/frontend/src/pages/xray/routing/useRoutingColumns.tsx b/frontend/src/pages/xray/routing/useRoutingColumns.tsx new file mode 100644 index 00000000..ec0b8e27 --- /dev/null +++ b/frontend/src/pages/xray/routing/useRoutingColumns.tsx @@ -0,0 +1,170 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown, Tag } from 'antd'; +import { + MoreOutlined, + EditOutlined, + DeleteOutlined, + ExportOutlined, + ClusterOutlined, + ArrowUpOutlined, + ArrowDownOutlined, + HolderOutlined, +} from '@ant-design/icons'; +import type { ColumnsType } from 'antd/es/table'; + +import CriterionRow from './CriterionRow'; +import type { RuleRow } from './types'; + +interface RoutingColumnsParams { + isMobile: boolean; + rowsLength: number; + onHandlePointerDown: (idx: number, ev: React.PointerEvent) => void; + openEdit: (idx: number) => void; + moveUp: (idx: number) => void; + moveDown: (idx: number) => void; + confirmDelete: (idx: number) => void; +} + +export function useRoutingColumns({ + isMobile, + rowsLength, + onHandlePointerDown, + openEdit, + moveUp, + moveDown, + confirmDelete, +}: RoutingColumnsParams): ColumnsType { + const { t } = useTranslation(); + return useMemo( + () => [ + { + title: '#', + align: 'center', + width: 100, + key: 'action', + render: (_v, _r, index) => ( +
+ onHandlePointerDown(index, ev)} + /> + {index + 1} +
+ {!isMobile && ( +
+
+ ), + }, + { + title: t('pages.xray.rules.source'), + align: 'left', + width: 180, + key: 'source', + render: (_v, record) => ( +
+ {record.sourceIP && } + {record.sourcePort && } + {record.vlessRoute && } + {!record.sourceIP && !record.sourcePort && !record.vlessRoute && } +
+ ), + }, + { + title: t('pages.inbounds.network'), + align: 'left', + width: 180, + key: 'network', + render: (_v, record) => ( +
+ {record.network && } + {record.protocol && } + {record.attrs && } + {!record.network && !record.protocol && !record.attrs && } +
+ ), + }, + { + title: t('pages.xray.rules.dest'), + align: 'left', + key: 'destination', + render: (_v, record) => ( +
+ {record.ip && } + {record.domain && } + {record.port && } + {!record.ip && !record.domain && !record.port && } +
+ ), + }, + { + title: t('pages.xray.Inbounds'), + align: 'left', + width: 180, + key: 'inbound', + render: (_v, record) => ( +
+ {record.inboundTag && } + {record.user && } + {!record.inboundTag && !record.user && } +
+ ), + }, + { + title: t('pages.xray.Outbounds'), + align: 'left', + width: 170, + key: 'outbound', + render: (_v, record) => + record.outboundTag ? ( +
+ + {record.outboundTag} +
+ ) : ( + + ), + }, + { + title: t('pages.xray.Balancers'), + align: 'left', + width: 150, + key: 'balancer', + render: (_v, record) => + record.balancerTag ? ( +
+ + {record.balancerTag} +
+ ) : ( + + ), + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [t, isMobile, rowsLength], + ); +}