mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 05:04:22 +00:00
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.
This commit is contained in:
parent
7739c3367d
commit
27a53f6f77
6 changed files with 379 additions and 284 deletions
17
frontend/src/pages/xray/routing/CriterionRow.tsx
Normal file
17
frontend/src/pages/xray/routing/CriterionRow.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Tooltip title={title}>
|
||||||
|
<span className="criterion-row">
|
||||||
|
<span className="criterion-label">{label}</span>
|
||||||
|
<span className="criterion-value">{parts[0]}</span>
|
||||||
|
{parts.length > 1 && <span className="criterion-more">+{parts.length - 1}</span>}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,21 +1,14 @@
|
||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Dropdown, Modal, Space, Table, Tag, Tooltip } from 'antd';
|
import { Button, Modal, Space, Table } from 'antd';
|
||||||
import {
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
PlusOutlined,
|
|
||||||
MoreOutlined,
|
|
||||||
EditOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
ExportOutlined,
|
|
||||||
ClusterOutlined,
|
|
||||||
ArrowUpOutlined,
|
|
||||||
ArrowDownOutlined,
|
|
||||||
HolderOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
|
||||||
|
|
||||||
import RuleFormModal from './RuleFormModal';
|
import RuleFormModal from './RuleFormModal';
|
||||||
import type { RoutingRule } 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 { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
|
||||||
import type { RuleObject } from '@/schemas/routing';
|
import type { RuleObject } from '@/schemas/routing';
|
||||||
import './RoutingTab.css';
|
import './RoutingTab.css';
|
||||||
|
|
@ -28,41 +21,6 @@ interface RoutingTabProps {
|
||||||
isMobile: boolean;
|
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({
|
export default function RoutingTab({
|
||||||
templateSettings,
|
templateSettings,
|
||||||
setTemplateSettings,
|
setTemplateSettings,
|
||||||
|
|
@ -268,151 +226,15 @@ export default function RoutingTab({
|
||||||
document.addEventListener('pointercancel', onUp);
|
document.addEventListener('pointercancel', onUp);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ruleCriteriaChips(rule: RuleRow) {
|
const desktopColumns = useRoutingColumns({
|
||||||
const chips: { label: string; value?: string }[] = [];
|
isMobile,
|
||||||
if (rule.domain) chips.push({ label: 'Domain', value: rule.domain });
|
rowsLength: rows.length,
|
||||||
if (rule.ip) chips.push({ label: 'IP', value: rule.ip });
|
onHandlePointerDown,
|
||||||
if (rule.port) chips.push({ label: 'Port', value: rule.port });
|
openEdit,
|
||||||
if (rule.sourceIP) chips.push({ label: 'Src IP', value: rule.sourceIP });
|
moveUp,
|
||||||
if (rule.sourcePort) chips.push({ label: 'Src Port', value: rule.sourcePort });
|
moveDown,
|
||||||
if (rule.network) chips.push({ label: 'L4', value: rule.network });
|
confirmDelete,
|
||||||
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<RuleRow> = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
title: '#',
|
|
||||||
align: 'center',
|
|
||||||
width: 100,
|
|
||||||
key: 'action',
|
|
||||||
render: (_v, _r, index) => (
|
|
||||||
<div className="action-cell">
|
|
||||||
<HolderOutlined
|
|
||||||
className="drag-handle"
|
|
||||||
title={t('pages.xray.routing.dragToReorder')}
|
|
||||||
onPointerDown={(ev: React.PointerEvent) => onHandlePointerDown(index, ev)}
|
|
||||||
/>
|
|
||||||
<span className="row-index">{index + 1}</span>
|
|
||||||
<div className={!isMobile ? 'action-buttons' : ''}>
|
|
||||||
{!isMobile && (
|
|
||||||
<Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
|
|
||||||
)}
|
|
||||||
<Dropdown
|
|
||||||
trigger={['click']}
|
|
||||||
menu={{
|
|
||||||
items: [
|
|
||||||
...(isMobile
|
|
||||||
? [{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) }]
|
|
||||||
: []),
|
|
||||||
{ key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
|
|
||||||
{
|
|
||||||
key: 'down',
|
|
||||||
label: <ArrowDownOutlined />,
|
|
||||||
disabled: index === rows.length - 1,
|
|
||||||
onClick: () => moveDown(index),
|
|
||||||
},
|
|
||||||
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button shape="circle" size="small" icon={<MoreOutlined />} />
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('pages.xray.rules.source'),
|
|
||||||
align: 'left',
|
|
||||||
width: 180,
|
|
||||||
key: 'source',
|
|
||||||
render: (_v, record) => (
|
|
||||||
<div className="criterion-flow">
|
|
||||||
{record.sourceIP && <CriterionRow label="IP" value={record.sourceIP} title={`Source IP: ${record.sourceIP}`} />}
|
|
||||||
{record.sourcePort && <CriterionRow label="Port" value={record.sourcePort} title={`Source port: ${record.sourcePort}`} />}
|
|
||||||
{record.vlessRoute && <CriterionRow label="VLESS" value={record.vlessRoute} title={`VLESS route: ${record.vlessRoute}`} />}
|
|
||||||
{!record.sourceIP && !record.sourcePort && !record.vlessRoute && <span className="criterion-empty">—</span>}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('pages.inbounds.network'),
|
|
||||||
align: 'left',
|
|
||||||
width: 180,
|
|
||||||
key: 'network',
|
|
||||||
render: (_v, record) => (
|
|
||||||
<div className="criterion-flow">
|
|
||||||
{record.network && <CriterionRow label="L4" value={record.network} title={`L4: ${record.network}`} />}
|
|
||||||
{record.protocol && <CriterionRow label="Protocol" value={record.protocol} title={`Protocol: ${record.protocol}`} />}
|
|
||||||
{record.attrs && <CriterionRow label="Attrs" value={record.attrs} title={`Attrs: ${record.attrs}`} />}
|
|
||||||
{!record.network && !record.protocol && !record.attrs && <span className="criterion-empty">—</span>}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('pages.xray.rules.dest'),
|
|
||||||
align: 'left',
|
|
||||||
key: 'destination',
|
|
||||||
render: (_v, record) => (
|
|
||||||
<div className="criterion-flow">
|
|
||||||
{record.ip && <CriterionRow label="IP" value={record.ip} title={`Destination IP: ${record.ip}`} />}
|
|
||||||
{record.domain && <CriterionRow label="Domain" value={record.domain} title={`Domain: ${record.domain}`} />}
|
|
||||||
{record.port && <CriterionRow label="Port" value={record.port} title={`Destination port: ${record.port}`} />}
|
|
||||||
{!record.ip && !record.domain && !record.port && <span className="criterion-empty">—</span>}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('pages.xray.Inbounds'),
|
|
||||||
align: 'left',
|
|
||||||
width: 180,
|
|
||||||
key: 'inbound',
|
|
||||||
render: (_v, record) => (
|
|
||||||
<div className="criterion-flow">
|
|
||||||
{record.inboundTag && <CriterionRow label="Tag" value={record.inboundTag} title={`Inbound tag: ${record.inboundTag}`} />}
|
|
||||||
{record.user && <CriterionRow label="User" value={record.user} title={`User: ${record.user}`} />}
|
|
||||||
{!record.inboundTag && !record.user && <span className="criterion-empty">—</span>}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('pages.xray.Outbounds'),
|
|
||||||
align: 'left',
|
|
||||||
width: 170,
|
|
||||||
key: 'outbound',
|
|
||||||
render: (_v, record) =>
|
|
||||||
record.outboundTag ? (
|
|
||||||
<div className="target-row">
|
|
||||||
<ExportOutlined className="target-icon" />
|
|
||||||
<Tag color="green">{record.outboundTag}</Tag>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="criterion-empty">—</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('pages.xray.Balancers'),
|
|
||||||
align: 'left',
|
|
||||||
width: 150,
|
|
||||||
key: 'balancer',
|
|
||||||
render: (_v, record) =>
|
|
||||||
record.balancerTag ? (
|
|
||||||
<div className="target-row">
|
|
||||||
<ClusterOutlined className="target-icon" />
|
|
||||||
<Tag color="purple">{record.balancerTag}</Tag>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="criterion-empty">—</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
[t, isMobile, rows.length],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -423,83 +245,16 @@ export default function RoutingTab({
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<div className="rule-list">
|
<RuleCardList
|
||||||
{rows.length === 0 ? (
|
rows={rows}
|
||||||
<div className="rule-empty">—</div>
|
draggedIndex={draggedIndex}
|
||||||
) : (
|
dropTargetIndex={dropTargetIndex}
|
||||||
rows.map((rule, index) => (
|
onHandlePointerDown={onHandlePointerDown}
|
||||||
<div
|
openEdit={openEdit}
|
||||||
key={rule.key}
|
moveUp={moveUp}
|
||||||
className={`rule-card ${draggedIndex === index ? 'row-dragging' : ''} ${
|
moveDown={moveDown}
|
||||||
dropTargetIndex === index && draggedIndex != null && index < draggedIndex ? 'drop-before' : ''
|
confirmDelete={confirmDelete}
|
||||||
} ${dropTargetIndex === index && draggedIndex != null && index > draggedIndex ? 'drop-after' : ''}`}
|
/>
|
||||||
data-row-key={index}
|
|
||||||
>
|
|
||||||
<div className="rule-card-head">
|
|
||||||
<HolderOutlined
|
|
||||||
className="drag-handle"
|
|
||||||
onPointerDown={(ev) => onHandlePointerDown(index, ev)}
|
|
||||||
/>
|
|
||||||
<span className="rule-number">#{index + 1}</span>
|
|
||||||
<Dropdown
|
|
||||||
trigger={['click']}
|
|
||||||
menu={{
|
|
||||||
items: [
|
|
||||||
{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) },
|
|
||||||
{ key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
|
|
||||||
{ key: 'down', label: <ArrowDownOutlined />, disabled: index === rows.length - 1, onClick: () => moveDown(index) },
|
|
||||||
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button shape="circle" size="small" icon={<MoreOutlined />} />
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rule-flow">
|
|
||||||
<div className="flow-side">
|
|
||||||
<span className="flow-label">{t('pages.xray.Inbounds')}</span>
|
|
||||||
{rule.inboundTag ? (
|
|
||||||
<Tag color="blue" className="flow-tag">{chipPreview(rule.inboundTag)}</Tag>
|
|
||||||
) : (
|
|
||||||
<span className="criterion-empty">any</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="flow-arrow">→</span>
|
|
||||||
<div className="flow-side flow-side-target">
|
|
||||||
<span className="flow-label">
|
|
||||||
{rule.balancerTag ? t('pages.xray.balancer') || 'Balancer' : t('pages.xray.Outbounds')}
|
|
||||||
</span>
|
|
||||||
{rule.outboundTag ? (
|
|
||||||
<Tag color="green" className="flow-tag">
|
|
||||||
<ExportOutlined /> {rule.outboundTag}
|
|
||||||
</Tag>
|
|
||||||
) : rule.balancerTag ? (
|
|
||||||
<Tag color="purple" className="flow-tag">
|
|
||||||
<ClusterOutlined /> {rule.balancerTag}
|
|
||||||
</Tag>
|
|
||||||
) : (
|
|
||||||
<span className="criterion-empty">—</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{ruleCriteriaChips(rule).length > 0 && (
|
|
||||||
<div className="rule-criteria">
|
|
||||||
{ruleCriteriaChips(rule).map((chip) => (
|
|
||||||
<Tooltip key={chip.label} title={`${chip.label}: ${chip.value}`}>
|
|
||||||
<span className="criterion-chip">
|
|
||||||
<span className="criterion-chip-label">{chip.label}</span>
|
|
||||||
<span className="criterion-chip-value">{chipPreview(chip.value)}</span>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<Table
|
<Table
|
||||||
columns={desktopColumns}
|
columns={desktopColumns}
|
||||||
|
|
@ -534,17 +289,3 @@ export default function RoutingTab({
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CriterionRow({ label, value, title }: { label: string; value?: string; title: string }) {
|
|
||||||
const parts = csv(value);
|
|
||||||
if (parts.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<Tooltip title={title}>
|
|
||||||
<span className="criterion-row">
|
|
||||||
<span className="criterion-label">{label}</span>
|
|
||||||
<span className="criterion-value">{parts[0]}</span>
|
|
||||||
{parts.length > 1 && <span className="criterion-more">+{parts.length - 1}</span>}
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
118
frontend/src/pages/xray/routing/RuleCardList.tsx
Normal file
118
frontend/src/pages/xray/routing/RuleCardList.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="rule-list">
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="rule-empty">—</div>
|
||||||
|
) : (
|
||||||
|
rows.map((rule, index) => (
|
||||||
|
<div
|
||||||
|
key={rule.key}
|
||||||
|
className={`rule-card ${draggedIndex === index ? 'row-dragging' : ''} ${
|
||||||
|
dropTargetIndex === index && draggedIndex != null && index < draggedIndex ? 'drop-before' : ''
|
||||||
|
} ${dropTargetIndex === index && draggedIndex != null && index > draggedIndex ? 'drop-after' : ''}`}
|
||||||
|
data-row-key={index}
|
||||||
|
>
|
||||||
|
<div className="rule-card-head">
|
||||||
|
<HolderOutlined
|
||||||
|
className="drag-handle"
|
||||||
|
onPointerDown={(ev) => onHandlePointerDown(index, ev)}
|
||||||
|
/>
|
||||||
|
<span className="rule-number">#{index + 1}</span>
|
||||||
|
<Dropdown
|
||||||
|
trigger={['click']}
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) },
|
||||||
|
{ key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
|
||||||
|
{ key: 'down', label: <ArrowDownOutlined />, disabled: index === rows.length - 1, onClick: () => moveDown(index) },
|
||||||
|
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button shape="circle" size="small" icon={<MoreOutlined />} />
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rule-flow">
|
||||||
|
<div className="flow-side">
|
||||||
|
<span className="flow-label">{t('pages.xray.Inbounds')}</span>
|
||||||
|
{rule.inboundTag ? (
|
||||||
|
<Tag color="blue" className="flow-tag">{chipPreview(rule.inboundTag)}</Tag>
|
||||||
|
) : (
|
||||||
|
<span className="criterion-empty">any</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="flow-arrow">→</span>
|
||||||
|
<div className="flow-side flow-side-target">
|
||||||
|
<span className="flow-label">
|
||||||
|
{rule.balancerTag ? t('pages.xray.balancer') || 'Balancer' : t('pages.xray.Outbounds')}
|
||||||
|
</span>
|
||||||
|
{rule.outboundTag ? (
|
||||||
|
<Tag color="green" className="flow-tag">
|
||||||
|
<ExportOutlined /> {rule.outboundTag}
|
||||||
|
</Tag>
|
||||||
|
) : rule.balancerTag ? (
|
||||||
|
<Tag color="purple" className="flow-tag">
|
||||||
|
<ClusterOutlined /> {rule.balancerTag}
|
||||||
|
</Tag>
|
||||||
|
) : (
|
||||||
|
<span className="criterion-empty">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ruleCriteriaChips(rule).length > 0 && (
|
||||||
|
<div className="rule-criteria">
|
||||||
|
{ruleCriteriaChips(rule).map((chip) => (
|
||||||
|
<Tooltip key={chip.label} title={`${chip.label}: ${chip.value}`}>
|
||||||
|
<span className="criterion-chip">
|
||||||
|
<span className="criterion-chip-label">{chip.label}</span>
|
||||||
|
<span className="criterion-chip-value">{chipPreview(chip.value)}</span>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
frontend/src/pages/xray/routing/helpers.ts
Normal file
33
frontend/src/pages/xray/routing/helpers.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
16
frontend/src/pages/xray/routing/types.ts
Normal file
16
frontend/src/pages/xray/routing/types.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
170
frontend/src/pages/xray/routing/useRoutingColumns.tsx
Normal file
170
frontend/src/pages/xray/routing/useRoutingColumns.tsx
Normal file
|
|
@ -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<RuleRow> {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: '#',
|
||||||
|
align: 'center',
|
||||||
|
width: 100,
|
||||||
|
key: 'action',
|
||||||
|
render: (_v, _r, index) => (
|
||||||
|
<div className="action-cell">
|
||||||
|
<HolderOutlined
|
||||||
|
className="drag-handle"
|
||||||
|
title={t('pages.xray.routing.dragToReorder')}
|
||||||
|
onPointerDown={(ev: React.PointerEvent) => onHandlePointerDown(index, ev)}
|
||||||
|
/>
|
||||||
|
<span className="row-index">{index + 1}</span>
|
||||||
|
<div className={!isMobile ? 'action-buttons' : ''}>
|
||||||
|
{!isMobile && (
|
||||||
|
<Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
|
||||||
|
)}
|
||||||
|
<Dropdown
|
||||||
|
trigger={['click']}
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
...(isMobile
|
||||||
|
? [{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) }]
|
||||||
|
: []),
|
||||||
|
{ key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
|
||||||
|
{
|
||||||
|
key: 'down',
|
||||||
|
label: <ArrowDownOutlined />,
|
||||||
|
disabled: index === rowsLength - 1,
|
||||||
|
onClick: () => moveDown(index),
|
||||||
|
},
|
||||||
|
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button shape="circle" size="small" icon={<MoreOutlined />} />
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.xray.rules.source'),
|
||||||
|
align: 'left',
|
||||||
|
width: 180,
|
||||||
|
key: 'source',
|
||||||
|
render: (_v, record) => (
|
||||||
|
<div className="criterion-flow">
|
||||||
|
{record.sourceIP && <CriterionRow label="IP" value={record.sourceIP} title={`Source IP: ${record.sourceIP}`} />}
|
||||||
|
{record.sourcePort && <CriterionRow label="Port" value={record.sourcePort} title={`Source port: ${record.sourcePort}`} />}
|
||||||
|
{record.vlessRoute && <CriterionRow label="VLESS" value={record.vlessRoute} title={`VLESS route: ${record.vlessRoute}`} />}
|
||||||
|
{!record.sourceIP && !record.sourcePort && !record.vlessRoute && <span className="criterion-empty">—</span>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.inbounds.network'),
|
||||||
|
align: 'left',
|
||||||
|
width: 180,
|
||||||
|
key: 'network',
|
||||||
|
render: (_v, record) => (
|
||||||
|
<div className="criterion-flow">
|
||||||
|
{record.network && <CriterionRow label="L4" value={record.network} title={`L4: ${record.network}`} />}
|
||||||
|
{record.protocol && <CriterionRow label="Protocol" value={record.protocol} title={`Protocol: ${record.protocol}`} />}
|
||||||
|
{record.attrs && <CriterionRow label="Attrs" value={record.attrs} title={`Attrs: ${record.attrs}`} />}
|
||||||
|
{!record.network && !record.protocol && !record.attrs && <span className="criterion-empty">—</span>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.xray.rules.dest'),
|
||||||
|
align: 'left',
|
||||||
|
key: 'destination',
|
||||||
|
render: (_v, record) => (
|
||||||
|
<div className="criterion-flow">
|
||||||
|
{record.ip && <CriterionRow label="IP" value={record.ip} title={`Destination IP: ${record.ip}`} />}
|
||||||
|
{record.domain && <CriterionRow label="Domain" value={record.domain} title={`Domain: ${record.domain}`} />}
|
||||||
|
{record.port && <CriterionRow label="Port" value={record.port} title={`Destination port: ${record.port}`} />}
|
||||||
|
{!record.ip && !record.domain && !record.port && <span className="criterion-empty">—</span>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.xray.Inbounds'),
|
||||||
|
align: 'left',
|
||||||
|
width: 180,
|
||||||
|
key: 'inbound',
|
||||||
|
render: (_v, record) => (
|
||||||
|
<div className="criterion-flow">
|
||||||
|
{record.inboundTag && <CriterionRow label="Tag" value={record.inboundTag} title={`Inbound tag: ${record.inboundTag}`} />}
|
||||||
|
{record.user && <CriterionRow label="User" value={record.user} title={`User: ${record.user}`} />}
|
||||||
|
{!record.inboundTag && !record.user && <span className="criterion-empty">—</span>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.xray.Outbounds'),
|
||||||
|
align: 'left',
|
||||||
|
width: 170,
|
||||||
|
key: 'outbound',
|
||||||
|
render: (_v, record) =>
|
||||||
|
record.outboundTag ? (
|
||||||
|
<div className="target-row">
|
||||||
|
<ExportOutlined className="target-icon" />
|
||||||
|
<Tag color="green">{record.outboundTag}</Tag>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="criterion-empty">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.xray.Balancers'),
|
||||||
|
align: 'left',
|
||||||
|
width: 150,
|
||||||
|
key: 'balancer',
|
||||||
|
render: (_v, record) =>
|
||||||
|
record.balancerTag ? (
|
||||||
|
<div className="target-row">
|
||||||
|
<ClusterOutlined className="target-icon" />
|
||||||
|
<Tag color="purple">{record.balancerTag}</Tag>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="criterion-empty">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[t, isMobile, rowsLength],
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue