diff --git a/web/src/components/topup/SubscriptionPlansCard.jsx b/web/src/components/topup/SubscriptionPlansCard.jsx
index a619c745..49518ff1 100644
--- a/web/src/components/topup/SubscriptionPlansCard.jsx
+++ b/web/src/components/topup/SubscriptionPlansCard.jsx
@@ -37,6 +37,8 @@ import SubscriptionPurchaseModal from './modals/SubscriptionPurchaseModal';
import {
formatSubscriptionDuration,
formatSubscriptionResetPeriod,
+ getResetCycleCount,
+ getSubscriptionEndDate,
} from '../../helpers/subscriptionFormat';
const { Text } = Typography;
@@ -232,6 +234,16 @@ const SubscriptionPlansCard = ({
return map;
}, [plans]);
+ const planMap = useMemo(() => {
+ const map = new Map();
+ (plans || []).forEach((p) => {
+ const plan = p?.plan;
+ if (!plan?.id) return;
+ map.set(plan.id, plan);
+ });
+ return map;
+ }, [plans]);
+
const getPlanPurchaseCount = (planId) =>
planPurchaseCountMap.get(planId) || 0;
@@ -251,6 +263,27 @@ const SubscriptionPlansCard = ({
return Math.round((used / total) * 100);
};
+ const getPlanQuotaMetaForDisplay = (plan, startDate = new Date()) => {
+ const totalAmount = Number(plan?.total_amount || 0);
+ const resetPeriod = plan?.quota_reset_period || 'never';
+ const recurring = totalAmount > 0 && resetPeriod !== 'never';
+ if (!recurring) return { recurring: false, cycleAmount: totalAmount, estimatedTotalAmount: totalAmount };
+ const endDate = getSubscriptionEndDate(startDate, plan);
+ const cycles = endDate ? getResetCycleCount(plan, startDate, endDate) : 1;
+ return { recurring: true, cycleAmount: totalAmount, estimatedTotalAmount: totalAmount * Math.max(1, cycles) };
+ };
+
+ const getSubscriptionQuotaMeta = (subscription, plan) => {
+ const totalAmount = Number(subscription?.amount_total || 0);
+ const resetPeriod = plan?.quota_reset_period || 'never';
+ const recurring = totalAmount > 0 && resetPeriod !== 'never';
+ if (!recurring) return { recurring: false, cycleAmount: totalAmount, estimatedTotalAmount: totalAmount };
+ const startDate = new Date(Number(subscription?.start_time || 0) * 1000);
+ const endDate = new Date(Number(subscription?.end_time || 0) * 1000);
+ const cycles = getResetCycleCount(plan, startDate, endDate);
+ return { recurring: true, cycleAmount: totalAmount, estimatedTotalAmount: totalAmount * Math.max(1, cycles) };
+ };
+
const cardContent = (
<>
{/* 卡片头部 */}
@@ -387,6 +420,8 @@ const SubscriptionPlansCard = ({
totalAmount > 0
? Math.max(0, totalAmount - usedAmount)
: 0;
+ const plan = planMap.get(subscription?.plan_id);
+ const quotaMeta = getSubscriptionQuotaMeta(subscription, plan);
const planTitle =
planTitleMap.get(subscription?.plan_id) || '';
const remainDays = getRemainingDays(sub);
@@ -463,6 +498,17 @@ const SubscriptionPlansCard = ({
)}
+ {quotaMeta.recurring && (
+
+ {t('\u672c\u5468\u671f\u989d\u5ea6')}?{renderQuota(quotaMeta.cycleAmount)} ? {' '}
+ {t('\u9884\u8ba1\u7d2f\u8ba1\u989d\u5ea6')}?{renderQuota(quotaMeta.estimatedTotalAmount)}
+ {plan ? (
+
+ ? {t('\u6309')} {formatSubscriptionResetPeriod(plan, t)} {t('\u91cd\u7f6e\u4f30\u7b97')}
+
+ ) : null}
+
+ )}
{!isLast && }
);
@@ -482,6 +528,7 @@ const SubscriptionPlansCard = ({
{plans.map((p, index) => {
const plan = p?.plan;
const totalAmount = Number(plan?.total_amount || 0);
+ const quotaMeta = getPlanQuotaMetaForDisplay(plan);
const { symbol, rate } = getCurrencyConfig();
const price = Number(plan?.price_amount || 0);
const convertedPrice = price * rate;
@@ -493,8 +540,16 @@ const SubscriptionPlansCard = ({
const limitLabel = limit > 0 ? `${t('限购')} ${limit}` : null;
const totalLabel =
totalAmount > 0
- ? `${t('总额度')}: ${renderQuota(totalAmount)}`
- : `${t('总额度')}: ${t('不限')}`;
+ ? `${t('\u603b\u989d\u5ea6')}: ${renderQuota(totalAmount)}`
+ : `${t('\u603b\u989d\u5ea6')}: ${t('\u4e0d\u9650')}`;
+ const periodQuotaLabel =
+ quotaMeta.recurring && quotaMeta.cycleAmount > 0
+ ? `${t('\u6bcf\u5468\u671f\u989d\u5ea6')}: ${renderQuota(quotaMeta.cycleAmount)}`
+ : null;
+ const estimatedTotalLabel =
+ quotaMeta.recurring && quotaMeta.estimatedTotalAmount > 0
+ ? `${t('\u9884\u8ba1\u7d2f\u8ba1\u989d\u5ea6')}: ${renderQuota(quotaMeta.estimatedTotalAmount)}`
+ : null;
const upgradeLabel = plan?.upgrade_group
? `${t('升级分组')}: ${plan.upgrade_group}`
: null;
@@ -507,6 +562,18 @@ const SubscriptionPlansCard = ({
label: `${t('有效期')}: ${formatSubscriptionDuration(plan, t)}`,
},
resetLabel ? { label: resetLabel } : null,
+ periodQuotaLabel
+ ? {
+ label: periodQuotaLabel,
+ tooltip: `${t('\u5f53\u524d\u6bcf\u6b21\u91cd\u7f6e\u540e\u53ef\u7528\u989d\u5ea6')}?${renderQuota(quotaMeta.cycleAmount)}`,
+ }
+ : null,
+ estimatedTotalLabel
+ ? {
+ label: estimatedTotalLabel,
+ tooltip: t('\u6309\u5f53\u524d\u6709\u6548\u671f\u548c\u91cd\u7f6e\u5468\u671f\u4f30\u7b97\uff0c\u4e0d\u7ed3\u8f6c\u672a\u7528\u989d\u5ea6'),
+ }
+ : null,
totalAmount > 0
? {
label: totalLabel,
diff --git a/web/src/components/topup/modals/SubscriptionPurchaseModal.jsx b/web/src/components/topup/modals/SubscriptionPurchaseModal.jsx
index 8bd861ee..47ba1361 100644
--- a/web/src/components/topup/modals/SubscriptionPurchaseModal.jsx
+++ b/web/src/components/topup/modals/SubscriptionPurchaseModal.jsx
@@ -36,6 +36,8 @@ import { getCurrencyConfig } from '../../../helpers/render';
import {
formatSubscriptionDuration,
formatSubscriptionResetPeriod,
+ getResetCycleCount,
+ getSubscriptionEndDate,
} from '../../../helpers/subscriptionFormat';
const { Text } = Typography;
@@ -59,6 +61,17 @@ const SubscriptionPurchaseModal = ({
}) => {
const plan = selectedPlan?.plan;
const totalAmount = Number(plan?.total_amount || 0);
+ const resetPeriod = plan?.quota_reset_period || 'never';
+ const recurringQuota = totalAmount > 0 && resetPeriod !== 'never';
+ const estimatedCycles = (() => {
+ if (!recurringQuota) return 1;
+ const startDate = new Date();
+ const endDate = getSubscriptionEndDate(startDate, plan);
+ return endDate ? getResetCycleCount(plan, startDate, endDate) : 1;
+ })();
+ const estimatedTotalAmount = recurringQuota
+ ? totalAmount * Math.max(1, estimatedCycles)
+ : totalAmount;
const { symbol, rate } = getCurrencyConfig();
const price = plan ? Number(plan.price_amount || 0) : 0;
const convertedPrice = price * rate;
@@ -146,6 +159,28 @@ const SubscriptionPurchaseModal = ({
)}
+ {recurringQuota && (
+ <>
+
+
+ {t('\u6bcf\u5468\u671f\u989d\u5ea6')}?
+
+
+ {renderQuota(totalAmount)}
+
+
+
+
+ {t('\u9884\u8ba1\u7d2f\u8ba1\u989d\u5ea6')}?
+
+
+
+ {renderQuota(estimatedTotalAmount)}
+
+
+
+ >
+ )}
{plan?.upgrade_group ? (
diff --git a/web/src/helpers/subscriptionFormat.js b/web/src/helpers/subscriptionFormat.js
index 6e49a839..a7679a75 100644
--- a/web/src/helpers/subscriptionFormat.js
+++ b/web/src/helpers/subscriptionFormat.js
@@ -9,9 +9,10 @@ export function formatSubscriptionDuration(plan, t) {
custom: t('自定义'),
};
if (unit === 'custom') {
- const seconds = plan?.custom_seconds || 0;
+ const seconds = Number(plan?.custom_seconds || 0);
if (seconds >= 86400) return `${Math.floor(seconds / 86400)} ${t('天')}`;
if (seconds >= 3600) return `${Math.floor(seconds / 3600)} ${t('小时')}`;
+ if (seconds >= 60) return `${Math.floor(seconds / 60)} ${t('分钟')}`;
return `${seconds} ${t('秒')}`;
}
return `${value} ${unitLabels[unit] || unit}`;
@@ -32,3 +33,77 @@ export function formatSubscriptionResetPeriod(plan, t) {
}
return t('不重置');
}
+
+export function getSubscriptionEndDate(startDate, plan) {
+ if (!startDate || !plan) return null;
+ const start = new Date(startDate);
+ if (Number.isNaN(start.getTime())) return null;
+ const unit = plan?.duration_unit || 'month';
+ const value = Number(plan?.duration_value || 1);
+ const customSeconds = Number(plan?.custom_seconds || 0);
+ const end = new Date(start.getTime());
+ switch (unit) {
+ case 'year':
+ end.setFullYear(end.getFullYear() + value);
+ break;
+ case 'month':
+ end.setMonth(end.getMonth() + value);
+ break;
+ case 'day':
+ end.setDate(end.getDate() + value);
+ break;
+ case 'hour':
+ end.setHours(end.getHours() + value);
+ break;
+ case 'custom':
+ end.setSeconds(end.getSeconds() + customSeconds);
+ break;
+ default:
+ return null;
+ }
+ return end;
+}
+
+export function getResetCycleCount(plan, startDate, endDate) {
+ if (!plan || !startDate || !endDate) return 1;
+ const period = plan?.quota_reset_period || 'never';
+ if (period === 'never') return 1;
+
+ const start = new Date(startDate);
+ const end = new Date(endDate);
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return 1;
+ if (end <= start) return 1;
+
+ const nextReset = (current) => {
+ const next = new Date(current.getTime());
+ switch (period) {
+ case 'daily':
+ next.setDate(next.getDate() + 1);
+ return next;
+ case 'weekly':
+ next.setDate(next.getDate() + 7);
+ return next;
+ case 'monthly':
+ next.setMonth(next.getMonth() + 1);
+ return next;
+ case 'custom': {
+ const seconds = Number(plan?.quota_reset_custom_seconds || 0);
+ if (seconds <= 0) return null;
+ next.setSeconds(next.getSeconds() + seconds);
+ return next;
+ }
+ default:
+ return null;
+ }
+ };
+
+ let cycles = 1;
+ let current = start;
+ for (let i = 0; i < 10000; i += 1) {
+ const next = nextReset(current);
+ if (!next || next >= end) break;
+ cycles += 1;
+ current = next;
+ }
+ return cycles;
+}