diff --git a/frontend/src/app/layout/app-header.test.tsx b/frontend/src/app/layout/app-header.test.tsx index 39088912..1fcffa03 100644 --- a/frontend/src/app/layout/app-header.test.tsx +++ b/frontend/src/app/layout/app-header.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { MemoryRouter, Route, Routes, useLocation } from 'react-router' @@ -96,6 +96,7 @@ function renderHeader( afterEach(() => { cleanup() window.localStorage.clear() + window.sessionStorage.clear() window.history.replaceState({ idx: 0 }, '') }) @@ -223,6 +224,52 @@ describe('AppHeader', () => { expect(apis.logout).toHaveBeenCalledWith('rotated-refresh-token') }) + it('登录工作台后显示一次邀请奖励提示,打开账号菜单时收起', async () => { + window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token') + renderHeader('/workspace') + + expect(await screen.findByRole('status', { name: '邀请奖励提示' })).toBeTruthy() + fireEvent.click(await screen.findByRole('button', { name: '打开账号菜单' })) + + expect(screen.queryByRole('status', { name: '邀请奖励提示' })).toBeNull() + }) + + it('邀请提示可以直达邀请奖励,并在关闭或十五秒后收起', async () => { + window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token') + const timeoutSpy = vi.spyOn(window, 'setTimeout') + renderHeader('/workspace') + + const hint = await screen.findByRole('status', { name: '邀请奖励提示' }) + expect(screen.getByRole('link', { name: '去看看邀请奖励' }).getAttribute('href')).toBe( + '/account?section=invite', + ) + const timerCall = timeoutSpy.mock.calls.find(([, delay]) => delay === 15_000) + expect(timerCall).toBeTruthy() + const timerCallback = timerCall?.[0] + expect(typeof timerCallback).toBe('function') + act(() => { + if (typeof timerCallback === 'function') timerCallback() + }) + expect(screen.queryByRole('status', { name: '邀请奖励提示' })).toBeNull() + + window.sessionStorage.clear() + cleanup() + renderHeader('/workspace') + expect(await screen.findByRole('status', { name: '邀请奖励提示' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '关闭邀请奖励提示' })) + expect(hint.isConnected).toBe(false) + }) + + it('当前登录会话离开工作台后不重复显示', async () => { + window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token') + renderHeader('/workspace') + expect(await screen.findByRole('status', { name: '邀请奖励提示' })).toBeTruthy() + + fireEvent.click(screen.getByRole('link', { name: '项目资产' })) + fireEvent.click(screen.getByRole('link', { name: '首页' })) + expect(screen.queryByRole('status', { name: '邀请奖励提示' })).toBeNull() + }) + it('远端退出失败时仍清除本地会话并返回首页', async () => { window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token') const apis = createApis() diff --git a/frontend/src/app/layout/app-header.tsx b/frontend/src/app/layout/app-header.tsx index 357340fd..76bf54e3 100644 --- a/frontend/src/app/layout/app-header.tsx +++ b/frontend/src/app/layout/app-header.tsx @@ -3,7 +3,7 @@ import { Link, useLocation, useNavigate } from 'react-router' import { quotaApis as defaultQuotaApis } from '@/entities' import type { QuotaApis } from '@/entities' -import { useAuthSession } from '@/features/auth-session' +import { AUTH_SESSION_STORAGE_PREFIX, useAuthSession } from '@/features/auth-session' import { useQuotaBalance } from '@/features/quota' import { PageBackButton } from './page-back-button' @@ -22,6 +22,7 @@ export interface AppHeaderProps { } const accountMenuExitDurationMs = 260 +const inviteHintStorageKey = `${AUTH_SESSION_STORAGE_PREFIX}invite-hint-seen.v1` /** 四个入口对应四种去处:回首页、看资产、做新东西、核验已完成的造型。 */ const productNavigation: ProductNavigationItem[] = [ @@ -79,6 +80,7 @@ export function AppHeader({ quotaApis = defaultQuotaApis }: AppHeaderProps = {}) const navigate = useNavigate() const session = useAuthSession() const [accountMenuState, setAccountMenuState] = useState('closed') + const [inviteHintVisible, setInviteHintVisible] = useState(false) const accountMenuOpen = accountMenuState === 'open' const creditBalance = useQuotaBalance( accountMenuState !== 'closed' && session.state.status === 'authenticated', @@ -99,15 +101,40 @@ export function AppHeader({ quotaApis = defaultQuotaApis }: AppHeaderProps = {}) return () => window.clearTimeout(timer) }, [accountMenuState]) + useEffect(() => { + if (pathname !== '/workspace' || session.state.status !== 'authenticated') { + setInviteHintVisible(false) + return + } + + if (window.sessionStorage.getItem(inviteHintStorageKey) === '1') return + + window.sessionStorage.setItem(inviteHintStorageKey, '1') + setInviteHintVisible(true) + + const timer = window.setTimeout(() => { + setInviteHintVisible(false) + }, 15_000) + + return () => window.clearTimeout(timer) + }, [pathname, session.state.status]) + function signOut() { + window.sessionStorage.removeItem(inviteHintStorageKey) const returnHome = () => navigate('/', { replace: true }) void session.logout().then(returnHome, returnHome) } function toggleAccountMenu() { + dismissInviteHint() setAccountMenuState((state) => (state === 'open' ? 'closing' : 'open')) } + function dismissInviteHint() { + window.sessionStorage.setItem(inviteHintStorageKey, '1') + setInviteHintVisible(false) + } + function finishAccountMenuMotion() { if (accountMenuState === 'closing') { setAccountMenuState('closed') @@ -210,6 +237,36 @@ export function AppHeader({ quotaApis = defaultQuotaApis }: AppHeaderProps = {}) ) : ( <> + {inviteHintVisible ? ( +
+
+
+

+ 邀请好友,双方各得 200 积分 +

+ + 去看看邀请奖励 + +
+ +
+
+ ) : null}