feat: add asset operation wallet ledger
Some checks failed
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-04-28 12:14:07 +08:00
parent 3cdbf36859
commit 04dfce57e6
16 changed files with 780 additions and 669 deletions

View File

@@ -34,6 +34,7 @@ import type {
PlatformBrowseHistoryEntry,
ProfileDashboardCardKey,
ProfileDashboardSummary,
ProfileWalletLedgerResponse,
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
ProfileReferralInviteCenterResponse,
@@ -46,6 +47,7 @@ import {
createRpgProfileRechargeOrder,
getRpgProfileRechargeCenter,
getRpgProfileReferralInviteCenter,
getRpgProfileWalletLedger,
redeemRpgProfileReferralInviteCode,
} from '../../services/rpg-entry/rpgProfileClient';
import type { CustomWorldProfile } from '../../types';
@@ -923,6 +925,128 @@ function formatMembershipDuration(days: number) {
return `${days}`;
}
const WALLET_LEDGER_SOURCE_LABELS: Record<string, string> = {
points_recharge: '叙世币充值',
invite_inviter_reward: '邀请奖励',
invite_invitee_reward: '填写邀请码奖励',
snapshot_sync: '账户同步',
asset_operation_consume: '资产操作消耗',
asset_operation_refund: '资产操作退回',
};
function formatWalletLedgerAmount(amountDelta: number) {
return amountDelta > 0 ? `+${amountDelta}` : `${amountDelta}`;
}
function WalletLedgerModal({
ledger,
fallbackBalance,
isLoading,
error,
onClose,
onRetry,
}: {
ledger: ProfileWalletLedgerResponse | null;
fallbackBalance: number;
isLoading: boolean;
error: string | null;
onClose: () => void;
onRetry: () => void;
}) {
const entries = ledger?.entries ?? [];
const balance = entries[0]?.balanceAfter ?? fallbackBalance;
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
<div className="relative max-h-[min(92vh,42rem)] w-full max-w-[30rem] overflow-hidden rounded-[1.35rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_38%,#f8fafc_100%)] text-zinc-950 shadow-2xl">
<button
type="button"
onClick={onClose}
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/78 text-[#ff4056] shadow-sm"
aria-label="关闭叙世币账单"
>
×
</button>
<div className="max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
<div className="pr-10">
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
LEDGER
</div>
<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>
</div>
</div>
{error ? (
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-3 text-sm text-rose-700">
<div>{error}</div>
<button
type="button"
onClick={onRetry}
className="mt-3 rounded-full bg-[#ff4056] px-4 py-2 text-xs font-black text-white"
>
</button>
</div>
) : isLoading ? (
<div className="mt-5 space-y-3">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className="h-16 animate-pulse rounded-xl bg-zinc-100"
/>
))}
</div>
) : entries.length === 0 ? (
<div className="mt-5 rounded-xl border border-zinc-200 bg-white px-4 py-8 text-center text-sm font-semibold text-zinc-500">
</div>
) : (
<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}
className="flex items-center justify-between gap-3 rounded-xl border border-zinc-200 bg-white px-3 py-3 shadow-sm"
>
<div className="min-w-0">
<div className="truncate text-sm font-black text-zinc-900">
{label}
</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 ${
isIncome ? 'text-emerald-600' : 'text-rose-500'
}`}
>
{formatWalletLedgerAmount(entry.amountDelta)}
</div>
<div className="mt-1 text-[11px] font-semibold text-zinc-400">
{entry.balanceAfter}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
function AccountRechargeModal({
center,
activeTab,
@@ -1304,6 +1428,13 @@ export function RpgEntryHomeView({
const [isLoadingRecharge, setIsLoadingRecharge] = useState(false);
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
useState<string | null>(null);
const [isWalletLedgerOpen, setIsWalletLedgerOpen] = useState(false);
const [walletLedger, setWalletLedger] =
useState<ProfileWalletLedgerResponse | null>(null);
const [walletLedgerError, setWalletLedgerError] = useState<string | null>(
null,
);
const [isLoadingWalletLedger, setIsLoadingWalletLedger] = useState(false);
const [profilePopupPanel, setProfilePopupPanel] =
useState<ProfilePopupPanel | null>(null);
const [referralCenter, setReferralCenter] =
@@ -1415,6 +1546,23 @@ export function RpgEntryHomeView({
})
.finally(() => setIsLoadingRecharge(false));
};
const loadWalletLedger = () => {
setWalletLedgerError(null);
setIsLoadingWalletLedger(true);
void getRpgProfileWalletLedger()
.then(setWalletLedger)
.catch((error: unknown) => {
setWalletLedger(null);
setWalletLedgerError(
error instanceof Error ? error.message : '读取叙世币账单失败',
);
})
.finally(() => setIsLoadingWalletLedger(false));
};
const openWalletLedgerPanel = () => {
setIsWalletLedgerOpen(true);
loadWalletLedger();
};
const submitRechargeProduct = (product: ProfileRechargeProduct) => {
if (submittingRechargeProductId) {
return;
@@ -1865,7 +2013,7 @@ export function RpgEntryHomeView({
label="剩余叙世币"
value="暂不可用"
icon={Coins}
onClick={onOpenProfileDashboardCard}
onClick={openWalletLedgerPanel}
/>
<ProfileStatCard
cardKey="playTime"
@@ -1889,7 +2037,7 @@ export function RpgEntryHomeView({
label="剩余叙世币"
value={formatDashboardCount(remainingNarrativeCoins)}
icon={Coins}
onClick={onOpenProfileDashboardCard}
onClick={openWalletLedgerPanel}
/>
<ProfileStatCard
cardKey="playTime"
@@ -2318,6 +2466,16 @@ export function RpgEntryHomeView({
onSubmitRedeem={submitReferralInviteCode}
/>
) : null}
{isWalletLedgerOpen ? (
<WalletLedgerModal
ledger={walletLedger}
fallbackBalance={remainingNarrativeCoins}
isLoading={isLoadingWalletLedger}
error={walletLedgerError}
onClose={() => setIsWalletLedgerOpen(false)}
onRetry={loadWalletLedger}
/>
) : null}
</div>
);
}
@@ -2422,6 +2580,16 @@ export function RpgEntryHomeView({
onSubmitRedeem={submitReferralInviteCode}
/>
) : null}
{isWalletLedgerOpen ? (
<WalletLedgerModal
ledger={walletLedger}
fallbackBalance={remainingNarrativeCoins}
isLoading={isLoadingWalletLedger}
error={walletLedgerError}
onClose={() => setIsWalletLedgerOpen(false)}
onRetry={loadWalletLedger}
/>
) : null}
</div>
);
}