重构(钱包): 共享组件与钱包 store 改用三池余额
- createProfileWalletStore 的余额状态改为三池视图,去掉「响应只有 walletBalance 时兜底」的旧分支,缺少 mudPointBalance 一律算读取失败 - 泥点明细面板并列展示永久泥点 / 月度泥点 / 每日免费泥点,月度行带北京时间刷新时刻;「不限时泥点」标签改为「永久泥点」 - 充值弹层当前余额改用三池合计,不再读 walletBalance - 三池与首充赠送相关的 store、明细面板、充值弹层测试同步更新 Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
@@ -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: '',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user