diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 84f61bea8..d1eb5f9dd 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -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(null); + const [walletLedgerLoading, setWalletLedgerLoading] = useState(false); + const [walletLedgerError, setWalletLedgerError] = useState( + null, + ); const [rechargeOpen, setRechargeOpen] = useState(false); const [rechargeCenter, setRechargeCenter] = useState(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} /> @@ -9436,6 +9466,18 @@ export function WorkspaceLauncher({ onCloseNativePayment={() => setNativeRechargePayment(null)} /> ) : null} + {walletLedgerOpen ? ( + setWalletLedgerOpen(false)} + onRetry={() => void loadWalletLedger()} + /> + ) : null} {agentChatGoalDialog ? (
( + '/api/profile/wallet-ledger', + { method: 'GET' }, + '读取泥点账单失败', + ); +} + export function listClientShowcaseResources() { return requestClientApi( '/api/editor/showcase/resources', diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index fe3cc85a6..487829fa2 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -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) => { 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 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4cb046e0d..a43a6f252 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 32e523847..8a83461e1 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.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 原生目录选择器选择项目路径;用户取消目录选择时不覆盖已有输入或草稿。 diff --git a/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.css b/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.css new file mode 100644 index 000000000..841706e9f --- /dev/null +++ b/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.css @@ -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); +} diff --git a/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx b/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx new file mode 100644 index 000000000..a063bdcec --- /dev/null +++ b/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx @@ -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 { + 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( + , + ); + + 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( + , + ); + + expect(screen.getByRole('status', { name: '泥点账单加载中' })).toBeTruthy(); + await user.click(screen.getByRole('button', { name: '关闭泥点账单' })); + expect(onClose).toHaveBeenCalledTimes(1); + + rerender( + , + ); + expect(screen.getByText('暂无账单记录')).toBeTruthy(); + expect(screen.getByText('40泥点')).toBeTruthy(); + + rerender( + , + ); + 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: '资产操作消耗', + }, + ], + }); +}); diff --git a/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.tsx b/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.tsx new file mode 100644 index 000000000..a733ab079 --- /dev/null +++ b/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.tsx @@ -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 ( +
+
+ + +
+
+ LEDGER +
+
+

+ 泥点账单 +

+ + +
+
+ + {error ? ( +
+
{error}
+ +
+ ) : isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, index) => ( +
+ ))} +
+ ) : presentation.entries.length === 0 ? ( +
+ 暂无账单记录 +
+ ) : ( +
+ {presentation.entries.map((entry) => ( +
+
+
+ {entry.sourceLabel} +
+
+ {entry.createdAtLabel} +
+
+
+
+ {entry.amountLabel} +
+
+ {entry.balanceLabel} +
+
+
+ ))} +
+ )} +
+
+ ); +} + +export { + buildWalletLedgerPresentation, + formatWalletLedgerAmount, + formatWalletLedgerDate, + getWalletLedgerSourceLabel, +} from './model'; diff --git a/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts new file mode 100644 index 000000000..c39f623f4 --- /dev/null +++ b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts @@ -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; + +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), + })), + }; +} diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 9ac688e74..2cbbe999c 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -620,7 +620,7 @@ describe('ImageCanvasEditorView', () => { name: '泥点 1,234', }); - fireEvent.click(walletButton); + fireEvent.mouseEnter(walletButton); const details = await screen.findByRole('dialog', { name: '泥点账户详情', diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index ec2b62c5b..cbbab7bc0 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -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, diff --git a/src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx b/src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx deleted file mode 100644 index 3ab869248..000000000 --- a/src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx +++ /dev/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( - , - ); - - 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( - , - ); - - await user.click(screen.getByRole('button', { name: '重新加载' })); - expect(onRetry).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx b/src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx deleted file mode 100644 index a5799c623..000000000 --- a/src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx +++ /dev/null @@ -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 ( - - } - className="bg-white/70" - > - {walletLedgerPresentation.balanceLabel} - - } - /> - - -
{error}
- - 重新加载 - - - ) : null - } - isLoading={isLoading} - loadingState={ - - } - isEmpty={entries.length === 0} - emptyState={ - - 暂无账单记录 - - } - > -
- {entries.map((entry) => ( - -
-
- {entry.sourceLabel} -
-
- {formatPlatformWorldTime(entry.createdAt)} -
-
-
-
- {entry.amountLabel} -
-
- {entry.balanceLabel} -
-
-
- ))} -
-
-
- ); -} diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 9bb8c182e..df74aec8c 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -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, diff --git a/src/components/rpg-entry/rpgEntryProfileFundsViewModel.test.ts b/src/components/rpg-entry/rpgEntryProfileFundsViewModel.test.ts index 478e38f06..4c8736d64 100644 --- a/src/components/rpg-entry/rpgEntryProfileFundsViewModel.test.ts +++ b/src/components/rpg-entry/rpgEntryProfileFundsViewModel.test.ts @@ -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 { - 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 { @@ -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'); diff --git a/src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts b/src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts index ad0c725f1..9db135fb9 100644 --- a/src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts +++ b/src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts @@ -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; - -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 '每月';