每日免费发放额为 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:
@@ -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}
|
||||
|
||||
@@ -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 超出上限"),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user