新增会员与泥点共享弹层并改版钱包入口

- PlatformProfileRechargeModal 重写为「会员订阅 / 泥点充值」双 Tab,含三池概览、套餐卡、周期切换、会员状态、套餐对比表与确认订单页
- 新增 membershipCopy 收敛数量估算、推荐标签、模型权限与并发展示文案,价格与权益仍读后端目录
- 新增 membershipProduct 提供由档位与周期拼出的会员 productId,避免与组件混导出
- PlatformMudPointWalletEntry 入口改为「可用总额 + 行内月度/永久 + 会员与泥点」,使用详情与兑换码保留为次级入口
- 同步改写共享弹层测试,覆盖双 Tab、升级报价与确认页

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-03 12:12:05 +08:00
parent 76fd96fa06
commit 9ba3aec026
5 changed files with 1390 additions and 137 deletions
@@ -88,6 +88,14 @@ export function PlatformMudPointWalletEntry({
displayedBalance === null ? '--' : formatMudPointCount(displayedBalance);
const detailsRequestKey = `balance:${balance ?? breakdown?.totalPoints ?? 'unknown'}`;
const monthlyResetLabel = formatMudPointResetAt(breakdown?.monthlyResetsAt);
const monthlyInlineLabel = formatMudPointCount(
breakdown?.monthlyPoints ?? 0,
true,
);
const permanentInlineLabel = formatMudPointCount(
breakdown?.permanentPoints ?? 0,
true,
);
const cancelPendingClose = useCallback(() => {
if (closeTimerRef.current !== null) {
@@ -192,7 +200,9 @@ export function PlatformMudPointWalletEntry({
<button
type="button"
className="flex min-w-0 items-center gap-1.5 px-2 font-black outline-none transition-colors hover:bg-white/70 focus-visible:bg-white/80"
aria-label={`泥点 ${exactBalanceLabel}`}
aria-label={`泥点账户:可用总额 ${exactBalanceLabel},月度 ${formatMudPointCount(
breakdown?.monthlyPoints ?? 0,
)},永久 ${formatMudPointCount(breakdown?.permanentPoints ?? 0)}`}
aria-expanded={isOpen}
aria-haspopup="dialog"
aria-busy={isLoading}
@@ -218,8 +228,16 @@ export function PlatformMudPointWalletEntry({
className={`${isCompact ? 'h-[1.05rem] w-[1.05rem]' : 'h-[1.2rem] w-[1.2rem]'} shrink-0 object-cover mix-blend-multiply`}
/>
<span className="truncate whitespace-nowrap">
泥点 {balanceLabel}
可用总额 {balanceLabel}
</span>
{!isCompact && breakdown ? (
<span className="hidden shrink-0 items-center gap-1 truncate whitespace-nowrap text-[10px] font-bold text-[var(--platform-text-soft)] sm:inline-flex">
<span aria-hidden="true" className="opacity-40">
|
</span>
月度 {monthlyInlineLabel} · 永久 {permanentInlineLabel}
</span>
) : null}
</button>
<span
className="my-1.5 w-px shrink-0 bg-[rgba(196,153,120,0.36)]"
@@ -233,7 +251,7 @@ export function PlatformMudPointWalletEntry({
onRecharge();
}}
>
充值
会员与泥点
</button>
{onRedeemCode ? (
<>
@@ -258,13 +276,13 @@ export function PlatformMudPointWalletEntry({
{isOpen ? (
<div
role="dialog"
aria-label="泥点账户详情"
aria-label="会员与泥点账户详情"
onMouseEnter={isCompact ? undefined : cancelPendingClose}
className="absolute right-0 top-[calc(100%+0.5rem)] z-[95] w-[min(19rem,calc(100vw-1rem))] overflow-hidden rounded-[1.25rem] border border-[rgba(194,145,111,0.46)] bg-[linear-gradient(180deg,#fffdfa,#fff7ef)] text-left shadow-[0_1.15rem_2.8rem_rgba(76,44,27,0.14),0_0.18rem_0.55rem_rgba(112,62,32,0.07)]"
>
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="text-[15px] font-black text-[var(--platform-text-strong)]">
泥点 {exactBalanceLabel}
可用总额 {exactBalanceLabel}
</div>
<button
type="button"
@@ -274,7 +292,7 @@ export function PlatformMudPointWalletEntry({
onRecharge();
}}
>
充值
会员与泥点
</button>
</div>
@@ -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(
<PlatformProfileRechargeModal
center={{
mudPointBalance: buildMudPointBalance(29),
membership: buildNormalMembership(),
pointProducts: buildPointProducts(),
membershipPlans: [STARTER_PLAN, PLUS_PLAN],
latestOrder: null,
hasPointsRecharged: false,
}}
isLoading={false}
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={onSubmitMembershipCheckout}
onConfirmNativePayment={vi.fn()}
onCloseNativePayment={vi.fn()}
/>,
);
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(
<PlatformProfileRechargeModal
center={{
mudPointBalance: buildMudPointBalance(615),
membership: buildActiveMembership('plus', 'yearly'),
pointProducts: buildPointProducts(),
membershipPlans: [STARTER_PLAN, PLUS_PLAN, MAX_PLAN],
latestOrder: null,
hasPointsRecharged: true,
}}
isLoading={false}
error={null}
submittingProductId={null}
nativePayment={null}
membershipQuote={{
currentPlan: 'plus',
targetPlan: 'max',
cycleKind: 'yearly',
cycleIndex: 4,
cycleCount: 12,
amountCents: 10_000,
grantedPointsDelta: 1_250,
monthlyBalanceAfter: 1_865,
expiresAt: '2027-01-01T09:00:00+08:00',
cycleResetsAt: '2026-04-20T09:00:00+08:00',
remainingFullMonths: 8,
}}
isLoadingMembershipQuote={false}
membershipQuoteError={null}
onClose={vi.fn()}
onRetry={vi.fn()}
onBuy={vi.fn()}
onRequestMembershipQuote={onRequestMembershipQuote}
onSubmitMembershipCheckout={onSubmitMembershipCheckout}
onConfirmNativePayment={vi.fn()}
onCloseNativePayment={vi.fn()}
/>,
);
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(
<PlatformProfileRechargeModal
center={{
mudPointBalance: buildMudPointBalance(0),
mudPointBalance: buildMudPointBalance(29),
membership: buildNormalMembership(),
pointProducts: [],
membershipPlans: [buildMembershipPlan()],
membershipPlans: [STARTER_PLAN],
latestOrder: null,
hasPointsRecharged: false,
}}
@@ -166,17 +329,23 @@ describe('PlatformProfileRechargeModal', () => {
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()}
/>,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,89 @@
import type {
ProfileMembershipModelAccess,
ProfileMembershipPlan,
} from '../../contracts/runtime';
/**
* 会员侧的纯展示文案。
*
* 价格、每期泥点、模型权限、并发上限、标题的唯一真相源是后端 `profile_membership_plan` 目录,
* 这里只放后端没有、也不该由后端承担的市场表达(数量估算、推荐标签、省费口径)。
*/
export const RECOMMENDED_MEMBERSHIP_PLAN: ProfileMembershipPlan = 'plus';
/** 并发上限哨兵值:与后端 `MEMBERSHIP_UNLIMITED_CONCURRENCY` 对齐,`>= 128` 视为不设上限。 */
export const UNLIMITED_CONCURRENT_JOB_LIMIT = 128;
type MembershipPlanCopy = {
/** 每月预计可完成的数量估算。 */
estimateCount: string;
/** 数量估算对应的项目形态。 */
estimateKind: string;
/** 数量估算的补充说明。 */
estimateAlt: string;
};
const MEMBERSHIP_PLAN_COPY: Partial<
Record<ProfileMembershipPlan, MembershipPlanCopy>
> = {
starter: {
estimateCount: '1–2 个',
estimateKind: '简单小游戏原型',
estimateAlt: '先验证核心玩法与基础交互',
},
plus: {
estimateCount: '2–4 个',
estimateKind: '轻量微抖小游戏',
estimateAlt: '按可玩版本估算',
},
pro: {
estimateCount: '1–2 个',
estimateKind: '中等项目 Demo',
estimateAlt: '或约 7–14 个轻量小游戏',
},
max: {
estimateCount: '1–2 个',
estimateKind: '独立游戏可玩章节',
estimateAlt: '或约 2–5 个中等项目 Demo',
},
};
const FALLBACK_PLAN_COPY: MembershipPlanCopy = {
estimateCount: '按实际创作量',
estimateKind: '以作品规模为准',
estimateAlt: '具体消耗取决于创作复杂度',
};
export function resolveMembershipPlanCopy(
plan: ProfileMembershipPlan,
): MembershipPlanCopy {
return MEMBERSHIP_PLAN_COPY[plan] ?? FALLBACK_PLAN_COPY;
}
export function resolveMembershipModelAccessLabel(
modelAccess: ProfileMembershipModelAccess,
): string {
return modelAccess === 'full' ? '基础及高性能模型' : '基础模型';
}
/** 套餐卡里的并发文案。 */
export function resolveMembershipConcurrencyLabel(
concurrentJobLimit: number,
): string {
return concurrentJobLimit >= UNLIMITED_CONCURRENT_JOB_LIMIT
? '独立任务并发不设套餐上限'
: `同时运行 ${concurrentJobLimit} 个独立任务`;
}
/** 对比表里的并发短文案。 */
export function resolveMembershipConcurrencyShortLabel(
concurrentJobLimit: number,
): string {
return concurrentJobLimit >= UNLIMITED_CONCURRENT_JOB_LIMIT
? '不设套餐上限'
: `${concurrentJobLimit} 个`;
}
export function isUnlimitedConcurrency(concurrentJobLimit: number): boolean {
return concurrentJobLimit >= UNLIMITED_CONCURRENT_JOB_LIMIT;
}
@@ -0,0 +1,15 @@
import type {
ProfileMembershipCycleKind,
ProfileMembershipPlan,
} from '../../contracts/runtime';
/**
* 会员商品没有配置行,`product_id` 由「档位 + 周期」拼出,与后端
* `parse_runtime_profile_membership_product_id` 双向对应。
*/
export function resolveMembershipProductId(
plan: ProfileMembershipPlan,
cycleKind: ProfileMembershipCycleKind,
) {
return `membership-${plan}-${cycleKind}`;
}