SubPage.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. import { useCallback, useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import {
  4. Button,
  5. Card,
  6. Col,
  7. ConfigProvider,
  8. Descriptions,
  9. Divider,
  10. Dropdown,
  11. Layout,
  12. Menu,
  13. message,
  14. Popover,
  15. QRCode,
  16. Row,
  17. Space,
  18. Tag,
  19. Tooltip,
  20. } from 'antd';
  21. import {
  22. AndroidOutlined,
  23. AppleOutlined,
  24. CopyOutlined,
  25. DownOutlined,
  26. MoonFilled,
  27. MoonOutlined,
  28. QrcodeOutlined,
  29. SunOutlined,
  30. TranslationOutlined,
  31. } from '@ant-design/icons';
  32. import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
  33. import { isPostQuantumLink } from '@/lib/xray/inbound-link';
  34. import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
  35. import { setMessageInstance } from '@/utils/messageBus';
  36. import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
  37. import SubUsageSummary from './SubUsageSummary';
  38. import './SubPage.css';
  39. const QR_SIZE = 240;
  40. const subData = window.__SUB_PAGE_DATA__ || {};
  41. const sId = subData.sId || '';
  42. const enabled = !!subData.enabled;
  43. const download = subData.download || '0';
  44. const upload = subData.upload || '0';
  45. const total = subData.total || '∞';
  46. const used = subData.used || '0';
  47. const remained = subData.remained || '';
  48. const totalByte = Number(subData.totalByte || 0);
  49. const expireMs = Number(subData.expire || 0) * 1000;
  50. const lastOnlineMs = Number(subData.lastOnline || 0);
  51. const subUrl = subData.subUrl || '';
  52. const subJsonUrl = subData.subJsonUrl || '';
  53. const subClashUrl = subData.subClashUrl || '';
  54. const subTitle = subData.subTitle || '';
  55. const links: string[] = Array.isArray(subData.links) ? subData.links : [];
  56. const linkEmails: string[] = Array.isArray(subData.emails) ? subData.emails : [];
  57. const subEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
  58. const datepicker = subData.datepicker || 'gregorian';
  59. const isUnlimited = totalByte <= 0 && expireMs === 0;
  60. const isActive = (() => {
  61. if (!enabled) return false;
  62. if (totalByte > 0) {
  63. const usedByteCalc = Number(subData.usedByte || 0)
  64. || (Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0));
  65. if (usedByteCalc >= totalByte) return false;
  66. }
  67. if (expireMs > 0 && Date.now() >= expireMs) return false;
  68. return true;
  69. })();
  70. export default function SubPage() {
  71. const { t } = useTranslation();
  72. const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
  73. const [messageApi, messageContextHolder] = message.useMessage();
  74. useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
  75. const [isMobile, setIsMobile] = useState<boolean>(() => window.innerWidth < 576);
  76. const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
  77. useEffect(() => {
  78. const onResize = () => setIsMobile(window.innerWidth < 576);
  79. window.addEventListener('resize', onResize);
  80. return () => window.removeEventListener('resize', onResize);
  81. }, []);
  82. const onLangChange = useCallback((next: string) => {
  83. setLang(next);
  84. LanguageManager.setLanguage(next);
  85. }, []);
  86. const cycleTheme = useCallback(() => {
  87. pauseAnimationsUntilLeave('sub-theme-cycle');
  88. if (!isDark) {
  89. toggleTheme();
  90. if (isUltra) toggleUltra();
  91. } else if (!isUltra) {
  92. toggleUltra();
  93. } else {
  94. toggleUltra();
  95. toggleTheme();
  96. }
  97. }, [isDark, isUltra, toggleTheme, toggleUltra]);
  98. const copy = useCallback(async (value: string) => {
  99. if (!value) return;
  100. const ok = await ClipboardManager.copyText(value);
  101. if (ok) messageApi.success(t('copied'));
  102. }, [t, messageApi]);
  103. const copyAll = useCallback(async () => {
  104. if (links.length === 0) return;
  105. const allLinks = links.join('\n');
  106. const ok = await ClipboardManager.copyText(allLinks);
  107. if (ok) messageApi.success(t('subscription.copyAllConfigsCopied'));
  108. }, [t, messageApi]);
  109. const open = useCallback((url: string) => {
  110. if (!url) return;
  111. window.open(url, '_blank');
  112. }, []);
  113. const shadowrocketUrl = useMemo(() => {
  114. if (!subUrl) return '';
  115. const separator = subUrl.includes('?') ? '&' : '?';
  116. const rawUrl = subUrl + separator + 'flag=shadowrocket';
  117. const base64Url = btoa(rawUrl);
  118. const remark = encodeURIComponent(subTitle || sId || 'Subscription');
  119. return `shadowrocket://add/sub://${base64Url}?remark=${remark}`;
  120. }, []);
  121. const v2boxUrl = useMemo(
  122. () => `v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`,
  123. [],
  124. );
  125. const streisandUrl = useMemo(() => `streisand://import/${encodeURIComponent(subUrl)}`, []);
  126. const happUrl = useMemo(() => `happ://add/${subUrl}`, []);
  127. const pageClass = useMemo(() => {
  128. const classes = ['subscription-page'];
  129. if (isDark) classes.push('is-dark');
  130. if (isUltra) classes.push('is-ultra');
  131. return classes.join(' ');
  132. }, [isDark, isUltra]);
  133. const descriptionsItems = useMemo(() => {
  134. const items = [
  135. { key: 'subId', label: t('subscription.subId'), children: sId },
  136. ...(subEmail ? [{ key: 'email', label: t('subscription.email'), children: subEmail }] : []),
  137. {
  138. key: 'status',
  139. label: t('subscription.status'),
  140. children: !enabled
  141. ? <Tag color="red">{t('subscription.inactive')}</Tag>
  142. : isUnlimited
  143. ? <Tag color="purple">{t('subscription.unlimited')}</Tag>
  144. : <Tag color={isActive ? 'green' : 'red'}>
  145. {isActive ? t('subscription.active') : t('subscription.inactive')}
  146. </Tag>,
  147. },
  148. { key: 'down', label: t('subscription.downloaded'), children: download },
  149. { key: 'up', label: t('subscription.uploaded'), children: upload },
  150. { key: 'used', label: t('usage'), children: used },
  151. { key: 'total', label: t('subscription.totalQuota'), children: total },
  152. ];
  153. if (totalByte > 0) {
  154. items.push({ key: 'remained', label: t('remained'), children: remained });
  155. }
  156. items.push({
  157. key: 'lastOnline',
  158. label: t('lastOnline'),
  159. children: lastOnlineMs > 0 ? IntlUtil.formatDate(lastOnlineMs, datepicker) : '-',
  160. });
  161. items.push({
  162. key: 'expiry',
  163. label: t('subscription.expiry'),
  164. children: expireMs === 0
  165. ? t('subscription.noExpiry')
  166. : IntlUtil.formatDate(expireMs, datepicker),
  167. });
  168. return items;
  169. }, [t]);
  170. const androidMenuItems = useMemo(() => [
  171. {
  172. key: 'android-v2box',
  173. label: 'V2Box',
  174. onClick: () => open(`v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`),
  175. },
  176. {
  177. key: 'android-v2rayng',
  178. label: 'V2RayNG',
  179. onClick: () => open(`v2rayng://install-config?url=${encodeURIComponent(subUrl)}`),
  180. },
  181. { key: 'android-singbox', label: 'Sing-box', onClick: () => copy(subUrl) },
  182. { key: 'android-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
  183. { key: 'android-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
  184. { key: 'android-happ', label: 'Happ', onClick: () => open(`happ://add/${subUrl}`) },
  185. ], [copy, open]);
  186. const iosMenuItems = useMemo(() => [
  187. { key: 'ios-shadowrocket', label: 'Shadowrocket', onClick: () => open(shadowrocketUrl) },
  188. { key: 'ios-v2box', label: 'V2Box', onClick: () => open(v2boxUrl) },
  189. { key: 'ios-streisand', label: 'Streisand', onClick: () => open(streisandUrl) },
  190. { key: 'ios-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
  191. { key: 'ios-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
  192. { key: 'ios-happ', label: 'Happ', onClick: () => open(happUrl) },
  193. ], [copy, open, shadowrocketUrl, v2boxUrl, streisandUrl, happUrl]);
  194. const langMenuItems = useMemo(
  195. () => (LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map((l) => ({
  196. key: l.value,
  197. label: (
  198. <Space size={8}>
  199. <span aria-hidden="true">{l.icon}</span>
  200. <span>{l.name}</span>
  201. </Space>
  202. ),
  203. })),
  204. [],
  205. );
  206. const themeIcon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
  207. const cardTitle = (
  208. <Space>
  209. <span>{t('subscription.title')}</span>
  210. <Tag>{sId}</Tag>
  211. </Space>
  212. );
  213. const cardExtra = (
  214. <Space size={8} align="center">
  215. <Button
  216. shape="circle"
  217. size="large"
  218. className="toolbar-btn"
  219. aria-label={t('menu.theme')}
  220. title={t('menu.theme')}
  221. icon={themeIcon}
  222. onClick={cycleTheme}
  223. />
  224. <Popover
  225. rootClassName={isDark ? 'dark' : 'light'}
  226. placement="bottomRight"
  227. trigger="click"
  228. styles={{ content: { padding: 4 } }}
  229. content={
  230. <Menu
  231. mode="vertical"
  232. selectable
  233. selectedKeys={[lang]}
  234. items={langMenuItems}
  235. onClick={({ key }) => onLangChange(key)}
  236. style={{ border: 'none', minWidth: 160 }}
  237. />
  238. }
  239. >
  240. <Button
  241. shape="circle"
  242. size="large"
  243. className="toolbar-btn"
  244. aria-label={t('pages.settings.language')}
  245. icon={<TranslationOutlined />}
  246. />
  247. </Popover>
  248. </Space>
  249. );
  250. return (
  251. <ConfigProvider theme={antdThemeConfig}>
  252. {messageContextHolder}
  253. <Layout className={pageClass}>
  254. <Layout.Content className="content">
  255. <Row justify="center">
  256. <Col xs={24} sm={22} md={18} lg={14} xl={12}>
  257. <Card hoverable className="subscription-card" title={cardTitle} extra={cardExtra}>
  258. <Descriptions
  259. bordered
  260. column={1}
  261. size="small"
  262. className="info-table"
  263. items={descriptionsItems}
  264. />
  265. <SubUsageSummary
  266. usedByte={Number(subData.usedByte || 0)
  267. || (Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0))}
  268. totalByte={totalByte}
  269. usedLabel={used}
  270. totalLabel={total}
  271. remainedLabel={remained}
  272. expireMs={expireMs}
  273. isActive={isActive}
  274. />
  275. {(subUrl || subJsonUrl || subClashUrl) && (
  276. <>
  277. <Divider>{t('subscription.title')}</Divider>
  278. <div className="links-section">
  279. {subUrl && (
  280. <div className="sub-link-row">
  281. <Tag color="green" className="sub-link-tag">SUB</Tag>
  282. <a
  283. href={subUrl}
  284. target="_blank"
  285. rel="noopener noreferrer"
  286. className="sub-link-title sub-link-anchor"
  287. title={subUrl}
  288. >
  289. {sId}
  290. </a>
  291. <div className="sub-link-actions">
  292. <Button size="small" icon={<CopyOutlined />} onClick={() => copy(subUrl)} aria-label={t('copy')} title={t('copy')} />
  293. <Popover
  294. trigger="click"
  295. placement="left"
  296. destroyOnHidden
  297. content={
  298. <div className="sub-link-qr-popover">
  299. <Tag color="green" className="qr-tag">{t('pages.settings.subSettings')}</Tag>
  300. <QRCode value={subUrl} size={QR_SIZE} type="svg" bordered={false} color="#000000" bgColor="#ffffff" />
  301. </div>
  302. }
  303. >
  304. <Button size="small" icon={<QrcodeOutlined />} aria-label="QR" title="QR" />
  305. </Popover>
  306. </div>
  307. </div>
  308. )}
  309. {subJsonUrl && (
  310. <div className="sub-link-row">
  311. <Tag color="purple" className="sub-link-tag">JSON</Tag>
  312. <a
  313. href={subJsonUrl}
  314. target="_blank"
  315. rel="noopener noreferrer"
  316. className="sub-link-title sub-link-anchor"
  317. title={subJsonUrl}
  318. >
  319. {sId}
  320. </a>
  321. <div className="sub-link-actions">
  322. <Button size="small" icon={<CopyOutlined />} onClick={() => copy(subJsonUrl)} aria-label={t('copy')} title={t('copy')} />
  323. <Popover
  324. trigger="click"
  325. placement="left"
  326. destroyOnHidden
  327. content={
  328. <div className="sub-link-qr-popover">
  329. <Tag color="purple" className="qr-tag">{t('pages.settings.subSettings')} JSON</Tag>
  330. <QRCode value={subJsonUrl} size={QR_SIZE} type="svg" bordered={false} color="#000000" bgColor="#ffffff" />
  331. </div>
  332. }
  333. >
  334. <Button size="small" icon={<QrcodeOutlined />} aria-label="QR" title="QR" />
  335. </Popover>
  336. </div>
  337. </div>
  338. )}
  339. {subClashUrl && (
  340. <div className="sub-link-row">
  341. <Tooltip title="Clash / Mihomo">
  342. <Tag color="gold" className="sub-link-tag">CLASH</Tag>
  343. </Tooltip>
  344. <a
  345. href={subClashUrl}
  346. target="_blank"
  347. rel="noopener noreferrer"
  348. className="sub-link-title sub-link-anchor"
  349. title={subClashUrl}
  350. >
  351. {sId}
  352. </a>
  353. <div className="sub-link-actions">
  354. <Button size="small" icon={<CopyOutlined />} onClick={() => copy(subClashUrl)} aria-label={t('copy')} title={t('copy')} />
  355. <Popover
  356. trigger="click"
  357. placement="left"
  358. destroyOnHidden
  359. content={
  360. <div className="sub-link-qr-popover">
  361. <Tag color="gold" className="qr-tag">Clash / Mihomo</Tag>
  362. <QRCode value={subClashUrl} size={QR_SIZE} type="svg" bordered={false} color="#000000" bgColor="#ffffff" />
  363. </div>
  364. }
  365. >
  366. <Button size="small" icon={<QrcodeOutlined />} aria-label="QR" title="QR" />
  367. </Popover>
  368. </div>
  369. </div>
  370. )}
  371. </div>
  372. </>
  373. )}
  374. {links.length > 0 && (
  375. <>
  376. <Divider>{t('pages.inbounds.copyLink')}</Divider>
  377. <div className="links-section">
  378. <div className="sub-link-row">
  379. <span className="sub-link-title">{t('subscription.copyAllConfigs')}</span>
  380. <div className="sub-link-actions">
  381. <Button
  382. size="small"
  383. icon={<CopyOutlined />}
  384. onClick={copyAll}
  385. aria-label={t('subscription.copyAllConfigs')}
  386. title={t('subscription.copyAllConfigs')}
  387. />
  388. </div>
  389. </div>
  390. {links.map((link, idx) => {
  391. const parts = parseLinkParts(link);
  392. const fallback = `Link ${idx + 1}`;
  393. const rowTitle = parts?.remark || fallback;
  394. const qrLabel = [parts?.remark, linkEmails[idx]].filter(Boolean).join('-') || rowTitle;
  395. const canQr = !isPostQuantumLink(link);
  396. return (
  397. <div key={link} className="sub-link-row">
  398. {parts
  399. ? <LinkTags parts={parts} />
  400. : <Tag className="sub-link-tag">LINK</Tag>}
  401. <span className="sub-link-title" title={rowTitle}>
  402. {rowTitle}
  403. </span>
  404. <div className="sub-link-actions">
  405. <Button
  406. size="small"
  407. icon={<CopyOutlined />}
  408. onClick={() => copy(link)}
  409. aria-label={t('copy')}
  410. title={t('copy')}
  411. />
  412. {canQr && (
  413. <Popover
  414. trigger="click"
  415. placement="left"
  416. destroyOnHidden
  417. content={
  418. <div className="sub-link-qr-popover">
  419. <Tag className="qr-tag">{qrLabel}</Tag>
  420. <QRCode
  421. value={link}
  422. size={220}
  423. type="svg"
  424. bordered={false}
  425. color="#000000"
  426. bgColor="#ffffff"
  427. />
  428. </div>
  429. }
  430. >
  431. <Button
  432. size="small"
  433. icon={<QrcodeOutlined />}
  434. aria-label="QR"
  435. title="QR"
  436. />
  437. </Popover>
  438. )}
  439. </div>
  440. </div>
  441. );
  442. })}
  443. </div>
  444. </>
  445. )}
  446. <Row gutter={[8, 8]} justify="center" className="apps-row">
  447. <Col xs={24} sm={12} className="app-col">
  448. <Dropdown trigger={['click']} menu={{ items: androidMenuItems }}>
  449. <Button block={isMobile} size="large" type="primary">
  450. <AndroidOutlined /> Android <DownOutlined />
  451. </Button>
  452. </Dropdown>
  453. </Col>
  454. <Col xs={24} sm={12} className="app-col">
  455. <Dropdown trigger={['click']} menu={{ items: iosMenuItems }}>
  456. <Button block={isMobile} size="large" type="primary">
  457. <AppleOutlined /> iOS <DownOutlined />
  458. </Button>
  459. </Dropdown>
  460. </Col>
  461. </Row>
  462. </Card>
  463. </Col>
  464. </Row>
  465. </Layout.Content>
  466. </Layout>
  467. </ConfigProvider>
  468. );
  469. }