Merge pull request #3 from codex/coin-consume
# Conflicts: # packages/shared/src/contracts/runtime.ts # server-rs/crates/module-runtime/src/lib.rs # server-rs/crates/shared-contracts/src/runtime.rs # server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs # src/components/rpg-entry/RpgEntryHomeView.tsx
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
@@ -11,7 +11,29 @@ import {
|
||||
} from './RpgEntryHomeView';
|
||||
import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation';
|
||||
|
||||
const { mockGetRpgProfileWalletLedger } = vi.hoisted(() => ({
|
||||
mockGetRpgProfileWalletLedger: vi.fn(async () => ({
|
||||
entries: [
|
||||
{
|
||||
id: 'ledger-1',
|
||||
amountDelta: -1,
|
||||
balanceAfter: 29,
|
||||
sourceType: 'asset_operation_consume',
|
||||
createdAt: '2026-04-28T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'ledger-2',
|
||||
amountDelta: 30,
|
||||
balanceAfter: 30,
|
||||
sourceType: 'invite_invitee_reward',
|
||||
createdAt: '2026-04-28T09:00:00Z',
|
||||
},
|
||||
],
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||
getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger,
|
||||
getRpgProfileRechargeCenter: vi.fn(async () => ({
|
||||
walletBalance: 0,
|
||||
membership: {
|
||||
@@ -269,20 +291,34 @@ afterEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
test('opens recharge modal and submits points product', async () => {
|
||||
test('opens wallet ledger modal from narrative coin card', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
|
||||
renderProfileView(onRechargeSuccess);
|
||||
await user.click(screen.getByText('会员充值'));
|
||||
renderProfileView();
|
||||
await user.click(screen.getByText('剩余叙世币'));
|
||||
|
||||
expect(await screen.findByText('账户充值')).toBeTruthy();
|
||||
expect(await screen.findByText('叙世币充值')).toBeTruthy();
|
||||
expect(await screen.findByText('60叙世币')).toBeTruthy();
|
||||
expect(await screen.findByText('叙世币账单')).toBeTruthy();
|
||||
expect(mockGetRpgProfileWalletLedger).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('资产操作消耗')).toBeTruthy();
|
||||
expect(screen.getByText('-1')).toBeTruthy();
|
||||
expect(screen.getByText('填写邀请码奖励')).toBeTruthy();
|
||||
expect(screen.getByText('+30')).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('首充送60叙世币'));
|
||||
test('wallet ledger modal shows empty and error states', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] });
|
||||
|
||||
await waitFor(() => expect(onRechargeSuccess).toHaveBeenCalledTimes(1));
|
||||
renderProfileView();
|
||||
await user.click(screen.getByText('剩余叙世币'));
|
||||
expect(await screen.findByText('暂无账单记录')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByLabelText('关闭叙世币账单'));
|
||||
mockGetRpgProfileWalletLedger.mockRejectedValueOnce(new Error('加载失败'));
|
||||
await user.click(screen.getByText('剩余叙世币'));
|
||||
|
||||
expect(await screen.findByText('加载失败')).toBeTruthy();
|
||||
expect(screen.getByText('重新加载')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('shows a reachable login entry in logged out mobile shell', async () => {
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
PlatformBrowseHistoryEntry,
|
||||
ProfileDashboardCardKey,
|
||||
ProfileDashboardSummary,
|
||||
ProfileWalletLedgerResponse,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
ProfileSaveArchiveSummary,
|
||||
RedeemProfileReferralInviteCodeResponse,
|
||||
@@ -42,6 +43,7 @@ import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapsho
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
getRpgProfileReferralInviteCenter,
|
||||
getRpgProfileWalletLedger,
|
||||
redeemRpgProfileReferralInviteCode,
|
||||
redeemRpgProfileRewardCode,
|
||||
} from '../../services/rpg-entry/rpgProfileClient';
|
||||
@@ -907,6 +909,129 @@ function ProfileShortcutButton({
|
||||
);
|
||||
}
|
||||
|
||||
const WALLET_LEDGER_SOURCE_LABELS: Record<string, string> = {
|
||||
points_recharge: '叙世币充值',
|
||||
invite_inviter_reward: '邀请奖励',
|
||||
invite_invitee_reward: '填写邀请码奖励',
|
||||
snapshot_sync: '账户同步',
|
||||
asset_operation_consume: '资产操作消耗',
|
||||
asset_operation_refund: '资产操作退回',
|
||||
redeem_code_reward: '兑换码奖励',
|
||||
};
|
||||
|
||||
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 RewardCodeRedeemModal({
|
||||
value,
|
||||
isSubmitting,
|
||||
@@ -1160,6 +1285,13 @@ export function RpgEntryHomeView({
|
||||
const [rewardCodeSuccess, setRewardCodeSuccess] = 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] =
|
||||
@@ -1257,6 +1389,23 @@ export function RpgEntryHomeView({
|
||||
}
|
||||
authUi?.openLoginModal();
|
||||
};
|
||||
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 openProfilePopupPanel = (panel: ProfilePopupPanel) => {
|
||||
setProfilePopupPanel(panel);
|
||||
setReferralError(null);
|
||||
@@ -1711,7 +1860,7 @@ export function RpgEntryHomeView({
|
||||
label="剩余叙世币"
|
||||
value="暂不可用"
|
||||
icon={Coins}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
onClick={openWalletLedgerPanel}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playTime"
|
||||
@@ -1735,7 +1884,7 @@ export function RpgEntryHomeView({
|
||||
label="剩余叙世币"
|
||||
value={formatDashboardCount(remainingNarrativeCoins)}
|
||||
icon={Coins}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
onClick={openWalletLedgerPanel}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playTime"
|
||||
@@ -2152,6 +2301,16 @@ export function RpgEntryHomeView({
|
||||
onSubmitRedeem={submitReferralInviteCode}
|
||||
/>
|
||||
) : null}
|
||||
{isWalletLedgerOpen ? (
|
||||
<WalletLedgerModal
|
||||
ledger={walletLedger}
|
||||
fallbackBalance={remainingNarrativeCoins}
|
||||
isLoading={isLoadingWalletLedger}
|
||||
error={walletLedgerError}
|
||||
onClose={() => setIsWalletLedgerOpen(false)}
|
||||
onRetry={loadWalletLedger}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2255,6 +2414,16 @@ export function RpgEntryHomeView({
|
||||
onSubmitRedeem={submitReferralInviteCode}
|
||||
/>
|
||||
) : null}
|
||||
{isWalletLedgerOpen ? (
|
||||
<WalletLedgerModal
|
||||
ledger={walletLedger}
|
||||
fallbackBalance={remainingNarrativeCoins}
|
||||
isLoading={isLoadingWalletLedger}
|
||||
error={walletLedgerError}
|
||||
onClose={() => setIsWalletLedgerOpen(false)}
|
||||
onRetry={loadWalletLedger}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user