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} - -
-
- } - > -
-
- ); - })} -
- - )} + {(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} + +
+
+ } + > +
+
+ ); + })} + + + )} + 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)}% + + )} +
+
+ ); +}