XrayPage.tsx 13 KB

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