extract and reuse detail modal

This commit is contained in:
2026-07-17 11:04:52 +08:00
parent 51ccd54f48
commit 3cf67a5172
16 changed files with 547 additions and 386 deletions
@@ -620,7 +620,7 @@ describe('ImageCanvasEditorView', () => {
name: '泥点 1,234',
});
fireEvent.click(walletButton);
fireEvent.mouseEnter(walletButton);
const details = await screen.findByRole('dialog', {
name: '泥点账户详情',
@@ -25,8 +25,8 @@ import { getPlatformProfileDashboard } from '../../services/platform-entry/platf
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
import { PlatformProfileRechargeModal } from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
import { PlatformProfileWalletLedgerModal } from '../../../packages/shared/src/components/PlatformProfileWalletLedgerModal';
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal';
import {
PlatformRechargePaymentConfirmationMask,
PlatformRechargePaymentResultDialog,
@@ -1,58 +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 { PlatformProfileWalletLedgerModal } from './PlatformProfileWalletLedgerModal';
describe('PlatformProfileWalletLedgerModal', () => {
test('renders ledger entries with shared balance presentation', () => {
render(
<PlatformProfileWalletLedgerModal
ledger={{
entries: [
{
id: 'ledger-1',
sourceType: 'daily_task_reward',
amountDelta: 12,
balanceAfter: 88,
createdAt: '2026-06-10T08:00:00.000Z',
},
],
}}
fallbackBalance={40}
isLoading={false}
error={null}
onClose={vi.fn()}
onRetry={vi.fn()}
/>,
);
const dialog = screen.getByRole('dialog', { name: '泥点账单' });
expect(within(dialog).getByText('88泥点')).toBeTruthy();
expect(within(dialog).getByText('每日任务奖励')).toBeTruthy();
expect(within(dialog).getByText('+12')).toBeTruthy();
expect(within(dialog).getByText('余额 88')).toBeTruthy();
});
test('retries from the shared error state', async () => {
const user = userEvent.setup();
const onRetry = vi.fn();
render(
<PlatformProfileWalletLedgerModal
ledger={null}
fallbackBalance={40}
isLoading={false}
error="账单加载失败"
onClose={vi.fn()}
onRetry={onRetry}
/>,
);
await user.click(screen.getByRole('button', { name: '重新加载' }));
expect(onRetry).toHaveBeenCalledTimes(1);
});
});
@@ -1,140 +0,0 @@
import { Coins } from 'lucide-react';
import type { ProfileWalletLedgerResponse } from '../../../packages/shared/src/contracts/runtime';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformProfileContentRow } from '../common/PlatformProfileContentRow';
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
import { PlatformProfileSummaryHeader } from '../common/PlatformProfileSummaryHeader';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell';
import { buildWalletLedgerPresentation } from '../rpg-entry/rpgEntryProfileFundsViewModel';
import { formatPlatformWorldTime } from '../rpg-entry/rpgEntryWorldPresentation';
export type PlatformProfileWalletLedgerModalProps = {
ledger: ProfileWalletLedgerResponse | null;
fallbackBalance: number;
isLoading: boolean;
error: string | null;
onClose: () => void;
onRetry: () => void;
};
/**
* 个人中心泥点账单弹窗。
* 保持 RPG 首页里既有的展示文案、状态分支和交互,仅把实现提取为共享组件。
*/
export function PlatformProfileWalletLedgerModal({
ledger,
fallbackBalance,
isLoading,
error,
onClose,
onRetry,
}: PlatformProfileWalletLedgerModalProps) {
const walletLedgerPresentation = buildWalletLedgerPresentation(
ledger,
fallbackBalance,
);
const entries = walletLedgerPresentation.entries;
return (
<PlatformProfileSecondaryModalShell
title="泥点账单"
onClose={onClose}
closeLabel="关闭泥点账单"
closeButtonClassName="bg-white/78"
panelClassName="relative !max-h-[min(92vh,42rem)] !max-w-[30rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_38%,#f8fafc_100%)] text-zinc-950 shadow-2xl !rounded-[1.35rem] sm:!rounded-[1.35rem]"
contentClassName="relative max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5"
>
<PlatformProfileSummaryHeader
kicker="LEDGER"
title="泥点账单"
badge={
<PlatformPillBadge
tone="profile"
icon={<Coins className="h-3.5 w-3.5 text-[#ff4056]" />}
className="bg-white/70"
>
{walletLedgerPresentation.balanceLabel}
</PlatformPillBadge>
}
/>
<PlatformAsyncStatePanel
errorState={
error ? (
<PlatformStatusMessage
tone="error"
className="mt-4 rounded-xl py-3"
>
<div>{error}</div>
<PlatformActionButton
surface="profile"
shape="pill"
size="xs"
className="mt-3"
onClick={onRetry}
>
重新加载
</PlatformActionButton>
</PlatformStatusMessage>
) : null
}
isLoading={isLoading}
loadingState={
<PlatformProfileSkeletonList
count={5}
containerClassName="mt-5 space-y-3"
itemClassName="h-16"
/>
}
isEmpty={entries.length === 0}
emptyState={
<PlatformEmptyState
surface="subpanel"
size="inline"
className="mt-5 py-8"
>
暂无账单记录
</PlatformEmptyState>
}
>
<div className="mt-5 space-y-2.5">
{entries.map((entry) => (
<PlatformProfileContentRow
key={entry.id}
surface="flat"
radius="xs"
padding="none"
className="flex items-center justify-between gap-3 px-3 py-3 shadow-sm"
>
<div className="min-w-0">
<div className="truncate text-sm font-black text-zinc-900">
{entry.sourceLabel}
</div>
<div className="mt-1 text-xs font-semibold text-zinc-500">
{formatPlatformWorldTime(entry.createdAt)}
</div>
</div>
<div className="shrink-0 text-right">
<div
className={`text-base font-black ${
entry.isIncome ? 'text-emerald-600' : 'text-rose-500'
}`}
>
{entry.amountLabel}
</div>
<div className="mt-1 text-[11px] font-semibold text-zinc-400">
{entry.balanceLabel}
</div>
</div>
</PlatformProfileContentRow>
))}
</div>
</PlatformAsyncStatePanel>
</PlatformProfileSecondaryModalShell>
);
}
@@ -134,9 +134,9 @@ import {
} from '../platform-entry/PlatformProfilePrimitives';
import { PlatformProfileQrScannerModal } from '../platform-entry/PlatformProfileQrScannerModal';
import { PlatformProfileRechargeModal } from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
import { PlatformProfileWalletLedgerModal } from '../../../packages/shared/src/components/PlatformProfileWalletLedgerModal';
import { PlatformProfileReferralModal } from '../platform-entry/PlatformProfileReferralModal';
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal';
import {
PlatformRechargePaymentConfirmationMask,
PlatformRechargePaymentResultDialog,
@@ -3,31 +3,14 @@ import { expect, test } from 'vitest';
import type {
ProfileMembership,
ProfileRechargeProduct,
ProfileWalletLedgerEntry,
} from '../../../packages/shared/src/contracts/runtime';
import {
buildMembershipCycleLabel,
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 {
@@ -68,89 +51,6 @@ function buildMembership(
};
}
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('recharge_refund_recovery')).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',
});
const recoveryEntry = buildLedgerEntry({
id: 'ledger-recovery',
amountDelta: -30,
balanceAfter: 49,
sourceType: 'recharge_refund_recovery',
});
expect(
buildWalletLedgerPresentation(
{ entries: [incomeEntry, outcomeEntry, recoveryEntry] },
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: '资产操作消耗',
},
{
amountLabel: '-30',
balanceLabel: '余额 49',
createdAt: '2026-06-03T00:00:00.000Z',
id: 'ledger-recovery',
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');
@@ -1,90 +1,9 @@
import type {
ProfileMembership,
ProfileRechargeProduct,
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: '注册赠送',
points_recharge: '泥点充值',
invite_inviter_reward: '邀请奖励',
invite_invitee_reward: '填写邀请码奖励',
snapshot_sync: '账户同步',
membership_period_grant: '会员周期发放',
membership_period_reset: '会员周期重置',
daily_free_grant: '每日免费发放',
daily_free_reset: '每日免费重置',
asset_operation_consume: '资产操作消耗',
asset_operation_refund: '资产操作退回',
recharge_refund_recovery: '充值退款追回',
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),
};
}
function formatMembershipPeriodPrefix(periodDays: number) {
if (periodDays === 30) {
return '每月';