= useMemo(
- () => [
- {
- title: '#',
- align: 'center',
- width: 100,
- key: 'action',
- render: (_v, _r, index) => (
-
-
onHandlePointerDown(index, ev)}
- />
- {index + 1}
-
- {!isMobile && (
-
} onClick={() => openEdit(index)} />
- )}
-
{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) },
- ],
- }}
- >
- } />
-
-
-
- ),
- },
- {
- 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 && (
+
} onClick={() => openEdit(index)} />
+ )}
+
{t('edit')}>, onClick: () => openEdit(index) }]
+ : []),
+ { key: 'up', label: , disabled: index === 0, onClick: () => moveUp(index) },
+ {
+ key: 'down',
+ label: ,
+ disabled: index === rowsLength - 1,
+ onClick: () => moveDown(index),
+ },
+ { key: 'del', danger: true, label: <> {t('delete')}>, onClick: () => confirmDelete(index) },
+ ],
+ }}
+ >
+ } />
+
+
+
+ ),
+ },
+ {
+ 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],
+ );
+}