extract recharge model

This commit is contained in:
2026-07-16 19:58:03 +08:00
parent 79f88a1b56
commit 601133d748
10 changed files with 372 additions and 91 deletions
@@ -24,7 +24,7 @@ import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform'
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal';
import { PlatformProfileRechargeModal } from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal';
import {
@@ -1,309 +0,0 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, test, vi } from 'vitest';
import type { ProfileRechargeProduct } from '../../../packages/shared/src/contracts/runtime';
import { PlatformProfileRechargeModal } from './PlatformProfileRechargeModal';
function buildNormalMembership() {
return {
status: 'normal' as const,
tier: 'normal' as const,
startedAt: null,
expiresAt: null,
updatedAt: null,
cycleStartedAt: null,
cycleResetsAt: null,
cycleGrantedPoints: 0,
cycleRemainingPoints: 0,
cyclePeriodDays: 30,
};
}
function buildPointProduct(
overrides: Partial<ProfileRechargeProduct> = {},
): ProfileRechargeProduct {
return {
productId: 'points_60',
title: '60泥点',
priceCents: 600,
kind: 'points' as const,
pointsAmount: 60,
bonusPoints: 0,
durationDays: 0,
badgeLabel: '',
description: '60泥点',
tier: 'normal' as const,
membershipPeriodPoints: 0,
membershipPeriodDays: 0,
membershipQueueLimit: 0,
membershipDiscountBps: 0,
...overrides,
};
}
function buildPointProducts(): ProfileRechargeProduct[] {
return [
buildPointProduct(),
buildPointProduct({
productId: 'points_180',
title: '180泥点',
priceCents: 1800,
pointsAmount: 180,
bonusPoints: 90,
badgeLabel: '首充加赠',
description: '首充加赠90泥点',
}),
buildPointProduct({
productId: 'points_300',
title: '300泥点',
priceCents: 3000,
pointsAmount: 300,
bonusPoints: 150,
badgeLabel: '首充加赠',
description: '首充加赠150泥点',
}),
buildPointProduct({
productId: 'points_680',
title: '680泥点',
priceCents: 6800,
pointsAmount: 680,
bonusPoints: 340,
badgeLabel: '首充加赠',
description: '首充加赠340泥点',
}),
];
}
function buildMembershipProduct(): ProfileRechargeProduct {
return {
...buildPointProduct(),
productId: 'member_month',
title: '会员月卡',
priceCents: 2800,
kind: 'membership',
pointsAmount: 0,
durationDays: 30,
description: '会员月卡',
tier: 'month',
membershipPeriodDays: 30,
};
}
vi.mock('qrcode', () => ({
default: {
toDataURL: vi.fn(async () => 'data:image/png;base64,wechat-native-qr'),
},
}));
describe('PlatformProfileRechargeModal', () => {
test('renders the four point products and forwards the selected buy action', async () => {
const user = userEvent.setup();
const onBuy = vi.fn();
render(
<PlatformProfileRechargeModal
center={{
walletBalance: 29,
membership: buildNormalMembership(),
pointProducts: buildPointProducts(),
membershipProducts: [buildMembershipProduct()],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
}}
isLoading={false}
error={null}
submittingProductId={null}
nativePayment={null}
onClose={vi.fn()}
onRetry={vi.fn()}
onBuy={onBuy}
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泥点 +90泥点 ¥18 购买',
}),
).toBeTruthy();
expect(
within(dialog).getByRole('button', {
name: '300泥点 +150泥点 ¥30 购买',
}),
).toBeTruthy();
expect(
within(dialog).getByRole('button', {
name: '680泥点 +340泥点 ¥68 购买',
}),
).toBeTruthy();
expect(within(dialog).getAllByText('首充加赠')).toHaveLength(3);
expect(within(dialog).queryByRole('tablist')).toBeNull();
expect(within(dialog).queryByText('会员月卡')).toBeNull();
await user.click(
within(dialog).getByRole('button', {
name: '180泥点 +90泥点 ¥18 购买',
}),
);
expect(onBuy).toHaveBeenCalledWith(
expect.objectContaining({ productId: 'points_180' }),
);
});
test('shows the point-product empty state without exposing membership purchase', () => {
render(
<PlatformProfileRechargeModal
center={{
walletBalance: 0,
membership: buildNormalMembership(),
pointProducts: [],
membershipProducts: [buildMembershipProduct()],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
}}
isLoading={false}
error={null}
submittingProductId={null}
nativePayment={null}
onClose={vi.fn()}
onRetry={vi.fn()}
onBuy={vi.fn()}
onConfirmNativePayment={vi.fn()}
onCloseNativePayment={vi.fn()}
/>,
);
expect(screen.getByText('暂无可购买套餐')).toBeTruthy();
expect(screen.queryByText('会员月卡')).toBeNull();
expect(screen.queryByRole('tablist')).toBeNull();
});
test('opens native payment QR code in a separate dialog', async () => {
const user = userEvent.setup();
const onConfirmNativePayment = vi.fn();
const onCloseNativePayment = vi.fn();
render(
<PlatformProfileRechargeModal
center={{
walletBalance: 12,
membership: buildNormalMembership(),
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
}}
isLoading={false}
error={null}
submittingProductId={null}
nativePayment={{
orderId: 'rcg_native_001',
productTitle: '100泥点',
amountCents: 1000,
codeUrl: 'weixin://wxpay/bizpayurl?pr=test',
expiresAt: '2099-01-01T00:05:00Z',
isConfirming: false,
confirmMessage: '暂未确认到账,请确认付款完成后再点一次。',
}}
onClose={vi.fn()}
onRetry={vi.fn()}
onBuy={vi.fn()}
onConfirmNativePayment={onConfirmNativePayment}
onCloseNativePayment={onCloseNativePayment}
/>,
);
const rechargeDialog = screen.getByRole('dialog', {
name: '购买更多泥点',
});
const paymentDialog = screen.getByRole('dialog', {
name: '微信扫码支付',
});
expect(
within(rechargeDialog).queryByAltText('微信 Native 支付二维码'),
).toBeNull();
expect(
await within(paymentDialog).findByAltText('微信 Native 支付二维码'),
).toBeTruthy();
expect(within(paymentDialog).getByText('100泥点')).toBeTruthy();
expect(within(paymentDialog).getByText('¥10')).toBeTruthy();
expect(
within(paymentDialog).getByText(
'暂未确认到账,请确认付款完成后再点一次。',
),
).toBeTruthy();
await user.click(
within(paymentDialog).getByRole('button', { name: '我已支付' }),
);
expect(onConfirmNativePayment).toHaveBeenCalledTimes(1);
await user.click(
within(paymentDialog).getByRole('button', {
name: '关闭微信扫码支付',
}),
);
expect(onCloseNativePayment).toHaveBeenCalledTimes(1);
});
test('shows expired native payment state when qr countdown ends', async () => {
const onConfirmNativePayment = vi.fn();
render(
<PlatformProfileRechargeModal
center={{
walletBalance: 12,
membership: buildNormalMembership(),
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
}}
isLoading={false}
error={null}
submittingProductId={null}
nativePayment={{
orderId: 'rcg_native_expired',
productTitle: '100泥点',
amountCents: 1000,
codeUrl: 'weixin://wxpay/bizpayurl?pr=expired',
expiresAt: '2000-01-01T00:00:00Z',
isConfirming: false,
}}
onClose={vi.fn()}
onRetry={vi.fn()}
onBuy={vi.fn()}
onConfirmNativePayment={onConfirmNativePayment}
onCloseNativePayment={vi.fn()}
/>,
);
const paymentDialog = screen.getByRole('dialog', {
name: '微信扫码支付',
});
const confirmButton = within(paymentDialog).getByRole('button', {
name: '已过期',
});
expect(
await within(paymentDialog).findByAltText('微信 Native 支付二维码'),
).toBeTruthy();
expect(within(paymentDialog).getAllByText('已过期')).toHaveLength(2);
expect(confirmButton).toHaveProperty('disabled', true);
expect(onConfirmNativePayment).not.toHaveBeenCalled();
});
});
@@ -1,337 +0,0 @@
import QRCode from 'qrcode';
import { useCallback, useEffect, useState } from 'react';
import { formatMudPointCount } from '@/packages/shared/src/utils/format.ts';
import type {
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
} from '../../../packages/shared/src/contracts/runtime';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { formatRechargePrice } from '../rpg-entry/rpgEntryProfileFundsViewModel';
import { PlatformProfileModalShell } from './PlatformProfileModalShell';
import type { NativeWechatPaymentState } from './usePlatformProfileCenterController';
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
export type PlatformProfileRechargeModalProps = {
center: ProfileRechargeCenterResponse | null;
isLoading: boolean;
error: string | null;
submittingProductId: string | null;
nativePayment: NativeWechatPaymentState | null;
onClose: () => void;
onRetry: () => void;
onBuy: (product: ProfileRechargeProduct) => void;
onConfirmNativePayment: () => void;
onCloseNativePayment: () => void;
};
/**
* 生成微信 Native 支付二维码图片,保持首页现有二维码尺寸与容错行为。
*/
function useWechatNativeQrCode(codeUrl: string | null) {
const [qrImageUrl, setQrImageUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setQrImageUrl(null);
if (!codeUrl) {
return () => {
cancelled = true;
};
}
void QRCode.toDataURL(codeUrl, {
errorCorrectionLevel: 'M',
margin: 1,
width: WECHAT_NATIVE_PAY_QR_IMAGE_SIZE,
}).then((dataUrl) => {
if (!cancelled) {
setQrImageUrl(dataUrl);
}
});
return () => {
cancelled = true;
};
}, [codeUrl]);
return qrImageUrl;
}
function formatNativePaymentRemaining(remainingMs: number) {
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function useNativePaymentRemaining(expiresAt: string) {
const resolveRemainingMs = useCallback(() => {
const expiresAtMs = Date.parse(expiresAt);
if (!Number.isFinite(expiresAtMs)) {
return 0;
}
return Math.max(0, expiresAtMs - Date.now());
}, [expiresAt]);
const [remainingMs, setRemainingMs] = useState(resolveRemainingMs);
useEffect(() => {
setRemainingMs(resolveRemainingMs());
const timer = window.setInterval(() => {
setRemainingMs(resolveRemainingMs());
}, 1000);
return () => window.clearInterval(timer);
}, [resolveRemainingMs]);
return {
isExpired: remainingMs <= 0,
label: formatNativePaymentRemaining(remainingMs),
};
}
/** 充值套餐行,复用后端下发的商品与逐档首充资格。 */
function RechargeProductCard({
product,
submittingProductId,
onBuy,
}: {
product: ProfileRechargeProduct;
submittingProductId: string | null;
onBuy: (product: ProfileRechargeProduct) => void;
}) {
const submitting = submittingProductId === product.productId;
const badgeLabel = product.badgeLabel;
const bonusLabel =
product.bonusPoints > 0
? `+${formatMudPointCount(product.bonusPoints)}泥点`
: null;
return (
<PlatformSubpanel
as="button"
type="button"
surface="platform"
onClick={() => onBuy(product)}
disabled={Boolean(submittingProductId)}
interactive
radius="sm"
padding="none"
aria-label={`${formatMudPointCount(product.pointsAmount)}泥点${bonusLabel ? ` ${bonusLabel}` : ''} ${formatRechargePrice(product.priceCents)} 购买`}
className="platform-recharge-product-row platform-interactive-card relative grid min-h-[4.5rem] grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 px-3.5 py-3 text-left"
>
<div className="min-w-0">
{badgeLabel ? (
<PlatformPillBadge
tone="warning"
size="xxs"
className="mb-1.5 max-w-[7rem] truncate px-2 py-0.5"
>
{badgeLabel}
</PlatformPillBadge>
) : null}
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
<span className="text-sm font-black text-[var(--platform-text-strong)]">
{formatMudPointCount(product.pointsAmount)}泥点
</span>
{bonusLabel ? (
<span className="text-xs font-bold text-[var(--platform-accent-strong)]">
{bonusLabel}
</span>
) : null}
</div>
</div>
<span className="text-sm font-black tabular-nums text-[var(--platform-text-strong)]">
{formatRechargePrice(product.priceCents)}
</span>
<span className="platform-primary-button rounded-full px-3 py-1.5 text-xs font-black">
{submitting ? '处理中' : '购买'}
</span>
</PlatformSubpanel>
);
}
function PlatformProfileWechatNativePaymentModal({
nativePayment,
nativeQrImageUrl,
onClose,
onConfirm,
}: {
nativePayment: NativeWechatPaymentState;
nativeQrImageUrl: string | null;
onClose: () => void;
onConfirm: () => void;
}) {
const remaining = useNativePaymentRemaining(nativePayment.expiresAt);
return (
<PlatformProfileModalShell
title="微信扫码支付"
description="请使用微信扫描二维码完成付款"
onClose={onClose}
closeLabel="关闭微信扫码支付"
closeDisabled={nativePayment.isConfirming}
size="sm"
zIndexClassName="z-[85]"
panelClassName="platform-recharge-modal !max-w-sm rounded-[1.4rem]"
bodyClassName="px-5 py-5"
>
<div className="text-center">
<div className="mb-4 rounded-2xl border border-white/10 bg-white/6 px-4 py-3 text-left">
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
支付项目
</div>
<div className="mt-1 text-sm font-black text-[var(--platform-text-strong)]">
{nativePayment.productTitle}
</div>
<div className="mt-3 text-xs font-bold text-[var(--platform-text-soft)]">
支付金额
</div>
<div className="mt-1 text-2xl font-black text-[var(--platform-text-strong)]">
{formatRechargePrice(nativePayment.amountCents)}
</div>
<div className="mt-3 flex items-center justify-between gap-3 text-xs font-bold text-[var(--platform-text-soft)]">
<span>剩余时间</span>
<span className="tabular-nums">
{remaining.isExpired ? '已过期' : remaining.label}
</span>
</div>
</div>
<div className="mx-auto flex h-[180px] w-[180px] items-center justify-center rounded-xl bg-white p-2">
{nativeQrImageUrl ? (
<img
src={nativeQrImageUrl}
alt="微信 Native 支付二维码"
className="h-full w-full"
/>
) : (
<span className="text-xs font-semibold text-slate-500">生成中</span>
)}
</div>
<PlatformActionButton
surface="profile"
size="xs"
className="mt-4 disabled:cursor-not-allowed"
onClick={onConfirm}
disabled={nativePayment.isConfirming || remaining.isExpired}
>
{nativePayment.isConfirming
? '确认中'
: remaining.isExpired
? '已过期'
: '我已支付'}
</PlatformActionButton>
{nativePayment.confirmMessage ? (
<div className="mt-3 text-xs font-semibold text-[var(--platform-text-soft)]">
{nativePayment.confirmMessage}
</div>
) : null}
</div>
</PlatformProfileModalShell>
);
}
/** 主站与编辑器共用的泥点购买弹窗。 */
export function PlatformProfileRechargeModal({
center,
isLoading,
error,
submittingProductId,
nativePayment,
onClose,
onRetry,
onBuy,
onConfirmNativePayment,
onCloseNativePayment,
}: PlatformProfileRechargeModalProps) {
const nativeQrImageUrl = useWechatNativeQrCode(
nativePayment?.codeUrl ?? null,
);
const products = center?.pointProducts ?? [];
const currentBalance =
center?.mudPointBalance?.totalPoints ?? center?.walletBalance;
return (
<>
<PlatformProfileModalShell
title="购买更多泥点"
description={
currentBalance === undefined
? '当前余额读取中'
: `当前余额 ${formatMudPointCount(currentBalance)} 泥点`
}
onClose={onClose}
closeLabel="关闭购买更多泥点"
size="md"
panelClassName="platform-recharge-modal !max-w-[34rem] rounded-[1.4rem]"
bodyClassName="max-h-[min(76vh,36rem)] overflow-y-auto px-5 py-5"
>
<PlatformAsyncStatePanel
errorState={
error ? (
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="mt-4 rounded-2xl font-semibold"
>
<div>{error}</div>
<PlatformActionButton
surface="profile"
size="xs"
className="mt-3"
onClick={onRetry}
>
重新加载
</PlatformActionButton>
</PlatformStatusMessage>
) : null
}
isLoading={isLoading}
loadingState={
<PlatformProfileSkeletonList
count={4}
containerClassName="grid gap-2.5"
itemClassName="h-[4.5rem] rounded-lg bg-white/10"
/>
}
isEmpty={products.length === 0}
emptyState={
<PlatformEmptyState
surface="subpanel"
size="inline"
className="mt-1"
>
暂无可购买套餐
</PlatformEmptyState>
}
>
<div className="grid gap-2.5">
{products.map((product) => (
<RechargeProductCard
key={product.productId}
product={product}
submittingProductId={submittingProductId}
onBuy={onBuy}
/>
))}
</div>
</PlatformAsyncStatePanel>
</PlatformProfileModalShell>
{nativePayment ? (
<PlatformProfileWechatNativePaymentModal
nativePayment={nativePayment}
nativeQrImageUrl={nativeQrImageUrl}
onClose={onCloseNativePayment}
onConfirm={onConfirmNativePayment}
/>
) : null}
</>
);
}
@@ -9,8 +9,8 @@ import {
type ProfileTaskCenterResponse,
type ProfileWalletLedgerResponse,
type RedeemProfileRewardCodeResponse,
type WechatNativePayment,
} from '../../../packages/shared/src/contracts/runtime';
import type { PlatformProfileRechargeNativePaymentState } from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
import {
clearStoredAccessToken,
refreshStoredAccessToken,
@@ -85,13 +85,8 @@ export type WechatRechargeOrderConfirmationState = {
orderId: string;
};
export type NativeWechatPaymentState = WechatNativePayment & {
orderId: string;
productTitle: string;
amountCents: number;
isConfirming: boolean;
confirmMessage?: string;
};
export type NativeWechatPaymentState =
PlatformProfileRechargeNativePaymentState;
function isWechatJsapiMissingIdentityError(error: unknown) {
return (
@@ -133,7 +133,7 @@ import {
ProfileStatCardSkeleton,
} from '../platform-entry/PlatformProfilePrimitives';
import { PlatformProfileQrScannerModal } from '../platform-entry/PlatformProfileQrScannerModal';
import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal';
import { PlatformProfileRechargeModal } from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
import { PlatformProfileReferralModal } from '../platform-entry/PlatformProfileReferralModal';
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal';
@@ -4,6 +4,7 @@ import type {
ProfileWalletLedgerEntry,
ProfileWalletLedgerResponse,
} from '../../../packages/shared/src/contracts/runtime';
export { formatRechargePrice } from '../../../packages/shared/src/utils/format';
const PROFILE_WALLET_LEDGER_SOURCE_LABELS = {
new_user_registration_reward: '注册赠送',
@@ -84,11 +85,6 @@ export function buildWalletLedgerPresentation(
};
}
export function formatRechargePrice(priceCents: number) {
const yuan = priceCents / 100;
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`;
}
function formatMembershipPeriodPrefix(periodDays: number) {
if (periodDays === 30) {
return '每月';
-8
View File
@@ -9398,14 +9398,6 @@ button.image-canvas-editor__reference-chip:disabled {
backdrop-filter: blur(12px);
}
.platform-recharge-modal {
border: 1px solid var(--platform-modal-border);
background: var(--platform-modal-fill);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 24px 80px rgba(0, 0, 0, 0.18);
}
.platform-overlay {
background: var(--platform-overlay-fill);
}