XrayPage.tsx 12 KB

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