每日免费发放额为 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
@@ -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
);
}
/**
* 月度泥点的刷新时刻展示文案。
*