- 泥点 {exactBalanceLabel}
+ 可用总额 {exactBalanceLabel}
diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx
index 1654e6221..1e14639b5 100644
--- a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx
+++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx
@@ -25,6 +25,26 @@ function buildNormalMembership() {
};
}
+function buildActiveMembership(
+ plan: 'plus' | 'max',
+ cycleKind: 'monthly' | 'yearly',
+) {
+ return {
+ status: 'active' as const,
+ plan,
+ startedAt: '2026-01-01T00:00:00+08:00',
+ expiresAt: '2027-01-01T00:00:00+08:00',
+ updatedAt: '2026-04-01T00:00:00+08:00',
+ cycleStartedAt: '2026-03-20T09:00:00+08:00',
+ cycleResetsAt: '2026-04-20T09:00:00+08:00',
+ cycleGrantedPoints: 1_150,
+ cycleRemainingPoints: 615,
+ cycleIndex: 4,
+ cycleCount: 12,
+ cycleKind,
+ };
+}
+
/** 三池余额 fixture:每日免费 / 月度 / 永久。 */
function buildMudPointBalance(totalPoints: number) {
return {
@@ -54,7 +74,7 @@ function buildPointProduct(
};
}
-/// 六档充点商品:¥1 = 10 泥点,没有首充赠送。
+// 六档充点商品:¥1 = 10 泥点,没有首充赠送。
function buildPointProducts(): ProfileRechargeProduct[] {
return [60, 180, 300, 680, 1_280, 3_280].map((pointsAmount) =>
buildPointProduct({
@@ -67,20 +87,44 @@ function buildPointProducts(): ProfileRechargeProduct[] {
);
}
-function buildMembershipPlan() {
- return {
- plan: 'starter' as const,
- title: 'Starter',
- rank: 1,
- monthPriceCents: 3_900,
- yearPriceCents: 39_000,
- periodPoints: 400,
- modelAccess: 'basic' as const,
- concurrentJobLimit: 1,
- sortOrder: 10,
- enabled: true,
- };
-}
+const STARTER_PLAN = {
+ plan: 'starter' as const,
+ title: 'Starter',
+ rank: 1,
+ monthPriceCents: 3_900,
+ yearPriceCents: 39_000,
+ periodPoints: 400,
+ modelAccess: 'basic' as const,
+ concurrentJobLimit: 1,
+ sortOrder: 10,
+ enabled: true,
+};
+
+const PLUS_PLAN = {
+ ...STARTER_PLAN,
+ plan: 'plus' as const,
+ title: 'Plus',
+ rank: 2,
+ monthPriceCents: 9_900,
+ yearPriceCents: 99_000,
+ periodPoints: 1_150,
+ modelAccess: 'full' as const,
+ concurrentJobLimit: 3,
+ sortOrder: 20,
+};
+
+const MAX_PLAN = {
+ ...STARTER_PLAN,
+ plan: 'max' as const,
+ title: 'Max',
+ rank: 4,
+ monthPriceCents: 69_900,
+ yearPriceCents: 699_000,
+ periodPoints: 8_650,
+ modelAccess: 'full' as const,
+ concurrentJobLimit: 128,
+ sortOrder: 40,
+};
vi.mock('qrcode', () => ({
default: {
@@ -89,7 +133,134 @@ vi.mock('qrcode', () => ({
}));
describe('PlatformProfileRechargeModal', () => {
- test('renders the six point products and forwards the selected buy action', async () => {
+ test('renders the membership plans and forwards a first-time purchase', async () => {
+ const user = userEvent.setup();
+ const onSubmitMembershipCheckout = vi.fn();
+
+ render(
+
,
+ );
+
+ expect(screen.getByRole('dialog', { name: '会员与泥点' })).toBeTruthy();
+ expect(screen.getByText('可用总额 29 泥点')).toBeTruthy();
+ const tablist = screen.getByRole('tablist', { name: '会员与泥点' });
+ expect(
+ within(tablist)
+ .getByRole('tab', { name: '会员订阅' })
+ .getAttribute('aria-selected'),
+ ).toBe('true');
+ expect(screen.getAllByText('Starter').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Plus').length).toBeGreaterThan(0);
+
+ await user.click(
+ screen.getByRole('button', { name: 'Starter 月付 开通会员' }),
+ );
+
+ const checkout = screen.getByRole('dialog', { name: '确认订单' });
+ expect(within(checkout).getByText('¥39')).toBeTruthy();
+ expect(within(checkout).getByText('400(首期)')).toBeTruthy();
+ expect(within(checkout).getByText('微信支付')).toBeTruthy();
+ expect(within(checkout).queryByText(/报价已失效/)).toBeNull();
+ expect(within(checkout).queryByText(/倒计时/)).toBeNull();
+
+ await user.click(
+ within(checkout).getByRole('button', { name: '支付 ¥39' }),
+ );
+
+ expect(onSubmitMembershipCheckout).toHaveBeenCalledWith(
+ 'starter',
+ 'monthly',
+ );
+ });
+
+ test('requests the server quote and shows the upgrade amount', async () => {
+ const user = userEvent.setup();
+ const onRequestMembershipQuote = vi.fn();
+ const onSubmitMembershipCheckout = vi.fn();
+
+ render(
+
,
+ );
+
+ expect(
+ screen.getByRole('button', { name: 'Plus 年付 当前套餐' }),
+ ).toHaveProperty('disabled', true);
+ expect(screen.getByText('当前 Plus · 年付')).toBeTruthy();
+
+ await user.click(screen.getByRole('button', { name: 'Max 年付 补差升级' }));
+
+ expect(onRequestMembershipQuote).toHaveBeenCalledWith('max');
+
+ const checkout = screen.getByRole('dialog', { name: '确认套餐变更' });
+ expect(within(checkout).getByText('¥100')).toBeTruthy();
+ expect(within(checkout).getByText('本期补发月度泥点')).toBeTruthy();
+
+ await user.click(
+ within(checkout).getByRole('button', { name: '补差升级 ¥100' }),
+ );
+
+ expect(onSubmitMembershipCheckout).toHaveBeenCalledWith('max', 'yearly');
+ });
+
+ test('recharge tab picks a point product and forwards the buy action', async () => {
const user = userEvent.setup();
const onBuy = vi.fn();
@@ -99,7 +270,7 @@ describe('PlatformProfileRechargeModal', () => {
mudPointBalance: buildMudPointBalance(29),
membership: buildNormalMembership(),
pointProducts: buildPointProducts(),
- membershipPlans: [buildMembershipPlan()],
+ membershipPlans: [STARTER_PLAN],
latestOrder: null,
hasPointsRecharged: false,
}}
@@ -107,58 +278,50 @@ describe('PlatformProfileRechargeModal', () => {
error={null}
submittingProductId={null}
nativePayment={null}
+ membershipQuote={null}
+ isLoadingMembershipQuote={false}
+ membershipQuoteError={null}
onClose={vi.fn()}
onRetry={vi.fn()}
onBuy={onBuy}
+ onRequestMembershipQuote={vi.fn()}
+ onSubmitMembershipCheckout={vi.fn()}
onConfirmNativePayment={vi.fn()}
onCloseNativePayment={vi.fn()}
/>,
);
- const dialog = screen.getByRole('dialog', { name: '购买更多泥点' });
- expect(within(dialog).getByText('当前余额 29 泥点')).toBeTruthy();
- expect(
- within(dialog).getByRole('button', { name: '60泥点 ¥6 购买' }),
- ).toBeTruthy();
- expect(
- within(dialog).getByRole('button', { name: '180泥点 ¥18 购买' }),
- ).toBeTruthy();
- expect(
- within(dialog).getByRole('button', {
- name: '300泥点 ¥30 购买',
- }),
- ).toBeTruthy();
- expect(
- within(dialog).getByRole('button', {
- name: '680泥点 ¥68 购买',
- }),
- ).toBeTruthy();
- expect(within(dialog).queryAllByText('首充加赠')).toHaveLength(0);
- expect(
- within(dialog).getByRole('button', { name: '1,280泥点 ¥128 购买' }),
- ).toBeTruthy();
- expect(
- within(dialog).getByRole('button', { name: '3,280泥点 ¥328 购买' }),
- ).toBeTruthy();
- expect(within(dialog).queryByRole('tablist')).toBeNull();
- expect(within(dialog).queryByText('Starter')).toBeNull();
+ await user.click(screen.getByRole('tab', { name: '泥点充值' }));
+ const products = screen.getByRole('radiogroup', { name: '泥点充值档位' });
await user.click(
- within(dialog).getByRole('button', { name: '180泥点 ¥18 购买' }),
- );
- expect(onBuy).toHaveBeenCalledWith(
- expect.objectContaining({ productId: 'points_180' }),
+ within(products).getByRole('radio', { name: '60泥点 ¥6' }),
);
+
+ await user.click(screen.getByRole('button', { name: '充值 ¥6' }));
+
+ const checkout = screen.getByRole('dialog', { name: '确认订单' });
+ expect(within(checkout).getByText('60 永久泥点')).toBeTruthy();
+
+ await user.click(within(checkout).getByRole('button', { name: '充值 ¥6' }));
+
+ expect(onBuy).toHaveBeenCalledTimes(1);
+ expect(onBuy.mock.calls[0][0]).toMatchObject({
+ productId: 'points_60',
+ priceCents: 600,
+ });
});
- test('shows the point-product empty state without exposing membership purchase', () => {
+ test('shows the empty state when the recharge tab has no point product', async () => {
+ const user = userEvent.setup();
+
render(
{
error={null}
submittingProductId={null}
nativePayment={null}
+ membershipQuote={null}
+ isLoadingMembershipQuote={false}
+ membershipQuoteError={null}
onClose={vi.fn()}
onRetry={vi.fn()}
onBuy={vi.fn()}
+ onRequestMembershipQuote={vi.fn()}
+ onSubmitMembershipCheckout={vi.fn()}
onConfirmNativePayment={vi.fn()}
onCloseNativePayment={vi.fn()}
/>,
);
+ await user.click(screen.getByRole('tab', { name: '泥点充值' }));
+
+ expect(screen.getAllByText('Starter').length).toBeGreaterThan(0);
expect(screen.getByText('暂无可购买套餐')).toBeTruthy();
- expect(screen.queryByText('Starter')).toBeNull();
- expect(screen.queryByRole('tablist')).toBeNull();
});
test('opens native payment QR code in a separate dialog', async () => {
@@ -197,6 +366,9 @@ describe('PlatformProfileRechargeModal', () => {
isLoading={false}
error={null}
submittingProductId={null}
+ membershipQuote={null}
+ isLoadingMembershipQuote={false}
+ membershipQuoteError={null}
nativePayment={{
orderId: 'rcg_native_001',
productTitle: '100泥点',
@@ -209,13 +381,15 @@ describe('PlatformProfileRechargeModal', () => {
onClose={vi.fn()}
onRetry={vi.fn()}
onBuy={vi.fn()}
+ onRequestMembershipQuote={vi.fn()}
+ onSubmitMembershipCheckout={vi.fn()}
onConfirmNativePayment={onConfirmNativePayment}
onCloseNativePayment={onCloseNativePayment}
/>,
);
const rechargeDialog = screen.getByRole('dialog', {
- name: '购买更多泥点',
+ name: '会员与泥点',
});
const paymentDialog = screen.getByRole('dialog', {
name: '微信扫码支付',
@@ -264,6 +438,9 @@ describe('PlatformProfileRechargeModal', () => {
isLoading={false}
error={null}
submittingProductId={null}
+ membershipQuote={null}
+ isLoadingMembershipQuote={false}
+ membershipQuoteError={null}
nativePayment={{
orderId: 'rcg_native_expired',
productTitle: '100泥点',
@@ -275,6 +452,8 @@ describe('PlatformProfileRechargeModal', () => {
onClose={vi.fn()}
onRetry={vi.fn()}
onBuy={vi.fn()}
+ onRequestMembershipQuote={vi.fn()}
+ onSubmitMembershipCheckout={vi.fn()}
onConfirmNativePayment={onConfirmNativePayment}
onCloseNativePayment={vi.fn()}
/>,
diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx
index 24c6c8f7b..d337dbdf1 100644
--- a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx
+++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx
@@ -2,17 +2,35 @@ import './index.css';
import QRCode from 'qrcode';
import type { ReactNode } from 'react';
-import { useCallback, useEffect, useId, useState } from 'react';
+import { useCallback, useEffect, useId, useMemo, useState } from 'react';
import type {
+ ProfileMembership,
+ ProfileMembershipCycleKind,
+ ProfileMembershipPlan,
+ ProfileMembershipPlanRecord,
+ ProfileMembershipUpgradeQuote,
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
} from '../../contracts/runtime';
import { formatMudPointCount, formatRechargePrice } from '../../utils/format';
-import { resolveProfileMudPointDisplayTotal } from '../../utils/mudPoints';
+import {
+ formatMudPointResetAt,
+ resolveProfileMudPointDisplayTotal,
+} from '../../utils/mudPoints';
+import {
+ RECOMMENDED_MEMBERSHIP_PLAN,
+ resolveMembershipConcurrencyLabel,
+ resolveMembershipConcurrencyShortLabel,
+ resolveMembershipModelAccessLabel,
+ resolveMembershipPlanCopy,
+} from './membershipCopy';
+import { resolveMembershipProductId } from './membershipProduct';
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
+type PlatformProfileRechargeTab = 'subscription' | 'recharge';
+
export type PlatformProfileRechargeNativePaymentState = {
orderId: string;
productTitle: string;
@@ -23,15 +41,40 @@ export type PlatformProfileRechargeNativePaymentState = {
confirmMessage?: string;
};
+/** 弹层内的结算目标:泥点商品直接带商品行,会员只有「档位 + 周期」。 */
+export type PlatformProfileRechargeCheckoutTarget =
+ | {
+ kind: 'points';
+ product: ProfileRechargeProduct;
+ }
+ | {
+ kind: 'membership';
+ plan: ProfileMembershipPlan;
+ cycleKind: ProfileMembershipCycleKind;
+ mode: 'purchase' | 'upgrade';
+ };
+
export type PlatformProfileRechargeModalProps = {
center: ProfileRechargeCenterResponse | null;
isLoading: boolean;
error: string | null;
submittingProductId: string | null;
nativePayment: PlatformProfileRechargeNativePaymentState | null;
+ /** 升级报价;仅升级确认页会用到,首次购买不走报价接口。 */
+ membershipQuote: ProfileMembershipUpgradeQuote | null;
+ isLoadingMembershipQuote: boolean;
+ membershipQuoteError: string | null;
onClose: () => void;
onRetry: () => void;
+ /** 泥点商品下单(确认页「充值」触发)。 */
onBuy: (product: ProfileRechargeProduct) => void;
+ /** 升级前取服务端报价;只有需要补差的档位才会调用。 */
+ onRequestMembershipQuote: (plan: ProfileMembershipPlan) => void;
+ /** 会员下单;productId 由档位与周期拼出,复用泥点下单接口。 */
+ onSubmitMembershipCheckout: (
+ plan: ProfileMembershipPlan,
+ cycleKind: ProfileMembershipCycleKind,
+ ) => void;
onConfirmNativePayment: () => void;
onCloseNativePayment: () => void;
};
@@ -128,16 +171,19 @@ function PlatformRechargeActionButton({
children,
className,
disabled,
+ ariaLabel,
onClick,
}: {
children: ReactNode;
className?: string;
disabled?: boolean;
+ ariaLabel?: string;
onClick?: () => void;
}) {
return (