diff --git a/frontend/src/pages/clients/ClientInfoModal.tsx b/frontend/src/pages/clients/ClientInfoModal.tsx
index e9d2b0a2..fdd20b5a 100644
--- a/frontend/src/pages/clients/ClientInfoModal.tsx
+++ b/frontend/src/pages/clients/ClientInfoModal.tsx
@@ -393,9 +393,11 @@ export default function ClientInfoModal({
{t('pages.inbounds.copyLink')}
{links.map((link, idx) => {
const meta = parseLinkMeta(link);
- const qrRemark = meta.remark || `${t('pages.clients.link')} ${idx + 1}`;
const rowTitle = trimEmail(meta.remark, client.email)
|| `${t('pages.clients.link')} ${idx + 1}`;
+ const qrRemark = client.email
+ ? `${rowTitle}-${client.email}`
+ : (meta.remark || `${t('pages.clients.link')} ${idx + 1}`);
const canQr = !isPostQuantumLink(link);
return (
diff --git a/frontend/src/pages/sub/SubPage.tsx b/frontend/src/pages/sub/SubPage.tsx
index 4aecd42e..c99b7cff 100644
--- a/frontend/src/pages/sub/SubPage.tsx
+++ b/frontend/src/pages/sub/SubPage.tsx
@@ -33,6 +33,7 @@ import {
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
import { setMessageInstance } from '@/utils/messageBus';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
+import SubUsageSummary from './SubUsageSummary';
import './SubPage.css';
const QR_SIZE = 240;
@@ -354,72 +355,16 @@ export default function SubPage() {
items={descriptionsItems}
/>
- {links.length > 0 && (
- <>
-
{t('pages.inbounds.copyLink')}
-
- {links.map((link, idx) => {
- const meta = parseLinkMeta(link, idx);
- const rowTitle = trimEmail(meta.remark, linkEmails[idx] || '') || meta.remark;
- const canQr = !isPostQuantumLink(link);
- return (
-
-
- {meta.protocol}
-
-
- {rowTitle}
-
-
-
}
- onClick={() => copy(link)}
- aria-label={t('copy')}
- title={t('copy')}
- />
- {canQr && (
-
-
- {meta.remark}
-
-
-
- }
- >
-
}
- aria-label="QR"
- title="QR"
- />
-
- )}
-
-
- );
- })}
-
- >
- )}
+
{(subUrl || subJsonUrl || subClashUrl) && (
<>
@@ -521,6 +466,75 @@ export default function SubPage() {
>
)}
+ {links.length > 0 && (
+ <>
+ {t('pages.inbounds.copyLink')}
+
+ {links.map((link, idx) => {
+ const meta = parseLinkMeta(link, idx);
+ const rowEmail = linkEmails[idx] || '';
+ const rowTitle = trimEmail(meta.remark, rowEmail) || meta.remark;
+ const qrLabel = rowEmail ? `${rowTitle}-${rowEmail}` : meta.remark;
+ const canQr = !isPostQuantumLink(link);
+ return (
+
+
+ {meta.protocol}
+
+
+ {rowTitle}
+
+
+
}
+ onClick={() => copy(link)}
+ aria-label={t('copy')}
+ title={t('copy')}
+ />
+ {canQr && (
+
+
+ {qrLabel}
+
+
+
+ }
+ >
+
}
+ aria-label="QR"
+ title="QR"
+ />
+
+ )}
+
+
+ );
+ })}
+
+ >
+ )}
+
diff --git a/frontend/src/pages/sub/SubUsageSummary.css b/frontend/src/pages/sub/SubUsageSummary.css
new file mode 100644
index 00000000..d6f7cba8
--- /dev/null
+++ b/frontend/src/pages/sub/SubUsageSummary.css
@@ -0,0 +1,87 @@
+.usage-summary {
+ margin-top: 12px;
+ padding: 14px 16px;
+ background: var(--ant-color-fill-alter);
+ border: 1px solid var(--ant-color-border-secondary);
+ border-radius: 12px;
+}
+
+.usage-summary.is-inactive {
+ opacity: 0.7;
+ border-color: var(--ant-color-error-border);
+}
+
+.usage-summary-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 8px;
+}
+
+.usage-summary-labels {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+ font-variant-numeric: tabular-nums;
+ min-width: 0;
+}
+
+.usage-summary-used {
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--ant-color-text);
+}
+
+.usage-summary-sep {
+ color: var(--ant-color-text-quaternary);
+ font-size: 16px;
+}
+
+.usage-summary-total {
+ font-size: 14px;
+ color: var(--ant-color-text-secondary);
+ font-weight: 500;
+}
+
+.usage-summary-chips {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
+.usage-summary-chips .ant-tag {
+ margin: 0;
+}
+
+.usage-summary-bar.ant-progress {
+ margin-bottom: 6px;
+}
+
+.usage-summary-bar .ant-progress-outer {
+ padding-inline-end: 0;
+}
+
+.usage-summary-bar .ant-progress-inner {
+ background: var(--ant-color-fill-secondary);
+}
+
+.usage-summary-foot {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 12px;
+ color: var(--ant-color-text-tertiary);
+ font-variant-numeric: tabular-nums;
+ min-height: 16px;
+}
+
+.usage-summary-remained::before {
+ content: '';
+}
+
+.usage-summary-pct {
+ font-weight: 600;
+ color: var(--ant-color-text-secondary);
+}
diff --git a/frontend/src/pages/sub/SubUsageSummary.tsx b/frontend/src/pages/sub/SubUsageSummary.tsx
new file mode 100644
index 00000000..7d59f779
--- /dev/null
+++ b/frontend/src/pages/sub/SubUsageSummary.tsx
@@ -0,0 +1,96 @@
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Progress, Tag } from 'antd';
+import { ClockCircleOutlined, ThunderboltOutlined } from '@ant-design/icons';
+
+import './SubUsageSummary.css';
+
+interface SubUsageSummaryProps {
+ usedByte: number;
+ totalByte: number;
+ usedLabel: string;
+ totalLabel: string;
+ remainedLabel: string;
+ expireMs: number;
+ isActive: boolean;
+}
+
+function pickStrokeColor(pct: number): { from: string; to: string } {
+ if (pct >= 90) return { from: '#ff7875', to: '#ff4d4f' };
+ if (pct >= 75) return { from: '#ffc53d', to: '#fa8c16' };
+ return { from: '#5fc983', to: '#36b37e' };
+}
+
+function formatExpiryChip(expireMs: number): { label: string; color: string } | null {
+ if (expireMs <= 0) return null;
+ const diff = expireMs - Date.now();
+ if (diff <= 0) return { label: 'Expired', color: 'red' };
+ const days = Math.floor(diff / 86400000);
+ if (days >= 1) return { label: `${days}d`, color: days <= 3 ? 'orange' : 'blue' };
+ const hours = Math.max(1, Math.floor(diff / 3600000));
+ return { label: `${hours}h`, color: 'orange' };
+}
+
+export default function SubUsageSummary({
+ usedByte,
+ totalByte,
+ usedLabel,
+ totalLabel,
+ remainedLabel,
+ expireMs,
+ isActive,
+}: SubUsageSummaryProps) {
+ const { t } = useTranslation();
+ const pct = useMemo(() => {
+ if (totalByte <= 0) return 0;
+ const v = (usedByte / totalByte) * 100;
+ if (!Number.isFinite(v)) return 0;
+ return Math.max(0, Math.min(100, v));
+ }, [usedByte, totalByte]);
+
+ const expiry = formatExpiryChip(expireMs);
+ const isUnlimited = totalByte <= 0;
+ const stroke = pickStrokeColor(pct);
+
+ return (
+
+
+
+ {usedLabel}
+ /
+ {isUnlimited ? '∞' : totalLabel}
+
+
+ {isUnlimited && (
+ }>
+ {t('subscription.unlimited')}
+
+ )}
+ {expiry && (
+ }>
+ {expiry.label}
+
+ )}
+
+
+ {!isUnlimited && (
+
+ )}
+
+ {!isUnlimited && (
+ <>
+ {remainedLabel}
+ {pct.toFixed(1)}%
+ >
+ )}
+
+
+ );
+}