RoutingTab.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. import { useCallback, useMemo, useRef, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Button, Modal, Space, Table, Tabs } from 'antd';
  4. import { ControlOutlined, PlusOutlined, UnorderedListOutlined } from '@ant-design/icons';
  5. import { catTabLabel } from '@/pages/settings/catTabLabel';
  6. import RoutingBasic from './RoutingBasic';
  7. import RuleFormModal from './RuleFormModal';
  8. import type { RoutingRule } from './RuleFormModal';
  9. import RuleCardList from './RuleCardList';
  10. import { useRoutingColumns } from './useRoutingColumns';
  11. import { arrJoin } from './helpers';
  12. import type { RuleRow } from './types';
  13. import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
  14. import type { RuleObject } from '@/schemas/routing';
  15. import './RoutingTab.css';
  16. interface RoutingTabProps {
  17. templateSettings: XraySettingsValue | null;
  18. setTemplateSettings: SetTemplate;
  19. inboundTags: string[];
  20. clientReverseTags: string[];
  21. subscriptionOutboundTags?: string[];
  22. isMobile: boolean;
  23. }
  24. export default function RoutingTab({
  25. templateSettings,
  26. setTemplateSettings,
  27. inboundTags,
  28. clientReverseTags,
  29. subscriptionOutboundTags,
  30. isMobile,
  31. }: RoutingTabProps) {
  32. const { t } = useTranslation();
  33. const [modal, modalContextHolder] = Modal.useModal();
  34. const [ruleModalOpen, setRuleModalOpen] = useState(false);
  35. const [editingRule, setEditingRule] = useState<RoutingRule | null>(null);
  36. const [editingIndex, setEditingIndex] = useState<number | null>(null);
  37. const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
  38. const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
  39. const dragRef = useRef<{ from: number | null; to: number | null; startY: number; moved: boolean }>({
  40. from: null,
  41. to: null,
  42. startY: 0,
  43. moved: false,
  44. });
  45. const rules = useMemo(
  46. () => (templateSettings?.routing?.rules || []) as RoutingRule[],
  47. [templateSettings?.routing?.rules],
  48. );
  49. const rulesRef = useRef(rules);
  50. rulesRef.current = rules;
  51. const rows: RuleRow[] = useMemo(
  52. () =>
  53. rules.map((rule, idx) => {
  54. const r: RuleRow = { key: idx };
  55. r.domain = arrJoin(rule.domain);
  56. r.ip = arrJoin(rule.ip);
  57. r.port = rule.port;
  58. r.sourcePort = rule.sourcePort;
  59. r.vlessRoute = rule.vlessRoute;
  60. r.network = rule.network;
  61. r.sourceIP = arrJoin(rule.sourceIP);
  62. r.user = arrJoin(rule.user);
  63. r.inboundTag = arrJoin(rule.inboundTag);
  64. r.protocol = arrJoin(rule.protocol);
  65. if (rule.attrs && typeof rule.attrs === 'object' && !Array.isArray(rule.attrs)) {
  66. r.attrs = JSON.stringify(rule.attrs, null, 2);
  67. }
  68. r.outboundTag = rule.outboundTag;
  69. r.balancerTag = rule.balancerTag;
  70. return r;
  71. }),
  72. [rules],
  73. );
  74. const mutate = useCallback(
  75. (mutator: (next: XraySettingsValue) => void) => {
  76. setTemplateSettings((prev) => {
  77. if (!prev) return prev;
  78. const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
  79. mutator(clone);
  80. return clone;
  81. });
  82. },
  83. [setTemplateSettings],
  84. );
  85. const inboundTagOptions = useMemo(() => {
  86. const seen = new Set<string>();
  87. const out: string[] = [];
  88. const push = (tag?: string) => {
  89. if (!tag || seen.has(tag)) return;
  90. seen.add(tag);
  91. out.push(tag);
  92. };
  93. for (const ib of (templateSettings?.inbounds as Array<{ tag?: string }>) || []) push(ib?.tag);
  94. for (const tag of inboundTags || []) push(tag);
  95. for (const ob of templateSettings?.outbounds || []) {
  96. const obx = ob as { reverse?: { tag?: string }; settings?: { reverse?: { tag?: string }; inboundTag?: string } };
  97. push(obx?.reverse?.tag || obx?.settings?.reverse?.tag || obx?.settings?.inboundTag);
  98. }
  99. push((templateSettings?.dns as { tag?: string } | undefined)?.tag);
  100. for (const s of (templateSettings?.dns as { servers?: Array<{ tag?: string }> } | undefined)?.servers || []) {
  101. if (typeof s === 'object' && s?.tag) push(s.tag);
  102. }
  103. return out;
  104. }, [templateSettings, inboundTags]);
  105. const outboundTagOptions = useMemo(() => {
  106. const out = new Set<string>(['']);
  107. for (const ob of templateSettings?.outbounds || []) {
  108. if (ob?.tag) out.add(ob.tag);
  109. }
  110. for (const tag of clientReverseTags || []) {
  111. if (tag) out.add(tag);
  112. }
  113. for (const tag of subscriptionOutboundTags || []) {
  114. if (tag) out.add(tag);
  115. }
  116. return [...out];
  117. }, [templateSettings?.outbounds, clientReverseTags, subscriptionOutboundTags]);
  118. const balancerTagOptions = useMemo(() => {
  119. const out: string[] = [''];
  120. for (const b of (templateSettings?.routing?.balancers as Array<{ tag?: string }>) || []) {
  121. if (b?.tag) out.push(b.tag);
  122. }
  123. return out;
  124. }, [templateSettings?.routing?.balancers]);
  125. function openAdd() {
  126. setEditingRule(null);
  127. setEditingIndex(null);
  128. setRuleModalOpen(true);
  129. }
  130. function openEdit(idx: number) {
  131. setEditingRule(rulesRef.current[idx]);
  132. setEditingIndex(idx);
  133. setRuleModalOpen(true);
  134. }
  135. function onRuleConfirm(rule: Record<string, unknown>) {
  136. if (JSON.stringify(rule).length <= 3) {
  137. setRuleModalOpen(false);
  138. return;
  139. }
  140. mutate((tt) => {
  141. if (!tt.routing) tt.routing = { rules: [] };
  142. if (!Array.isArray(tt.routing.rules)) tt.routing.rules = [];
  143. const typed = rule as unknown as RuleObject;
  144. if (editingIndex == null) tt.routing.rules.push(typed);
  145. else tt.routing.rules[editingIndex] = typed;
  146. });
  147. setRuleModalOpen(false);
  148. }
  149. function confirmDelete(idx: number) {
  150. modal.confirm({
  151. title: `${t('delete')} ${t('pages.xray.Routings')} #${idx + 1}?`,
  152. okText: t('delete'),
  153. okType: 'danger',
  154. cancelText: t('cancel'),
  155. onOk: () => mutate((tt) => {
  156. tt.routing?.rules?.splice(idx, 1);
  157. }),
  158. });
  159. }
  160. function moveUp(idx: number) {
  161. if (idx <= 0) return;
  162. mutate((tt) => {
  163. const list = tt.routing?.rules;
  164. if (!list) return;
  165. [list[idx - 1], list[idx]] = [list[idx], list[idx - 1]];
  166. });
  167. }
  168. function moveDown(idx: number) {
  169. mutate((tt) => {
  170. const list = tt.routing?.rules;
  171. if (!list || idx >= list.length - 1) return;
  172. [list[idx + 1], list[idx]] = [list[idx], list[idx + 1]];
  173. });
  174. }
  175. function onHandlePointerDown(idx: number, ev: React.PointerEvent) {
  176. if (ev.button != null && ev.button !== 0) return;
  177. ev.preventDefault();
  178. try {
  179. (ev.currentTarget as Element).setPointerCapture(ev.pointerId);
  180. } catch { /* ignore */ }
  181. dragRef.current = { from: idx, to: idx, startY: ev.clientY, moved: false };
  182. setDraggedIndex(idx);
  183. setDropTargetIndex(idx);
  184. const onMove = (e: PointerEvent) => {
  185. const state = dragRef.current;
  186. if (state.from == null) return;
  187. if (!state.moved && Math.abs(e.clientY - state.startY) < 5) return;
  188. state.moved = true;
  189. const el = document.elementFromPoint(e.clientX, e.clientY);
  190. if (!el) return;
  191. const target = el.closest('[data-row-key]');
  192. if (!target) return;
  193. const newIdx = Number(target.getAttribute('data-row-key'));
  194. if (Number.isFinite(newIdx) && newIdx !== state.to) {
  195. state.to = newIdx;
  196. setDropTargetIndex(newIdx);
  197. }
  198. };
  199. const onUp = () => {
  200. document.removeEventListener('pointermove', onMove);
  201. document.removeEventListener('pointerup', onUp);
  202. document.removeEventListener('pointercancel', onUp);
  203. const { from, to, moved } = dragRef.current;
  204. dragRef.current = { from: null, to: null, startY: 0, moved: false };
  205. setDraggedIndex(null);
  206. setDropTargetIndex(null);
  207. if (!moved || from == null || to == null || from === to) return;
  208. mutate((tt) => {
  209. const list = tt.routing?.rules;
  210. if (!list) return;
  211. const [movedItem] = list.splice(from, 1);
  212. list.splice(to, 0, movedItem);
  213. });
  214. };
  215. document.addEventListener('pointermove', onMove);
  216. document.addEventListener('pointerup', onUp);
  217. document.addEventListener('pointercancel', onUp);
  218. }
  219. const hasSource = rows.some((r) => r.sourceIP || r.sourcePort || r.vlessRoute);
  220. const hasBalancer = rows.some((r) => r.balancerTag);
  221. const desktopColumns = useRoutingColumns({
  222. isMobile,
  223. rowsLength: rows.length,
  224. showSource: hasSource,
  225. showBalancer: hasBalancer,
  226. onHandlePointerDown,
  227. openEdit,
  228. moveUp,
  229. moveDown,
  230. confirmDelete,
  231. });
  232. const tableScrollX = desktopColumns.reduce((sum, c) => {
  233. const col = c as { width?: number; hidden?: boolean };
  234. return col.hidden ? sum : sum + (typeof col.width === 'number' ? col.width : 0);
  235. }, 0);
  236. return (
  237. <>
  238. {modalContextHolder}
  239. <Tabs
  240. defaultActiveKey="basic"
  241. items={[
  242. {
  243. key: 'basic',
  244. label: catTabLabel(<ControlOutlined />, t('pages.xray.basicRouting'), isMobile),
  245. children: (
  246. <RoutingBasic
  247. templateSettings={templateSettings}
  248. setTemplateSettings={setTemplateSettings}
  249. />
  250. ),
  251. },
  252. {
  253. key: 'rules',
  254. label: catTabLabel(<UnorderedListOutlined />, t('pages.xray.Routings'), isMobile),
  255. children: (
  256. <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
  257. <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
  258. {t('pages.xray.Routings')}
  259. </Button>
  260. {isMobile ? (
  261. <RuleCardList
  262. rows={rows}
  263. draggedIndex={draggedIndex}
  264. dropTargetIndex={dropTargetIndex}
  265. onHandlePointerDown={onHandlePointerDown}
  266. openEdit={openEdit}
  267. moveUp={moveUp}
  268. moveDown={moveDown}
  269. confirmDelete={confirmDelete}
  270. />
  271. ) : (
  272. <Table
  273. columns={desktopColumns}
  274. dataSource={rows}
  275. rowKey={(r) => r.key}
  276. pagination={false}
  277. scroll={{ x: tableScrollX }}
  278. size="small"
  279. className="routing-table"
  280. onRow={(_record, index) => {
  281. const classes: string[] = [];
  282. const i = index ?? -1;
  283. if (draggedIndex === i) classes.push('row-dragging');
  284. if (dropTargetIndex === i && draggedIndex !== i && draggedIndex != null) {
  285. classes.push(i > draggedIndex ? 'drop-after' : 'drop-before');
  286. }
  287. return { className: classes.join(' '), 'data-row-key': i } as React.HTMLAttributes<HTMLElement>;
  288. }}
  289. />
  290. )}
  291. </Space>
  292. ),
  293. },
  294. ]}
  295. />
  296. <RuleFormModal
  297. open={ruleModalOpen}
  298. rule={editingRule}
  299. inboundTags={inboundTagOptions}
  300. outboundTags={outboundTagOptions}
  301. balancerTags={balancerTagOptions}
  302. onClose={() => setRuleModalOpen(false)}
  303. onConfirm={onRuleConfirm}
  304. />
  305. </>
  306. );
  307. }