extract mud point wallet entry component for reuse in tauri app

This commit is contained in:
2026-07-13 13:52:40 +08:00
parent 36a8719d0f
commit b7b4dd0066
9 changed files with 9 additions and 25 deletions
@@ -1,120 +0,0 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { PlatformMudPointWalletEntry } from './PlatformMudPointWalletEntry';
import { formatMudPointCount } from './platformMudPointWalletModel';
const breakdown = {
totalPoints: 207,
permanentPoints: 100,
limitedPoints: 80,
limitedExpiresAt: '2026-07-06T16:00:00Z',
dailyFreePoints: 27,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-07-12T16:00:00Z',
};
test('formats mud point counts consistently', () => {
expect(formatMudPointCount(12_345)).toBe('12,345');
expect(formatMudPointCount(12_345, true)).toBe('1.2万');
});
test('shows only permanent and daily free points in the shared wallet panel', async () => {
const user = userEvent.setup();
const onRequestDetails = vi.fn();
const onRecharge = vi.fn();
const onOpenLedger = vi.fn();
render(
<PlatformMudPointWalletEntry
balance={999}
breakdown={breakdown}
onRequestDetails={onRequestDetails}
onRecharge={onRecharge}
onOpenLedger={onOpenLedger}
/>,
);
const balanceButton = screen.getByRole('button', { name: '泥点 207' });
await user.hover(balanceButton);
const details = screen.getByRole('dialog', { name: '泥点账户详情' });
expect(details.className).toContain('rounded-[1.12rem]');
expect(within(details).getByText('不限时泥点')).toBeTruthy();
expect(within(details).getByText('按量充值、兑换码获得')).toBeTruthy();
expect(within(details).getByText('100')).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();
expect(onRequestDetails).not.toHaveBeenCalled();
await user.click(within(details).getByRole('button', { name: '使用详情' }));
expect(onOpenLedger).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole('button', { name: '充值' }));
expect(onRecharge).toHaveBeenCalledTimes(1);
});
test('keeps the desktop panel open while moving across the gap without click pinning', () => {
vi.useFakeTimers();
try {
render(
<PlatformMudPointWalletEntry
balance={207}
breakdown={breakdown}
onRequestDetails={vi.fn()}
onRecharge={vi.fn()}
onOpenLedger={vi.fn()}
/>,
);
const balanceButton = screen.getByRole('button', { name: '泥点 207' });
const root = balanceButton.closest('.platform-mud-point-wallet-entry');
expect(root).toBeTruthy();
fireEvent.mouseEnter(balanceButton);
const details = screen.getByRole('dialog', { name: '泥点账户详情' });
fireEvent.mouseLeave(root as HTMLElement, { relatedTarget: null });
fireEvent.mouseEnter(details);
act(() => vi.advanceTimersByTime(120));
expect(screen.getByRole('dialog', { name: '泥点账户详情' })).toBeTruthy();
balanceButton.focus();
fireEvent.click(balanceButton);
expect(screen.getByRole('dialog', { name: '泥点账户详情' })).toBeTruthy();
fireEvent.mouseLeave(root as HTMLElement, { relatedTarget: null });
act(() => vi.advanceTimersByTime(120));
expect(screen.queryByRole('dialog', { name: '泥点账户详情' })).toBeNull();
} finally {
vi.useRealTimers();
}
});
test('requests the balance breakdown when a compact entry opens', async () => {
const user = userEvent.setup();
const onRequestDetails = vi.fn();
render(
<PlatformMudPointWalletEntry
balance={20}
breakdown={null}
isLoading={false}
variant="mobile"
onRequestDetails={onRequestDetails}
onRecharge={vi.fn()}
onOpenLedger={vi.fn()}
/>,
);
await user.click(screen.getByRole('button', { name: '泥点 20' }));
expect(onRequestDetails).toHaveBeenCalledTimes(1);
expect(screen.getByText('余额明细暂不可用')).toBeTruthy();
});
@@ -1,269 +0,0 @@
import { ChevronRight, ReceiptText } from 'lucide-react';
import {
type FocusEvent,
type MouseEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import type { ProfileMudPointBalance } from '../../../packages/shared/src/contracts/runtime';
import { formatMudPointCount } from './platformMudPointWalletModel';
const MUD_POINT_ICON_SRC = '/creation-home/topbar-wallet.png';
export type PlatformMudPointWalletEntryProps = {
balance: number | null;
breakdown?: ProfileMudPointBalance | null;
isLoading?: boolean;
error?: string | null;
variant?: 'desktop' | 'mobile' | 'editor';
className?: string;
onRequestDetails: () => void;
onRecharge: () => void;
onOpenLedger: () => void;
};
function MudPointBalanceRow({
label,
points,
detail,
}: {
label: string;
points: number;
detail?: string | null;
}) {
return (
<div className="flex min-h-[3.25rem] items-center justify-between gap-4 border-t border-[var(--platform-subpanel-border)] px-4 py-2.5">
<div className="min-w-0">
<div className="text-[13px] font-bold text-[var(--platform-text-strong)]">
{label}
</div>
{detail ? (
<div className="mt-0.5 truncate text-[11px] text-[var(--platform-text-soft)]">
{detail}
</div>
) : null}
</div>
<div className="shrink-0 text-[15px] font-black tabular-nums text-[var(--platform-text-strong)]">
{formatMudPointCount(points)}
</div>
</div>
);
}
export function PlatformMudPointWalletEntry({
balance,
breakdown,
isLoading = false,
variant = 'desktop',
className,
onRequestDetails,
onRecharge,
onOpenLedger,
}: PlatformMudPointWalletEntryProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const isOpenRef = useRef(false);
const closeTimerRef = useRef<number | null>(null);
const [isOpen, setIsOpen] = useState(false);
const isCompact = variant === 'mobile';
const displayedBalance = breakdown?.totalPoints ?? balance;
const balanceLabel =
displayedBalance === null
? '--'
: formatMudPointCount(displayedBalance, true);
const exactBalanceLabel =
displayedBalance === null ? '--' : formatMudPointCount(displayedBalance);
const cancelPendingClose = useCallback(() => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
const closeDetails = useCallback(() => {
cancelPendingClose();
isOpenRef.current = false;
setIsOpen(false);
}, [cancelPendingClose]);
const requestAndOpen = useCallback(() => {
cancelPendingClose();
if (isOpenRef.current) {
return;
}
isOpenRef.current = true;
setIsOpen(true);
if (!breakdown && !isLoading) {
onRequestDetails();
}
}, [breakdown, cancelPendingClose, isLoading, onRequestDetails]);
useEffect(() => cancelPendingClose, [cancelPendingClose]);
useEffect(() => {
if (!isOpen) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) {
closeDetails();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeDetails();
}
};
document.addEventListener('pointerdown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('pointerdown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [closeDetails, isOpen]);
const closeAfterFocusLeaves = (event: FocusEvent<HTMLDivElement>) => {
const nextTarget = event.relatedTarget;
if (
!(nextTarget instanceof Node) ||
!event.currentTarget.contains(nextTarget)
) {
closeDetails();
}
};
const closeAfterPointerLeaves = (event: MouseEvent<HTMLDivElement>) => {
const nextTarget = event.relatedTarget;
if (
nextTarget instanceof Node &&
event.currentTarget.contains(nextTarget)
) {
return;
}
cancelPendingClose();
closeTimerRef.current = window.setTimeout(() => {
closeTimerRef.current = null;
closeDetails();
}, 120);
};
return (
<div
ref={rootRef}
className={`platform-mud-point-wallet-entry relative shrink-0 ${className ?? ''}`}
onMouseEnter={isCompact ? undefined : requestAndOpen}
onMouseLeave={isCompact ? undefined : closeAfterPointerLeaves}
onFocusCapture={isCompact ? undefined : requestAndOpen}
onBlurCapture={closeAfterFocusLeaves}
>
<div
className={`flex items-stretch overflow-hidden rounded-full border border-[rgba(214,184,159,0.78)] bg-[rgba(255,250,244,0.9)] text-[#6f3d24] shadow-[0_0.18rem_0.55rem_rgba(112,62,32,0.08)] ${
isCompact ? 'h-8 text-[11px]' : 'h-9 text-xs'
}`}
>
<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-expanded={isOpen}
aria-haspopup="dialog"
aria-busy={isLoading}
onClick={
isCompact
? () => {
if (isOpenRef.current) {
closeDetails();
return;
}
requestAndOpen();
}
: undefined
}
>
<img
src={MUD_POINT_ICON_SRC}
alt=""
aria-hidden="true"
draggable={false}
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}
</span>
</button>
<span
className="my-1.5 w-px shrink-0 bg-[rgba(196,153,120,0.62)]"
aria-hidden="true"
/>
<button
type="button"
className="shrink-0 px-2.5 font-black outline-none transition-colors hover:bg-white/75 focus-visible:bg-white/80"
onClick={() => {
closeDetails();
onRecharge();
}}
>
</button>
</div>
{isOpen ? (
<div
role="dialog"
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.12rem] border border-[var(--platform-subpanel-border)] bg-[#fffaf4] text-left shadow-[0_1rem_2.8rem_rgba(76,44,27,0.2)]"
>
<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}
</div>
<button
type="button"
className="text-xs font-black text-[var(--platform-accent-strong)] outline-none hover:underline focus-visible:underline"
onClick={() => {
closeDetails();
onRecharge();
}}
>
</button>
</div>
{breakdown ? (
<>
<MudPointBalanceRow
label="不限时泥点"
points={breakdown.permanentPoints}
detail="按量充值、兑换码获得"
/>
<MudPointBalanceRow
label="每日免费泥点"
points={breakdown.dailyFreePoints}
detail={`每天重置为 ${formatMudPointCount(breakdown.dailyFreeResetPoints)} 泥点`}
/>
</>
) : (
<div className="border-t border-[var(--platform-subpanel-border)] px-4 py-6 text-center text-xs font-semibold text-[var(--platform-text-soft)]">
{isLoading ? '余额读取中' : '余额明细暂不可用'}
</div>
)}
<button
type="button"
className="flex w-full items-center justify-center gap-2 border-t border-[var(--platform-subpanel-border)] px-4 py-3 text-[13px] font-black text-[var(--platform-text-strong)] outline-none transition-colors hover:bg-white/45 focus-visible:bg-white/55"
onClick={() => {
closeDetails();
onOpenLedger();
}}
>
<ReceiptText className="h-3.5 w-3.5" aria-hidden="true" />
使
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
) : null}
</div>
);
}
@@ -1,10 +0,0 @@
export function formatMudPointCount(value: number, compact = false) {
const normalizedValue = Math.max(0, Math.round(value));
if (compact && normalizedValue >= 100_000_000) {
return `${(normalizedValue / 100_000_000).toFixed(1)}亿`;
}
if (compact && normalizedValue >= 10_000) {
return `${(normalizedValue / 10_000).toFixed(1)}`;
}
return normalizedValue.toLocaleString('zh-CN');
}
@@ -108,9 +108,6 @@ describe('ImageCanvasTopbarView', () => {
});
expect(walletChip.textContent).toBe('泥点 1.2万');
expect(walletChip.querySelector('img')?.getAttribute('src')).toBe(
'/creation-home/topbar-wallet.png',
);
await user.hover(walletChip);
const details = screen.getByRole('dialog', { name: '泥点账户详情' });
@@ -8,7 +8,7 @@ import {
} from 'lucide-react';
import type { ProfileMudPointBalance } from '../../../packages/shared/src/contracts/runtime';
import { PlatformMudPointWalletEntry } from '../common/PlatformMudPointWalletEntry';
import { PlatformMudPointWalletEntry } from '../../../packages/shared/src/components/PlatformMudPointWalletEntry.tsx';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
@@ -8,7 +8,6 @@ import type {
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { formatMudPointCount } from '../common/platformMudPointWalletModel';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
@@ -16,6 +15,7 @@ import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { formatRechargePrice } from '../rpg-entry/rpgEntryProfileFundsViewModel';
import { PlatformProfileModalShell } from './PlatformProfileModalShell';
import type { NativeWechatPaymentState } from './usePlatformProfileCenterController';
import { formatMudPointCount } from '@/packages/shared/src/utils/format.ts';
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
export type PlatformProfileRechargeModalProps = {
@@ -4235,9 +4235,6 @@ test('logged in create tab shows real wallet balance beside the brand', () => {
expect(walletEntry?.className).toContain(
'platform-mobile-create-wallet-chip',
);
expect(walletButton.querySelector('img')?.getAttribute('src')).toBe(
'/creation-home/topbar-wallet.png',
);
expect(
within(topbar as HTMLElement).getByRole('button', { name: '充值' }),
).toBeTruthy();
@@ -89,7 +89,7 @@ import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformFilterToolbar } from '../common/PlatformFilterToolbar';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformMudPointWalletEntry } from '../common/PlatformMudPointWalletEntry';
import { PlatformMudPointWalletEntry } from '../../../packages/shared/src/components/PlatformMudPointWalletEntry.tsx';
import { PlatformNavigableListItem } from '../common/PlatformNavigableListItem';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import {