合并AI游戏创作智能体与通用Runtime基座
合并 codex/ai-game-creator-app 的独立客户端、多智能体 Runtime、Runner 与可扩展 LLM Provider 能力 保留 master 最新画布、钱包、后端和原生壳约束并完成四处语义化冲突合并 补齐根 Vitest 对独立 Tauri guest 模块的隔离测试别名并保持 HostBridge 依赖边界 统一正式工作台与 game-chat 的安全本地预览组件及 native-shell 静态门禁 修复合并态 Rust 格式、前端 lint 与共享文档验证口径
This commit is contained in:
+2
-2
@@ -10,8 +10,8 @@ import {
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { formatMudPointCount } from '../utils/format';
|
||||
import { PlatformMudPointWalletEntry } from './PlatformMudPointWalletEntry.tsx';
|
||||
import { formatMudPointCount } from '../../utils/format';
|
||||
import { PlatformMudPointWalletEntry } from './index.tsx';
|
||||
|
||||
const breakdown = {
|
||||
totalPoints: 207,
|
||||
+6
-4
@@ -8,9 +8,9 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type { ProfileMudPointBalance } from '../contracts/runtime';
|
||||
import MUD_POINT_ICON_SRC from '../icons/topbar-wallet.png';
|
||||
import { formatMudPointCount } from '../utils/format';
|
||||
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;
|
||||
@@ -56,6 +56,7 @@ export function PlatformMudPointWalletEntry({
|
||||
balance,
|
||||
breakdown,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
variant = 'desktop',
|
||||
className,
|
||||
onRequestDetails,
|
||||
@@ -255,6 +256,7 @@ export function PlatformMudPointWalletEntry({
|
||||
points={breakdown.permanentPoints}
|
||||
detail="按量充值、兑换码获得"
|
||||
/>
|
||||
{/*TODO 限时泥点 is not enabled by now */}
|
||||
<MudPointBalanceRow
|
||||
label="每日免费泥点"
|
||||
points={breakdown.dailyFreePoints}
|
||||
@@ -263,7 +265,7 @@ export function PlatformMudPointWalletEntry({
|
||||
</>
|
||||
) : (
|
||||
<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 ? '余额读取中' : '余额明细暂不可用'}
|
||||
{isLoading ? '余额读取中' : error || '余额明细暂不可用'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
.platform-profile-recharge-modal-backdrop {
|
||||
background: var(--platform-overlay-fill);
|
||||
color: var(--platform-text-strong);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal,
|
||||
.platform-recharge-modal {
|
||||
border: 1px solid var(--platform-modal-border);
|
||||
background: var(--platform-modal-fill);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1),
|
||||
0 24px 80px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__close {
|
||||
border: 1px solid rgba(255, 255, 255, 0.32);
|
||||
background: var(--platform-profile-chip-fill);
|
||||
color: var(--platform-text-soft);
|
||||
box-shadow: 0 10px 24px rgba(112, 57, 30, 0.08);
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__close:hover:not(:disabled) {
|
||||
background: var(--platform-profile-chip-hover-fill);
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__subpanel {
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
background: var(--platform-subpanel-fill);
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__interactive-card {
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
background 160ms ease;
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__interactive-card:hover:not(:disabled) {
|
||||
border-color: var(--platform-surface-hover-border);
|
||||
background: var(--platform-subpanel-fill);
|
||||
box-shadow: 0 12px 26px rgba(112, 57, 30, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__interactive-card:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__primary-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--platform-button-primary-border);
|
||||
background: var(--platform-button-primary-fill);
|
||||
color: var(--platform-button-primary-text);
|
||||
box-shadow: var(--platform-profile-action-shadow);
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
transform 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__primary-button:hover:not(:disabled) {
|
||||
box-shadow: 0 16px 34px rgba(182, 98, 63, 0.28);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__primary-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__status-error {
|
||||
border: 1px solid var(--platform-button-danger-border);
|
||||
background: var(--platform-button-danger-fill);
|
||||
color: var(--platform-button-danger-text);
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__empty {
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
background: var(--platform-subpanel-fill);
|
||||
color: var(--platform-text-soft);
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__badge-warning {
|
||||
border: 1px solid var(--platform-warm-border);
|
||||
background: var(--platform-warm-bg);
|
||||
color: var(--platform-warm-text);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { ProfileRechargeProduct } from '@/packages/shared/src';
|
||||
|
||||
import { PlatformProfileRechargeModal } from './index';
|
||||
|
||||
function buildNormalMembership() {
|
||||
return {
|
||||
status: 'normal' as const,
|
||||
tier: 'normal' as const,
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
cycleStartedAt: null,
|
||||
cycleResetsAt: null,
|
||||
cycleGrantedPoints: 0,
|
||||
cycleRemainingPoints: 0,
|
||||
cyclePeriodDays: 30,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPointProduct(
|
||||
overrides: Partial<ProfileRechargeProduct> = {},
|
||||
): ProfileRechargeProduct {
|
||||
return {
|
||||
productId: 'points_60',
|
||||
title: '60泥点',
|
||||
priceCents: 600,
|
||||
kind: 'points' as const,
|
||||
pointsAmount: 60,
|
||||
bonusPoints: 0,
|
||||
durationDays: 0,
|
||||
badgeLabel: '',
|
||||
description: '60泥点',
|
||||
tier: 'normal' as const,
|
||||
membershipPeriodPoints: 0,
|
||||
membershipPeriodDays: 0,
|
||||
membershipQueueLimit: 0,
|
||||
membershipDiscountBps: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPointProducts(): ProfileRechargeProduct[] {
|
||||
return [
|
||||
buildPointProduct(),
|
||||
buildPointProduct({
|
||||
productId: 'points_180',
|
||||
title: '180泥点',
|
||||
priceCents: 1800,
|
||||
pointsAmount: 180,
|
||||
bonusPoints: 90,
|
||||
badgeLabel: '首充加赠',
|
||||
description: '首充加赠90泥点',
|
||||
}),
|
||||
buildPointProduct({
|
||||
productId: 'points_300',
|
||||
title: '300泥点',
|
||||
priceCents: 3000,
|
||||
pointsAmount: 300,
|
||||
bonusPoints: 150,
|
||||
badgeLabel: '首充加赠',
|
||||
description: '首充加赠150泥点',
|
||||
}),
|
||||
buildPointProduct({
|
||||
productId: 'points_680',
|
||||
title: '680泥点',
|
||||
priceCents: 6800,
|
||||
pointsAmount: 680,
|
||||
bonusPoints: 340,
|
||||
badgeLabel: '首充加赠',
|
||||
description: '首充加赠340泥点',
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function buildMembershipProduct(): ProfileRechargeProduct {
|
||||
return {
|
||||
...buildPointProduct(),
|
||||
productId: 'member_month',
|
||||
title: '会员月卡',
|
||||
priceCents: 2800,
|
||||
kind: 'membership',
|
||||
pointsAmount: 0,
|
||||
durationDays: 30,
|
||||
description: '会员月卡',
|
||||
tier: 'month',
|
||||
membershipPeriodDays: 30,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('qrcode', () => ({
|
||||
default: {
|
||||
toDataURL: vi.fn(async () => 'data:image/png;base64,wechat-native-qr'),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('PlatformProfileRechargeModal', () => {
|
||||
test('renders the four point products and forwards the selected buy action', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onBuy = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformProfileRechargeModal
|
||||
center={{
|
||||
walletBalance: 29,
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: buildPointProducts(),
|
||||
membershipProducts: [buildMembershipProduct()],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
}}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
submittingProductId={null}
|
||||
nativePayment={null}
|
||||
onClose={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onBuy={onBuy}
|
||||
onConfirmNativePayment={vi.fn()}
|
||||
onCloseNativePayment={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '购买更多泥点' });
|
||||
expect(within(dialog).getByText('当前余额 29 泥点')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: '60泥点 ¥6 购买' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole('button', {
|
||||
name: '180泥点 +90泥点 ¥18 购买',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole('button', {
|
||||
name: '300泥点 +150泥点 ¥30 购买',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole('button', {
|
||||
name: '680泥点 +340泥点 ¥68 购买',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(within(dialog).getAllByText('首充加赠')).toHaveLength(3);
|
||||
expect(within(dialog).queryByRole('tablist')).toBeNull();
|
||||
expect(within(dialog).queryByText('会员月卡')).toBeNull();
|
||||
|
||||
await user.click(
|
||||
within(dialog).getByRole('button', {
|
||||
name: '180泥点 +90泥点 ¥18 购买',
|
||||
}),
|
||||
);
|
||||
expect(onBuy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ productId: 'points_180' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('shows the point-product empty state without exposing membership purchase', () => {
|
||||
render(
|
||||
<PlatformProfileRechargeModal
|
||||
center={{
|
||||
walletBalance: 0,
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: [],
|
||||
membershipProducts: [buildMembershipProduct()],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
}}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
submittingProductId={null}
|
||||
nativePayment={null}
|
||||
onClose={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onBuy={vi.fn()}
|
||||
onConfirmNativePayment={vi.fn()}
|
||||
onCloseNativePayment={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('暂无可购买套餐')).toBeTruthy();
|
||||
expect(screen.queryByText('会员月卡')).toBeNull();
|
||||
expect(screen.queryByRole('tablist')).toBeNull();
|
||||
});
|
||||
|
||||
test('opens native payment QR code in a separate dialog', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConfirmNativePayment = vi.fn();
|
||||
const onCloseNativePayment = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformProfileRechargeModal
|
||||
center={{
|
||||
walletBalance: 12,
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
}}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
submittingProductId={null}
|
||||
nativePayment={{
|
||||
orderId: 'rcg_native_001',
|
||||
productTitle: '100泥点',
|
||||
amountCents: 1000,
|
||||
codeUrl: 'weixin://wxpay/bizpayurl?pr=test',
|
||||
expiresAt: '2099-01-01T00:05:00Z',
|
||||
isConfirming: false,
|
||||
confirmMessage: '暂未确认到账,请确认付款完成后再点一次。',
|
||||
}}
|
||||
onClose={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onBuy={vi.fn()}
|
||||
onConfirmNativePayment={onConfirmNativePayment}
|
||||
onCloseNativePayment={onCloseNativePayment}
|
||||
/>,
|
||||
);
|
||||
|
||||
const rechargeDialog = screen.getByRole('dialog', {
|
||||
name: '购买更多泥点',
|
||||
});
|
||||
const paymentDialog = screen.getByRole('dialog', {
|
||||
name: '微信扫码支付',
|
||||
});
|
||||
|
||||
expect(
|
||||
within(rechargeDialog).queryByAltText('微信 Native 支付二维码'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
await within(paymentDialog).findByAltText('微信 Native 支付二维码'),
|
||||
).toBeTruthy();
|
||||
expect(within(paymentDialog).getByText('100泥点')).toBeTruthy();
|
||||
expect(within(paymentDialog).getByText('¥10')).toBeTruthy();
|
||||
expect(
|
||||
within(paymentDialog).getByText(
|
||||
'暂未确认到账,请确认付款完成后再点一次。',
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
await user.click(
|
||||
within(paymentDialog).getByRole('button', { name: '我已支付' }),
|
||||
);
|
||||
expect(onConfirmNativePayment).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(
|
||||
within(paymentDialog).getByRole('button', {
|
||||
name: '关闭微信扫码支付',
|
||||
}),
|
||||
);
|
||||
expect(onCloseNativePayment).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('shows expired native payment state when qr countdown ends', async () => {
|
||||
const onConfirmNativePayment = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformProfileRechargeModal
|
||||
center={{
|
||||
walletBalance: 12,
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
}}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
submittingProductId={null}
|
||||
nativePayment={{
|
||||
orderId: 'rcg_native_expired',
|
||||
productTitle: '100泥点',
|
||||
amountCents: 1000,
|
||||
codeUrl: 'weixin://wxpay/bizpayurl?pr=expired',
|
||||
expiresAt: '2000-01-01T00:00:00Z',
|
||||
isConfirming: false,
|
||||
}}
|
||||
onClose={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onBuy={vi.fn()}
|
||||
onConfirmNativePayment={onConfirmNativePayment}
|
||||
onCloseNativePayment={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const paymentDialog = screen.getByRole('dialog', {
|
||||
name: '微信扫码支付',
|
||||
});
|
||||
const confirmButton = within(paymentDialog).getByRole('button', {
|
||||
name: '已过期',
|
||||
});
|
||||
|
||||
expect(
|
||||
await within(paymentDialog).findByAltText('微信 Native 支付二维码'),
|
||||
).toBeTruthy();
|
||||
expect(within(paymentDialog).getAllByText('已过期')).toHaveLength(2);
|
||||
expect(confirmButton).toHaveProperty('disabled', true);
|
||||
expect(onConfirmNativePayment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
.platform-profile-wallet-ledger-modal-backdrop {
|
||||
background: rgba(0, 0, 0, 0.48);
|
||||
}
|
||||
|
||||
.platform-profile-wallet-ledger-modal {
|
||||
border: 1px solid var(--platform-modal-border);
|
||||
background: linear-gradient(180deg, #fff7f8 0%, #fff 38%, #f8fafc 100%);
|
||||
color: #18181b;
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.platform-profile-wallet-ledger-modal__close {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
color: #ff4056;
|
||||
box-shadow: 0 2px 8px rgba(112, 57, 30, 0.12);
|
||||
}
|
||||
|
||||
.platform-profile-wallet-ledger-modal__close:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.platform-profile-wallet-ledger-modal__badge {
|
||||
border-color: #ffe4e6;
|
||||
color: #e11d48;
|
||||
}
|
||||
|
||||
.platform-profile-wallet-ledger-modal__status-error {
|
||||
border: 1px solid var(--platform-button-danger-border);
|
||||
background: var(--platform-button-danger-fill);
|
||||
color: var(--platform-button-danger-text);
|
||||
}
|
||||
|
||||
.platform-profile-wallet-ledger-modal__retry {
|
||||
border: 1px solid rgba(255, 64, 86, 0.22);
|
||||
background: #fff;
|
||||
color: #e11d48;
|
||||
}
|
||||
|
||||
.platform-profile-wallet-ledger-modal__retry:hover {
|
||||
background: #fff1f2;
|
||||
}
|
||||
|
||||
.platform-profile-wallet-ledger-modal__row {
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { ProfileWalletLedgerEntry } from '../../contracts/runtime';
|
||||
import { PlatformProfileWalletLedgerModal } from './index';
|
||||
import {
|
||||
buildWalletLedgerPresentation,
|
||||
formatWalletLedgerAmount,
|
||||
formatWalletLedgerDate,
|
||||
getWalletLedgerSourceLabel,
|
||||
} from './model';
|
||||
|
||||
function buildLedgerEntry(
|
||||
overrides: Partial<ProfileWalletLedgerEntry> = {},
|
||||
): ProfileWalletLedgerEntry {
|
||||
return {
|
||||
id: 'ledger-1',
|
||||
amountDelta: 12,
|
||||
balanceAfter: 88,
|
||||
sourceType: 'daily_task_reward',
|
||||
createdAt: '2026-06-10T08:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PlatformProfileWalletLedgerModal', () => {
|
||||
test('renders ledger entries with the latest balance and UTC date', () => {
|
||||
render(
|
||||
<PlatformProfileWalletLedgerModal
|
||||
ledger={{ entries: [buildLedgerEntry()] }}
|
||||
fallbackBalance={40}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
onClose={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '泥点账单' });
|
||||
expect(within(dialog).getByText('88泥点')).toBeTruthy();
|
||||
expect(within(dialog).getByText('每日任务奖励')).toBeTruthy();
|
||||
expect(within(dialog).getByText('2026-06-10')).toBeTruthy();
|
||||
expect(within(dialog).getByText('+12')).toBeTruthy();
|
||||
expect(within(dialog).getByText('余额 88')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('supports close, loading, empty, and error retry states', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
const onRetry = vi.fn();
|
||||
const { rerender } = render(
|
||||
<PlatformProfileWalletLedgerModal
|
||||
ledger={null}
|
||||
fallbackBalance={40}
|
||||
isLoading
|
||||
error={null}
|
||||
onClose={onClose}
|
||||
onRetry={onRetry}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('status', { name: '泥点账单加载中' })).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '关闭泥点账单' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<PlatformProfileWalletLedgerModal
|
||||
ledger={{ entries: [] }}
|
||||
fallbackBalance={40}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
onClose={onClose}
|
||||
onRetry={onRetry}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('暂无账单记录')).toBeTruthy();
|
||||
expect(screen.getByText('40泥点')).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<PlatformProfileWalletLedgerModal
|
||||
ledger={null}
|
||||
fallbackBalance={40}
|
||||
isLoading={false}
|
||||
error="账单加载失败"
|
||||
onClose={onClose}
|
||||
onRetry={onRetry}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: '重新加载' }));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('builds wallet ledger presentation with stable source fallbacks', () => {
|
||||
expect(formatWalletLedgerAmount(-1)).toBe('-1');
|
||||
expect(formatWalletLedgerAmount(0)).toBe('0');
|
||||
expect(formatWalletLedgerAmount(30)).toBe('+30');
|
||||
expect(getWalletLedgerSourceLabel('asset_operation_consume')).toBe(
|
||||
'资产操作消耗',
|
||||
);
|
||||
expect(getWalletLedgerSourceLabel('future_source')).toBe('future_source');
|
||||
expect(getWalletLedgerSourceLabel('')).toBe('未知来源');
|
||||
expect(formatWalletLedgerDate('not-a-date')).toBe('not-a-date');
|
||||
|
||||
expect(
|
||||
buildWalletLedgerPresentation(
|
||||
{
|
||||
entries: [
|
||||
buildLedgerEntry({
|
||||
amountDelta: -1,
|
||||
sourceType: 'asset_operation_consume',
|
||||
}),
|
||||
],
|
||||
},
|
||||
12,
|
||||
),
|
||||
).toMatchObject({
|
||||
balance: 88,
|
||||
balanceLabel: '88泥点',
|
||||
entries: [
|
||||
{
|
||||
amountLabel: '-1',
|
||||
balanceLabel: '余额 88',
|
||||
createdAtLabel: '2026-06-10',
|
||||
isIncome: false,
|
||||
sourceLabel: '资产操作消耗',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import './index.css';
|
||||
|
||||
import { Coins, X } from 'lucide-react';
|
||||
import { useId } from 'react';
|
||||
|
||||
import type { ProfileWalletLedgerResponse } from '../../contracts/runtime';
|
||||
import { buildWalletLedgerPresentation } from './model';
|
||||
|
||||
export type PlatformProfileWalletLedgerModalProps = {
|
||||
ledger: ProfileWalletLedgerResponse | null;
|
||||
fallbackBalance: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onRetry: () => void;
|
||||
};
|
||||
|
||||
export function PlatformProfileWalletLedgerModal({
|
||||
ledger,
|
||||
fallbackBalance,
|
||||
isLoading,
|
||||
error,
|
||||
onClose,
|
||||
onRetry,
|
||||
}: PlatformProfileWalletLedgerModalProps) {
|
||||
const titleId = useId();
|
||||
const presentation = buildWalletLedgerPresentation(ledger, fallbackBalance);
|
||||
|
||||
return (
|
||||
<div className="platform-profile-wallet-ledger-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-3 py-5">
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
className="platform-profile-wallet-ledger-modal relative max-h-[min(92vh,42rem)] w-full max-w-[30rem] overflow-y-auto rounded-[1.35rem] px-4 pb-5 pt-4 sm:px-5"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭泥点账单"
|
||||
title="关闭泥点账单"
|
||||
onClick={onClose}
|
||||
className="platform-profile-wallet-ledger-modal__close absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<header className="pr-10">
|
||||
<div className="text-[10px] font-black uppercase text-rose-500">
|
||||
LEDGER
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2.5">
|
||||
<h2 id={titleId} className="text-xl font-black text-zinc-950">
|
||||
泥点账单
|
||||
</h2>
|
||||
<span className="platform-profile-wallet-ledger-modal__badge inline-flex items-center gap-1.5 rounded-full border border-rose-100 bg-white/70 px-2.5 py-1 text-[11px] font-black">
|
||||
<Coins
|
||||
className="h-3.5 w-3.5 text-[#ff4056]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{presentation.balanceLabel}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="platform-profile-wallet-ledger-modal__status-error mt-4 rounded-xl px-3 py-3 text-sm font-semibold"
|
||||
>
|
||||
<div>{error}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-profile-wallet-ledger-modal__retry mt-3 rounded-full px-3 py-1.5 text-xs font-black"
|
||||
onClick={onRetry}
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="泥点账单加载中"
|
||||
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>
|
||||
) : presentation.entries.length === 0 ? (
|
||||
<div className="platform-empty-state mt-5 rounded-xl border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] px-4 py-8 text-center text-sm font-semibold text-[var(--platform-text-soft)]">
|
||||
暂无账单记录
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 space-y-2.5">
|
||||
{presentation.entries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="platform-profile-wallet-ledger-modal__row flex items-center justify-between gap-3 rounded-lg border border-[var(--platform-subpanel-border)] px-3 py-3 shadow-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-black text-zinc-900">
|
||||
{entry.sourceLabel}
|
||||
</div>
|
||||
<div className="mt-1 text-xs font-semibold text-zinc-500">
|
||||
{entry.createdAtLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div
|
||||
className={`text-base font-black ${
|
||||
entry.isIncome ? 'text-emerald-600' : 'text-rose-500'
|
||||
}`}
|
||||
>
|
||||
{entry.amountLabel}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] font-semibold text-zinc-400">
|
||||
{entry.balanceLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type {
|
||||
ProfileWalletLedgerEntry,
|
||||
ProfileWalletLedgerResponse,
|
||||
} from '../../contracts/runtime';
|
||||
|
||||
const PROFILE_WALLET_LEDGER_SOURCE_LABELS = {
|
||||
new_user_registration_reward: '注册赠送',
|
||||
points_recharge: '泥点充值',
|
||||
invite_inviter_reward: '邀请奖励',
|
||||
invite_invitee_reward: '填写邀请码奖励',
|
||||
snapshot_sync: '账户同步',
|
||||
membership_period_grant: '会员周期发放',
|
||||
membership_period_reset: '会员周期重置',
|
||||
daily_free_grant: '每日免费发放',
|
||||
daily_free_reset: '每日免费重置',
|
||||
asset_operation_consume: '资产操作消耗',
|
||||
asset_operation_refund: '资产操作退回',
|
||||
recharge_refund_recovery: '充值退款追回',
|
||||
redeem_code_reward: '兑换码奖励',
|
||||
puzzle_author_incentive_claim: '拼图作者奖励',
|
||||
daily_task_reward: '每日任务奖励',
|
||||
} satisfies Record<ProfileWalletLedgerEntry['sourceType'], string>;
|
||||
|
||||
export type ProfileWalletLedgerEntryPresentation = {
|
||||
amountLabel: string;
|
||||
balanceLabel: string;
|
||||
createdAtLabel: string;
|
||||
id: string;
|
||||
isIncome: boolean;
|
||||
sourceLabel: string;
|
||||
};
|
||||
|
||||
export type ProfileWalletLedgerPresentation = {
|
||||
balance: number;
|
||||
balanceLabel: string;
|
||||
entries: ProfileWalletLedgerEntryPresentation[];
|
||||
};
|
||||
|
||||
export function getWalletLedgerSourceLabel(
|
||||
sourceType: string | null | undefined,
|
||||
) {
|
||||
const normalizedSourceType = sourceType?.trim() ?? '';
|
||||
if (!normalizedSourceType) {
|
||||
return '未知来源';
|
||||
}
|
||||
|
||||
return (
|
||||
PROFILE_WALLET_LEDGER_SOURCE_LABELS[
|
||||
normalizedSourceType as ProfileWalletLedgerEntry['sourceType']
|
||||
] ?? normalizedSourceType
|
||||
);
|
||||
}
|
||||
|
||||
export function formatWalletLedgerAmount(amountDelta: number) {
|
||||
return amountDelta > 0 ? `+${amountDelta}` : `${amountDelta}`;
|
||||
}
|
||||
|
||||
export function formatWalletLedgerDate(value: string) {
|
||||
const normalized = value.trim();
|
||||
const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u);
|
||||
let date: Date;
|
||||
|
||||
if (numericTimestamp?.[1]) {
|
||||
const rawTimestamp = Number(numericTimestamp[1]);
|
||||
const absoluteTimestamp = Math.abs(rawTimestamp);
|
||||
const timestampMs =
|
||||
absoluteTimestamp >= 1_000_000_000_000_000
|
||||
? rawTimestamp / 1000
|
||||
: absoluteTimestamp >= 1_000_000_000_000
|
||||
? rawTimestamp
|
||||
: absoluteTimestamp >= 1_000_000_000
|
||||
? rawTimestamp * 1000
|
||||
: Number.NaN;
|
||||
date = new Date(timestampMs);
|
||||
} else {
|
||||
date = new Date(normalized);
|
||||
}
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function buildWalletLedgerPresentation(
|
||||
ledger: ProfileWalletLedgerResponse | null,
|
||||
fallbackBalance: number,
|
||||
): ProfileWalletLedgerPresentation {
|
||||
const entries = ledger?.entries ?? [];
|
||||
const balance = entries[0]?.balanceAfter ?? fallbackBalance;
|
||||
|
||||
return {
|
||||
balance,
|
||||
balanceLabel: `${balance}泥点`,
|
||||
entries: entries.map((entry) => ({
|
||||
amountLabel: formatWalletLedgerAmount(entry.amountDelta),
|
||||
balanceLabel: `余额 ${entry.balanceAfter}`,
|
||||
createdAtLabel: formatWalletLedgerDate(entry.createdAt),
|
||||
id: entry.id,
|
||||
isIncome: entry.amountDelta > 0,
|
||||
sourceLabel: getWalletLedgerSourceLabel(entry.sourceType),
|
||||
})),
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ export type * from './barkBattle';
|
||||
export type * from './creationAudio';
|
||||
export type * from './creativeAgent';
|
||||
export * from './editorAgent';
|
||||
export * from './gameCreationApp';
|
||||
export * from './hostBridge';
|
||||
export type * from './hyper3d';
|
||||
export * from './playTypes';
|
||||
|
||||
@@ -205,6 +205,7 @@ export type ProfileRechargeOrder = {
|
||||
|
||||
export type ProfileRechargeCenterResponse = {
|
||||
walletBalance: number;
|
||||
// TODO mirror backend: should not nullable
|
||||
mudPointBalance?: ProfileMudPointBalance;
|
||||
membership: ProfileMembership;
|
||||
pointProducts: ProfileRechargeProduct[];
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
@@ -50,3 +50,5 @@ export type * from './contracts/visualNovel';
|
||||
export * from './http';
|
||||
export * from './llm/narrativeLanguage';
|
||||
export * from './llm/parsers';
|
||||
|
||||
// should not export component, instead, they should be ref by relative path
|
||||
|
||||
@@ -8,3 +8,8 @@ export function formatMudPointCount(value: number, compact = false) {
|
||||
}
|
||||
return normalizedValue.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
export function formatRechargePrice(priceCents: number) {
|
||||
const yuan = priceCents / 100;
|
||||
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user