extract and reuse detail modal
This commit is contained in:
@@ -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 '每月';
|
||||
|
||||
Reference in New Issue
Block a user