extract and reuse detail modal

This commit is contained in:
2026-07-17 11:04:52 +08:00
parent 51ccd54f48
commit 3cf67a5172
16 changed files with 547 additions and 386 deletions
+43 -1
View File
@@ -44,6 +44,7 @@ import {
PlatformProfileRechargeModal,
type PlatformProfileRechargeNativePaymentState,
} from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
import { PlatformProfileWalletLedgerModal } from '../../../packages/shared/src/components/PlatformProfileWalletLedgerModal';
import type {
AuthEntryResponse,
@@ -84,6 +85,7 @@ import type {
ProfileMudPointBalance,
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
ProfileWalletLedgerResponse,
} from '../../../packages/shared/src/contracts/runtime';
import {
API_RESPONSE_ENVELOPE_HEADER,
@@ -95,6 +97,7 @@ import {
createClientProfileRechargeOrder,
getClientProfileDashboard,
getClientProfileRechargeCenter,
getClientProfileWalletLedger,
} from './services/clientApi';
import HomeView, {
type HomeAgentMode,
@@ -5369,6 +5372,13 @@ export function WorkspaceLauncher({
'idle' | 'loading' | 'ready' | 'error'
>('idle');
const [mudPointBalanceError, setMudPointBalanceError] = useState('');
const [walletLedgerOpen, setWalletLedgerOpen] = useState(false);
const [walletLedger, setWalletLedger] =
useState<ProfileWalletLedgerResponse | null>(null);
const [walletLedgerLoading, setWalletLedgerLoading] = useState(false);
const [walletLedgerError, setWalletLedgerError] = useState<string | null>(
null,
);
const [rechargeOpen, setRechargeOpen] = useState(false);
const [rechargeCenter, setRechargeCenter] =
useState<ProfileRechargeCenterResponse | null>(null);
@@ -6203,6 +6213,26 @@ export function WorkspaceLauncher({
}
}
async function loadWalletLedger() {
setWalletLedgerLoading(true);
setWalletLedgerError(null);
try {
setWalletLedger(await getClientProfileWalletLedger());
} catch (error) {
setWalletLedger(null);
setWalletLedgerError(
error instanceof Error ? error.message : '读取泥点账单失败',
);
} finally {
setWalletLedgerLoading(false);
}
}
function openWalletLedger() {
setWalletLedgerOpen(true);
void loadWalletLedger();
}
function applyRechargeCenter(center: ProfileRechargeCenterResponse) {
setRechargeCenter(center);
setMudPointBalance(center.mudPointBalance ?? null);
@@ -8660,7 +8690,7 @@ export function WorkspaceLauncher({
error={mudPointBalanceError || null}
onRequestDetails={() => void loadMudPointBalance()}
onRecharge={openRecharge}
onOpenLedger={() => showLauncherNotice('使用详情')}
onOpenLedger={openWalletLedger}
/>
</div>
@@ -9436,6 +9466,18 @@ export function WorkspaceLauncher({
onCloseNativePayment={() => setNativeRechargePayment(null)}
/>
) : null}
{walletLedgerOpen ? (
<PlatformProfileWalletLedgerModal
ledger={walletLedger}
fallbackBalance={
profileDashboard?.walletBalance ?? mudPointBalance?.totalPoints ?? 0
}
isLoading={walletLedgerLoading}
error={walletLedgerError}
onClose={() => setWalletLedgerOpen(false)}
onRetry={() => void loadWalletLedger()}
/>
) : null}
{agentChatGoalDialog ? (
<div
className="launcher-dialog-backdrop"
@@ -5,6 +5,7 @@ import {
CreateProfileRechargeOrderResponse,
ProfileDashboardSummary,
ProfileRechargeCenterResponse,
ProfileWalletLedgerResponse,
unwrapApiResponse,
} from '../../../../packages/shared/src';
@@ -167,6 +168,14 @@ export function confirmClientWechatProfileRechargeOrder(orderId: string) {
);
}
export function getClientProfileWalletLedger() {
return requestClientApi<ProfileWalletLedgerResponse>(
'/api/profile/wallet-ledger',
{ method: 'GET' },
'读取泥点账单失败',
);
}
export function listClientShowcaseResources() {
return requestClientApi<EditorShowcaseResourceListResponse>(
'/api/editor/showcase/resources',
@@ -1443,6 +1443,58 @@ describe('AI 游戏创作 App 界面边界', () => {
});
it('starts from the client home and opens a project in the same window', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/profile/dashboard') {
return new Response(
JSON.stringify({
walletBalance: 40,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-07-17T00:00:00.000Z',
}),
{ status: 200 },
);
}
if (url === '/api/profile/recharge-center') {
return new Response(
JSON.stringify({
walletBalance: 40,
mudPointBalance: {
totalPoints: 40,
permanentPoints: 20,
limitedPoints: 0,
limitedExpiresAt: null,
dailyFreePoints: 20,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-07-18T00:00:00.000Z',
},
products: [],
membership: null,
}),
{ status: 200 },
);
}
if (url === '/api/profile/wallet-ledger') {
return new Response(
JSON.stringify({
entries: [
{
id: 'ledger-test-1',
sourceType: 'daily_task_reward',
amountDelta: 12,
balanceAfter: 40,
createdAt: '2026-07-17T00:00:00.000Z',
},
],
}),
{ status: 200 },
);
}
throw new Error(`unexpected fetch ${url}`);
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
@@ -1488,8 +1540,20 @@ describe('AI 游戏创作 App 界面边界', () => {
);
expect(screen.queryByText('已读取账户')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '使用详情' }));
expect(screen.getByRole('dialog', { name: '使用详情' })).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '知道了' }));
const ledgerDialog = await screen.findByRole('dialog', {
name: '泥点账单',
});
expect(
await within(ledgerDialog).findByText('每日任务奖励'),
).not.toBeNull();
expect(
fetchSpy.mock.calls.filter(
([input]) => String(input) === '/api/profile/wallet-ledger',
),
).toHaveLength(1);
fireEvent.click(
within(ledgerDialog).getByRole('button', { name: '关闭泥点账单' }),
);
expect(screen.queryByRole('button', { name: 'Agent 聊天' })).toBeNull();
expect(
screen
@@ -107,6 +107,7 @@
- 背景:主站与图片画板的泥点余额入口、余额明细和充值弹窗存在不同实现,旧充值口径仍展示六档泥点、首充双倍和会员购买 / 升级入口,容易让展示、商品资格与后端余额真相发生漂移。
- 决策:主站与图片画板统一复用公共泥点资产入口,收起态展示总额与充值,展开态只展示不限时泥点、每日免费泥点和使用详情;充值中心 BFF 继续统一下发总额、三桶余额、限时到期时间、每日免费基础重置额及下次重置时间,前端不得自行相减推算,但会员周期限时泥点仅用于存量兼容和后端结算,当前版本不在前台展示。默认泥点商品收敛为 `60 / ¥6``180 + 90 / ¥18``300 + 150 / ¥30``680 + 340 / ¥68` 四档,`60` 档无赠送,后三档按现有 `user_id + product_id` 独立资格规则首次购买加赠 `50%`。当前版本关闭会员购买页签、会员商品和购买 / 升级入口。
- 2026-07-17 追加:主站、图片画板与 AI 游戏创作独立 App 的泥点账单统一复用 `packages/shared/src/components/PlatformProfileWalletLedgerModal`。共享组件只依赖 `ProfileWalletLedgerResponse`,承接来源 label、金额正负号、UTC 日期、余额兜底和 loading / empty / error 展示;`/api/profile/wallet-ledger` 请求、鉴权、打开状态与重试生命周期继续由各宿主持有,不把账户事实或后端副作用下沉到共享 UI。
- 影响范围:`profile_recharge_product_config` 默认商品、充值中心 read model、共享前后端契约、主站与图片画板泥点资产入口、充值弹窗、后台充值商品默认值。
- 验证方式:充值与统一入口定向前端测试、`npm run typecheck`、充值商品定向 Rust 测试、`cargo check -p spacetime-module -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml``npm run check:encoding``git diff --check`
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
@@ -526,7 +526,7 @@ game-project/
- 聊天输入 `/publish` 只使用主窗口当前已加载的 manifest、最近 run trace、预览状态、资产来源和最近命令摘要,在聊天里生成发布准备清单,列出原型通过状态、预览、任务、资产、音频、包装说明和试玩包导出状态,并提供 `/run``/trace``/agent-resume ``/export` 草稿;该命令不调用 Tauri 读写、不启动或打开预览、不读取文件、不新增普通用户面板,真正导出仍由用户发送 `/export` 并走确认流。
- 开发窗口可从 Agent 状态列表进入单个专业 Agent 对话并管理其 Session;正式用户项目开发页只读展示专业 Agent 协作状态,不提供单 Agent 对话入口、Session 控件或工具台。
- v1 普通用户登录后直接进入单窗口客户端首页;同一窗口中切换首页、项目组、指南 / 反馈和项目开发页。项目组页管理最近项目、打开项目、新建项目和显示目录;打开项目只切换到项目开发页,不调用 `open_game_creator_workspace_window` 打开第二窗口。旧 Tauri 窗口 command 只保留兼容,不进入用户主流程。
- 2026-07-16 补充,2026-07-17 扩展:普通用户窗口顶部账户资产统一复用 `PlatformMudPointWalletEntry`;首屏余额来自 `/api/profile/dashboard`,展开时按需读取 `/api/profile/recharge-center` 中的泥点拆分。顶部资产“充值”与侧栏账户菜单“充值泥点”共用 `PlatformProfileRechargeModal`,桌面壳固定使用 `wechat_native` 下单并在弹窗内完成扫码与到账确认,成功后同步刷新顶部余额和泥点拆分。AI 游戏创作壳的 Tailwind 入口必须显式扫描 `packages/shared/src/components`,避免共享组件的 utility 样式在构建时被遗漏。
- 2026-07-16 补充,2026-07-17 扩展:普通用户窗口顶部账户资产统一复用 `PlatformMudPointWalletEntry`;首屏余额来自 `/api/profile/dashboard`,展开时按需读取 `/api/profile/recharge-center` 中的泥点拆分。顶部资产“充值”与侧栏账户菜单“充值泥点”共用 `PlatformProfileRechargeModal`,桌面壳固定使用 `wechat_native` 下单并在弹窗内完成扫码与到账确认,成功后同步刷新顶部余额和泥点拆分。“使用详情”统一打开 `packages/shared` 中的 `PlatformProfileWalletLedgerModal`,由各宿主分别维护打开、加载、失败重试状态并读取 `/api/profile/wallet-ledger`,共享组件只承接账单来源文案、金额与日期展示及 loading / empty / error 视图,不发请求、不持有账户事实。AI 游戏创作壳的 Tailwind 入口必须显式扫描 `packages/shared/src/components`,避免共享组件的 utility 样式在构建时被遗漏。
- 主窗口可通过系统文件管理器显示当前项目目录,也可在聊天输入 `/open-project` 走同一只读打开动作;该操作只打开本地目录,不初始化项目、不写项目文件、不切换工作区。主窗口头部显示最近 `.agent/run.latest.json` 的 run 状态摘要和当前项目预览状态,并通过“刷新状态”重新读取同一 trace,不新增状态数据库。
- 首页、项目组页和项目开发页共用单窗口壳的全局运行时配置弹窗,读写 Tauri 应用配置目录中的 `game-creator.config.json`;正式 Supervisor 项目页缺配置时只显示错误,不自动打开该弹窗。API Key 仍不进入本地项目、trace、manifest 或聊天记录。
- 首页发送和项目组新建都通过 Tauri 原生目录选择器选择项目路径;用户取消目录选择时不覆盖已有输入或草稿。
@@ -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 {
buildWalletLedgerPresentation,
formatWalletLedgerAmount,
formatWalletLedgerDate,
getWalletLedgerSourceLabel,
PlatformProfileWalletLedgerModal,
} from './index';
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,137 @@
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>
);
}
export {
buildWalletLedgerPresentation,
formatWalletLedgerAmount,
formatWalletLedgerDate,
getWalletLedgerSourceLabel,
} from './model';
@@ -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),
})),
};
}
@@ -620,7 +620,7 @@ describe('ImageCanvasEditorView', () => {
name: '泥点 1,234',
});
fireEvent.click(walletButton);
fireEvent.mouseEnter(walletButton);
const details = await screen.findByRole('dialog', {
name: '泥点账户详情',
@@ -25,8 +25,8 @@ import { getPlatformProfileDashboard } from '../../services/platform-entry/platf
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
import { PlatformProfileRechargeModal } from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
import { PlatformProfileWalletLedgerModal } from '../../../packages/shared/src/components/PlatformProfileWalletLedgerModal';
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal';
import {
PlatformRechargePaymentConfirmationMask,
PlatformRechargePaymentResultDialog,
@@ -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>
);
}
@@ -134,9 +134,9 @@ import {
} from '../platform-entry/PlatformProfilePrimitives';
import { PlatformProfileQrScannerModal } from '../platform-entry/PlatformProfileQrScannerModal';
import { PlatformProfileRechargeModal } from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
import { PlatformProfileWalletLedgerModal } from '../../../packages/shared/src/components/PlatformProfileWalletLedgerModal';
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,90 +1,9 @@
import type {
ProfileMembership,
ProfileRechargeProduct,
ProfileWalletLedgerEntry,
ProfileWalletLedgerResponse,
} from '../../../packages/shared/src/contracts/runtime';
export { formatRechargePrice } from '../../../packages/shared/src/utils/format';
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),
};
}
function formatMembershipPeriodPrefix(periodDays: number) {
if (periodDays === 30) {
return '每月';