diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 0322d9e9d..0cc109130 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -553,6 +553,8 @@ export interface AdminUpsertProfileRedeemCodeRequest { enabled: boolean; allowedUserIds: string[]; allowedPublicUserCodes: string[]; + startsAt?: string | null; + expiresAt?: string | null; } export interface AdminUpsertProfileInviteCodeRequest { @@ -614,6 +616,8 @@ export interface ProfileRedeemCodeAdminResponse { globalUsedCount: number; enabled: boolean; allowedUserIds: string[]; + startsAt?: string | null; + expiresAt?: string | null; createdBy: string; createdAt: string; updatedAt: string; diff --git a/apps/admin-web/src/pages/AdminRedeemCodePage.test.tsx b/apps/admin-web/src/pages/AdminRedeemCodePage.test.tsx new file mode 100644 index 000000000..2e3884bdd --- /dev/null +++ b/apps/admin-web/src/pages/AdminRedeemCodePage.test.tsx @@ -0,0 +1,166 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, expect, test, vi } from 'vitest'; + +import { + disableProfileRedeemCode, + listProfileRedeemCodes, + upsertProfileRedeemCode, +} from '../api/adminApiClient'; +import type { ProfileRedeemCodeAdminResponse } from '../api/adminApiTypes'; +import { AdminRedeemCodePage } from './AdminRedeemCodePage'; + +vi.mock('../api/adminApiClient', () => ({ + disableProfileRedeemCode: vi.fn(), + formatAdminApiError: vi.fn((error: unknown) => + error instanceof Error ? error.message : '请求失败', + ), + isAdminApiError: vi.fn(() => false), + listProfileRedeemCodes: vi.fn(), + upsertProfileRedeemCode: vi.fn(), +})); + +const baseEntry: ProfileRedeemCodeAdminResponse = { + code: 'LONG-LIVED', + mode: 'public', + rewardPoints: 100, + maxUses: 1, + globalUsedCount: 0, + enabled: true, + allowedUserIds: [], + startsAt: null, + expiresAt: null, + createdBy: 'admin', + createdAt: '2026-07-13T01:00:00Z', + updatedAt: '2026-07-13T01:00:00Z', +}; + +const entries: ProfileRedeemCodeAdminResponse[] = [ + baseEntry, + { + ...baseEntry, + code: 'PENDING', + startsAt: '2999-01-01T00:00:00Z', + }, + { + ...baseEntry, + code: 'EXPIRED', + expiresAt: '2000-01-01T00:00:00Z', + }, + { + ...baseEntry, + code: 'ACTIVE', + startsAt: '2000-01-01T00:00:00Z', + expiresAt: '2999-01-01T00:00:00Z', + }, + { + ...baseEntry, + code: 'DISABLED', + enabled: false, + startsAt: '2000-01-01T00:00:00Z', + expiresAt: '2999-01-01T00:00:00Z', + }, +]; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(listProfileRedeemCodes).mockResolvedValue({ + entries, + operations: [], + }); + vi.mocked(upsertProfileRedeemCode).mockResolvedValue(baseEntry); + vi.mocked(disableProfileRedeemCode).mockResolvedValue({ + ...baseEntry, + enabled: false, + }); +}); + +test('兑换码列表展示生效状态与日期范围', async () => { + render(); + + expect((await rowForCode('LONG-LIVED')).textContent).toContain('长期有效'); + expect((await rowForCode('LONG-LIVED')).textContent).toContain('立即 / 长期'); + expect((await rowForCode('PENDING')).textContent).toContain('未生效'); + expect((await rowForCode('EXPIRED')).textContent).toContain('已过期'); + expect((await rowForCode('ACTIVE')).textContent).toContain('有效'); + expect((await rowForCode('DISABLED')).textContent).toContain('停用'); +}); + +test('点击兑换码回填本地日期输入', async () => { + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole('button', { name: 'ACTIVE' })); + + expect((screen.getByLabelText('开始时间') as HTMLInputElement).value).toBe( + toLocalInputValue('2000-01-01T00:00:00Z'), + ); + expect((screen.getByLabelText('截止时间') as HTMLInputElement).value).toBe( + toLocalInputValue('2999-01-01T00:00:00Z'), + ); +}); + +test('兑换码拒绝截止时间不晚于开始时间的配置', async () => { + render(); + await screen.findByRole('button', { name: 'LONG-LIVED' }); + + fireEvent.change(screen.getByLabelText('Code'), { + target: { value: 'INVALID' }, + }); + fireEvent.change(screen.getByLabelText('开始时间'), { + target: { value: '2026-07-13T10:00' }, + }); + fireEvent.change(screen.getByLabelText('截止时间'), { + target: { value: '2026-07-13T10:00' }, + }); + + expect(screen.getByText('截止时间必须晚于开始时间')).toBeTruthy(); + expect( + (screen.getByRole('button', { name: '保存' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect(upsertProfileRedeemCode).not.toHaveBeenCalled(); +}); + +test('兑换码保存时把本地时间转换为 ISO 并保留空边界', async () => { + const user = userEvent.setup(); + render(); + await screen.findByRole('button', { name: 'LONG-LIVED' }); + + fireEvent.change(screen.getByLabelText('Code'), { + target: { value: 'WINDOWED' }, + }); + fireEvent.change(screen.getByLabelText('开始时间'), { + target: { value: '2026-07-13T10:30' }, + }); + await user.click(screen.getByRole('button', { name: '保存' })); + await user.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => { + expect(upsertProfileRedeemCode).toHaveBeenCalledWith( + 'admin-token', + expect.objectContaining({ + code: 'WINDOWED', + startsAt: new Date('2026-07-13T10:30').toISOString(), + expiresAt: null, + }), + ); + }); +}); + +async function rowForCode(code: string) { + const button = await screen.findByRole('button', { name: code }); + const row = button.closest('tr'); + if (!row) { + throw new Error(`未找到兑换码 ${code} 所在行`); + } + return row; +} + +function toLocalInputValue(value: string) { + const date = new Date(value); + const offsetMs = date.getTimezoneOffset() * 60 * 1000; + return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16); +} diff --git a/apps/admin-web/src/pages/AdminRedeemCodePage.tsx b/apps/admin-web/src/pages/AdminRedeemCodePage.tsx index f1df9c315..6afe195d5 100644 --- a/apps/admin-web/src/pages/AdminRedeemCodePage.tsx +++ b/apps/admin-web/src/pages/AdminRedeemCodePage.tsx @@ -34,6 +34,8 @@ export function AdminRedeemCodePage({ const [rewardPoints, setRewardPoints] = useState('100'); const [maxUses, setMaxUses] = useState('1'); const [enabled, setEnabled] = useState(true); + const [startsAt, setStartsAt] = useState(''); + const [expiresAt, setExpiresAt] = useState(''); const [allowedUserIds, setAllowedUserIds] = useState(''); const [allowedPublicUserCodes, setAllowedPublicUserCodes] = useState(''); const [disableCode, setDisableCode] = useState(''); @@ -73,6 +75,12 @@ export function AdminRedeemCodePage({ } setErrorMessage(''); + const validityError = validateValidityWindow(startsAt, expiresAt); + if (validityError) { + setErrorMessage(validityError); + return; + } + const confirmed = await confirmWrite({ action: '保存兑换码', target: code.trim(), @@ -92,6 +100,8 @@ export function AdminRedeemCodePage({ allowedUserIds: mode === 'private' ? splitLines(allowedUserIds) : [], allowedPublicUserCodes: mode === 'private' ? splitLines(allowedPublicUserCodes) : [], + startsAt: startsAt ? toIsoDateTime(startsAt) : null, + expiresAt: expiresAt ? toIsoDateTime(expiresAt) : null, }); fillForm(response); await refreshRedeemCodes(); @@ -137,11 +147,15 @@ export function AdminRedeemCodePage({ setRewardPoints(String(entry.rewardPoints)); setMaxUses(String(entry.maxUses)); setEnabled(entry.enabled); + setStartsAt(toDateTimeLocalValue(entry.startsAt)); + setExpiresAt(toDateTimeLocalValue(entry.expiresAt)); setAllowedUserIds(entry.allowedUserIds.join('\n')); setAllowedPublicUserCodes(''); setDisableCode(entry.code); } + const validityError = validateValidityWindow(startsAt, expiresAt); + return (
@@ -222,6 +236,25 @@ export function AdminRedeemCodePage({
+
+ + +
+ {mode === 'private' ? (
) : null} + {validityError && validityError !== errorMessage ? ( +
+ {validityError} +
+ ) : null}