diff --git a/apps/admin-web/src/pages/AdminProfileWalletConfigPage.test.tsx b/apps/admin-web/src/pages/AdminProfileWalletConfigPage.test.tsx index 1697c489a..4a95de136 100644 --- a/apps/admin-web/src/pages/AdminProfileWalletConfigPage.test.tsx +++ b/apps/admin-web/src/pages/AdminProfileWalletConfigPage.test.tsx @@ -94,7 +94,7 @@ test('账号配置页一次保存初始和每日免费泥点', async () => { ); }); -test('账号配置页拒绝非正整数每日免费额度', async () => { +test('账号配置页拒绝负数或非整数每日免费额度', async () => { render( { (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( + , + ); + 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, + }), + ); +}); diff --git a/apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx b/apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx index 9b3b4ea08..434451913 100644 --- a/apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx +++ b/apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx @@ -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({ @@ -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; +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 465ae9866..f970f10c4 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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` 解析它),在第一句失败文案里与「失败」重复。 diff --git a/docs/technical/【技术设计】会员与泥点前端改造-2026-10-03.md b/docs/technical/【技术设计】会员与泥点前端改造-2026-10-03.md index a36f87b32..88a963eb5 100644 --- a/docs/technical/【技术设计】会员与泥点前端改造-2026-10-03.md +++ b/docs/technical/【技术设计】会员与泥点前端改造-2026-10-03.md @@ -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"的用例。 diff --git a/packages/shared/src/components/PlatformMudPointWalletEntry/index.test.tsx b/packages/shared/src/components/PlatformMudPointWalletEntry/index.test.tsx index 982ddaeb6..7ead4be93 100644 --- a/packages/shared/src/components/PlatformMudPointWalletEntry/index.test.tsx +++ b/packages/shared/src/components/PlatformMudPointWalletEntry/index.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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', () => { , ); - expect(screen.getByRole('button', { name: '泥点 207' })).toBeTruthy(); + expect( + screen.getByRole('button', { name: /可用总额 207,月度/ }), + ).toBeTruthy(); rerender(); - 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', () => , ); - fireEvent.click(screen.getByRole('button', { name: '泥点 20' })); + fireEvent.click(screen.getByRole('button', { name: /可用总额 20,月度/ })); expect(onRequestDetails).not.toHaveBeenCalled(); rerender(); @@ -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(); }); diff --git a/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx b/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx index 5d8dd7e16..97fd52512 100644 --- a/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx +++ b/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx @@ -15,6 +15,7 @@ import { formatMudPointResetAt, type ProfileMudPointBalanceView, resolveProfileMudPointBalanceView, + shouldShowProfileDailyFreePool, } from '../../utils/mudPoints'; export type PlatformMudPointWalletEntryProps = { @@ -312,11 +313,13 @@ export function PlatformMudPointWalletEntry({ : '会员每期额度,刷新日清零' } /> - + {shouldShowProfileDailyFreePool(breakdown) ? ( + + ) : null} ) : (
diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx index 58875c494..18db8512a 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx @@ -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( + , + ); + + 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( + , + ); + + const dialog = screen.getByRole('dialog', { name: '会员与泥点' }); + expect(within(dialog).getByText('每日免费')).toBeTruthy(); + }); }); diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx index 2b02f5c57..9c920eea9 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx @@ -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({ )}
-
+
{pools.map((pool) => (
))}
- {dailyFreeResetsAt ? ( + {showDailyFree && dailyFreeResetsAt ? (

创作扣点顺序:每日免费 → 月度 → 永久,{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} diff --git a/packages/shared/src/utils/mudPoints.ts b/packages/shared/src/utils/mudPoints.ts index c56568910..d65ec3ba3 100644 --- a/packages/shared/src/utils/mudPoints.ts +++ b/packages/shared/src/utils/mudPoints.ts @@ -41,6 +41,29 @@ export function resolveProfileMudPointDisplayTotal( return resolveProfileMudPointBalanceView(balance).totalPoints; } +/** + * 是否展示「每日免费」池。 + * + * 后端把每日发放额 `dailyFreeResetPoints` 配置为 0 时,表示不再提供该池, + * 前端不再展示每日免费相关入口;配置随时可改回 > 0,无需额外开关。当天仍有存量 + * (`dailyFreePoints > 0`)时仍要展示:扣点顺序不变,这部分存量会被优先扣减, + * 不能隐身。 + */ +export function shouldShowProfileDailyFreePool( + balance: + | Pick + | null + | undefined, +): boolean { + if (!balance) { + return false; + } + return ( + (balance.dailyFreePoints ?? 0) > 0 || + (balance.dailyFreeResetPoints ?? 0) > 0 + ); +} + /** * 月度泥点的刷新时刻展示文案。 * diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index 31e93298f..0b6b6d8b5 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -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, diff --git a/server-rs/crates/module-runtime/src/errors.rs b/server-rs/crates/module-runtime/src/errors.rs index 570ebfe05..010c50654 100644 --- a/server-rs/crates/module-runtime/src/errors.rs +++ b/server-rs/crates/module-runtime/src/errors.rs @@ -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 超出上限"), diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index b3e48fb39..ae6bb9fb9 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -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, ); } diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 8b8ee140c..a29a45263 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -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( diff --git a/src/components/image-editor/ImageCanvasTopbarView.test.tsx b/src/components/image-editor/ImageCanvasTopbarView.test.tsx index da67d9064..3d1c4928a 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.test.tsx @@ -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);