refactor: 收口个人资金展示模型

This commit is contained in:
2026-06-03 18:06:31 +08:00
parent d67abecc9e
commit f67f57b415
7 changed files with 343 additions and 49 deletions

View File

@@ -305,12 +305,21 @@ const {
amountDelta: -1,
balanceAfter: 29,
sourceType: 'asset_operation_consume',
createdAt: '2026-05-03T08:00:00Z',
},
{
id: 'ledger-2',
amountDelta: 30,
balanceAfter: 30,
sourceType: 'invite_invitee_reward',
createdAt: '2026-05-03T09:00:00Z',
},
{
id: 'ledger-3',
amountDelta: 5,
balanceAfter: 35,
sourceType: 'puzzle_author_incentive_claim',
createdAt: '2026-05-03T10:00:00Z',
},
],
})),
@@ -413,11 +422,6 @@ const originalUserAgent = navigator.userAgent;
const originalMaxTouchPoints = navigator.maxTouchPoints;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
const DEFAULT_PROFILE_CREATED_AT = '2026-04-01T00:00:00.000Z';
function buildFreshProfileCreatedAt() {
return new Date().toISOString();
}
function dispatchPointerEvent(
target: HTMLElement,
@@ -1169,6 +1173,7 @@ test('opens wallet ledger modal from narrative coin card', async () => {
expect(screen.getByText('-1')).toBeTruthy();
expect(screen.getByText('填写邀请码奖励')).toBeTruthy();
expect(screen.getByText('+30')).toBeTruthy();
expect(screen.getByText('拼图作者奖励')).toBeTruthy();
});
test('profile recharge modal shows native qr code on desktop web by default', async () => {

View File

@@ -141,6 +141,12 @@ import {
formatPlayedWorkType,
formatTotalPlayTimeHours,
} from './rpgEntryProfileDashboardPresentation';
import {
buildMembershipLabel,
buildRechargeProductValueLabel,
buildWalletLedgerPresentation,
formatRechargePrice,
} from './rpgEntryProfileFundsViewModel';
import {
buildProfileTaskCardSummary,
buildProfileTaskProgressLabel,
@@ -2150,27 +2156,6 @@ function ProfileNicknameModal({
);
}
const WALLET_LEDGER_SOURCE_LABELS: Record<string, string> = {
new_user_registration_reward: '注册赠送',
points_recharge: '泥点充值',
invite_inviter_reward: '邀请奖励',
invite_invitee_reward: '填写邀请码奖励',
snapshot_sync: '账户同步',
asset_operation_consume: '资产操作消耗',
asset_operation_refund: '资产操作退回',
redeem_code_reward: '兑换码奖励',
daily_task_reward: '每日任务奖励',
};
function formatWalletLedgerAmount(amountDelta: number) {
return amountDelta > 0 ? `+${amountDelta}` : `${amountDelta}`;
}
function formatRechargePrice(priceCents: number) {
const yuan = priceCents / 100;
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`;
}
function clearWechatPayResultHash() {
if (typeof window === 'undefined') {
return;
@@ -2378,12 +2363,8 @@ function RechargeProductCard({
onBuy: (product: ProfileRechargeProduct) => void;
}) {
const submitting = submittingProductId === product.productId;
const effectiveBonusPoints = product.bonusPoints;
const badgeLabel = product.badgeLabel;
const value =
product.kind === 'points'
? `${product.pointsAmount}${effectiveBonusPoints > 0 ? `+${effectiveBonusPoints}` : ''}泥点`
: `${product.durationDays}`;
const value = buildRechargeProductValueLabel(product);
return (
<button
@@ -2447,12 +2428,10 @@ function ProfileRechargeModal({
activeTab === 'points'
? (center?.pointProducts ?? [])
: (center?.membershipProducts ?? []);
const memberLabel =
center?.membership.status === 'active'
? center.membership.expiresAt
? `会员至 ${formatSnapshotTime(center.membership.expiresAt)}`
: '会员已生效'
: '普通用户';
const memberLabel = buildMembershipLabel(
center?.membership,
formatSnapshotTime,
);
return (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
@@ -2664,8 +2643,11 @@ function WalletLedgerModal({
onClose: () => void;
onRetry: () => void;
}) {
const entries = ledger?.entries ?? [];
const balance = entries[0]?.balanceAfter ?? fallbackBalance;
const walletLedgerPresentation = buildWalletLedgerPresentation(
ledger,
fallbackBalance,
);
const entries = walletLedgerPresentation.entries;
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
@@ -2686,7 +2668,7 @@ function WalletLedgerModal({
<div className="mt-1 text-2xl font-black"></div>
<div className="mt-3 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-white/70 px-3 py-1.5 text-xs font-bold text-zinc-600">
<Coins className="h-3.5 w-3.5 text-[#ff4056]" />
<span>{balance}</span>
<span>{walletLedgerPresentation.balanceLabel}</span>
</div>
</div>
@@ -2717,11 +2699,6 @@ function WalletLedgerModal({
) : (
<div className="mt-5 space-y-2.5">
{entries.map((entry) => {
const isIncome = entry.amountDelta > 0;
const label =
WALLET_LEDGER_SOURCE_LABELS[entry.sourceType] ??
entry.sourceType;
return (
<div
key={entry.id}
@@ -2729,7 +2706,7 @@ function WalletLedgerModal({
>
<div className="min-w-0">
<div className="truncate text-sm font-black text-zinc-900">
{label}
{entry.sourceLabel}
</div>
<div className="mt-1 text-xs font-semibold text-zinc-500">
{formatPlatformWorldTime(entry.createdAt)}
@@ -2738,13 +2715,15 @@ function WalletLedgerModal({
<div className="shrink-0 text-right">
<div
className={`text-base font-black ${
isIncome ? 'text-emerald-600' : 'text-rose-500'
entry.isIncome
? 'text-emerald-600'
: 'text-rose-500'
}`}
>
{formatWalletLedgerAmount(entry.amountDelta)}
{entry.amountLabel}
</div>
<div className="mt-1 text-[11px] font-semibold text-zinc-400">
{entry.balanceAfter}
{entry.balanceLabel}
</div>
</div>
</div>

View File

@@ -0,0 +1,161 @@
import { expect, test } from 'vitest';
import type {
ProfileMembership,
ProfileRechargeProduct,
ProfileWalletLedgerEntry,
} from '../../../packages/shared/src/contracts/runtime';
import {
buildMembershipLabel,
buildRechargeProductValueLabel,
buildWalletLedgerPresentation,
formatRechargePrice,
formatWalletLedgerAmount,
getWalletLedgerSourceLabel,
} from './rpgEntryProfileFundsViewModel';
function buildLedgerEntry(
overrides: Partial<ProfileWalletLedgerEntry> = {},
): ProfileWalletLedgerEntry {
return {
id: 'ledger-1',
amountDelta: 30,
balanceAfter: 80,
sourceType: 'invite_invitee_reward',
createdAt: '2026-06-03T00:00:00.000Z',
...overrides,
};
}
function buildRechargeProduct(
overrides: Partial<ProfileRechargeProduct> = {},
): ProfileRechargeProduct {
return {
productId: 'points_60',
title: '60泥点',
priceCents: 600,
kind: 'points',
pointsAmount: 60,
bonusPoints: 60,
durationDays: 0,
badgeLabel: '首充双倍',
description: '首充送60泥点',
tier: 'normal',
...overrides,
};
}
function buildMembership(
overrides: Partial<ProfileMembership> = {},
): ProfileMembership {
return {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
...overrides,
};
}
test('profile funds ViewModel formats ledger amount labels', () => {
expect(formatWalletLedgerAmount(-1)).toBe('-1');
expect(formatWalletLedgerAmount(0)).toBe('0');
expect(formatWalletLedgerAmount(30)).toBe('+30');
});
test('profile funds ViewModel resolves ledger source labels with raw fallback', () => {
expect(getWalletLedgerSourceLabel('asset_operation_consume')).toBe(
'资产操作消耗',
);
expect(getWalletLedgerSourceLabel('puzzle_author_incentive_claim')).toBe(
'拼图作者奖励',
);
expect(getWalletLedgerSourceLabel('future_source')).toBe('future_source');
expect(getWalletLedgerSourceLabel('')).toBe('未知来源');
});
test('profile funds ViewModel builds wallet ledger presentation', () => {
const incomeEntry = buildLedgerEntry({
id: 'ledger-income',
amountDelta: 30,
balanceAfter: 80,
sourceType: 'puzzle_author_incentive_claim',
});
const outcomeEntry = buildLedgerEntry({
id: 'ledger-outcome',
amountDelta: -1,
balanceAfter: 79,
sourceType: 'asset_operation_consume',
});
expect(
buildWalletLedgerPresentation(
{ entries: [incomeEntry, outcomeEntry] },
12,
),
).toEqual({
balance: 80,
balanceLabel: '80泥点',
entries: [
{
amountLabel: '+30',
balanceLabel: '余额 80',
createdAt: '2026-06-03T00:00:00.000Z',
id: 'ledger-income',
isIncome: true,
sourceLabel: '拼图作者奖励',
},
{
amountLabel: '-1',
balanceLabel: '余额 79',
createdAt: '2026-06-03T00:00:00.000Z',
id: 'ledger-outcome',
isIncome: false,
sourceLabel: '资产操作消耗',
},
],
});
expect(buildWalletLedgerPresentation({ entries: [] }, 12)).toEqual({
balance: 12,
balanceLabel: '12泥点',
entries: [],
});
});
test('profile funds ViewModel formats recharge product and membership labels', () => {
expect(formatRechargePrice(600)).toBe('¥6');
expect(formatRechargePrice(650)).toBe('¥6.50');
expect(buildRechargeProductValueLabel(buildRechargeProduct())).toBe(
'60+60泥点',
);
expect(
buildRechargeProductValueLabel(
buildRechargeProduct({
kind: 'membership',
pointsAmount: 0,
bonusPoints: 0,
durationDays: 30,
}),
),
).toBe('30天');
expect(buildMembershipLabel(buildMembership(), (value) => value)).toBe(
'普通用户',
);
expect(
buildMembershipLabel(
buildMembership({ status: 'active', expiresAt: null }),
(value) => value,
),
).toBe('会员已生效');
expect(
buildMembershipLabel(
buildMembership({
status: 'active',
expiresAt: '2026-06-03T00:00:00.000Z',
}),
() => '06/03 08:00',
),
).toBe('会员至 06/03 08:00');
});

View File

@@ -0,0 +1,108 @@
import type {
ProfileMembership,
ProfileRechargeProduct,
ProfileWalletLedgerEntry,
ProfileWalletLedgerResponse,
} from '../../../packages/shared/src/contracts/runtime';
const PROFILE_WALLET_LEDGER_SOURCE_LABELS = {
new_user_registration_reward: '注册赠送',
points_recharge: '泥点充值',
invite_inviter_reward: '邀请奖励',
invite_invitee_reward: '填写邀请码奖励',
snapshot_sync: '账户同步',
asset_operation_consume: '资产操作消耗',
asset_operation_refund: '资产操作退回',
redeem_code_reward: '兑换码奖励',
puzzle_author_incentive_claim: '拼图作者奖励',
daily_task_reward: '每日任务奖励',
} satisfies Record<ProfileWalletLedgerEntry['sourceType'], string>;
export type ProfileWalletLedgerEntryPresentation = {
amountLabel: string;
balanceLabel: string;
createdAt: string;
id: string;
isIncome: boolean;
sourceLabel: string;
};
export type ProfileWalletLedgerPresentation = {
balance: number;
balanceLabel: string;
entries: ProfileWalletLedgerEntryPresentation[];
};
export function getWalletLedgerSourceLabel(
sourceType: string | null | undefined,
) {
const normalizedSourceType = sourceType?.trim() ?? '';
if (!normalizedSourceType) {
return '未知来源';
}
return (
PROFILE_WALLET_LEDGER_SOURCE_LABELS[
normalizedSourceType as ProfileWalletLedgerEntry['sourceType']
] ?? normalizedSourceType
);
}
export function formatWalletLedgerAmount(amountDelta: number) {
return amountDelta > 0 ? `+${amountDelta}` : `${amountDelta}`;
}
export function buildWalletLedgerEntryPresentation(
entry: ProfileWalletLedgerEntry,
): ProfileWalletLedgerEntryPresentation {
return {
amountLabel: formatWalletLedgerAmount(entry.amountDelta),
balanceLabel: `余额 ${entry.balanceAfter}`,
createdAt: entry.createdAt,
id: entry.id,
isIncome: entry.amountDelta > 0,
sourceLabel: getWalletLedgerSourceLabel(entry.sourceType),
};
}
export function buildWalletLedgerPresentation(
ledger: ProfileWalletLedgerResponse | null,
fallbackBalance: number,
): ProfileWalletLedgerPresentation {
const entries = ledger?.entries ?? [];
const balance = entries[0]?.balanceAfter ?? fallbackBalance;
return {
balance,
balanceLabel: `${balance}泥点`,
entries: entries.map(buildWalletLedgerEntryPresentation),
};
}
export function formatRechargePrice(priceCents: number) {
const yuan = priceCents / 100;
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`;
}
export function buildRechargeProductValueLabel(product: ProfileRechargeProduct) {
if (product.kind === 'membership') {
return `${product.durationDays}`;
}
return `${product.pointsAmount}${
product.bonusPoints > 0 ? `+${product.bonusPoints}` : ''
}泥点`;
}
export function buildMembershipLabel(
membership: ProfileMembership | null | undefined,
formatTime: (value: string) => string,
) {
if (membership?.status !== 'active') {
return '普通用户';
}
return membership.expiresAt
? `会员至 ${formatTime(membership.expiresAt)}`
: '会员已生效';
}