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