每日免费发放额为 0 时前端隐藏该池

- 后端:module-runtime 的 daily_free_points_per_day 校验由「必须大于 0」放宽为只校验上界,错误变体改名 DailyFreePointsPerDayOverflow
- 前端共享层:mudPoints 新增 shouldShowProfileDailyFreePool,判定 remaining > 0 || reset > 0,判定收口一处
- 钱包入口:PlatformMudPointWalletEntry 在每日免费发放额为 0 且无存量时隐藏该行
- 充值弹层:PlatformProfileRechargeModal 池概览按可见池渲染(三列变两列),扣点顺序提示与泥点确认页文案随可见性切换
- Admin Web:账号配置页每日免费泥点允许填 0(校验、min 与文案同步)
- 测试:删除「每日免费必然存在」断言,新增隐藏 / 有存量仍显示 / 发放额为正三类用例,并修正钱包入口测试在 HEAD 上失效的选择器
- 文档:更新会员与泥点前端改造设计文档 §14 与 shared-memory 决策记录
This commit is contained in:
2026-10-03 16:48:12 +08:00
parent 43f0c51120
commit 1a106eadef
14 changed files with 326 additions and 55 deletions
@@ -94,7 +94,7 @@ test('账号配置页一次保存初始和每日免费泥点', async () => {
);
});
test('账号配置页拒绝非正整数每日免费额度', async () => {
test('账号配置页拒绝负数或非整数每日免费额度', async () => {
render(
<AdminProfileWalletConfigPage
token="admin-token"
@@ -109,5 +109,38 @@ test('账号配置页拒绝非正整数每日免费额度', async () => {
(screen.getByRole('button', { name: '保存' }) as HTMLButtonElement)
.disabled,
).toBe(true);
fireEvent.change(input, { target: { value: '-1' } });
expect(
(screen.getByRole('button', { name: '保存' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(upsertProfileWalletConfig).not.toHaveBeenCalled();
});
test('账号配置页允许把每日免费泥点配置为 0', async () => {
const user = userEvent.setup();
vi.mocked(upsertProfileWalletConfig).mockResolvedValue({
...configResponse,
dailyFreePointsPerDay: 0,
});
render(
<AdminProfileWalletConfigPage
token="admin-token"
result={configResponse}
onUnauthorized={vi.fn()}
onResultChange={vi.fn()}
/>,
);
fireEvent.change(await screen.findByLabelText('每日免费泥点数'), {
target: { value: '0' },
});
await user.click(screen.getByRole('button', { name: '保存' }));
expect(screen.getByText('初始 100 泥点,每日免费 0 泥点')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(upsertProfileWalletConfig).toHaveBeenCalledWith('admin-token', {
initialMudPoints: 100,
dailyFreePointsPerDay: 0,
}),
);
});
@@ -66,11 +66,11 @@ export function AdminProfileWalletConfigPage({
if (isSaving) {
return;
}
const normalizedDailyFreePointsPerDay = parsePositiveInteger(
const normalizedDailyFreePointsPerDay = parseNonNegativeInteger(
dailyFreePointsPerDay,
);
if (!normalizedDailyFreePointsPerDay) {
setErrorMessage('每日免费泥点数必须是大于 0 的整数');
if (normalizedDailyFreePointsPerDay === null) {
setErrorMessage('每日免费泥点数必须是不小于 0 的整数');
return;
}
@@ -139,7 +139,7 @@ export function AdminProfileWalletConfigPage({
<AdminField label="每日免费泥点数">
<input
min={1}
min={0}
step={1}
type="number"
value={dailyFreePointsPerDay}
@@ -154,7 +154,7 @@ export function AdminProfileWalletConfigPage({
disabled={
isSaving ||
!parsePositiveInteger(initialMudPoints) ||
!parsePositiveInteger(dailyFreePointsPerDay)
parseNonNegativeInteger(dailyFreePointsPerDay) === null
}
type="submit"
>
@@ -205,3 +205,11 @@ function parsePositiveInteger(value: string) {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
}
function parseNonNegativeInteger(value: string): number | null {
if (value.trim() === '') {
return null;
}
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
}
@@ -1,4 +1,14 @@
# 决策记录
## 2026-10-03 每日免费发放额为 0 时前端隐藏该池
- 背景:运营需要一个可逆的「不提供每日免费泥点」状态。不给它新增 `retired` 状态位或新字段,直接把后台配置 `daily_free_points_per_day` 配成 0,让「每日免费发放额」这个普通数值自己表达;前端据此隐藏每日免费相关入口。
- 决策(信号取 `dailyFreeResetPoints` 而非剩余):`ProfileMudPointBalance.dailyFreeResetPoints <= 0` 表示不提供该池。`dailyFreePoints`(当天剩余)为 0 是每天用完后的正常状态,不能当信号。
- 决策(显示规则 `remaining > 0 || reset > 0`):当天仍有存量(配置中途改成 0,但当天已发未用完)时必须继续展示——扣点顺序不变,这部分存量会被优先扣减,隐藏后用户会「有余额看不见、还被先扣」。判定收口到 `packages/shared/src/utils/mudPoints.ts` 的 `shouldShowProfileDailyFreePool`,三端共享组件共用,消费方 controller 不改。
- 决策(后端放开校验、不新增契约字段):`server-rs/crates/module-runtime/src/commands.rs` 的 `daily_free_points_per_day` 由「必须 > 0」放宽为 `0..=i64::MAX`,错误变体改名为 `DailyFreePointsPerDayOverflow`;`0` 本就是 `u64` 合法值,`shared-contracts` / ts-rs 生成物 / OpenAPI 不改。发放与账本逻辑不变(`amount_delta == 0` 时后端本就跳过写账)。
- 影响范围:`packages/shared/src/utils/mudPoints.ts`、`PlatformMudPointWalletEntry`、`PlatformProfileRechargeModal`(池概览 3 列变 2 列、扣点顺序提示与泥点确认页文案随可见性切换)、`apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx`(每日免费允许填 0)、`server-rs/crates/module-runtime/{commands,errors,lib}.rs`;`daily_free_grant` / `daily_free_reset` 账本文案保留,历史流水不改写。
- 验证:`cargo check -p module-runtime`、`cargo test -p module-runtime profile_wallet_config_allows_zero_daily_free_points`、`cargo fmt --check`、根 `npm run typecheck`、`npm run admin-web:typecheck`、共享层与 admin 定向 vitest 22/22、`npm run check:encoding`、`git diff --check` 通过。
## 2026-10-02 资源编辑远端失败的原始原因穿出到工具错误,资源编辑错误通道补一层 typed
- 背景:轮询到 `status=failed` 时客户端只读 `status`,丢掉平台在同一个响应里给的 `error`(契约 `ExternalEditorGenerationJobResponse.error`),统一写 `terminal_failure_code = remote-generation-failed` 并返回「remote-terminal-failed: 资源编辑生成失败」。平台的可行动原因就此消失:模型与用户卡片只看到一句「失败了」,重试路径(`ensure_resource_edit_phase_resumable`)也只有分类码。这违反 `pitfalls.md`「远端资源编辑终态必须指出唯一出口」里已写下的口径——「首次失败的原始拒绝说明继续由当次错误文案承担」;提交期 HTTP 400 分支(`editor_api_rejection_reason`)兑现了,轮询分支没有。另外 `remote-terminal-failed:` 只是文案前缀(全仓没有 `starts_with` 解析它),在第一句失败文案里与「失败」重复。
@@ -300,3 +300,43 @@ onConfirmMembershipPayment: () => void;
### 13.3 既有问题(非本次引入)
- `ImageCanvasEditorView.test.tsx > shows the breakdown read failure without inventing rows` 在改动前的基线同样失败(已用 `git stash` 对照验证):弹层打开时仍处于 loading 分支,断言未等待错误态。属既有时序问题,本次未修改该行为。
---
## 14. 追加变更:每日免费发放额为 0 时隐藏(2026-10-03)
### 14.1 语义
- **不引入** `retired` / 状态位 / 新字段;每日免费只是一个普通数值,发放额可以是 0。
- **唯一信号**:`ProfileMudPointBalance.dailyFreeResetPoints <= 0`(后端每日发放额,来自后台配置 `daily_free_points_per_day`)。
- **可逆**:后台把配置改回 > 0,前端下次读取自动恢复展示,无需额外开关。
- **契约不变**:`ProfileMudPointBalanceResponse` 字段仍是必填 `u64`,`0` 本就合法;不改 `shared-contracts`、ts-rs 生成物与 OpenAPI。
- **扣减与账本逻辑不变**:每日免费为 0 时不再产生发放账本(`profile.rs` 在 `amount_delta == 0` 时直接返回),创作扣点顺序仍为每日免费 → 月度 → 永久。
### 14.2 显示规则
```
showDailyFreePool = dailyFreePoints > 0 || dailyFreeResetPoints > 0
```
| 场景 | dailyFreePoints | dailyFreeResetPoints | 表现 |
|---|---|---|---|
| 当天正常用完 | 0 | 20 | 显示("每天重置为 20") |
| 每日免费发放额为 0 | 0 | 0 | 隐藏 |
| 配置中途改为 0,当天仍有存量 | > 0 | 0 | 显示(存量仍会被优先扣减,不能隐身) |
判定收敛到 `packages/shared/src/utils/mudPoints.ts` 的纯函数,三端共享组件共用,消费方 controller 不改。
### 14.3 改动点
- 后端 `server-rs/crates/module-runtime/src/commands.rs`:`daily_free_points_per_day` 由"必须 > 0"放宽为 `0..=i64::MAX`;`errors.rs` 对应文案同步。
- Admin Web `apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx`:每日免费泥点允许填 0。
- 共享层 `packages/shared/src/utils/mudPoints.ts`:新增 `shouldShowProfileDailyFreePool`。
- 共享层 `PlatformMudPointWalletEntry`:为 0 时隐藏"每日免费泥点"行。
- 共享层 `PlatformProfileRechargeModal`:池概览按可见池动态渲染(三列变两列)、隐藏扣点顺序提示中与每日免费相关的口径。
- 账本来源文案 `daily_free_grant` / `daily_free_reset` 保留,历史流水不改写。
### 14.4 测试
- 删除"每日免费必然存在"的断言(`PlatformMudPointWalletEntry`、`ImageCanvasTopbarView`、`ImageCanvasEditorView`),改为在共享层新增"发放额为 0 隐藏 / 有存量仍显示"的行为用例。
- 后端与 Admin 追加"允许 0"的用例。
@@ -21,7 +21,7 @@ test('formats mud point counts consistently', () => {
expect(formatMudPointCount(12_345, true)).toBe('1.2万');
});
test('shows permanent, monthly and daily free points in the shared wallet panel', async () => {
test('shows permanent and monthly points in the shared wallet panel', async () => {
const user = userEvent.setup();
const onRequestDetails = vi.fn();
const onRecharge = vi.fn();
@@ -37,10 +37,12 @@ test('shows permanent, monthly and daily free points in the shared wallet panel'
/>,
);
const balanceButton = screen.getByRole('button', { name: '泥点 207' });
const balanceButton = screen.getByRole('button', {
name: /可用总额 207,月度/,
});
await user.hover(balanceButton);
const details = screen.getByRole('dialog', { name: '泥点账户详情' });
const details = screen.getByRole('dialog', { name: '会员与泥点账户详情' });
expect(details.className).toContain('rounded-[1.25rem]');
expect(within(details).getByText('永久泥点')).toBeTruthy();
expect(within(details).getByText('月度泥点')).toBeTruthy();
@@ -51,18 +53,64 @@ test('shows permanent, monthly and daily free points in the shared wallet panel'
within(details).getByText('2026-07-07 00:00 清零并发放新额度'),
).toBeTruthy();
expect(within(details).queryByText('限时泥点')).toBeNull();
expect(within(details).getByText('每日免费泥点')).toBeTruthy();
expect(within(details).getByText('27')).toBeTruthy();
expect(within(details).getByText('每天重置为 20 泥点')).toBeTruthy();
expect(onRequestDetails).toHaveBeenCalledTimes(1);
await user.click(within(details).getByRole('button', { name: '使用详情' }));
expect(onOpenLedger).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole('button', { name: '充值' }));
await user.click(screen.getByRole('button', { name: '会员与泥点' }));
expect(onRecharge).toHaveBeenCalledTimes(1);
});
test('hides the daily free row when the daily grant is zero', async () => {
const user = userEvent.setup();
render(
<PlatformMudPointWalletEntry
balance={100}
breakdown={{
...breakdown,
dailyFreePoints: 0,
dailyFreeResetPoints: 0,
}}
onRequestDetails={vi.fn()}
onRecharge={vi.fn()}
onOpenLedger={vi.fn()}
/>,
);
await user.hover(screen.getByRole('button', { name: /可用总额 100,月度/ }));
const details = screen.getByRole('dialog', { name: '会员与泥点账户详情' });
expect(within(details).queryByText('每日免费泥点')).toBeNull();
expect(within(details).getByText('永久泥点')).toBeTruthy();
expect(within(details).getByText('月度泥点')).toBeTruthy();
});
test('keeps the daily free row while the same-day remainder is still spendable', async () => {
const user = userEvent.setup();
render(
<PlatformMudPointWalletEntry
balance={115}
breakdown={{
...breakdown,
dailyFreePoints: 15,
dailyFreeResetPoints: 0,
}}
onRequestDetails={vi.fn()}
onRecharge={vi.fn()}
onOpenLedger={vi.fn()}
/>,
);
await user.hover(screen.getByRole('button', { name: /可用总额 115,月度/ }));
const details = screen.getByRole('dialog', { name: '会员与泥点账户详情' });
expect(within(details).getByText('每日免费泥点')).toBeTruthy();
expect(within(details).getByText('15')).toBeTruthy();
});
test('shows the redeem-code entry directly in the wallet bar when provided', async () => {
const user = userEvent.setup();
const onRedeemCode = vi.fn();
@@ -93,12 +141,18 @@ test('keeps the chip on the live balance when cached details are stale', () => {
<PlatformMudPointWalletEntry balance={207} {...props} />,
);
expect(screen.getByRole('button', { name: '泥点 207' })).toBeTruthy();
expect(
screen.getByRole('button', { name: /可用总额 207,月度/ }),
).toBeTruthy();
rerender(<PlatformMudPointWalletEntry balance={195} {...props} />);
expect(screen.getByRole('button', { name: '泥点 195' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '泥点 207' })).toBeNull();
expect(
screen.getByRole('button', { name: /可用总额 195,月度/ }),
).toBeTruthy();
expect(
screen.queryByRole('button', { name: /可用总额 207,月度/ }),
).toBeNull();
});
test('refreshes cached balance details while open and after reopening', () => {
@@ -113,7 +167,9 @@ test('refreshes cached balance details while open and after reopening', () => {
/>,
);
const balanceButton = screen.getByRole('button', { name: '泥点 207' });
const balanceButton = screen.getByRole('button', {
name: /可用总额 207,月度/,
});
fireEvent.mouseEnter(balanceButton);
expect(onRequestDetails).toHaveBeenCalledTimes(1);
@@ -127,11 +183,15 @@ test('refreshes cached balance details while open and after reopening', () => {
/>,
);
expect(screen.getByRole('dialog', { name: '泥点账户详情' })).toBeTruthy();
expect(
screen.getByRole('dialog', { name: '会员与泥点账户详情' }),
).toBeTruthy();
expect(onRequestDetails).toHaveBeenCalledTimes(2);
fireEvent.pointerDown(document.body);
fireEvent.mouseEnter(screen.getByRole('button', { name: '泥点 195' }));
fireEvent.mouseEnter(
screen.getByRole('button', { name: /可用总额 195,月度/ }),
);
expect(onRequestDetails).toHaveBeenCalledTimes(3);
});
@@ -149,7 +209,7 @@ test('requests missing details after the opening balance load completes', () =>
<PlatformMudPointWalletEntry {...props} isLoading />,
);
fireEvent.click(screen.getByRole('button', { name: '泥点 20' }));
fireEvent.click(screen.getByRole('button', { name: /可用总额 20,月度/ }));
expect(onRequestDetails).not.toHaveBeenCalled();
rerender(<PlatformMudPointWalletEntry {...props} isLoading={false} />);
@@ -170,25 +230,33 @@ test('keeps the desktop panel open while moving across the gap without click pin
/>,
);
const balanceButton = screen.getByRole('button', { name: '泥点 207' });
const balanceButton = screen.getByRole('button', {
name: /可用总额 207,月度/,
});
const root = balanceButton.closest('.platform-mud-point-wallet-entry');
expect(root).toBeTruthy();
fireEvent.mouseEnter(balanceButton);
const details = screen.getByRole('dialog', { name: '泥点账户详情' });
const details = screen.getByRole('dialog', { name: '会员与泥点账户详情' });
fireEvent.mouseLeave(root as HTMLElement, { relatedTarget: null });
fireEvent.mouseEnter(details);
act(() => vi.advanceTimersByTime(120));
expect(screen.getByRole('dialog', { name: '泥点账户详情' })).toBeTruthy();
expect(
screen.getByRole('dialog', { name: '会员与泥点账户详情' }),
).toBeTruthy();
balanceButton.focus();
fireEvent.click(balanceButton);
expect(screen.getByRole('dialog', { name: '泥点账户详情' })).toBeTruthy();
expect(
screen.getByRole('dialog', { name: '会员与泥点账户详情' }),
).toBeTruthy();
fireEvent.mouseLeave(root as HTMLElement, { relatedTarget: null });
act(() => vi.advanceTimersByTime(120));
expect(screen.queryByRole('dialog', { name: '泥点账户详情' })).toBeNull();
expect(
screen.queryByRole('dialog', { name: '会员与泥点账户详情' }),
).toBeNull();
} finally {
vi.useRealTimers();
}
@@ -210,7 +278,7 @@ test('requests the balance breakdown when a compact entry opens', async () => {
/>,
);
await user.click(screen.getByRole('button', { name: '泥点 20' }));
await user.click(screen.getByRole('button', { name: /可用总额 20,月度/ }));
expect(onRequestDetails).toHaveBeenCalledTimes(1);
expect(screen.getByText('余额明细暂不可用')).toBeTruthy();
});
@@ -15,6 +15,7 @@ import {
formatMudPointResetAt,
type ProfileMudPointBalanceView,
resolveProfileMudPointBalanceView,
shouldShowProfileDailyFreePool,
} from '../../utils/mudPoints';
export type PlatformMudPointWalletEntryProps = {
@@ -312,11 +313,13 @@ export function PlatformMudPointWalletEntry({
: '会员每期额度,刷新日清零'
}
/>
<MudPointBalanceRow
label="每日免费泥点"
points={breakdown.dailyFreePoints}
detail={`每天重置为 ${formatMudPointCount(breakdown.dailyFreeResetPoints)} 泥点`}
/>
{shouldShowProfileDailyFreePool(breakdown) ? (
<MudPointBalanceRow
label="每日免费泥点"
points={breakdown.dailyFreePoints}
detail={`每天重置为 ${formatMudPointCount(breakdown.dailyFreeResetPoints)} 泥点`}
/>
) : null}
</>
) : (
<div className="border-t border-[rgba(194,145,111,0.28)] px-4 py-6 text-center text-xs font-semibold text-[var(--platform-text-soft)]">
@@ -474,4 +474,74 @@ describe('PlatformProfileRechargeModal', () => {
expect(confirmButton).toHaveProperty('disabled', true);
expect(onConfirmNativePayment).not.toHaveBeenCalled();
});
test('hides the daily free pool when the daily grant is zero', () => {
render(
<PlatformProfileRechargeModal
center={{
mudPointBalance: {
...buildMudPointBalance(100),
dailyFreePoints: 0,
dailyFreeResetPoints: 0,
},
membership: buildNormalMembership(),
pointProducts: [],
membershipPlans: [STARTER_PLAN],
latestOrder: null,
hasPointsRecharged: false,
}}
isLoading={false}
error={null}
submittingProductId={null}
membershipQuote={null}
isLoadingMembershipQuote={false}
membershipQuoteError={null}
nativePayment={null}
onClose={vi.fn()}
onRetry={vi.fn()}
onBuy={vi.fn()}
onRequestMembershipQuote={vi.fn()}
onSubmitMembershipCheckout={vi.fn()}
onConfirmNativePayment={vi.fn()}
onCloseNativePayment={vi.fn()}
/>,
);
const dialog = screen.getByRole('dialog', { name: '会员与泥点' });
expect(within(dialog).queryByText('每日免费')).toBeNull();
expect(within(dialog).getByText('月度泥点')).toBeTruthy();
expect(within(dialog).getByText('永久泥点')).toBeTruthy();
});
test('shows the daily free pool while the daily grant is positive', () => {
render(
<PlatformProfileRechargeModal
center={{
mudPointBalance: buildMudPointBalance(100),
membership: buildNormalMembership(),
pointProducts: [],
membershipPlans: [STARTER_PLAN],
latestOrder: null,
hasPointsRecharged: false,
}}
isLoading={false}
error={null}
submittingProductId={null}
membershipQuote={null}
isLoadingMembershipQuote={false}
membershipQuoteError={null}
nativePayment={null}
onClose={vi.fn()}
onRetry={vi.fn()}
onBuy={vi.fn()}
onRequestMembershipQuote={vi.fn()}
onSubmitMembershipCheckout={vi.fn()}
onConfirmNativePayment={vi.fn()}
onCloseNativePayment={vi.fn()}
/>,
);
const dialog = screen.getByRole('dialog', { name: '会员与泥点' });
expect(within(dialog).getByText('每日免费')).toBeTruthy();
});
});
@@ -17,6 +17,7 @@ import { formatMudPointCount, formatRechargePrice } from '../../utils/format';
import {
formatMudPointResetAt,
resolveProfileMudPointDisplayTotal,
shouldShowProfileDailyFreePool,
} from '../../utils/mudPoints';
import {
resolveMembershipConcurrencyLabel,
@@ -376,15 +377,20 @@ function PlatformMudPointPoolOverview({
center: ProfileRechargeCenterResponse | null;
}) {
const balance = center?.mudPointBalance;
const showDailyFree = shouldShowProfileDailyFreePool(balance);
const dailyFreeResetsAt = formatMudPointResetAt(balance?.dailyFreeResetsAt);
const monthlyResetsAt = formatMudPointResetAt(balance?.monthlyResetsAt);
const pools = [
{
key: 'daily',
label: '每日免费',
points: balance?.dailyFreePoints ?? 0,
detail: `每天重置为 ${formatMudPointCount(balance?.dailyFreeResetPoints ?? 0)}`,
},
...(showDailyFree
? [
{
key: 'daily',
label: '每日免费',
points: balance?.dailyFreePoints ?? 0,
detail: `每天重置为 ${formatMudPointCount(balance?.dailyFreeResetPoints ?? 0)}`,
},
]
: []),
{
key: 'monthly',
label: '月度泥点',
@@ -413,7 +419,9 @@ function PlatformMudPointPoolOverview({
)}
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div
className={`grid ${showDailyFree ? 'grid-cols-3' : 'grid-cols-2'} gap-2`}
>
{pools.map((pool) => (
<div
key={pool.key}
@@ -431,7 +439,7 @@ function PlatformMudPointPoolOverview({
</div>
))}
</div>
{dailyFreeResetsAt ? (
{showDailyFree && dailyFreeResetsAt ? (
<p className="text-[10px] text-[var(--platform-text-soft)]">
创作扣点顺序:每日免费 → 月度 → 永久,{dailyFreeResetsAt}{' '}
重置每日免费额度。
@@ -853,6 +861,7 @@ function PlatformRechargeCheckoutDialog({
isLoadingMembershipQuote,
membershipQuoteError,
submittingProductId,
showDailyFreePool,
onClose,
onBuy,
onRequestMembershipQuote,
@@ -865,6 +874,7 @@ function PlatformRechargeCheckoutDialog({
isLoadingMembershipQuote: boolean;
membershipQuoteError: string | null;
submittingProductId: string | null;
showDailyFreePool: boolean;
onClose: () => void;
onBuy: (product: ProfileRechargeProduct) => void;
onRequestMembershipQuote: (plan: ProfileMembershipPlan) => void;
@@ -894,7 +904,11 @@ function PlatformRechargeCheckoutDialog({
value: `${formatMudPointCount(product.pointsAmount)} 永久泥点`,
},
]}
note="永久有效,不会随会员到期清零。创作时先扣每日免费与月度泥点,不足部分再扣永久泥点。"
note={
showDailyFreePool
? '永久有效,不会随会员到期清零。创作时先扣每日免费与月度泥点,不足部分再扣永久泥点。'
: '永久有效,不会随会员到期清零。创作时先扣月度泥点,不足部分再扣永久泥点。'
}
/>
);
}
@@ -1213,6 +1227,9 @@ export function PlatformProfileRechargeModal({
const currentBalance = resolveProfileMudPointDisplayTotal(
center?.mudPointBalance,
);
const showDailyFreePool = shouldShowProfileDailyFreePool(
center?.mudPointBalance,
);
// 后端目录已按 rank 升序返回,这里只过滤不可购买与占位档。
const membershipPlans = useMemo(
() =>
@@ -1457,6 +1474,7 @@ export function PlatformProfileRechargeModal({
isLoadingMembershipQuote={isLoadingMembershipQuote}
membershipQuoteError={membershipQuoteError}
submittingProductId={submittingProductId}
showDailyFreePool={showDailyFreePool}
onClose={() => setCheckout(null)}
onBuy={onBuy}
onRequestMembershipQuote={onRequestMembershipQuote}
+23
View File
@@ -41,6 +41,29 @@ export function resolveProfileMudPointDisplayTotal(
return resolveProfileMudPointBalanceView(balance).totalPoints;
}
/**
* 是否展示「每日免费」池。
*
* 后端把每日发放额 `dailyFreeResetPoints` 配置为 0 时,表示不再提供该池,
* 前端不再展示每日免费相关入口;配置随时可改回 > 0,无需额外开关。当天仍有存量
* (`dailyFreePoints > 0`)时仍要展示:扣点顺序不变,这部分存量会被优先扣减,
* 不能隐身。
*/
export function shouldShowProfileDailyFreePool(
balance:
| Pick<ProfileMudPointBalance, 'dailyFreePoints' | 'dailyFreeResetPoints'>
| null
| undefined,
): boolean {
if (!balance) {
return false;
}
return (
(balance.dailyFreePoints ?? 0) > 0 ||
(balance.dailyFreeResetPoints ?? 0) > 0
);
}
/**
* 月度泥点的刷新时刻展示文案。
*
@@ -99,8 +99,8 @@ pub fn build_runtime_profile_wallet_config_admin_upsert_input(
if initial_mud_points == 0 || initial_mud_points > i64::MAX as u64 {
return Err(RuntimeProfileFieldError::InvalidInitialWalletPoints);
}
if daily_free_points_per_day == 0 || daily_free_points_per_day > i64::MAX as u64 {
return Err(RuntimeProfileFieldError::InvalidDailyFreePointsPerDay);
if daily_free_points_per_day > i64::MAX as u64 {
return Err(RuntimeProfileFieldError::DailyFreePointsPerDayOverflow);
}
Ok(RuntimeProfileWalletConfigAdminUpsertInput {
admin_user_id,
@@ -51,7 +51,7 @@ pub enum RuntimeProfileFieldError {
InvalidWalletAmount,
InvalidWalletMetadata,
InvalidInitialWalletPoints,
InvalidDailyFreePointsPerDay,
DailyFreePointsPerDayOverflow,
WalletAmountOverflow,
WalletBalanceOverflow,
InsufficientWalletBalance,
@@ -138,8 +138,8 @@ impl std::fmt::Display for RuntimeProfileFieldError {
Self::InvalidInitialWalletPoints => {
f.write_str("profile_wallet_config.initial_mud_points 必须大于 0")
}
Self::InvalidDailyFreePointsPerDay => {
f.write_str("profile_wallet_config.daily_free_points_per_day 必须大于 0")
Self::DailyFreePointsPerDayOverflow => {
f.write_str("profile_wallet_config.daily_free_points_per_day 超出上限")
}
Self::WalletAmountOverflow => f.write_str("profile.wallet_amount 超出上限"),
Self::WalletBalanceOverflow => f.write_str("profile.wallet_balance 超出上限"),
+7 -7
View File
@@ -688,7 +688,7 @@ mod tests {
}
#[test]
fn profile_wallet_config_requires_positive_daily_free_points() {
fn profile_wallet_config_allows_zero_daily_free_points() {
assert_eq!(
build_runtime_profile_wallet_config_admin_upsert_input(
"admin-1".to_string(),
@@ -696,19 +696,19 @@ mod tests {
1_713_680_000_000_000,
0,
)
.expect_err("zero daily free points should fail"),
RuntimeProfileFieldError::InvalidDailyFreePointsPerDay,
.expect("zero daily free points should pass")
.daily_free_points_per_day,
0,
);
assert_eq!(
build_runtime_profile_wallet_config_admin_upsert_input(
"admin-1".to_string(),
100,
1_713_680_000_000_000,
35,
i64::MAX as u64 + 1,
)
.expect("positive daily free points should pass")
.daily_free_points_per_day,
35,
.expect_err("overflowing daily free points should fail"),
RuntimeProfileFieldError::DailyFreePointsPerDayOverflow,
);
}
@@ -1,4 +1,4 @@
/* @vitest-environment jsdom */
/* @vitest-environment jsdom */
import { canvasViewportToWorldTransform } from '@genarrative/image-canvas-react';
import {
@@ -817,7 +817,6 @@ describe('ImageCanvasEditorView', () => {
});
expect(within(details).getByText('永久泥点')).toBeTruthy();
expect(within(details).getByText('月度泥点')).toBeTruthy();
expect(within(details).getByText('每日免费泥点')).toBeTruthy();
await user.click(within(details).getByRole('button', { name: '使用详情' }));
expect(
@@ -118,7 +118,6 @@ describe('ImageCanvasTopbarView', () => {
});
expect(within(details).getByText('永久泥点')).toBeTruthy();
expect(within(details).getByText('月度泥点')).toBeTruthy();
expect(within(details).getByText('每日免费泥点')).toBeTruthy();
await user.click(within(details).getByRole('button', { name: '使用详情' }));
expect(props.onOpenWalletLedger).toHaveBeenCalledTimes(1);