RoutingTab.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Alert, Button, Dropdown, Modal, Space, Table, Tabs, message } from 'antd';
  4. import {
  5. AimOutlined,
  6. ControlOutlined,
  7. ExportOutlined,
  8. ImportOutlined,
  9. MoreOutlined,
  10. PlusOutlined,
  11. UnorderedListOutlined,
  12. } from '@ant-design/icons';
  13. import { catTabLabel } from '@/pages/settings/catTabLabel';
  14. import PromptModal from '@/components/feedback/PromptModal';
  15. import TextModal from '@/components/feedback/TextModal';
  16. import { isBalancerLoopbackTag } from '../balancers/balancer-loopback';
  17. import RoutingBasic from './RoutingBasic';
  18. import RouteTester from './RouteTester';
  19. import RuleFormModal from './RuleFormModal';
  20. import type { RoutingRule } from './RuleFormModal';
  21. import RuleCardList from './RuleCardList';
  22. import { useRoutingColumns } from './useRoutingColumns';
  23. import { arrJoin, originalRuleIndex } from './helpers';
  24. import type { RuleRow } from './types';
  25. import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
  26. import type { RuleObject } from '@/schemas/routing';
  27. import './RoutingTab.css';
  28. interface RoutingTabProps {
  29. templateSettings: XraySettingsValue | null;
  30. setTemplateSettings: SetTemplate;
  31. inboundTags: string[];
  32. clientReverseTags: string[];
  33. subscriptionOutboundTags?: string[];
  34. isMobile: boolean;
  35. }
  36. export default function RoutingTab({
  37. templateSettings,
  38. setTemplateSettings,
  39. inboundTags,
  40. clientReverseTags,
  41. subscriptionOutboundTags,
  42. isMobile,
  43. }: RoutingTabProps) {
  44. const { t } = useTranslation();
  45. const [messageApi, messageContextHolder] = message.useMessage();
  46. const [modal, modalContextHolder] = Modal.useModal();
  47. const [ruleModalOpen, setRuleModalOpen] = useState(false);
  48. const [editingRule, setEditingRule] = useState<RoutingRule | null>(null);
  49. const [editingIndex, setEditingIndex] = useState<number | null>(null);
  50. const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
  51. const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
  52. const dragRef = useRef<{
  53. from: number | null;
  54. to: number | null;
  55. startY: number;
  56. moved: boolean;
  57. }>({
  58. from: null,
  59. to: null,
  60. startY: 0,
  61. moved: false,
  62. });
  63. const routingScript = templateSettings?.routing?.script ?? '';
  64. const rules = useMemo(
  65. () => (templateSettings?.routing?.rules || []) as RoutingRule[],
  66. [templateSettings?.routing?.rules],
  67. );
  68. const rulesRef = useRef(rules);
  69. const rowsRef = useRef<RuleRow[]>([]);
  70. const rows: RuleRow[] = useMemo(
  71. () =>
  72. rules
  73. .map((rule, idx) => {
  74. const r: RuleRow = { key: idx };
  75. r.enabled = rule.enabled !== false;
  76. r.domain = arrJoin(rule.domain);
  77. r.ip = arrJoin(rule.ip);
  78. r.port = rule.port;
  79. r.sourcePort = rule.sourcePort;
  80. r.vlessRoute = rule.vlessRoute;
  81. r.network = rule.network;
  82. r.sourceIP = arrJoin(rule.sourceIP);
  83. r.user = arrJoin(rule.user);
  84. r.inboundTag = arrJoin(rule.inboundTag);
  85. r.protocol = arrJoin(rule.protocol);
  86. if (rule.attrs && typeof rule.attrs === 'object' && !Array.isArray(rule.attrs)) {
  87. r.attrs = JSON.stringify(rule.attrs, null, 2);
  88. }
  89. r.comment = rule.comment || undefined;
  90. r.outboundTag = rule.outboundTag;
  91. r.balancerTag = rule.balancerTag;
  92. return r;
  93. })
  94. .filter((r) => {
  95. const inboundTags = (rules[r.key]?.inboundTag || []) as string[];
  96. return !inboundTags.some(isBalancerLoopbackTag);
  97. }),
  98. [rules],
  99. );
  100. useEffect(() => {
  101. rulesRef.current = rules;
  102. rowsRef.current = rows;
  103. });
  104. const mutate = useCallback(
  105. (mutator: (next: XraySettingsValue) => void) => {
  106. setTemplateSettings((prev) => {
  107. if (!prev) return prev;
  108. const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
  109. mutator(clone);
  110. return clone;
  111. });
  112. },
  113. [setTemplateSettings],
  114. );
  115. const inboundTagOptions = useMemo(() => {
  116. const seen = new Set<string>();
  117. const out: string[] = [];
  118. const push = (tag?: string) => {
  119. if (!tag || seen.has(tag) || isBalancerLoopbackTag(tag)) return;
  120. seen.add(tag);
  121. out.push(tag);
  122. };
  123. for (const ib of (templateSettings?.inbounds as Array<{ tag?: string }>) || []) push(ib?.tag);
  124. for (const tag of inboundTags || []) push(tag);
  125. for (const ob of templateSettings?.outbounds || []) {
  126. const obx = ob as {
  127. reverse?: { tag?: string };
  128. settings?: { reverse?: { tag?: string }; inboundTag?: string };
  129. };
  130. push(obx?.reverse?.tag || obx?.settings?.reverse?.tag || obx?.settings?.inboundTag);
  131. }
  132. push((templateSettings?.dns as { tag?: string } | undefined)?.tag);
  133. for (const s of (templateSettings?.dns as { servers?: Array<{ tag?: string }> } | undefined)
  134. ?.servers || []) {
  135. if (typeof s === 'object' && s?.tag) push(s.tag);
  136. }
  137. return out;
  138. }, [templateSettings, inboundTags]);
  139. const outboundTagOptions = useMemo(() => {
  140. const out = new Set<string>(['']);
  141. for (const ob of templateSettings?.outbounds || []) {
  142. if (ob?.tag) out.add(ob.tag);
  143. }
  144. for (const tag of clientReverseTags || []) {
  145. if (tag) out.add(tag);
  146. }
  147. for (const tag of subscriptionOutboundTags || []) {
  148. if (tag) out.add(tag);
  149. }
  150. return [...out];
  151. }, [templateSettings?.outbounds, clientReverseTags, subscriptionOutboundTags]);
  152. const balancerTagOptions = useMemo(() => {
  153. const out: string[] = [''];
  154. for (const b of (templateSettings?.routing?.balancers as Array<{ tag?: string }>) || []) {
  155. if (b?.tag) out.push(b.tag);
  156. }
  157. return out;
  158. }, [templateSettings?.routing?.balancers]);
  159. const [importOpen, setImportOpen] = useState(false);
  160. const [exportOpen, setExportOpen] = useState(false);
  161. const [exportContent, setExportContent] = useState('');
  162. function exportRules() {
  163. setExportContent(JSON.stringify(rules, null, 2));
  164. setExportOpen(true);
  165. }
  166. function importRules(value: string) {
  167. let parsed: unknown;
  168. try {
  169. parsed = JSON.parse(value);
  170. } catch {
  171. messageApi.error(t('pages.xray.importInvalidJson'));
  172. return;
  173. }
  174. const obj = parsed as { rules?: unknown; routing?: { rules?: unknown } };
  175. const list = Array.isArray(parsed)
  176. ? parsed
  177. : Array.isArray(obj?.rules)
  178. ? obj.rules
  179. : Array.isArray(obj?.routing?.rules)
  180. ? obj.routing!.rules
  181. : null;
  182. if (!list) {
  183. messageApi.error(t('pages.xray.importInvalidJson'));
  184. return;
  185. }
  186. mutate((tt) => {
  187. if (!tt.routing) tt.routing = { rules: [] };
  188. if (!Array.isArray(tt.routing.rules)) tt.routing.rules = [];
  189. tt.routing.rules.push(...(list as RuleObject[]));
  190. });
  191. setImportOpen(false);
  192. }
  193. function openAdd() {
  194. setEditingRule(null);
  195. setEditingIndex(null);
  196. setRuleModalOpen(true);
  197. }
  198. function openEdit(idx: number) {
  199. const target = originalRuleIndex(rowsRef.current, idx);
  200. setEditingRule(rulesRef.current[target]);
  201. setEditingIndex(target);
  202. setRuleModalOpen(true);
  203. }
  204. function onRuleConfirm(rule: Record<string, unknown>) {
  205. if (JSON.stringify(rule).length <= 3) {
  206. setRuleModalOpen(false);
  207. return;
  208. }
  209. mutate((tt) => {
  210. if (!tt.routing) tt.routing = { rules: [] };
  211. if (!Array.isArray(tt.routing.rules)) tt.routing.rules = [];
  212. const typed = rule as unknown as RuleObject;
  213. if (editingIndex == null) tt.routing.rules.push(typed);
  214. else tt.routing.rules[editingIndex] = typed;
  215. });
  216. setRuleModalOpen(false);
  217. }
  218. function confirmDelete(idx: number) {
  219. const target = originalRuleIndex(rowsRef.current, idx);
  220. modal.confirm({
  221. title: `${t('delete')} ${t('pages.xray.Routings')} #${idx + 1}?`,
  222. okText: t('delete'),
  223. okType: 'danger',
  224. cancelText: t('cancel'),
  225. onOk: () =>
  226. mutate((tt) => {
  227. tt.routing?.rules?.splice(target, 1);
  228. }),
  229. });
  230. }
  231. function moveUp(idx: number) {
  232. if (idx <= 0) return;
  233. const target = originalRuleIndex(rowsRef.current, idx);
  234. const prev = originalRuleIndex(rowsRef.current, idx - 1);
  235. mutate((tt) => {
  236. const list = tt.routing?.rules;
  237. if (!list || !list[target] || !list[prev]) return;
  238. [list[prev], list[target]] = [list[target], list[prev]];
  239. });
  240. }
  241. function moveDown(idx: number) {
  242. if (idx >= rowsRef.current.length - 1) return;
  243. const target = originalRuleIndex(rowsRef.current, idx);
  244. const next = originalRuleIndex(rowsRef.current, idx + 1);
  245. mutate((tt) => {
  246. const list = tt.routing?.rules;
  247. if (!list || !list[target] || !list[next]) return;
  248. [list[next], list[target]] = [list[target], list[next]];
  249. });
  250. }
  251. function toggleRule(idx: number, enabled: boolean) {
  252. const target = originalRuleIndex(rowsRef.current, idx);
  253. mutate((tt) => {
  254. const list = tt.routing?.rules;
  255. if (!list || !list[target]) return;
  256. list[target].enabled = enabled;
  257. });
  258. }
  259. function onHandlePointerDown(idx: number, ev: React.PointerEvent) {
  260. if (ev.button != null && ev.button !== 0) return;
  261. ev.preventDefault();
  262. try {
  263. (ev.currentTarget as Element).setPointerCapture(ev.pointerId);
  264. } catch {
  265. /* ignore */
  266. }
  267. dragRef.current = { from: idx, to: idx, startY: ev.clientY, moved: false };
  268. setDraggedIndex(idx);
  269. setDropTargetIndex(idx);
  270. const onMove = (e: PointerEvent) => {
  271. const state = dragRef.current;
  272. if (state.from == null) return;
  273. if (!state.moved && Math.abs(e.clientY - state.startY) < 5) return;
  274. state.moved = true;
  275. const el = document.elementFromPoint(e.clientX, e.clientY);
  276. if (!el) return;
  277. const target = el.closest('[data-row-key]');
  278. if (!target) return;
  279. const newIdx = Number(target.getAttribute('data-row-key'));
  280. if (Number.isFinite(newIdx) && newIdx !== state.to) {
  281. state.to = newIdx;
  282. setDropTargetIndex(newIdx);
  283. }
  284. };
  285. const onUp = () => {
  286. document.removeEventListener('pointermove', onMove);
  287. document.removeEventListener('pointerup', onUp);
  288. document.removeEventListener('pointercancel', onUp);
  289. const { from, to, moved } = dragRef.current;
  290. dragRef.current = { from: null, to: null, startY: 0, moved: false };
  291. setDraggedIndex(null);
  292. setDropTargetIndex(null);
  293. if (!moved || from == null || to == null || from === to) return;
  294. const fromOrig = originalRuleIndex(rowsRef.current, from);
  295. const toOrig = originalRuleIndex(rowsRef.current, to);
  296. mutate((tt) => {
  297. const list = tt.routing?.rules;
  298. if (!list) return;
  299. const [movedItem] = list.splice(fromOrig, 1);
  300. list.splice(toOrig, 0, movedItem);
  301. });
  302. };
  303. document.addEventListener('pointermove', onMove);
  304. document.addEventListener('pointerup', onUp);
  305. document.addEventListener('pointercancel', onUp);
  306. }
  307. const hasSource = rows.some((r) => r.sourceIP || r.sourcePort || r.vlessRoute);
  308. const hasBalancer = rows.some((r) => r.balancerTag);
  309. const desktopColumns = useRoutingColumns({
  310. isMobile,
  311. rowsLength: rows.length,
  312. showSource: hasSource,
  313. showBalancer: hasBalancer,
  314. onHandlePointerDown,
  315. openEdit,
  316. moveUp,
  317. moveDown,
  318. confirmDelete,
  319. toggleRule,
  320. });
  321. const tableScrollX = desktopColumns.reduce((sum, c) => {
  322. const col = c as { width?: number; hidden?: boolean };
  323. return col.hidden ? sum : sum + (typeof col.width === 'number' ? col.width : 0);
  324. }, 0);
  325. return (
  326. <>
  327. {modalContextHolder}
  328. {messageContextHolder}
  329. {routingScript.trim() !== '' && (
  330. <Alert
  331. type="warning"
  332. showIcon
  333. style={{ marginBottom: 12 }}
  334. title={t('pages.xray.routingScriptActive', { section: t('pages.xray.basicTemplate') })}
  335. />
  336. )}
  337. <Tabs
  338. defaultActiveKey="basic"
  339. items={[
  340. {
  341. key: 'basic',
  342. label: catTabLabel(<ControlOutlined />, t('pages.xray.basicRouting'), isMobile),
  343. children: (
  344. <RoutingBasic
  345. templateSettings={templateSettings}
  346. setTemplateSettings={setTemplateSettings}
  347. />
  348. ),
  349. },
  350. {
  351. key: 'rules',
  352. label: catTabLabel(<UnorderedListOutlined />, t('pages.xray.Routings'), isMobile),
  353. children: (
  354. <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
  355. <Space wrap>
  356. <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
  357. {t('pages.xray.Routings')}
  358. </Button>
  359. <Dropdown
  360. trigger={['click']}
  361. menu={{
  362. items: [
  363. {
  364. key: 'import',
  365. icon: <ImportOutlined />,
  366. label: t('pages.xray.importRules'),
  367. onClick: () => setImportOpen(true),
  368. },
  369. {
  370. key: 'export',
  371. icon: <ExportOutlined />,
  372. label: t('pages.xray.exportRules'),
  373. disabled: rules.length === 0,
  374. onClick: exportRules,
  375. },
  376. ],
  377. }}
  378. >
  379. <Button icon={<MoreOutlined />}>{t('more')}</Button>
  380. </Dropdown>
  381. </Space>
  382. {isMobile ? (
  383. <RuleCardList
  384. rows={rows}
  385. draggedIndex={draggedIndex}
  386. dropTargetIndex={dropTargetIndex}
  387. onHandlePointerDown={onHandlePointerDown}
  388. openEdit={openEdit}
  389. moveUp={moveUp}
  390. moveDown={moveDown}
  391. confirmDelete={confirmDelete}
  392. toggleRule={toggleRule}
  393. />
  394. ) : (
  395. <Table
  396. columns={desktopColumns}
  397. dataSource={rows}
  398. rowKey={(r) => r.key}
  399. pagination={false}
  400. scroll={{ x: tableScrollX }}
  401. size="small"
  402. className="routing-table"
  403. onRow={(_record, index) => {
  404. const classes: string[] = [];
  405. const i = index ?? -1;
  406. if (draggedIndex === i) classes.push('row-dragging');
  407. if (dropTargetIndex === i && draggedIndex !== i && draggedIndex != null) {
  408. classes.push(i > draggedIndex ? 'drop-after' : 'drop-before');
  409. }
  410. return {
  411. className: classes.join(' '),
  412. 'data-row-key': i,
  413. } as React.HTMLAttributes<HTMLElement>;
  414. }}
  415. />
  416. )}
  417. </Space>
  418. ),
  419. },
  420. {
  421. key: 'tester',
  422. label: catTabLabel(<AimOutlined />, t('pages.xray.routeTester'), isMobile),
  423. children: <RouteTester inboundTags={inboundTagOptions} isMobile={isMobile} />,
  424. },
  425. ]}
  426. />
  427. <RuleFormModal
  428. open={ruleModalOpen}
  429. rule={editingRule}
  430. inboundTags={inboundTagOptions}
  431. outboundTags={outboundTagOptions}
  432. balancerTags={balancerTagOptions}
  433. onClose={() => setRuleModalOpen(false)}
  434. onConfirm={onRuleConfirm}
  435. />
  436. <PromptModal
  437. open={importOpen}
  438. onClose={() => setImportOpen(false)}
  439. title={t('pages.xray.importRules')}
  440. okText={t('pages.xray.importRules')}
  441. type="textarea"
  442. json
  443. onConfirm={importRules}
  444. />
  445. <TextModal
  446. open={exportOpen}
  447. onClose={() => setExportOpen(false)}
  448. title={t('pages.xray.exportRules')}
  449. content={exportContent}
  450. fileName="routing-rules.json"
  451. json
  452. />
  453. </>
  454. );
  455. }