feat(sub): add raw subscription download actions (#6017)

* feat(sub): add raw subscription downloads

* fix(sub): address review feedback

* feat(sub): add download buttons to client subscriptions

* fix(sub): fetch subscription before download

---------

Co-authored-by: w3struk <w3struk@gmail.com>
This commit is contained in:
w3struk
2026-07-21 18:52:33 +05:00
committed by GitHub
parent d38c912dc1
commit 123fac222b
5 changed files with 146 additions and 14 deletions
+33 -2
View File
@@ -1,9 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Modal, Popover, Tag, Tooltip, message } from 'antd';
import { CopyOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-design/icons';
import { CopyOutlined, DownloadOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-design/icons';
import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
import { ClipboardManager, FileManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
import { formatInboundLabel } from '@/lib/inbounds/label';
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
import { useDatepicker } from '@/hooks/useDatepicker';
@@ -64,6 +64,12 @@ const DEFAULT_SUB: SubSettings = {
publicHost: '',
};
const SUBSCRIPTION_DOWNLOAD_NAMES = {
standard: 'subscription-standard.txt',
json: 'subscription-json.json',
clash: 'subscription-clash.yaml',
} as const;
export default function ClientInfoModal({
open,
client,
@@ -89,6 +95,7 @@ export default function ClientInfoModal({
const [ipsLoading, setIpsLoading] = useState(false);
const [ipsClearing, setIpsClearing] = useState(false);
const [ipsModalOpen, setIpsModalOpen] = useState(false);
const [downloadingFormat, setDownloadingFormat] = useState<keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES | null>(null);
useEffect(() => {
if (!open) {
@@ -148,6 +155,21 @@ export default function ClientInfoModal({
if (ok) messageApi.success(t('copied'));
}
async function downloadSubscription(url: string, format: keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES) {
if (!url || downloadingFormat) return;
setDownloadingFormat(format);
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Subscription download failed');
const content = await response.text();
FileManager.downloadTextFile(content, SUBSCRIPTION_DOWNLOAD_NAMES[format]);
} catch (_) {
messageApi.error(t('somethingWentWrong'));
} finally {
setDownloadingFormat(null);
}
}
async function loadIps() {
if (!client?.email) return;
setIpsLoading(true);
@@ -383,6 +405,9 @@ export default function ClientInfoModal({
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subLink)} />
</Tooltip>
<Tooltip title={t('download')}>
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} loading={downloadingFormat === 'standard'} disabled={downloadingFormat !== null} onClick={() => void downloadSubscription(subLink, 'standard')} />
</Tooltip>
<Popover
trigger="click"
placement="left"
@@ -411,6 +436,9 @@ export default function ClientInfoModal({
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subJsonLink)} />
</Tooltip>
<Tooltip title={t('download')}>
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} loading={downloadingFormat === 'json'} disabled={downloadingFormat !== null} onClick={() => void downloadSubscription(subJsonLink, 'json')} />
</Tooltip>
<Popover
trigger="click"
placement="left"
@@ -442,6 +470,9 @@ export default function ClientInfoModal({
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subClashLink)} />
</Tooltip>
<Tooltip title={t('download')}>
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} loading={downloadingFormat === 'clash'} disabled={downloadingFormat !== null} onClick={() => void downloadSubscription(subClashLink, 'clash')} />
</Tooltip>
<Popover
trigger="click"
placement="left"
+21
View File
@@ -24,6 +24,7 @@ import {
AppleOutlined,
CopyOutlined,
DownOutlined,
DownloadOutlined,
MoonFilled,
MoonOutlined,
QrcodeOutlined,
@@ -64,6 +65,8 @@ const subEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
const datepicker = subData.datepicker || 'gregorian';
const announce = subData.announce || '';
const appendRawView = (url: string) => `${url}${url.includes('?') ? '&' : '?'}view=raw`;
const isUnlimited = totalByte <= 0 && expireMs === 0;
const isActive = (() => {
if (!enabled) return false;
@@ -354,6 +357,15 @@ export default function SubPage() {
{sId}
</a>
<div className="sub-link-actions">
<Button
size="small"
href={appendRawView(subJsonUrl)}
target="_blank"
rel="noopener noreferrer"
icon={<DownloadOutlined />}
aria-label={t('download')}
title={t('download')}
/>
<Button size="small" icon={<CopyOutlined />} onClick={() => copy(subJsonUrl)} aria-label={t('copy')} title={t('copy')} />
<Popover
trigger="click"
@@ -386,6 +398,15 @@ export default function SubPage() {
{sId}
</a>
<div className="sub-link-actions">
<Button
size="small"
href={appendRawView(subClashUrl)}
target="_blank"
rel="noopener noreferrer"
icon={<DownloadOutlined />}
aria-label={t('download')}
title={t('download')}
/>
<Button size="small" icon={<CopyOutlined />} onClick={() => copy(subClashUrl)} aria-label={t('copy')} title={t('copy')} />
<Popover
trigger="click"