重构(钱包): 共享组件与钱包 store 改用三池余额

- createProfileWalletStore 的余额状态改为三池视图,去掉「响应只有 walletBalance 时兜底」的旧分支,缺少 mudPointBalance 一律算读取失败
- 泥点明细面板并列展示永久泥点 / 月度泥点 / 每日免费泥点,月度行带北京时间刷新时刻;「不限时泥点」标签改为「永久泥点」
- 充值弹层当前余额改用三池合计,不再读 walletBalance
- 三池与首充赠送相关的 store、明细面板、充值弹层测试同步更新

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-02 19:00:23 +08:00
parent abc92d230b
commit 3b9d645042
6 changed files with 118 additions and 110 deletions
@@ -8,10 +8,9 @@ import { formatMudPointCount } from '../../utils/format';
import { PlatformMudPointWalletEntry } from './index.tsx';
const breakdown = {
totalPoints: 207,
permanentPoints: 100,
limitedPoints: 80,
limitedExpiresAt: '2026-07-06T16:00:00Z',
monthlyPoints: 80,
monthlyResetsAt: '2026-07-06T16:00:00Z',
dailyFreePoints: 27,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-07-12T16:00:00Z',
@@ -22,7 +21,7 @@ test('formats mud point counts consistently', () => {
expect(formatMudPointCount(12_345, true)).toBe('1.2万');
});
test('shows only permanent and daily free points in the shared wallet panel', async () => {
test('shows permanent, monthly and daily free points in the shared wallet panel', async () => {
const user = userEvent.setup();
const onRequestDetails = vi.fn();
const onRecharge = vi.fn();
@@ -43,11 +42,15 @@ test('shows only permanent and daily free points in the shared wallet panel', as
const details = screen.getByRole('dialog', { name: '泥点账户详情' });
expect(details.className).toContain('rounded-[1.25rem]');
expect(within(details).getByText('不限时泥点')).toBeTruthy();
expect(within(details).getByText('按量充值、兑换码获得')).toBeTruthy();
expect(within(details).getByText('永久泥点')).toBeTruthy();
expect(within(details).getByText('月度泥点')).toBeTruthy();
expect(within(details).getByText('不过期,创作照常扣点')).toBeTruthy();
expect(within(details).getByText('100')).toBeTruthy();
expect(within(details).getByText('80')).toBeTruthy();
expect(
within(details).getByText('2026-07-07 00:00 清零并发放新额度'),
).toBeTruthy();
expect(within(details).queryByText('限时泥点')).toBeNull();
expect(within(details).queryByText('2026-07-07 到期')).toBeNull();
expect(within(details).getByText('每日免费泥点')).toBeTruthy();
expect(within(details).getByText('27')).toBeTruthy();
expect(within(details).getByText('每天重置为 20 泥点')).toBeTruthy();
@@ -11,10 +11,15 @@ import {
import type { ProfileMudPointBalance } from '../../contracts/runtime';
import MUD_POINT_ICON_SRC from '../../icons/topbar-wallet.png';
import { formatMudPointCount } from '../../utils/format';
import {
formatMudPointResetAt,
type ProfileMudPointBalanceView,
resolveProfileMudPointBalanceView,
} from '../../utils/mudPoints';
export type PlatformMudPointWalletEntryProps = {
balance: number | null;
breakdown?: ProfileMudPointBalance | null;
breakdown?: ProfileMudPointBalance | ProfileMudPointBalanceView | null;
isLoading?: boolean;
error?: string | null;
variant?: 'desktop' | 'mobile' | 'editor';
@@ -55,7 +60,7 @@ function MudPointBalanceRow({
export function PlatformMudPointWalletEntry({
balance,
breakdown,
breakdown: rawBreakdown,
isLoading = false,
error = null,
variant = 'desktop',
@@ -71,6 +76,9 @@ export function PlatformMudPointWalletEntry({
const closeTimerRef = useRef<number | null>(null);
const [isOpen, setIsOpen] = useState(false);
const isCompact = variant === 'mobile';
const breakdown = rawBreakdown
? resolveProfileMudPointBalanceView(rawBreakdown)
: null;
const displayedBalance = balance ?? breakdown?.totalPoints ?? null;
const balanceLabel =
displayedBalance === null
@@ -79,6 +87,7 @@ export function PlatformMudPointWalletEntry({
const exactBalanceLabel =
displayedBalance === null ? '--' : formatMudPointCount(displayedBalance);
const detailsRequestKey = `balance:${balance ?? breakdown?.totalPoints ?? 'unknown'}`;
const monthlyResetLabel = formatMudPointResetAt(breakdown?.monthlyResetsAt);
const cancelPendingClose = useCallback(() => {
if (closeTimerRef.current !== null) {
@@ -272,11 +281,19 @@ export function PlatformMudPointWalletEntry({
{breakdown ? (
<>
<MudPointBalanceRow
label="不限时泥点"
label="永久泥点"
points={breakdown.permanentPoints}
detail="按量充值、兑换码获得"
detail="不过期,创作照常扣点"
/>
<MudPointBalanceRow
label="月度泥点"
points={breakdown.monthlyPoints}
detail={
monthlyResetLabel
? `${monthlyResetLabel} 清零并发放新额度`
: '会员每期额度,刷新日清零'
}
/>
{/*TODO 限时泥点 is not enabled by now */}
<MudPointBalanceRow
label="每日免费泥点"
points={breakdown.dailyFreePoints}
@@ -11,7 +11,7 @@ import { PlatformProfileRechargeModal } from './index';
function buildNormalMembership() {
return {
status: 'normal' as const,
tier: 'normal' as const,
plan: 'normal' as const,
startedAt: null,
expiresAt: null,
updatedAt: null,
@@ -19,7 +19,21 @@ function buildNormalMembership() {
cycleResetsAt: null,
cycleGrantedPoints: 0,
cycleRemainingPoints: 0,
cyclePeriodDays: 30,
cycleIndex: 1,
cycleCount: 1,
cycleKind: 'monthly' as const,
};
}
/** 三池余额 fixture:每日免费 / 月度 / 永久。 */
function buildMudPointBalance(totalPoints: number) {
return {
dailyFreePoints: 0,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
monthlyPoints: 0,
monthlyResetsAt: null,
permanentPoints: totalPoints,
};
}
@@ -36,60 +50,35 @@ function buildPointProduct(
durationDays: 0,
badgeLabel: '',
description: '60泥点',
tier: 'normal' as const,
membershipPeriodPoints: 0,
membershipPeriodDays: 0,
membershipQueueLimit: 0,
membershipDiscountBps: 0,
...overrides,
};
}
/// 六档充点商品:¥1 = 10 泥点,没有首充赠送。
function buildPointProducts(): ProfileRechargeProduct[] {
return [
buildPointProduct(),
return [60, 180, 300, 680, 1_280, 3_280].map((pointsAmount) =>
buildPointProduct({
productId: 'points_180',
title: '180泥点',
priceCents: 1800,
pointsAmount: 180,
bonusPoints: 90,
badgeLabel: '首充加赠',
description: '首充加赠90泥点',
productId: `points_${pointsAmount}`,
title: `${pointsAmount}泥点`,
priceCents: pointsAmount * 10,
pointsAmount,
description: `${pointsAmount}泥点`,
}),
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 {
function buildMembershipPlan() {
return {
...buildPointProduct(),
productId: 'member_month',
title: '会员月卡',
priceCents: 2800,
kind: 'membership',
pointsAmount: 0,
durationDays: 30,
description: '会员月卡',
tier: 'month',
membershipPeriodDays: 30,
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,
};
}
@@ -100,18 +89,17 @@ vi.mock('qrcode', () => ({
}));
describe('PlatformProfileRechargeModal', () => {
test('renders the four point products and forwards the selected buy action', async () => {
test('renders the six point products and forwards the selected buy action', async () => {
const user = userEvent.setup();
const onBuy = vi.fn();
render(
<PlatformProfileRechargeModal
center={{
walletBalance: 29,
mudPointBalance: buildMudPointBalance(29),
membership: buildNormalMembership(),
pointProducts: buildPointProducts(),
membershipProducts: [buildMembershipProduct()],
benefits: [],
membershipPlans: [buildMembershipPlan()],
latestOrder: null,
hasPointsRecharged: false,
}}
@@ -132,29 +120,31 @@ describe('PlatformProfileRechargeModal', () => {
expect(
within(dialog).getByRole('button', { name: '60泥点 ¥6 购买' }),
).toBeTruthy();
expect(
within(dialog).getByRole('button', { name: '180泥点 ¥18 购买' }),
).toBeTruthy();
expect(
within(dialog).getByRole('button', {
name: '180泥点 +90泥点 ¥18 购买',
name: '300泥点 ¥30 购买',
}),
).toBeTruthy();
expect(
within(dialog).getByRole('button', {
name: '300泥点 +150泥点 ¥30 购买',
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: '680泥点 +340泥点 ¥68 购买',
}),
within(dialog).getByRole('button', { name: '3,280泥点 ¥328 购买' }),
).toBeTruthy();
expect(within(dialog).getAllByText('首充加赠')).toHaveLength(3);
expect(within(dialog).queryByRole('tablist')).toBeNull();
expect(within(dialog).queryByText('会员月卡')).toBeNull();
expect(within(dialog).queryByText('Starter')).toBeNull();
await user.click(
within(dialog).getByRole('button', {
name: '180泥点 +90泥点 ¥18 购买',
}),
within(dialog).getByRole('button', { name: '180泥点 ¥18 购买' }),
);
expect(onBuy).toHaveBeenCalledWith(
expect.objectContaining({ productId: 'points_180' }),
@@ -165,11 +155,10 @@ describe('PlatformProfileRechargeModal', () => {
render(
<PlatformProfileRechargeModal
center={{
walletBalance: 0,
mudPointBalance: buildMudPointBalance(0),
membership: buildNormalMembership(),
pointProducts: [],
membershipProducts: [buildMembershipProduct()],
benefits: [],
membershipPlans: [buildMembershipPlan()],
latestOrder: null,
hasPointsRecharged: false,
}}
@@ -186,7 +175,7 @@ describe('PlatformProfileRechargeModal', () => {
);
expect(screen.getByText('暂无可购买套餐')).toBeTruthy();
expect(screen.queryByText('会员月卡')).toBeNull();
expect(screen.queryByText('Starter')).toBeNull();
expect(screen.queryByRole('tablist')).toBeNull();
});
@@ -198,11 +187,10 @@ describe('PlatformProfileRechargeModal', () => {
render(
<PlatformProfileRechargeModal
center={{
walletBalance: 12,
mudPointBalance: buildMudPointBalance(12),
membership: buildNormalMembership(),
pointProducts: [],
membershipProducts: [],
benefits: [],
membershipPlans: [],
latestOrder: null,
hasPointsRecharged: false,
}}
@@ -266,11 +254,10 @@ describe('PlatformProfileRechargeModal', () => {
render(
<PlatformProfileRechargeModal
center={{
walletBalance: 12,
mudPointBalance: buildMudPointBalance(12),
membership: buildNormalMembership(),
pointProducts: [],
membershipProducts: [],
benefits: [],
membershipPlans: [],
latestOrder: null,
hasPointsRecharged: false,
}}
@@ -9,6 +9,7 @@ import type {
ProfileRechargeProduct,
} from '../../contracts/runtime';
import { formatMudPointCount, formatRechargePrice } from '../../utils/format';
import { resolveProfileMudPointDisplayTotal } from '../../utils/mudPoints';
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
@@ -463,8 +464,9 @@ export function PlatformProfileRechargeModal({
nativePayment?.codeUrl ?? null,
);
const products = center?.pointProducts ?? [];
const currentBalance =
center?.mudPointBalance?.totalPoints ?? center?.walletBalance;
const currentBalance = resolveProfileMudPointDisplayTotal(
center?.mudPointBalance,
);
return (
<>
@@ -8,10 +8,9 @@ import { createProfileWalletStore } from './createProfileWalletStore';
function balance(totalPoints: number): ProfileMudPointBalance {
return {
totalPoints,
permanentPoints: totalPoints,
limitedPoints: 0,
limitedExpiresAt: null,
monthlyPoints: 0,
monthlyResetsAt: null,
dailyFreePoints: 0,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
@@ -254,31 +253,29 @@ describe('createProfileWalletStore', () => {
expect(store.getState().mudPointBalance?.totalPoints).toBe(100);
});
test('retains a legacy total without inventing a balance breakdown', async () => {
test('treats a center response without a balance breakdown as an error', async () => {
const store = createProfileWalletStore({
getRechargeCenter: vi.fn().mockResolvedValue({
walletBalance: 37,
} as ProfileRechargeCenterResponse),
getRechargeCenter: vi
.fn()
.mockResolvedValue({} as ProfileRechargeCenterResponse),
});
store.getState().setWalletOwner('user-a');
await store.getState().onWalletBalanceMayHaveChanged();
expect(store.getState()).toMatchObject({
legacyWalletBalance: 37,
legacyWalletBalance: null,
mudPointBalance: null,
mudPointBalanceStatus: 'error',
mudPointBalanceError: '泥点明细读取失败',
});
});
test('clears a stale balance breakdown when a newer refresh only has a legacy total', async () => {
test('keeps the last known good breakdown when a newer refresh fails', async () => {
const getRechargeCenter = vi
.fn()
.mockResolvedValueOnce(center(90))
.mockResolvedValueOnce({
walletBalance: 37,
} as ProfileRechargeCenterResponse);
.mockRejectedValueOnce(new Error('network down'));
const store = createProfileWalletStore({ getRechargeCenter });
store.getState().setWalletOwner('user-a');
@@ -286,11 +283,11 @@ describe('createProfileWalletStore', () => {
await store.getState().onWalletBalanceMayHaveChanged();
expect(store.getState()).toMatchObject({
legacyWalletBalance: 37,
mudPointBalance: null,
legacyWalletBalance: 90,
mudPointBalanceStatus: 'error',
mudPointBalanceError: '泥点明细读取失败',
});
expect(store.getState().mudPointBalance?.permanentPoints).toBe(90);
});
test('applies an owner-scoped legacy mutation snapshot atomically', () => {
@@ -4,6 +4,10 @@ import type {
ProfileMudPointBalance,
ProfileRechargeCenterResponse,
} from '../contracts/runtime';
import {
type ProfileMudPointBalanceView,
resolveProfileMudPointBalanceView,
} from '../utils/mudPoints';
export type MudPointBalanceStatus = 'idle' | 'loading' | 'ready' | 'error';
@@ -22,7 +26,7 @@ export type ProfileWalletBalanceSnapshot = Readonly<{
export type ProfileWalletStore = {
ownerUserId: string | null;
mudPointBalance: ProfileMudPointBalance | null;
mudPointBalance: ProfileMudPointBalanceView | null;
legacyWalletBalance: number | null;
mudPointBalanceStatus: MudPointBalanceStatus;
mudPointBalanceError: string;
@@ -32,7 +36,7 @@ export type ProfileWalletStore = {
) => ProfileWalletBalanceSnapshot | null;
applyWalletBalanceSnapshot: (
snapshot: ProfileWalletBalanceSnapshot,
balance: ProfileMudPointBalance,
balance: ProfileMudPointBalance | ProfileMudPointBalanceView,
) => boolean;
applyLegacyWalletBalanceSnapshot: (
snapshot: ProfileWalletBalanceSnapshot,
@@ -119,9 +123,10 @@ export function createProfileWalletStore(
snapshot.operationSequence,
);
settledRefreshVersion = snapshot.invalidationVersion;
const mudPointBalance = resolveProfileMudPointBalanceView(balance);
set({
mudPointBalance: balance,
legacyWalletBalance: balance.totalPoints,
mudPointBalance,
legacyWalletBalance: mudPointBalance.totalPoints,
mudPointBalanceStatus: 'ready',
mudPointBalanceError: '',
});
@@ -181,19 +186,16 @@ export function createProfileWalletStore(
continue;
}
if (!center.mudPointBalance) {
if (Number.isFinite(center.walletBalance)) {
set({
mudPointBalance: null,
legacyWalletBalance: center.walletBalance,
});
}
throw new Error('充值中心响应缺少泥点余额');
}
settledRefreshVersion = requestedVersion;
const mudPointBalance = resolveProfileMudPointBalanceView(
center.mudPointBalance,
);
set({
mudPointBalance: center.mudPointBalance,
legacyWalletBalance: center.mudPointBalance.totalPoints,
mudPointBalance,
legacyWalletBalance: mudPointBalance.totalPoints,
mudPointBalanceStatus: 'ready',
mudPointBalanceError: '',
});