refactor: 收口个人资金展示模型
This commit is contained in:
@@ -1249,6 +1249,14 @@
|
||||
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|edutainment"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "logged out home recommendation next starts the next puzzle work"`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`。
|
||||
- 关联文档:`docs/technical/【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md`。
|
||||
|
||||
## 2026-06-03 Profile Funds ViewModel 收口
|
||||
|
||||
- 背景:个人资金展示规则散在 `RpgEntryHomeView.tsx`,且账单来源 label 表漏掉后端契约已有的 `puzzle_author_incentive_claim`,会把原始枚举值直接外显。
|
||||
- 决策:新增 `src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts` 作为个人资金展示 Module,Interface 收口账单来源文案、金额正负号、余额兜底、充值价格、商品主值与会员摘要;页面保留弹窗布局、支付流程、微信渠道和订单轮询副作用。
|
||||
- 影响范围:泥点账单弹窗、充值商品卡片、账户充值弹窗会员摘要。
|
||||
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryProfileFundsViewModel.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "wallet ledger|profile recharge modal shows native qr code"`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`。
|
||||
- 关联文档:`docs/technical/【前端架构】ProfileFundsViewModel收口计划-2026-06-03.md`。
|
||||
|
||||
## 2026-05-26 前端不外露图片模型名
|
||||
|
||||
- 背景:拼图与相关结果页、生成进度和错误提示里直接显示 `gpt-image-2`、`gemini-3.1-flash-image-preview`、`image-2` 等名称,会把内部模型路由暴露给普通用户。
|
||||
|
||||
@@ -53,6 +53,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_
|
||||
|
||||
个人数据卡、钱包 chip 与“玩过”弹窗的计数、时长、作品类型和作品号展示收口到 `src/components/rpg-entry/rpgEntryProfileDashboardPresentation.ts`,规则见 [【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileDashboardPresentation%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
|
||||
|
||||
个人资金展示的账单来源、金额正负号、余额兜底、充值价格、商品主值和会员摘要收口到 `src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts`,规则见 [【前端架构】ProfileFundsViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileFundsViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
|
||||
|
||||
## 推荐阅读顺序
|
||||
|
||||
1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。
|
||||
|
||||
31
docs/technical/【前端架构】ProfileFundsViewModel收口计划-2026-06-03.md
Normal file
31
docs/technical/【前端架构】ProfileFundsViewModel收口计划-2026-06-03.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 【前端架构】Profile Funds ViewModel 收口计划
|
||||
|
||||
## 背景
|
||||
|
||||
`RpgEntryHomeView.tsx` 原先直接维护钱包账单来源文案、金额正负号、账单余额兜底、充值价格、充值商品主值和会员摘要文案。这些规则散在页面 **Implementation** 内,且已与 `ProfileWalletLedgerEntry.sourceType` 契约产生漂移:后端可返回 `puzzle_author_incentive_claim`,页面没有对应中文 label,会把原始枚举值外显给用户。
|
||||
|
||||
## 决策
|
||||
|
||||
新增 `src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts` 作为个人资金展示 **Module**。该 **Module** 的 **Interface** 收口为:
|
||||
|
||||
- `getWalletLedgerSourceLabel(sourceType)`:统一账单来源中文文案,补齐 `puzzle_author_incentive_claim`。
|
||||
- `formatWalletLedgerAmount(amountDelta)`:统一账单金额正负号。
|
||||
- `buildWalletLedgerPresentation(ledger, fallbackBalance)`:统一余额兜底与账单行 presentation。
|
||||
- `formatRechargePrice(priceCents)` 与 `buildRechargeProductValueLabel(product)`:统一充值商品价格与主值文案。
|
||||
- `buildMembershipLabel(membership, formatTime)`:统一会员摘要文案,并保留页面现有时间格式 Adapter。
|
||||
|
||||
`RpgEntryHomeView.tsx` 只消费该 **Module** 输出,保留弹窗布局、支付流程、微信渠道和轮询副作用。资金展示规则的 **Locality** 收口到纯函数测试,后续新增账单来源或调整价格 / 会员文案时不再穿透页面 JSX。
|
||||
|
||||
## 约定
|
||||
|
||||
- 未知账单来源仍保留原始 sourceType 兜底,避免新后端枚举被空白吞掉。
|
||||
- 账单余额继续沿用既有口径:有账单时取第一条 `balanceAfter`,无账单时使用外部 fallback balance。
|
||||
- 本次只收展示 **Interface**,不迁移支付确认、微信跳转、订单轮询或弹窗状态。
|
||||
|
||||
## 验证
|
||||
|
||||
- `npm run test -- src/components/rpg-entry/rpgEntryProfileFundsViewModel.test.ts`
|
||||
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "wallet ledger|profile recharge modal shows native qr code"`
|
||||
- 针对变更文件执行 ESLint
|
||||
- `npm run typecheck`
|
||||
- `npm run check:encoding`
|
||||
@@ -305,12 +305,21 @@ const {
|
||||
amountDelta: -1,
|
||||
balanceAfter: 29,
|
||||
sourceType: 'asset_operation_consume',
|
||||
createdAt: '2026-05-03T08:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'ledger-2',
|
||||
amountDelta: 30,
|
||||
balanceAfter: 30,
|
||||
sourceType: 'invite_invitee_reward',
|
||||
createdAt: '2026-05-03T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'ledger-3',
|
||||
amountDelta: 5,
|
||||
balanceAfter: 35,
|
||||
sourceType: 'puzzle_author_incentive_claim',
|
||||
createdAt: '2026-05-03T10:00:00Z',
|
||||
},
|
||||
],
|
||||
})),
|
||||
@@ -413,11 +422,6 @@ const originalUserAgent = navigator.userAgent;
|
||||
const originalMaxTouchPoints = navigator.maxTouchPoints;
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||
const DEFAULT_PROFILE_CREATED_AT = '2026-04-01T00:00:00.000Z';
|
||||
|
||||
function buildFreshProfileCreatedAt() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function dispatchPointerEvent(
|
||||
target: HTMLElement,
|
||||
@@ -1169,6 +1173,7 @@ test('opens wallet ledger modal from narrative coin card', async () => {
|
||||
expect(screen.getByText('-1')).toBeTruthy();
|
||||
expect(screen.getByText('填写邀请码奖励')).toBeTruthy();
|
||||
expect(screen.getByText('+30')).toBeTruthy();
|
||||
expect(screen.getByText('拼图作者奖励')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile recharge modal shows native qr code on desktop web by default', async () => {
|
||||
|
||||
@@ -141,6 +141,12 @@ import {
|
||||
formatPlayedWorkType,
|
||||
formatTotalPlayTimeHours,
|
||||
} from './rpgEntryProfileDashboardPresentation';
|
||||
import {
|
||||
buildMembershipLabel,
|
||||
buildRechargeProductValueLabel,
|
||||
buildWalletLedgerPresentation,
|
||||
formatRechargePrice,
|
||||
} from './rpgEntryProfileFundsViewModel';
|
||||
import {
|
||||
buildProfileTaskCardSummary,
|
||||
buildProfileTaskProgressLabel,
|
||||
@@ -2150,27 +2156,6 @@ function ProfileNicknameModal({
|
||||
);
|
||||
}
|
||||
|
||||
const WALLET_LEDGER_SOURCE_LABELS: Record<string, string> = {
|
||||
new_user_registration_reward: '注册赠送',
|
||||
points_recharge: '泥点充值',
|
||||
invite_inviter_reward: '邀请奖励',
|
||||
invite_invitee_reward: '填写邀请码奖励',
|
||||
snapshot_sync: '账户同步',
|
||||
asset_operation_consume: '资产操作消耗',
|
||||
asset_operation_refund: '资产操作退回',
|
||||
redeem_code_reward: '兑换码奖励',
|
||||
daily_task_reward: '每日任务奖励',
|
||||
};
|
||||
|
||||
function formatWalletLedgerAmount(amountDelta: number) {
|
||||
return amountDelta > 0 ? `+${amountDelta}` : `${amountDelta}`;
|
||||
}
|
||||
|
||||
function formatRechargePrice(priceCents: number) {
|
||||
const yuan = priceCents / 100;
|
||||
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function clearWechatPayResultHash() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
@@ -2378,12 +2363,8 @@ function RechargeProductCard({
|
||||
onBuy: (product: ProfileRechargeProduct) => void;
|
||||
}) {
|
||||
const submitting = submittingProductId === product.productId;
|
||||
const effectiveBonusPoints = product.bonusPoints;
|
||||
const badgeLabel = product.badgeLabel;
|
||||
const value =
|
||||
product.kind === 'points'
|
||||
? `${product.pointsAmount}${effectiveBonusPoints > 0 ? `+${effectiveBonusPoints}` : ''}泥点`
|
||||
: `${product.durationDays}天`;
|
||||
const value = buildRechargeProductValueLabel(product);
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -2447,12 +2428,10 @@ function ProfileRechargeModal({
|
||||
activeTab === 'points'
|
||||
? (center?.pointProducts ?? [])
|
||||
: (center?.membershipProducts ?? []);
|
||||
const memberLabel =
|
||||
center?.membership.status === 'active'
|
||||
? center.membership.expiresAt
|
||||
? `会员至 ${formatSnapshotTime(center.membership.expiresAt)}`
|
||||
: '会员已生效'
|
||||
: '普通用户';
|
||||
const memberLabel = buildMembershipLabel(
|
||||
center?.membership,
|
||||
formatSnapshotTime,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
@@ -2664,8 +2643,11 @@ function WalletLedgerModal({
|
||||
onClose: () => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const entries = ledger?.entries ?? [];
|
||||
const balance = entries[0]?.balanceAfter ?? fallbackBalance;
|
||||
const walletLedgerPresentation = buildWalletLedgerPresentation(
|
||||
ledger,
|
||||
fallbackBalance,
|
||||
);
|
||||
const entries = walletLedgerPresentation.entries;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
|
||||
@@ -2686,7 +2668,7 @@ function WalletLedgerModal({
|
||||
<div className="mt-1 text-2xl font-black">泥点账单</div>
|
||||
<div className="mt-3 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-white/70 px-3 py-1.5 text-xs font-bold text-zinc-600">
|
||||
<Coins className="h-3.5 w-3.5 text-[#ff4056]" />
|
||||
<span>{balance}泥点</span>
|
||||
<span>{walletLedgerPresentation.balanceLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2717,11 +2699,6 @@ function WalletLedgerModal({
|
||||
) : (
|
||||
<div className="mt-5 space-y-2.5">
|
||||
{entries.map((entry) => {
|
||||
const isIncome = entry.amountDelta > 0;
|
||||
const label =
|
||||
WALLET_LEDGER_SOURCE_LABELS[entry.sourceType] ??
|
||||
entry.sourceType;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
@@ -2729,7 +2706,7 @@ function WalletLedgerModal({
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-black text-zinc-900">
|
||||
{label}
|
||||
{entry.sourceLabel}
|
||||
</div>
|
||||
<div className="mt-1 text-xs font-semibold text-zinc-500">
|
||||
{formatPlatformWorldTime(entry.createdAt)}
|
||||
@@ -2738,13 +2715,15 @@ function WalletLedgerModal({
|
||||
<div className="shrink-0 text-right">
|
||||
<div
|
||||
className={`text-base font-black ${
|
||||
isIncome ? 'text-emerald-600' : 'text-rose-500'
|
||||
entry.isIncome
|
||||
? 'text-emerald-600'
|
||||
: 'text-rose-500'
|
||||
}`}
|
||||
>
|
||||
{formatWalletLedgerAmount(entry.amountDelta)}
|
||||
{entry.amountLabel}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] font-semibold text-zinc-400">
|
||||
余额 {entry.balanceAfter}
|
||||
{entry.balanceLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
161
src/components/rpg-entry/rpgEntryProfileFundsViewModel.test.ts
Normal file
161
src/components/rpg-entry/rpgEntryProfileFundsViewModel.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
ProfileMembership,
|
||||
ProfileRechargeProduct,
|
||||
ProfileWalletLedgerEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
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 {
|
||||
return {
|
||||
productId: 'points_60',
|
||||
title: '60泥点',
|
||||
priceCents: 600,
|
||||
kind: 'points',
|
||||
pointsAmount: 60,
|
||||
bonusPoints: 60,
|
||||
durationDays: 0,
|
||||
badgeLabel: '首充双倍',
|
||||
description: '首充送60泥点',
|
||||
tier: 'normal',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMembership(
|
||||
overrides: Partial<ProfileMembership> = {},
|
||||
): ProfileMembership {
|
||||
return {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
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('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',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildWalletLedgerPresentation(
|
||||
{ entries: [incomeEntry, outcomeEntry] },
|
||||
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: '资产操作消耗',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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');
|
||||
expect(buildRechargeProductValueLabel(buildRechargeProduct())).toBe(
|
||||
'60+60泥点',
|
||||
);
|
||||
expect(
|
||||
buildRechargeProductValueLabel(
|
||||
buildRechargeProduct({
|
||||
kind: 'membership',
|
||||
pointsAmount: 0,
|
||||
bonusPoints: 0,
|
||||
durationDays: 30,
|
||||
}),
|
||||
),
|
||||
).toBe('30天');
|
||||
expect(buildMembershipLabel(buildMembership(), (value) => value)).toBe(
|
||||
'普通用户',
|
||||
);
|
||||
expect(
|
||||
buildMembershipLabel(
|
||||
buildMembership({ status: 'active', expiresAt: null }),
|
||||
(value) => value,
|
||||
),
|
||||
).toBe('会员已生效');
|
||||
expect(
|
||||
buildMembershipLabel(
|
||||
buildMembership({
|
||||
status: 'active',
|
||||
expiresAt: '2026-06-03T00:00:00.000Z',
|
||||
}),
|
||||
() => '06/03 08:00',
|
||||
),
|
||||
).toBe('会员至 06/03 08:00');
|
||||
});
|
||||
108
src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts
Normal file
108
src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type {
|
||||
ProfileMembership,
|
||||
ProfileRechargeProduct,
|
||||
ProfileWalletLedgerEntry,
|
||||
ProfileWalletLedgerResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
|
||||
const PROFILE_WALLET_LEDGER_SOURCE_LABELS = {
|
||||
new_user_registration_reward: '注册赠送',
|
||||
points_recharge: '泥点充值',
|
||||
invite_inviter_reward: '邀请奖励',
|
||||
invite_invitee_reward: '填写邀请码奖励',
|
||||
snapshot_sync: '账户同步',
|
||||
asset_operation_consume: '资产操作消耗',
|
||||
asset_operation_refund: '资产操作退回',
|
||||
redeem_code_reward: '兑换码奖励',
|
||||
puzzle_author_incentive_claim: '拼图作者奖励',
|
||||
daily_task_reward: '每日任务奖励',
|
||||
} satisfies Record<ProfileWalletLedgerEntry['sourceType'], string>;
|
||||
|
||||
export type ProfileWalletLedgerEntryPresentation = {
|
||||
amountLabel: string;
|
||||
balanceLabel: string;
|
||||
createdAt: string;
|
||||
id: string;
|
||||
isIncome: boolean;
|
||||
sourceLabel: string;
|
||||
};
|
||||
|
||||
export type ProfileWalletLedgerPresentation = {
|
||||
balance: number;
|
||||
balanceLabel: string;
|
||||
entries: ProfileWalletLedgerEntryPresentation[];
|
||||
};
|
||||
|
||||
export function getWalletLedgerSourceLabel(
|
||||
sourceType: string | null | undefined,
|
||||
) {
|
||||
const normalizedSourceType = sourceType?.trim() ?? '';
|
||||
if (!normalizedSourceType) {
|
||||
return '未知来源';
|
||||
}
|
||||
|
||||
return (
|
||||
PROFILE_WALLET_LEDGER_SOURCE_LABELS[
|
||||
normalizedSourceType as ProfileWalletLedgerEntry['sourceType']
|
||||
] ?? normalizedSourceType
|
||||
);
|
||||
}
|
||||
|
||||
export function formatWalletLedgerAmount(amountDelta: number) {
|
||||
return amountDelta > 0 ? `+${amountDelta}` : `${amountDelta}`;
|
||||
}
|
||||
|
||||
export function buildWalletLedgerEntryPresentation(
|
||||
entry: ProfileWalletLedgerEntry,
|
||||
): ProfileWalletLedgerEntryPresentation {
|
||||
return {
|
||||
amountLabel: formatWalletLedgerAmount(entry.amountDelta),
|
||||
balanceLabel: `余额 ${entry.balanceAfter}`,
|
||||
createdAt: entry.createdAt,
|
||||
id: entry.id,
|
||||
isIncome: entry.amountDelta > 0,
|
||||
sourceLabel: getWalletLedgerSourceLabel(entry.sourceType),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWalletLedgerPresentation(
|
||||
ledger: ProfileWalletLedgerResponse | null,
|
||||
fallbackBalance: number,
|
||||
): ProfileWalletLedgerPresentation {
|
||||
const entries = ledger?.entries ?? [];
|
||||
const balance = entries[0]?.balanceAfter ?? fallbackBalance;
|
||||
|
||||
return {
|
||||
balance,
|
||||
balanceLabel: `${balance}泥点`,
|
||||
entries: entries.map(buildWalletLedgerEntryPresentation),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatRechargePrice(priceCents: number) {
|
||||
const yuan = priceCents / 100;
|
||||
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function buildRechargeProductValueLabel(product: ProfileRechargeProduct) {
|
||||
if (product.kind === 'membership') {
|
||||
return `${product.durationDays}天`;
|
||||
}
|
||||
|
||||
return `${product.pointsAmount}${
|
||||
product.bonusPoints > 0 ? `+${product.bonusPoints}` : ''
|
||||
}泥点`;
|
||||
}
|
||||
|
||||
export function buildMembershipLabel(
|
||||
membership: ProfileMembership | null | undefined,
|
||||
formatTime: (value: string) => string,
|
||||
) {
|
||||
if (membership?.status !== 'active') {
|
||||
return '普通用户';
|
||||
}
|
||||
|
||||
return membership.expiresAt
|
||||
? `会员至 ${formatTime(membership.expiresAt)}`
|
||||
: '会员已生效';
|
||||
}
|
||||
Reference in New Issue
Block a user