XrayPage.tsx 13 KB

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