| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480 |
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
- import { useTranslation } from 'react-i18next';
- import { Alert, Button, Dropdown, Modal, Space, Table, Tabs, message } from 'antd';
- import {
- AimOutlined,
- ControlOutlined,
- ExportOutlined,
- ImportOutlined,
- MoreOutlined,
- PlusOutlined,
- UnorderedListOutlined,
- } from '@ant-design/icons';
- import { catTabLabel } from '@/pages/settings/catTabLabel';
- import PromptModal from '@/components/feedback/PromptModal';
- import TextModal from '@/components/feedback/TextModal';
- import { isBalancerLoopbackTag } from '../balancers/balancer-loopback';
- import RoutingBasic from './RoutingBasic';
- import RouteTester from './RouteTester';
- import RuleFormModal from './RuleFormModal';
- import type { RoutingRule } from './RuleFormModal';
- import RuleCardList from './RuleCardList';
- import { useRoutingColumns } from './useRoutingColumns';
- import { arrJoin, originalRuleIndex } from './helpers';
- import type { RuleRow } from './types';
- 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[];
- subscriptionOutboundTags?: string[];
- isMobile: boolean;
- }
- export default function RoutingTab({
- templateSettings,
- setTemplateSettings,
- inboundTags,
- clientReverseTags,
- subscriptionOutboundTags,
- isMobile,
- }: RoutingTabProps) {
- const { t } = useTranslation();
- const [messageApi, messageContextHolder] = message.useMessage();
- const [modal, modalContextHolder] = Modal.useModal();
- const [ruleModalOpen, setRuleModalOpen] = useState(false);
- const [editingRule, setEditingRule] = useState<RoutingRule | null>(null);
- const [editingIndex, setEditingIndex] = useState<number | null>(null);
- const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
- const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
- const dragRef = useRef<{
- from: number | null;
- to: number | null;
- startY: number;
- moved: boolean;
- }>({
- from: null,
- to: null,
- startY: 0,
- moved: false,
- });
- const routingScript = templateSettings?.routing?.script ?? '';
- const rules = useMemo(
- () => (templateSettings?.routing?.rules || []) as RoutingRule[],
- [templateSettings?.routing?.rules],
- );
- const rulesRef = useRef(rules);
- const rowsRef = useRef<RuleRow[]>([]);
- const rows: RuleRow[] = useMemo(
- () =>
- rules
- .map((rule, idx) => {
- const r: RuleRow = { key: idx };
- r.enabled = rule.enabled !== false;
- 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.comment = rule.comment || undefined;
- r.outboundTag = rule.outboundTag;
- r.balancerTag = rule.balancerTag;
- return r;
- })
- .filter((r) => {
- const inboundTags = (rules[r.key]?.inboundTag || []) as string[];
- return !inboundTags.some(isBalancerLoopbackTag);
- }),
- [rules],
- );
- useEffect(() => {
- rulesRef.current = rules;
- rowsRef.current = rows;
- });
- 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<string>();
- const out: string[] = [];
- const push = (tag?: string) => {
- if (!tag || seen.has(tag) || isBalancerLoopbackTag(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<string>(['']);
- for (const ob of templateSettings?.outbounds || []) {
- if (ob?.tag) out.add(ob.tag);
- }
- for (const tag of clientReverseTags || []) {
- if (tag) out.add(tag);
- }
- for (const tag of subscriptionOutboundTags || []) {
- if (tag) out.add(tag);
- }
- return [...out];
- }, [templateSettings?.outbounds, clientReverseTags, subscriptionOutboundTags]);
- 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]);
- const [importOpen, setImportOpen] = useState(false);
- const [exportOpen, setExportOpen] = useState(false);
- const [exportContent, setExportContent] = useState('');
- function exportRules() {
- setExportContent(JSON.stringify(rules, null, 2));
- setExportOpen(true);
- }
- function importRules(value: string) {
- let parsed: unknown;
- try {
- parsed = JSON.parse(value);
- } catch {
- messageApi.error(t('pages.xray.importInvalidJson'));
- return;
- }
- const obj = parsed as { rules?: unknown; routing?: { rules?: unknown } };
- const list = Array.isArray(parsed)
- ? parsed
- : Array.isArray(obj?.rules)
- ? obj.rules
- : Array.isArray(obj?.routing?.rules)
- ? obj.routing!.rules
- : null;
- if (!list) {
- messageApi.error(t('pages.xray.importInvalidJson'));
- return;
- }
- mutate((tt) => {
- if (!tt.routing) tt.routing = { rules: [] };
- if (!Array.isArray(tt.routing.rules)) tt.routing.rules = [];
- tt.routing.rules.push(...(list as RuleObject[]));
- });
- setImportOpen(false);
- }
- function openAdd() {
- setEditingRule(null);
- setEditingIndex(null);
- setRuleModalOpen(true);
- }
- function openEdit(idx: number) {
- const target = originalRuleIndex(rowsRef.current, idx);
- setEditingRule(rulesRef.current[target]);
- setEditingIndex(target);
- setRuleModalOpen(true);
- }
- function onRuleConfirm(rule: Record<string, unknown>) {
- 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) {
- const target = originalRuleIndex(rowsRef.current, idx);
- 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(target, 1);
- }),
- });
- }
- function moveUp(idx: number) {
- if (idx <= 0) return;
- const target = originalRuleIndex(rowsRef.current, idx);
- const prev = originalRuleIndex(rowsRef.current, idx - 1);
- mutate((tt) => {
- const list = tt.routing?.rules;
- if (!list || !list[target] || !list[prev]) return;
- [list[prev], list[target]] = [list[target], list[prev]];
- });
- }
- function moveDown(idx: number) {
- if (idx >= rowsRef.current.length - 1) return;
- const target = originalRuleIndex(rowsRef.current, idx);
- const next = originalRuleIndex(rowsRef.current, idx + 1);
- mutate((tt) => {
- const list = tt.routing?.rules;
- if (!list || !list[target] || !list[next]) return;
- [list[next], list[target]] = [list[target], list[next]];
- });
- }
- function toggleRule(idx: number, enabled: boolean) {
- const target = originalRuleIndex(rowsRef.current, idx);
- mutate((tt) => {
- const list = tt.routing?.rules;
- if (!list || !list[target]) return;
- list[target].enabled = enabled;
- });
- }
- 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;
- const fromOrig = originalRuleIndex(rowsRef.current, from);
- const toOrig = originalRuleIndex(rowsRef.current, to);
- mutate((tt) => {
- const list = tt.routing?.rules;
- if (!list) return;
- const [movedItem] = list.splice(fromOrig, 1);
- list.splice(toOrig, 0, movedItem);
- });
- };
- document.addEventListener('pointermove', onMove);
- document.addEventListener('pointerup', onUp);
- document.addEventListener('pointercancel', onUp);
- }
- const hasSource = rows.some((r) => r.sourceIP || r.sourcePort || r.vlessRoute);
- const hasBalancer = rows.some((r) => r.balancerTag);
- const desktopColumns = useRoutingColumns({
- isMobile,
- rowsLength: rows.length,
- showSource: hasSource,
- showBalancer: hasBalancer,
- onHandlePointerDown,
- openEdit,
- moveUp,
- moveDown,
- confirmDelete,
- toggleRule,
- });
- const tableScrollX = desktopColumns.reduce((sum, c) => {
- const col = c as { width?: number; hidden?: boolean };
- return col.hidden ? sum : sum + (typeof col.width === 'number' ? col.width : 0);
- }, 0);
- return (
- <>
- {modalContextHolder}
- {messageContextHolder}
- {routingScript.trim() !== '' && (
- <Alert
- type="warning"
- showIcon
- style={{ marginBottom: 12 }}
- title={t('pages.xray.routingScriptActive', { section: t('pages.xray.basicTemplate') })}
- />
- )}
- <Tabs
- defaultActiveKey="basic"
- items={[
- {
- key: 'basic',
- label: catTabLabel(<ControlOutlined />, t('pages.xray.basicRouting'), isMobile),
- children: (
- <RoutingBasic
- templateSettings={templateSettings}
- setTemplateSettings={setTemplateSettings}
- />
- ),
- },
- {
- key: 'rules',
- label: catTabLabel(<UnorderedListOutlined />, t('pages.xray.Routings'), isMobile),
- children: (
- <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
- <Space wrap>
- <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
- {t('pages.xray.Routings')}
- </Button>
- <Dropdown
- trigger={['click']}
- menu={{
- items: [
- {
- key: 'import',
- icon: <ImportOutlined />,
- label: t('pages.xray.importRules'),
- onClick: () => setImportOpen(true),
- },
- {
- key: 'export',
- icon: <ExportOutlined />,
- label: t('pages.xray.exportRules'),
- disabled: rules.length === 0,
- onClick: exportRules,
- },
- ],
- }}
- >
- <Button icon={<MoreOutlined />}>{t('more')}</Button>
- </Dropdown>
- </Space>
- {isMobile ? (
- <RuleCardList
- rows={rows}
- draggedIndex={draggedIndex}
- dropTargetIndex={dropTargetIndex}
- onHandlePointerDown={onHandlePointerDown}
- openEdit={openEdit}
- moveUp={moveUp}
- moveDown={moveDown}
- confirmDelete={confirmDelete}
- toggleRule={toggleRule}
- />
- ) : (
- <Table
- columns={desktopColumns}
- dataSource={rows}
- rowKey={(r) => r.key}
- pagination={false}
- scroll={{ x: tableScrollX }}
- 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<HTMLElement>;
- }}
- />
- )}
- </Space>
- ),
- },
- {
- key: 'tester',
- label: catTabLabel(<AimOutlined />, t('pages.xray.routeTester'), isMobile),
- children: <RouteTester inboundTags={inboundTagOptions} isMobile={isMobile} />,
- },
- ]}
- />
- <RuleFormModal
- open={ruleModalOpen}
- rule={editingRule}
- inboundTags={inboundTagOptions}
- outboundTags={outboundTagOptions}
- balancerTags={balancerTagOptions}
- onClose={() => setRuleModalOpen(false)}
- onConfirm={onRuleConfirm}
- />
- <PromptModal
- open={importOpen}
- onClose={() => setImportOpen(false)}
- title={t('pages.xray.importRules')}
- okText={t('pages.xray.importRules')}
- type="textarea"
- json
- onConfirm={importRules}
- />
- <TextModal
- open={exportOpen}
- onClose={() => setExportOpen(false)}
- title={t('pages.xray.exportRules')}
- content={exportContent}
- fileName="routing-rules.json"
- json
- />
- </>
- );
- }
|