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 RuleFormModal from './RuleFormModal'; import type { RoutingRule } from './RuleFormModal'; import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting'; import type { RuleObject } from '@/schemas/routing'; import './RoutingTab.css'; interface RoutingTabProps { templateSettings: XraySettingsValue | null; setTemplateSettings: SetTemplate; inboundTags: string[]; clientReverseTags: string[]; 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, inboundTags, clientReverseTags, isMobile, }: RoutingTabProps) { const { t } = useTranslation(); const [modal, modalContextHolder] = Modal.useModal(); const [ruleModalOpen, setRuleModalOpen] = useState(false); const [editingRule, setEditingRule] = useState(null); const [editingIndex, setEditingIndex] = useState(null); const [draggedIndex, setDraggedIndex] = useState(null); const [dropTargetIndex, setDropTargetIndex] = useState(null); const dragRef = useRef<{ from: number | null; to: number | null; startY: number; moved: boolean }>({ from: null, to: null, startY: 0, moved: false, }); const rules = useMemo( () => (templateSettings?.routing?.rules || []) as RoutingRule[], [templateSettings?.routing?.rules], ); const rows: RuleRow[] = useMemo( () => rules.map((rule, idx) => { const r: RuleRow = { key: idx }; r.domain = arrJoin(rule.domain); r.ip = arrJoin(rule.ip); r.port = rule.port; r.sourcePort = rule.sourcePort; r.vlessRoute = rule.vlessRoute; r.network = rule.network; r.sourceIP = arrJoin(rule.sourceIP); r.user = arrJoin(rule.user); r.inboundTag = arrJoin(rule.inboundTag); r.protocol = arrJoin(rule.protocol); if (rule.attrs && typeof rule.attrs === 'object' && !Array.isArray(rule.attrs)) { r.attrs = JSON.stringify(rule.attrs, null, 2); } r.outboundTag = rule.outboundTag; r.balancerTag = rule.balancerTag; return r; }), [rules], ); const mutate = useCallback( (mutator: (next: XraySettingsValue) => void) => { setTemplateSettings((prev) => { if (!prev) return prev; const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue; mutator(clone); return clone; }); }, [setTemplateSettings], ); const inboundTagOptions = useMemo(() => { const seen = new Set(); const out: string[] = []; const push = (tag?: string) => { if (!tag || seen.has(tag)) return; seen.add(tag); out.push(tag); }; for (const ib of (templateSettings?.inbounds as Array<{ tag?: string }>) || []) push(ib?.tag); for (const tag of inboundTags || []) push(tag); for (const ob of templateSettings?.outbounds || []) { const obx = ob as { reverse?: { tag?: string }; settings?: { reverse?: { tag?: string }; inboundTag?: string } }; push(obx?.reverse?.tag || obx?.settings?.reverse?.tag || obx?.settings?.inboundTag); } push((templateSettings?.dns as { tag?: string } | undefined)?.tag); for (const s of (templateSettings?.dns as { servers?: Array<{ tag?: string }> } | undefined)?.servers || []) { if (typeof s === 'object' && s?.tag) push(s.tag); } return out; }, [templateSettings, inboundTags]); const outboundTagOptions = useMemo(() => { const out = new Set(['']); for (const ob of templateSettings?.outbounds || []) { if (ob?.tag) out.add(ob.tag); } for (const tag of clientReverseTags || []) { if (tag) out.add(tag); } return [...out]; }, [templateSettings?.outbounds, clientReverseTags]); const balancerTagOptions = useMemo(() => { const out: string[] = ['']; for (const b of (templateSettings?.routing?.balancers as Array<{ tag?: string }>) || []) { if (b?.tag) out.push(b.tag); } return out; }, [templateSettings?.routing?.balancers]); function openAdd() { setEditingRule(null); setEditingIndex(null); setRuleModalOpen(true); } function openEdit(idx: number) { setEditingRule(rules[idx]); setEditingIndex(idx); setRuleModalOpen(true); } function onRuleConfirm(rule: Record) { if (JSON.stringify(rule).length <= 3) { setRuleModalOpen(false); return; } mutate((tt) => { if (!tt.routing) tt.routing = { rules: [] }; if (!Array.isArray(tt.routing.rules)) tt.routing.rules = []; const typed = rule as unknown as RuleObject; if (editingIndex == null) tt.routing.rules.push(typed); else tt.routing.rules[editingIndex] = typed; }); setRuleModalOpen(false); } function confirmDelete(idx: number) { modal.confirm({ title: `${t('delete')} ${t('pages.xray.Routings')} #${idx + 1}?`, okText: t('delete'), okType: 'danger', cancelText: t('cancel'), onOk: () => mutate((tt) => { tt.routing?.rules?.splice(idx, 1); }), }); } function moveUp(idx: number) { if (idx <= 0) return; mutate((tt) => { const list = tt.routing?.rules; if (!list) return; [list[idx - 1], list[idx]] = [list[idx], list[idx - 1]]; }); } function moveDown(idx: number) { mutate((tt) => { const list = tt.routing?.rules; if (!list || idx >= list.length - 1) return; [list[idx + 1], list[idx]] = [list[idx], list[idx + 1]]; }); } function onHandlePointerDown(idx: number, ev: React.PointerEvent) { if (ev.button != null && ev.button !== 0) return; ev.preventDefault(); try { (ev.currentTarget as Element).setPointerCapture(ev.pointerId); } catch { /* ignore */ } dragRef.current = { from: idx, to: idx, startY: ev.clientY, moved: false }; setDraggedIndex(idx); setDropTargetIndex(idx); const onMove = (e: PointerEvent) => { const state = dragRef.current; if (state.from == null) return; if (!state.moved && Math.abs(e.clientY - state.startY) < 5) return; state.moved = true; const el = document.elementFromPoint(e.clientX, e.clientY); if (!el) return; const target = el.closest('[data-row-key]'); if (!target) return; const newIdx = Number(target.getAttribute('data-row-key')); if (Number.isFinite(newIdx) && newIdx !== state.to) { state.to = newIdx; setDropTargetIndex(newIdx); } }; const onUp = () => { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); document.removeEventListener('pointercancel', onUp); const { from, to, moved } = dragRef.current; dragRef.current = { from: null, to: null, startY: 0, moved: false }; setDraggedIndex(null); setDropTargetIndex(null); if (!moved || from == null || to == null || from === to) return; mutate((tt) => { const list = tt.routing?.rules; if (!list) return; const [movedItem] = list.splice(from, 1); list.splice(to, 0, movedItem); }); }; document.addEventListener('pointermove', onMove); document.addEventListener('pointerup', onUp); 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: '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: 'Destination', 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], ); return ( <> {modalContextHolder} {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)} ))}
)}
)) )}
) : ( r.key} pagination={false} scroll={{ x: 1150 }} size="small" className="routing-table" onRow={(_record, index) => { const classes: string[] = []; const i = index ?? -1; if (draggedIndex === i) classes.push('row-dragging'); if (dropTargetIndex === i && draggedIndex !== i && draggedIndex != null) { classes.push(i > draggedIndex ? 'drop-after' : 'drop-before'); } return { className: classes.join(' '), 'data-row-key': i } as React.HTMLAttributes; }} /> )} setRuleModalOpen(false)} onConfirm={onRuleConfirm} /> ); } 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}} ); }