f1f2332e61
钱包明细每次展开并在实时余额变化时读取最新拆分。 图片画板生成扣费或退款后同步刷新总额与充值中心。 充值中心读取增加 latest-wins 门禁并阻止旧响应覆盖权威结果。 补充钱包刷新、画板入口和充值乱序回归测试。 同步更新泥点统一资产入口决策记录。
287 lines
9.2 KiB
TypeScript
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>
|
|
);
|
|
}
|