Ai game creator app home sidebar (#86)
   TODO: 补充说明书链接 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/86 Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
This commit was merged in pull request #86.
This commit is contained in:
@@ -620,7 +620,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
name: '泥点 1,234',
|
||||
});
|
||||
|
||||
fireEvent.click(walletButton);
|
||||
fireEvent.mouseEnter(walletButton);
|
||||
|
||||
const details = await screen.findByRole('dialog', {
|
||||
name: '泥点账户详情',
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { PlatformProfileRechargeModal } from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
|
||||
import { PlatformProfileWalletLedgerModal } from '../../../packages/shared/src/components/PlatformProfileWalletLedgerModal';
|
||||
import type { EditorAgentGenerationResultEvent } from '../../../packages/shared/src/contracts/editorAgent';
|
||||
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import { loadFrontendRuntimeConfig } from '../../services/frontendRuntimeConfigService';
|
||||
@@ -24,9 +26,7 @@ import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform'
|
||||
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
|
||||
import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal';
|
||||
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
|
||||
import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal';
|
||||
import {
|
||||
PlatformRechargePaymentConfirmationMask,
|
||||
PlatformRechargePaymentResultDialog,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { PlatformMudPointWalletEntry } from '@/packages/shared/src/components/PlatformMudPointWalletEntry.tsx';
|
||||
import { PlatformMudPointWalletEntry } from '@/packages/shared/src/components/PlatformMudPointWalletEntry';
|
||||
|
||||
import type { ProfileMudPointBalance } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
/* @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/contracts/runtime';
|
||||
import { PlatformProfileRechargeModal } from './PlatformProfileRechargeModal';
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,337 +0,0 @@
|
||||
import QRCode from 'qrcode';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { formatMudPointCount } from '@/packages/shared/src/utils/format.ts';
|
||||
|
||||
import type {
|
||||
ProfileRechargeCenterResponse,
|
||||
ProfileRechargeProduct,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
|
||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
import { formatRechargePrice } from '../rpg-entry/rpgEntryProfileFundsViewModel';
|
||||
import { PlatformProfileModalShell } from './PlatformProfileModalShell';
|
||||
import type { NativeWechatPaymentState } from './usePlatformProfileCenterController';
|
||||
|
||||
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
|
||||
export type PlatformProfileRechargeModalProps = {
|
||||
center: ProfileRechargeCenterResponse | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
submittingProductId: string | null;
|
||||
nativePayment: NativeWechatPaymentState | null;
|
||||
onClose: () => void;
|
||||
onRetry: () => void;
|
||||
onBuy: (product: ProfileRechargeProduct) => void;
|
||||
onConfirmNativePayment: () => void;
|
||||
onCloseNativePayment: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成微信 Native 支付二维码图片,保持首页现有二维码尺寸与容错行为。
|
||||
*/
|
||||
function useWechatNativeQrCode(codeUrl: string | null) {
|
||||
const [qrImageUrl, setQrImageUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setQrImageUrl(null);
|
||||
if (!codeUrl) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
void QRCode.toDataURL(codeUrl, {
|
||||
errorCorrectionLevel: 'M',
|
||||
margin: 1,
|
||||
width: WECHAT_NATIVE_PAY_QR_IMAGE_SIZE,
|
||||
}).then((dataUrl) => {
|
||||
if (!cancelled) {
|
||||
setQrImageUrl(dataUrl);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [codeUrl]);
|
||||
|
||||
return qrImageUrl;
|
||||
}
|
||||
|
||||
function formatNativePaymentRemaining(remainingMs: number) {
|
||||
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function useNativePaymentRemaining(expiresAt: string) {
|
||||
const resolveRemainingMs = useCallback(() => {
|
||||
const expiresAtMs = Date.parse(expiresAt);
|
||||
if (!Number.isFinite(expiresAtMs)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, expiresAtMs - Date.now());
|
||||
}, [expiresAt]);
|
||||
const [remainingMs, setRemainingMs] = useState(resolveRemainingMs);
|
||||
|
||||
useEffect(() => {
|
||||
setRemainingMs(resolveRemainingMs());
|
||||
const timer = window.setInterval(() => {
|
||||
setRemainingMs(resolveRemainingMs());
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [resolveRemainingMs]);
|
||||
|
||||
return {
|
||||
isExpired: remainingMs <= 0,
|
||||
label: formatNativePaymentRemaining(remainingMs),
|
||||
};
|
||||
}
|
||||
|
||||
/** 充值套餐行,复用后端下发的商品与逐档首充资格。 */
|
||||
function RechargeProductCard({
|
||||
product,
|
||||
submittingProductId,
|
||||
onBuy,
|
||||
}: {
|
||||
product: ProfileRechargeProduct;
|
||||
submittingProductId: string | null;
|
||||
onBuy: (product: ProfileRechargeProduct) => void;
|
||||
}) {
|
||||
const submitting = submittingProductId === product.productId;
|
||||
const badgeLabel = product.badgeLabel;
|
||||
const bonusLabel =
|
||||
product.bonusPoints > 0
|
||||
? `+${formatMudPointCount(product.bonusPoints)}泥点`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PlatformSubpanel
|
||||
as="button"
|
||||
type="button"
|
||||
surface="platform"
|
||||
onClick={() => onBuy(product)}
|
||||
disabled={Boolean(submittingProductId)}
|
||||
interactive
|
||||
radius="sm"
|
||||
padding="none"
|
||||
aria-label={`${formatMudPointCount(product.pointsAmount)}泥点${bonusLabel ? ` ${bonusLabel}` : ''} ${formatRechargePrice(product.priceCents)} 购买`}
|
||||
className="platform-recharge-product-row platform-interactive-card relative grid min-h-[4.5rem] grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 px-3.5 py-3 text-left"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
{badgeLabel ? (
|
||||
<PlatformPillBadge
|
||||
tone="warning"
|
||||
size="xxs"
|
||||
className="mb-1.5 max-w-[7rem] truncate px-2 py-0.5"
|
||||
>
|
||||
{badgeLabel}
|
||||
</PlatformPillBadge>
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<span className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{formatMudPointCount(product.pointsAmount)}泥点
|
||||
</span>
|
||||
{bonusLabel ? (
|
||||
<span className="text-xs font-bold text-[var(--platform-accent-strong)]">
|
||||
{bonusLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-black tabular-nums text-[var(--platform-text-strong)]">
|
||||
{formatRechargePrice(product.priceCents)}
|
||||
</span>
|
||||
<span className="platform-primary-button rounded-full px-3 py-1.5 text-xs font-black">
|
||||
{submitting ? '处理中' : '购买'}
|
||||
</span>
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformProfileWechatNativePaymentModal({
|
||||
nativePayment,
|
||||
nativeQrImageUrl,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
nativePayment: NativeWechatPaymentState;
|
||||
nativeQrImageUrl: string | null;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const remaining = useNativePaymentRemaining(nativePayment.expiresAt);
|
||||
|
||||
return (
|
||||
<PlatformProfileModalShell
|
||||
title="微信扫码支付"
|
||||
description="请使用微信扫描二维码完成付款"
|
||||
onClose={onClose}
|
||||
closeLabel="关闭微信扫码支付"
|
||||
closeDisabled={nativePayment.isConfirming}
|
||||
size="sm"
|
||||
zIndexClassName="z-[85]"
|
||||
panelClassName="platform-recharge-modal !max-w-sm rounded-[1.4rem]"
|
||||
bodyClassName="px-5 py-5"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="mb-4 rounded-2xl border border-white/10 bg-white/6 px-4 py-3 text-left">
|
||||
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
|
||||
支付项目
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{nativePayment.productTitle}
|
||||
</div>
|
||||
<div className="mt-3 text-xs font-bold text-[var(--platform-text-soft)]">
|
||||
支付金额
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-black text-[var(--platform-text-strong)]">
|
||||
{formatRechargePrice(nativePayment.amountCents)}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-3 text-xs font-bold text-[var(--platform-text-soft)]">
|
||||
<span>剩余时间</span>
|
||||
<span className="tabular-nums">
|
||||
{remaining.isExpired ? '已过期' : remaining.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto flex h-[180px] w-[180px] items-center justify-center rounded-xl bg-white p-2">
|
||||
{nativeQrImageUrl ? (
|
||||
<img
|
||||
src={nativeQrImageUrl}
|
||||
alt="微信 Native 支付二维码"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs font-semibold text-slate-500">生成中</span>
|
||||
)}
|
||||
</div>
|
||||
<PlatformActionButton
|
||||
surface="profile"
|
||||
size="xs"
|
||||
className="mt-4 disabled:cursor-not-allowed"
|
||||
onClick={onConfirm}
|
||||
disabled={nativePayment.isConfirming || remaining.isExpired}
|
||||
>
|
||||
{nativePayment.isConfirming
|
||||
? '确认中'
|
||||
: remaining.isExpired
|
||||
? '已过期'
|
||||
: '我已支付'}
|
||||
</PlatformActionButton>
|
||||
{nativePayment.confirmMessage ? (
|
||||
<div className="mt-3 text-xs font-semibold text-[var(--platform-text-soft)]">
|
||||
{nativePayment.confirmMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</PlatformProfileModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** 主站与编辑器共用的泥点购买弹窗。 */
|
||||
export function PlatformProfileRechargeModal({
|
||||
center,
|
||||
isLoading,
|
||||
error,
|
||||
submittingProductId,
|
||||
nativePayment,
|
||||
onClose,
|
||||
onRetry,
|
||||
onBuy,
|
||||
onConfirmNativePayment,
|
||||
onCloseNativePayment,
|
||||
}: PlatformProfileRechargeModalProps) {
|
||||
const nativeQrImageUrl = useWechatNativeQrCode(
|
||||
nativePayment?.codeUrl ?? null,
|
||||
);
|
||||
const products = center?.pointProducts ?? [];
|
||||
const currentBalance =
|
||||
center?.mudPointBalance?.totalPoints ?? center?.walletBalance;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PlatformProfileModalShell
|
||||
title="购买更多泥点"
|
||||
description={
|
||||
currentBalance === undefined
|
||||
? '当前余额读取中'
|
||||
: `当前余额 ${formatMudPointCount(currentBalance)} 泥点`
|
||||
}
|
||||
onClose={onClose}
|
||||
closeLabel="关闭购买更多泥点"
|
||||
size="md"
|
||||
panelClassName="platform-recharge-modal !max-w-[34rem] rounded-[1.4rem]"
|
||||
bodyClassName="max-h-[min(76vh,36rem)] overflow-y-auto px-5 py-5"
|
||||
>
|
||||
<PlatformAsyncStatePanel
|
||||
errorState={
|
||||
error ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface="profile"
|
||||
size="xs"
|
||||
className="mt-4 rounded-2xl font-semibold"
|
||||
>
|
||||
<div>{error}</div>
|
||||
<PlatformActionButton
|
||||
surface="profile"
|
||||
size="xs"
|
||||
className="mt-3"
|
||||
onClick={onRetry}
|
||||
>
|
||||
重新加载
|
||||
</PlatformActionButton>
|
||||
</PlatformStatusMessage>
|
||||
) : null
|
||||
}
|
||||
isLoading={isLoading}
|
||||
loadingState={
|
||||
<PlatformProfileSkeletonList
|
||||
count={4}
|
||||
containerClassName="grid gap-2.5"
|
||||
itemClassName="h-[4.5rem] rounded-lg bg-white/10"
|
||||
/>
|
||||
}
|
||||
isEmpty={products.length === 0}
|
||||
emptyState={
|
||||
<PlatformEmptyState
|
||||
surface="subpanel"
|
||||
size="inline"
|
||||
className="mt-1"
|
||||
>
|
||||
暂无可购买套餐
|
||||
</PlatformEmptyState>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-2.5">
|
||||
{products.map((product) => (
|
||||
<RechargeProductCard
|
||||
key={product.productId}
|
||||
product={product}
|
||||
submittingProductId={submittingProductId}
|
||||
onBuy={onBuy}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PlatformAsyncStatePanel>
|
||||
</PlatformProfileModalShell>
|
||||
{nativePayment ? (
|
||||
<PlatformProfileWechatNativePaymentModal
|
||||
nativePayment={nativePayment}
|
||||
nativeQrImageUrl={nativeQrImageUrl}
|
||||
onClose={onCloseNativePayment}
|
||||
onConfirm={onConfirmNativePayment}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
/* @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 { PlatformProfileWalletLedgerModal } from './PlatformProfileWalletLedgerModal';
|
||||
|
||||
describe('PlatformProfileWalletLedgerModal', () => {
|
||||
test('renders ledger entries with shared balance presentation', () => {
|
||||
render(
|
||||
<PlatformProfileWalletLedgerModal
|
||||
ledger={{
|
||||
entries: [
|
||||
{
|
||||
id: 'ledger-1',
|
||||
sourceType: 'daily_task_reward',
|
||||
amountDelta: 12,
|
||||
balanceAfter: 88,
|
||||
createdAt: '2026-06-10T08:00:00.000Z',
|
||||
},
|
||||
],
|
||||
}}
|
||||
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('+12')).toBeTruthy();
|
||||
expect(within(dialog).getByText('余额 88')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('retries from the shared error state', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRetry = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformProfileWalletLedgerModal
|
||||
ledger={null}
|
||||
fallbackBalance={40}
|
||||
isLoading={false}
|
||||
error="账单加载失败"
|
||||
onClose={vi.fn()}
|
||||
onRetry={onRetry}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '重新加载' }));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
import { Coins } from 'lucide-react';
|
||||
|
||||
import type { ProfileWalletLedgerResponse } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
|
||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
import { PlatformProfileContentRow } from '../common/PlatformProfileContentRow';
|
||||
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
|
||||
import { PlatformProfileSummaryHeader } from '../common/PlatformProfileSummaryHeader';
|
||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell';
|
||||
import { buildWalletLedgerPresentation } from '../rpg-entry/rpgEntryProfileFundsViewModel';
|
||||
import { formatPlatformWorldTime } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
|
||||
export type PlatformProfileWalletLedgerModalProps = {
|
||||
ledger: ProfileWalletLedgerResponse | null;
|
||||
fallbackBalance: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onRetry: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 个人中心泥点账单弹窗。
|
||||
* 保持 RPG 首页里既有的展示文案、状态分支和交互,仅把实现提取为共享组件。
|
||||
*/
|
||||
export function PlatformProfileWalletLedgerModal({
|
||||
ledger,
|
||||
fallbackBalance,
|
||||
isLoading,
|
||||
error,
|
||||
onClose,
|
||||
onRetry,
|
||||
}: PlatformProfileWalletLedgerModalProps) {
|
||||
const walletLedgerPresentation = buildWalletLedgerPresentation(
|
||||
ledger,
|
||||
fallbackBalance,
|
||||
);
|
||||
const entries = walletLedgerPresentation.entries;
|
||||
|
||||
return (
|
||||
<PlatformProfileSecondaryModalShell
|
||||
title="泥点账单"
|
||||
onClose={onClose}
|
||||
closeLabel="关闭泥点账单"
|
||||
closeButtonClassName="bg-white/78"
|
||||
panelClassName="relative !max-h-[min(92vh,42rem)] !max-w-[30rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_38%,#f8fafc_100%)] text-zinc-950 shadow-2xl !rounded-[1.35rem] sm:!rounded-[1.35rem]"
|
||||
contentClassName="relative max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5"
|
||||
>
|
||||
<PlatformProfileSummaryHeader
|
||||
kicker="LEDGER"
|
||||
title="泥点账单"
|
||||
badge={
|
||||
<PlatformPillBadge
|
||||
tone="profile"
|
||||
icon={<Coins className="h-3.5 w-3.5 text-[#ff4056]" />}
|
||||
className="bg-white/70"
|
||||
>
|
||||
{walletLedgerPresentation.balanceLabel}
|
||||
</PlatformPillBadge>
|
||||
}
|
||||
/>
|
||||
|
||||
<PlatformAsyncStatePanel
|
||||
errorState={
|
||||
error ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
className="mt-4 rounded-xl py-3"
|
||||
>
|
||||
<div>{error}</div>
|
||||
<PlatformActionButton
|
||||
surface="profile"
|
||||
shape="pill"
|
||||
size="xs"
|
||||
className="mt-3"
|
||||
onClick={onRetry}
|
||||
>
|
||||
重新加载
|
||||
</PlatformActionButton>
|
||||
</PlatformStatusMessage>
|
||||
) : null
|
||||
}
|
||||
isLoading={isLoading}
|
||||
loadingState={
|
||||
<PlatformProfileSkeletonList
|
||||
count={5}
|
||||
containerClassName="mt-5 space-y-3"
|
||||
itemClassName="h-16"
|
||||
/>
|
||||
}
|
||||
isEmpty={entries.length === 0}
|
||||
emptyState={
|
||||
<PlatformEmptyState
|
||||
surface="subpanel"
|
||||
size="inline"
|
||||
className="mt-5 py-8"
|
||||
>
|
||||
暂无账单记录
|
||||
</PlatformEmptyState>
|
||||
}
|
||||
>
|
||||
<div className="mt-5 space-y-2.5">
|
||||
{entries.map((entry) => (
|
||||
<PlatformProfileContentRow
|
||||
key={entry.id}
|
||||
surface="flat"
|
||||
radius="xs"
|
||||
padding="none"
|
||||
className="flex items-center justify-between gap-3 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">
|
||||
{formatPlatformWorldTime(entry.createdAt)}
|
||||
</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>
|
||||
</PlatformProfileContentRow>
|
||||
))}
|
||||
</div>
|
||||
</PlatformAsyncStatePanel>
|
||||
</PlatformProfileSecondaryModalShell>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { PlatformProfileRechargeNativePaymentState } from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
|
||||
import {
|
||||
type ConfirmWechatProfileRechargeOrderResponse,
|
||||
type ProfileRechargeCenterResponse,
|
||||
@@ -9,7 +10,6 @@ import {
|
||||
type ProfileTaskCenterResponse,
|
||||
type ProfileWalletLedgerResponse,
|
||||
type RedeemProfileRewardCodeResponse,
|
||||
type WechatNativePayment,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
clearStoredAccessToken,
|
||||
@@ -85,13 +85,8 @@ export type WechatRechargeOrderConfirmationState = {
|
||||
orderId: string;
|
||||
};
|
||||
|
||||
export type NativeWechatPaymentState = WechatNativePayment & {
|
||||
orderId: string;
|
||||
productTitle: string;
|
||||
amountCents: number;
|
||||
isConfirming: boolean;
|
||||
confirmMessage?: string;
|
||||
};
|
||||
export type NativeWechatPaymentState =
|
||||
PlatformProfileRechargeNativePaymentState;
|
||||
|
||||
function isWechatJsapiMissingIdentityError(error: unknown) {
|
||||
return (
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { PlatformMudPointWalletEntry } from '@/packages/shared/src/components/PlatformMudPointWalletEntry.tsx';
|
||||
import { PlatformMudPointWalletEntry } from '@/packages/shared/src/components/PlatformMudPointWalletEntry';
|
||||
|
||||
import profileClockImage from '../../../media/profile/_Image (1).png';
|
||||
import profileGamepadImage from '../../../media/profile/_Image (2).png';
|
||||
@@ -51,6 +51,8 @@ import profileCommunityImage from '../../../media/profile/_Image (7).png';
|
||||
import profileFeedbackImage from '../../../media/profile/_Image (8).png';
|
||||
import profileMascotImage from '../../../media/profile/_Image (9).png';
|
||||
import profilePointImage from '../../../media/profile/_Image.png';
|
||||
import { PlatformProfileRechargeModal } from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
|
||||
import { PlatformProfileWalletLedgerModal } from '../../../packages/shared/src/components/PlatformProfileWalletLedgerModal';
|
||||
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
|
||||
import type {
|
||||
CustomWorldLibraryEntry,
|
||||
@@ -79,7 +81,6 @@ import {
|
||||
} from '../../services/host-bridge/hostBridge';
|
||||
import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import type { PlatformHomeTab } from '../platform-entry/platformEntryTypes';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
|
||||
import { LegalDocumentModal } from '../common/LegalDocumentModal';
|
||||
@@ -120,6 +121,7 @@ import {
|
||||
isEdutainmentEntryEnabled,
|
||||
} from '../platform-entry/platformEdutainmentVisibility';
|
||||
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
|
||||
import type { PlatformHomeTab } from '../platform-entry/platformEntryTypes';
|
||||
import type { ExternalGenerationQueueStatus } from '../platform-entry/platformExternalGenerationQueueStatusModel';
|
||||
import { PlatformProfileApiKeysModal } from '../platform-entry/PlatformProfileApiKeysModal';
|
||||
import { PlatformProfileGenerationQueueCard } from '../platform-entry/PlatformProfileGenerationQueueCard';
|
||||
@@ -133,10 +135,8 @@ import {
|
||||
ProfileStatCardSkeleton,
|
||||
} from '../platform-entry/PlatformProfilePrimitives';
|
||||
import { PlatformProfileQrScannerModal } from '../platform-entry/PlatformProfileQrScannerModal';
|
||||
import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal';
|
||||
import { PlatformProfileReferralModal } from '../platform-entry/PlatformProfileReferralModal';
|
||||
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
|
||||
import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal';
|
||||
import {
|
||||
PlatformRechargePaymentConfirmationMask,
|
||||
PlatformRechargePaymentResultDialog,
|
||||
|
||||
@@ -3,31 +3,14 @@ import { expect, test } from 'vitest';
|
||||
import type {
|
||||
ProfileMembership,
|
||||
ProfileRechargeProduct,
|
||||
ProfileWalletLedgerEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
buildMembershipCycleLabel,
|
||||
buildMembershipLabel,
|
||||
buildRechargeProductValueLabel,
|
||||
buildWalletLedgerPresentation,
|
||||
formatRechargePrice,
|
||||
formatWalletLedgerAmount,
|
||||
getWalletLedgerSourceLabel,
|
||||
} from './rpgEntryProfileFundsViewModel';
|
||||
|
||||
function buildLedgerEntry(
|
||||
overrides: Partial<ProfileWalletLedgerEntry> = {},
|
||||
): ProfileWalletLedgerEntry {
|
||||
return {
|
||||
id: 'ledger-1',
|
||||
amountDelta: 30,
|
||||
balanceAfter: 80,
|
||||
sourceType: 'invite_invitee_reward',
|
||||
createdAt: '2026-06-03T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRechargeProduct(
|
||||
overrides: Partial<ProfileRechargeProduct> = {},
|
||||
): ProfileRechargeProduct {
|
||||
@@ -68,89 +51,6 @@ function buildMembership(
|
||||
};
|
||||
}
|
||||
|
||||
test('profile funds ViewModel formats ledger amount labels', () => {
|
||||
expect(formatWalletLedgerAmount(-1)).toBe('-1');
|
||||
expect(formatWalletLedgerAmount(0)).toBe('0');
|
||||
expect(formatWalletLedgerAmount(30)).toBe('+30');
|
||||
});
|
||||
|
||||
test('profile funds ViewModel resolves ledger source labels with raw fallback', () => {
|
||||
expect(getWalletLedgerSourceLabel('asset_operation_consume')).toBe(
|
||||
'资产操作消耗',
|
||||
);
|
||||
expect(getWalletLedgerSourceLabel('puzzle_author_incentive_claim')).toBe(
|
||||
'拼图作者奖励',
|
||||
);
|
||||
expect(getWalletLedgerSourceLabel('recharge_refund_recovery')).toBe(
|
||||
'充值退款追回',
|
||||
);
|
||||
expect(getWalletLedgerSourceLabel('future_source')).toBe('future_source');
|
||||
expect(getWalletLedgerSourceLabel('')).toBe('未知来源');
|
||||
});
|
||||
|
||||
test('profile funds ViewModel builds wallet ledger presentation', () => {
|
||||
const incomeEntry = buildLedgerEntry({
|
||||
id: 'ledger-income',
|
||||
amountDelta: 30,
|
||||
balanceAfter: 80,
|
||||
sourceType: 'puzzle_author_incentive_claim',
|
||||
});
|
||||
const outcomeEntry = buildLedgerEntry({
|
||||
id: 'ledger-outcome',
|
||||
amountDelta: -1,
|
||||
balanceAfter: 79,
|
||||
sourceType: 'asset_operation_consume',
|
||||
});
|
||||
const recoveryEntry = buildLedgerEntry({
|
||||
id: 'ledger-recovery',
|
||||
amountDelta: -30,
|
||||
balanceAfter: 49,
|
||||
sourceType: 'recharge_refund_recovery',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildWalletLedgerPresentation(
|
||||
{ entries: [incomeEntry, outcomeEntry, recoveryEntry] },
|
||||
12,
|
||||
),
|
||||
).toEqual({
|
||||
balance: 80,
|
||||
balanceLabel: '80泥点',
|
||||
entries: [
|
||||
{
|
||||
amountLabel: '+30',
|
||||
balanceLabel: '余额 80',
|
||||
createdAt: '2026-06-03T00:00:00.000Z',
|
||||
id: 'ledger-income',
|
||||
isIncome: true,
|
||||
sourceLabel: '拼图作者奖励',
|
||||
},
|
||||
{
|
||||
amountLabel: '-1',
|
||||
balanceLabel: '余额 79',
|
||||
createdAt: '2026-06-03T00:00:00.000Z',
|
||||
id: 'ledger-outcome',
|
||||
isIncome: false,
|
||||
sourceLabel: '资产操作消耗',
|
||||
},
|
||||
{
|
||||
amountLabel: '-30',
|
||||
balanceLabel: '余额 49',
|
||||
createdAt: '2026-06-03T00:00:00.000Z',
|
||||
id: 'ledger-recovery',
|
||||
isIncome: false,
|
||||
sourceLabel: '充值退款追回',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(buildWalletLedgerPresentation({ entries: [] }, 12)).toEqual({
|
||||
balance: 12,
|
||||
balanceLabel: '12泥点',
|
||||
entries: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('profile funds ViewModel formats recharge product and membership labels', () => {
|
||||
expect(formatRechargePrice(600)).toBe('¥6');
|
||||
expect(formatRechargePrice(650)).toBe('¥6.50');
|
||||
|
||||
@@ -1,93 +1,8 @@
|
||||
import type {
|
||||
ProfileMembership,
|
||||
ProfileRechargeProduct,
|
||||
ProfileWalletLedgerEntry,
|
||||
ProfileWalletLedgerResponse,
|
||||
} from '../../../packages/shared/src/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;
|
||||
createdAt: 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 buildWalletLedgerEntryPresentation(
|
||||
entry: ProfileWalletLedgerEntry,
|
||||
): ProfileWalletLedgerEntryPresentation {
|
||||
return {
|
||||
amountLabel: formatWalletLedgerAmount(entry.amountDelta),
|
||||
balanceLabel: `余额 ${entry.balanceAfter}`,
|
||||
createdAt: entry.createdAt,
|
||||
id: entry.id,
|
||||
isIncome: entry.amountDelta > 0,
|
||||
sourceLabel: getWalletLedgerSourceLabel(entry.sourceType),
|
||||
};
|
||||
}
|
||||
|
||||
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(buildWalletLedgerEntryPresentation),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatRechargePrice(priceCents: number) {
|
||||
const yuan = priceCents / 100;
|
||||
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`;
|
||||
}
|
||||
export { formatRechargePrice } from '../../../packages/shared/src/utils/format';
|
||||
|
||||
function formatMembershipPeriodPrefix(periodDays: number) {
|
||||
if (periodDays === 30) {
|
||||
|
||||
@@ -9398,14 +9398,6 @@ button.image-canvas-editor__reference-chip:disabled {
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.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-overlay {
|
||||
background: var(--platform-overlay-fill);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user