Files
Genarrative/packages/shared/src/components/PlatformMudPointWalletEntry.tsx
T
kdletters f1f2332e61 修复泥点余额明细刷新
钱包明细每次展开并在实时余额变化时读取最新拆分。

图片画板生成扣费或退款后同步刷新总额与充值中心。

充值中心读取增加 latest-wins 门禁并阻止旧响应覆盖权威结果。

补充钱包刷新、画板入口和充值乱序回归测试。

同步更新泥点统一资产入口决策记录。
2026-07-20 12:48:28 +08:00

287 lines
9.2 KiB
TypeScript

import { ChevronRight, ReceiptText } from 'lucide-react';
import {
type FocusEvent,
type MouseEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import type { ProfileMudPointBalance } from '../contracts/runtime';
import MUD_POINT_ICON_SRC from '../icons/topbar-wallet.png';
import { formatMudPointCount } from '../utils/format';
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 requestedDetailsKeyForOpenRef = useRef<string | null>(null);
const closeTimerRef = useRef<number | null>(null);
const [isOpen, setIsOpen] = useState(false);
const isCompact = variant === 'mobile';
const displayedBalance = balance ?? breakdown?.totalPoints ?? null;
const balanceLabel =
displayedBalance === null
? '--'
: formatMudPointCount(displayedBalance, true);
const exactBalanceLabel =
displayedBalance === null ? '--' : formatMudPointCount(displayedBalance);
const detailsRequestKey = `balance:${balance ?? breakdown?.totalPoints ?? 'unknown'}`;
const cancelPendingClose = useCallback(() => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
const closeDetails = useCallback(() => {
cancelPendingClose();
isOpenRef.current = false;
requestedDetailsKeyForOpenRef.current = null;
setIsOpen(false);
}, [cancelPendingClose]);
const requestDetailsIfNeeded = useCallback(() => {
if (
isLoading ||
requestedDetailsKeyForOpenRef.current === detailsRequestKey
) {
return;
}
requestedDetailsKeyForOpenRef.current = detailsRequestKey;
onRequestDetails();
}, [detailsRequestKey, isLoading, onRequestDetails]);
const requestAndOpen = useCallback(() => {
cancelPendingClose();
if (isOpenRef.current) {
return;
}
isOpenRef.current = true;
setIsOpen(true);
requestDetailsIfNeeded();
}, [cancelPendingClose, requestDetailsIfNeeded]);
useEffect(() => cancelPendingClose, [cancelPendingClose]);
useEffect(() => {
if (isOpen) {
requestDetailsIfNeeded();
}
}, [isOpen, requestDetailsIfNeeded]);
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 ?? ''}`}
onMouseLeave={isCompact ? undefined : closeAfterPointerLeaves}
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}
onMouseEnter={isCompact ? undefined : requestAndOpen}
onFocus={isCompact ? undefined : requestAndOpen}
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>
);
}