XrayPage.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. import { useCallback, useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import {
  4. Alert,
  5. Button,
  6. Card,
  7. Col,
  8. ConfigProvider,
  9. FloatButton,
  10. Layout,
  11. message,
  12. Modal,
  13. Popover,
  14. Radio,
  15. Result,
  16. Row,
  17. Space,
  18. Spin,
  19. Tabs,
  20. Tooltip,
  21. } from 'antd';
  22. import {
  23. SettingOutlined,
  24. SwapOutlined,
  25. UploadOutlined,
  26. ClusterOutlined,
  27. DatabaseOutlined,
  28. CodeOutlined,
  29. QuestionCircleOutlined,
  30. } from '@ant-design/icons';
  31. import { useTheme } from '@/hooks/useTheme';
  32. import { useMediaQuery } from '@/hooks/useMediaQuery';
  33. import { useXraySetting } from '@/hooks/useXraySetting';
  34. import type { XraySettingsValue } from '@/hooks/useXraySetting';
  35. import AppSidebar from '@/components/AppSidebar';
  36. import JsonEditor from '@/components/JsonEditor';
  37. import { setMessageInstance } from '@/utils/messageBus';
  38. import BasicsTab from './BasicsTab';
  39. import RoutingTab from './RoutingTab';
  40. import OutboundsTab from './OutboundsTab';
  41. import BalancersTab from './BalancersTab';
  42. import DnsTab from './DnsTab';
  43. import WarpModal from './WarpModal';
  44. import NordModal from './NordModal';
  45. import './XrayPage.css';
  46. const TAB_KEYS = ['tpl-basic', 'tpl-routing', 'tpl-outbound', 'tpl-balancer', 'tpl-dns', 'tpl-advanced'];
  47. const SLUG_BY_KEY: Record<string, string> = {
  48. 'tpl-basic': 'basic',
  49. 'tpl-routing': 'routing',
  50. 'tpl-outbound': 'outbound',
  51. 'tpl-balancer': 'balancer',
  52. 'tpl-dns': 'dns',
  53. 'tpl-advanced': 'advanced',
  54. };
  55. const KEY_BY_SLUG: Record<string, string> = Object.fromEntries(
  56. Object.entries(SLUG_BY_KEY).map(([k, v]) => [v, k]),
  57. );
  58. type AdvKey = 'xraySetting' | 'inboundSettings' | 'outboundSettings' | 'routingRuleSettings';
  59. export default function XrayPage() {
  60. const { t } = useTranslation();
  61. const { isDark, isUltra, antdThemeConfig } = useTheme();
  62. const { isMobile } = useMediaQuery();
  63. const [messageApi, messageContextHolder] = message.useMessage();
  64. useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
  65. const xs = useXraySetting();
  66. const {
  67. fetched,
  68. spinning,
  69. saveDisabled,
  70. fetchError,
  71. xraySetting,
  72. setXraySetting,
  73. templateSettings,
  74. setTemplateSettings,
  75. outboundTestUrl,
  76. setOutboundTestUrl,
  77. inboundTags,
  78. clientReverseTags,
  79. restartResult,
  80. outboundsTraffic,
  81. outboundTestStates,
  82. testingAll,
  83. fetchAll,
  84. resetOutboundsTraffic,
  85. testOutbound,
  86. testAllOutbounds,
  87. saveAll,
  88. resetToDefault,
  89. restartXray,
  90. } = xs;
  91. const [modal, modalContextHolder] = Modal.useModal();
  92. const [warpOpen, setWarpOpen] = useState(false);
  93. const [nordOpen, setNordOpen] = useState(false);
  94. const [advSettings, setAdvSettings] = useState<AdvKey>('xraySetting');
  95. const [activeTabKey, setActiveTabKey] = useState(() => {
  96. const slug = window.location.hash.slice(1);
  97. return KEY_BY_SLUG[slug] || TAB_KEYS[0];
  98. });
  99. useEffect(() => {
  100. function syncTabFromHash() {
  101. const key = KEY_BY_SLUG[window.location.hash.slice(1)];
  102. if (key) setActiveTabKey(key);
  103. }
  104. window.addEventListener('hashchange', syncTabFromHash);
  105. return () => window.removeEventListener('hashchange', syncTabFromHash);
  106. }, []);
  107. function onTabChange(key: string) {
  108. setActiveTabKey(key);
  109. const slug = SLUG_BY_KEY[key];
  110. if (slug && window.location.hash !== `#${slug}`) {
  111. history.replaceState(null, '', `#${slug}`);
  112. }
  113. }
  114. const mutate = useCallback(
  115. (mutator: (next: XraySettingsValue) => void) => {
  116. setTemplateSettings((prev) => {
  117. if (!prev) return prev;
  118. const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
  119. mutator(clone);
  120. return clone;
  121. });
  122. },
  123. [setTemplateSettings],
  124. );
  125. const warpExist = !!templateSettings?.outbounds?.find((o) => o?.tag === 'warp');
  126. const nordExist = !!templateSettings?.outbounds?.find((o) => o?.tag?.startsWith?.('nord-'));
  127. async function onTestOutbound(idx: number, mode: string) {
  128. const outbound = templateSettings?.outbounds?.[idx];
  129. if (outbound) await testOutbound(idx, outbound, mode);
  130. }
  131. function onAddOutbound(outbound: Record<string, unknown>) {
  132. mutate((tt) => {
  133. if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
  134. tt.outbounds.push(outbound as never);
  135. });
  136. }
  137. function onResetOutbound(payload: { index: number; outbound: Record<string, unknown>; oldTag?: string; newTag?: string }) {
  138. mutate((tt) => {
  139. if (!tt.outbounds || payload.index < 0) return;
  140. tt.outbounds[payload.index] = payload.outbound as never;
  141. if (payload.oldTag && payload.newTag && payload.oldTag !== payload.newTag) {
  142. const rules = tt.routing?.rules || [];
  143. for (const r of rules) {
  144. if (r?.outboundTag === payload.oldTag) r.outboundTag = payload.newTag;
  145. }
  146. }
  147. });
  148. }
  149. function onRemoveOutboundByTag(tag: string) {
  150. mutate((tt) => {
  151. if (!tt.outbounds) return;
  152. const idx = tt.outbounds.findIndex((o) => o?.tag === tag);
  153. if (idx >= 0) tt.outbounds.splice(idx, 1);
  154. });
  155. }
  156. function onRemoveOutboundByIndex(index: number) {
  157. mutate((tt) => {
  158. if (tt.outbounds && index >= 0) tt.outbounds.splice(index, 1);
  159. });
  160. }
  161. function onRemoveRoutingRules(payload: { prefix: string }) {
  162. mutate((tt) => {
  163. const rules = tt.routing?.rules;
  164. if (!Array.isArray(rules)) return;
  165. tt.routing!.rules = rules.filter((r) => !r?.outboundTag?.startsWith?.(payload.prefix));
  166. });
  167. }
  168. const advancedText = useMemo(() => {
  169. if (advSettings === 'xraySetting') return xraySetting;
  170. const tpl = templateSettings;
  171. if (!tpl) return '';
  172. try {
  173. switch (advSettings) {
  174. case 'inboundSettings': return JSON.stringify(tpl.inbounds || [], null, 2);
  175. case 'outboundSettings': return JSON.stringify(tpl.outbounds || [], null, 2);
  176. case 'routingRuleSettings': return JSON.stringify(tpl.routing?.rules || [], null, 2);
  177. default: return '';
  178. }
  179. } catch {
  180. return '';
  181. }
  182. }, [advSettings, xraySetting, templateSettings]);
  183. function onAdvancedTextChange(next: string) {
  184. if (advSettings === 'xraySetting') {
  185. setXraySetting(next);
  186. return;
  187. }
  188. let parsed;
  189. try {
  190. parsed = JSON.parse(next);
  191. } catch {
  192. return;
  193. }
  194. mutate((tt) => {
  195. switch (advSettings) {
  196. case 'inboundSettings':
  197. tt.inbounds = parsed;
  198. break;
  199. case 'outboundSettings':
  200. tt.outbounds = parsed;
  201. break;
  202. case 'routingRuleSettings':
  203. if (!tt.routing) tt.routing = {};
  204. tt.routing.rules = parsed;
  205. break;
  206. }
  207. });
  208. }
  209. function confirmRestart() {
  210. modal.confirm({
  211. title: t('pages.xray.restartConfirmTitle'),
  212. content: t('pages.xray.restartConfirmContent'),
  213. okText: t('pages.xray.restart'),
  214. cancelText: t('cancel'),
  215. onOk: () => restartXray(),
  216. });
  217. }
  218. function onSaveAll() {
  219. try {
  220. JSON.parse(xraySetting);
  221. } catch (e) {
  222. messageApi.error(`Advanced JSON: ${(e as Error).message}`);
  223. setActiveTabKey('tpl-advanced');
  224. return;
  225. }
  226. saveAll();
  227. }
  228. const scrollTarget = () => document.getElementById('content-layout') || window;
  229. const pageClass = `xray-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
  230. return (
  231. <ConfigProvider theme={antdThemeConfig}>
  232. {messageContextHolder}
  233. {modalContextHolder}
  234. <Layout className={pageClass}>
  235. <AppSidebar />
  236. <Layout className="content-shell">
  237. <Layout.Content id="content-layout" className="content-area">
  238. <Spin spinning={spinning || !fetched} delay={200} description={t('loading')} size="large">
  239. {!fetched ? (
  240. <div className="loading-spacer" />
  241. ) : fetchError ? (
  242. <Result
  243. status="error"
  244. title={t('somethingWentWrong')}
  245. subTitle={fetchError}
  246. extra={<Button type="primary" onClick={fetchAll}>{t('check')}</Button>}
  247. />
  248. ) : (
  249. <Row gutter={[isMobile ? 8 : 16, isMobile ? 0 : 12]}>
  250. <Col span={24}>
  251. <Card hoverable>
  252. <Row className="header-row">
  253. <Col xs={24} sm={14} className="header-actions">
  254. <Space>
  255. <Button type="primary" disabled={saveDisabled} onClick={onSaveAll}>
  256. {t('pages.xray.save')}
  257. </Button>
  258. <Button type="primary" danger disabled={!saveDisabled} onClick={confirmRestart}>
  259. {t('pages.xray.restart')}
  260. </Button>
  261. {restartResult && (
  262. <Popover
  263. placement="rightTop"
  264. title={t('pages.xray.restartOutputTitle')}
  265. content={<pre className="restart-result">{restartResult}</pre>}
  266. >
  267. <QuestionCircleOutlined className="restart-icon" />
  268. </Popover>
  269. )}
  270. </Space>
  271. </Col>
  272. <Col xs={24} sm={10} className="header-info">
  273. <FloatButton.BackTop target={scrollTarget} visibilityHeight={200} />
  274. <Alert type="warning" showIcon title={t('pages.settings.infoDesc')} />
  275. </Col>
  276. </Row>
  277. </Card>
  278. </Col>
  279. <Col span={24}>
  280. <Card hoverable>
  281. <Tabs
  282. activeKey={activeTabKey}
  283. onChange={onTabChange}
  284. className={isMobile ? 'icons-only' : ''}
  285. items={[
  286. {
  287. key: 'tpl-basic',
  288. label: (
  289. <Tooltip title={isMobile ? t('pages.xray.basicTemplate') : ''}>
  290. <SettingOutlined />
  291. {!isMobile && <span>{` ${t('pages.xray.basicTemplate')}`}</span>}
  292. </Tooltip>
  293. ),
  294. children: (
  295. <BasicsTab
  296. templateSettings={templateSettings}
  297. setTemplateSettings={setTemplateSettings}
  298. outboundTestUrl={outboundTestUrl}
  299. onChangeOutboundTestUrl={setOutboundTestUrl}
  300. warpExist={warpExist}
  301. nordExist={nordExist}
  302. onShowWarp={() => setWarpOpen(true)}
  303. onShowNord={() => setNordOpen(true)}
  304. onResetDefault={resetToDefault}
  305. />
  306. ),
  307. },
  308. {
  309. key: 'tpl-routing',
  310. label: (
  311. <Tooltip title={isMobile ? t('pages.xray.Routings') : ''}>
  312. <SwapOutlined />
  313. {!isMobile && <span>{` ${t('pages.xray.Routings')}`}</span>}
  314. </Tooltip>
  315. ),
  316. children: (
  317. <RoutingTab
  318. templateSettings={templateSettings}
  319. setTemplateSettings={setTemplateSettings}
  320. inboundTags={inboundTags}
  321. clientReverseTags={clientReverseTags}
  322. isMobile={isMobile}
  323. />
  324. ),
  325. },
  326. {
  327. key: 'tpl-outbound',
  328. label: (
  329. <Tooltip title={isMobile ? t('pages.xray.Outbounds') : ''}>
  330. <UploadOutlined />
  331. {!isMobile && <span>{` ${t('pages.xray.Outbounds')}`}</span>}
  332. </Tooltip>
  333. ),
  334. children: (
  335. <OutboundsTab
  336. templateSettings={templateSettings}
  337. setTemplateSettings={setTemplateSettings}
  338. outboundsTraffic={outboundsTraffic}
  339. outboundTestStates={outboundTestStates}
  340. testingAll={testingAll}
  341. inboundTags={inboundTags}
  342. isMobile={isMobile}
  343. onResetTraffic={resetOutboundsTraffic}
  344. onTest={onTestOutbound}
  345. onTestAll={testAllOutbounds}
  346. onShowWarp={() => setWarpOpen(true)}
  347. onShowNord={() => setNordOpen(true)}
  348. />
  349. ),
  350. },
  351. {
  352. key: 'tpl-balancer',
  353. label: (
  354. <Tooltip title={isMobile ? t('pages.xray.Balancers') : ''}>
  355. <ClusterOutlined />
  356. {!isMobile && <span>{` ${t('pages.xray.Balancers')}`}</span>}
  357. </Tooltip>
  358. ),
  359. children: (
  360. <BalancersTab
  361. templateSettings={templateSettings}
  362. setTemplateSettings={setTemplateSettings}
  363. clientReverseTags={clientReverseTags}
  364. isMobile={isMobile}
  365. />
  366. ),
  367. },
  368. {
  369. key: 'tpl-dns',
  370. label: (
  371. <Tooltip title={isMobile ? 'DNS' : ''}>
  372. <DatabaseOutlined />
  373. {!isMobile && <span> DNS</span>}
  374. </Tooltip>
  375. ),
  376. children: (
  377. <DnsTab
  378. templateSettings={templateSettings}
  379. setTemplateSettings={setTemplateSettings}
  380. />
  381. ),
  382. },
  383. {
  384. key: 'tpl-advanced',
  385. label: (
  386. <Tooltip title={isMobile ? t('pages.xray.advancedTemplate') : ''}>
  387. <CodeOutlined />
  388. {!isMobile && <span>{` ${t('pages.xray.advancedTemplate')}`}</span>}
  389. </Tooltip>
  390. ),
  391. children: (
  392. <>
  393. <div className="advanced-meta">
  394. <h4>{t('pages.xray.Template')}</h4>
  395. <p>{t('pages.xray.TemplateDesc')}</p>
  396. </div>
  397. <Radio.Group
  398. value={advSettings}
  399. buttonStyle="solid"
  400. size={isMobile ? 'small' : 'middle'}
  401. style={{ margin: '12px 0' }}
  402. onChange={(e) => setAdvSettings(e.target.value)}
  403. >
  404. <Radio.Button value="xraySetting">{t('pages.xray.completeTemplate')}</Radio.Button>
  405. <Radio.Button value="inboundSettings">{t('pages.xray.Inbounds')}</Radio.Button>
  406. <Radio.Button value="outboundSettings">{t('pages.xray.Outbounds')}</Radio.Button>
  407. <Radio.Button value="routingRuleSettings">{t('pages.xray.Routings')}</Radio.Button>
  408. </Radio.Group>
  409. <JsonEditor
  410. value={advancedText}
  411. onChange={onAdvancedTextChange}
  412. minHeight="420px"
  413. maxHeight="720px"
  414. />
  415. </>
  416. ),
  417. },
  418. ]}
  419. />
  420. </Card>
  421. </Col>
  422. </Row>
  423. )}
  424. </Spin>
  425. </Layout.Content>
  426. </Layout>
  427. <WarpModal
  428. open={warpOpen}
  429. templateSettings={templateSettings}
  430. onClose={() => setWarpOpen(false)}
  431. onAddOutbound={onAddOutbound}
  432. onResetOutbound={onResetOutbound}
  433. onRemoveOutbound={onRemoveOutboundByTag}
  434. />
  435. <NordModal
  436. open={nordOpen}
  437. templateSettings={templateSettings}
  438. onClose={() => setNordOpen(false)}
  439. onAddOutbound={onAddOutbound}
  440. onResetOutbound={onResetOutbound}
  441. onRemoveOutbound={onRemoveOutboundByIndex}
  442. onRemoveRoutingRules={onRemoveRoutingRules}
  443. />
  444. </Layout>
  445. </ConfigProvider>
  446. );
  447. }