Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 87 additions & 14 deletions frontend/src/app/layout/app-header.test.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,75 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { MemoryRouter } from 'react-router'
import { 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'

import type { AuthTokens, UserApis } from '@/entities'
import { AuthSessionProvider } from '@/features/auth-session'
import { AppHeader } from './app-header'

afterEach(cleanup)
const user = {
id: '7',
email: 'reader@example.com',
nickname: 'Reader',
emailVerifiedAt: '2026-08-07T01:02:03Z',
statusCode: 0,
}

function tokens(): AuthTokens {
return { accessToken: 'access-token', refreshToken: 'rotated-refresh-token', user }
}

function createApis(): UserApis & Record<keyof UserApis, ReturnType<typeof vi.fn>> {
return {
sendCode: vi.fn(async () => undefined),
register: vi.fn(async () => tokens()),
login: vi.fn(async () => tokens()),
loginByCode: vi.fn(async () => tokens()),
refresh: vi.fn(async () => tokens()),
logout: vi.fn(async () => undefined),
me: vi.fn(async () => user),
changePassword: vi.fn(async () => undefined),
}
}

function LocationProbe() {
const location = useLocation()
return (
<output data-testid="location">{`${location.pathname}${location.search}${location.hash}`}</output>
)
}

function renderHeader(entry = '/', apis = createApis()) {
return {
apis,
...render(
<AuthSessionProvider apis={apis}>
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route
path="*"
element={
<>
<AppHeader />
<LocationProbe />
</>
}
/>
</Routes>
</MemoryRouter>
</AuthSessionProvider>,
),
}
}

afterEach(() => {
cleanup()
window.localStorage.clear()
})

describe('AppHeader', () => {
it('保留三个产品入口,并将工作流路由归入创作', () => {
render(
<MemoryRouter initialEntries={['/workflow-editor/run-1']}>
<AppHeader />
</MemoryRouter>,
)
renderHeader('/workflow-editor/run-1')

expect(screen.getByRole('link', { name: '返回 Windup 首页' }).getAttribute('href')).toBe('/')
expect(screen.getByRole('link', { name: '项目资产' }).getAttribute('href')).toBe('/projects')
Expand All @@ -22,14 +78,31 @@ describe('AppHeader', () => {
})

it('在首页只高亮首页一项', () => {
render(
<MemoryRouter initialEntries={['/']}>
<AppHeader />
</MemoryRouter>,
)
renderHeader()

expect(screen.getByRole('link', { name: '首页' }).getAttribute('aria-current')).toBe('page')
expect(screen.getByRole('link', { name: '项目资产' }).getAttribute('aria-current')).toBeNull()
expect(screen.getByRole('link', { name: '创作' }).getAttribute('aria-current')).toBeNull()
})

it('为访客提供可发现的登录入口并保留完整站内回跳地址', async () => {
renderHeader('/quick-start?mode=fast#brief')

const entry = await screen.findByRole('link', { name: '登录 / 注册' })
expect(entry.getAttribute('href')).toBe(
'/?account=login&returnTo=%2Fquick-start%3Fmode%3Dfast%23brief',
)
})

it('显示登录用户并在登出后回到首页访客态', async () => {
window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token')
const { apis } = renderHeader('/projects')

expect(await screen.findByText('Reader')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '退出登录' }))

await waitFor(() => expect(screen.getByTestId('location').textContent).toBe('/'))
expect(await screen.findByRole('link', { name: '登录 / 注册' })).toBeTruthy()
expect(apis.logout).toHaveBeenCalledWith('rotated-refresh-token')
})
})
115 changes: 83 additions & 32 deletions frontend/src/app/layout/app-header.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Link, useLocation } from 'react-router'
import { Link, useLocation, useNavigate } from 'react-router'

import { useAuthSession } from '@/features/auth-session'
Comment thread
huyanxius marked this conversation as resolved.

interface ProductNavigationItem {
to: string
Expand Down Expand Up @@ -53,19 +55,30 @@ function getWorkspaceLabel(pathname: string): { title: string; detail: string }
* 悬浮不占布局高度,页面顶部留白由页面或 PageContainer 自己让出。
*/
export function AppHeader() {
const { pathname } = useLocation()
const { pathname, search, hash } = useLocation()
const navigate = useNavigate()
const session = useAuthSession()
const workspace = getWorkspaceLabel(pathname)
const accountEntry = `/?${new URLSearchParams({
account: 'login',
returnTo: `${pathname}${search}${hash}`,
})}`

function signOut() {
const returnHome = () => navigate('/', { replace: true })
void session.logout().then(returnHome, returnHome)
}

return (
<header className="pointer-events-none fixed inset-x-0 top-3.5 z-50 flex items-start justify-between gap-2 px-3 text-[#1c231e] sm:gap-4 sm:px-[18px]">
<div className="pointer-events-auto flex min-h-[3.625rem] min-w-0 items-center gap-3 rounded-xl border border-[#171817]/14 bg-[#dfe3df] px-2.5 py-[7px] sm:min-w-[min(26rem,42vw)] sm:px-3.5">
<Link
to="/"
aria-label="返回 Windup 首页"
className="flex shrink-0 items-center gap-2 text-[#1c231e] focus-visible:rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#284331] md:border-r md:border-[#2d3b31]/12 md:pr-3"
className="flex min-h-11 min-w-11 shrink-0 items-center justify-center gap-2 text-[#1c231e] focus-visible:rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#284331] sm:min-w-0 sm:justify-start md:border-r md:border-[#2d3b31]/12 md:pr-3"
>
<img src="/windup-mark.svg" alt="" className="h-[1.6875rem] w-[1.6875rem]" />
<strong className="font-serif text-base leading-none">Windup</strong>
<strong className="hidden font-serif text-base leading-none sm:inline">Windup</strong>
</Link>

<span className="hidden min-w-0 gap-0.5 md:grid">
Expand All @@ -74,38 +87,76 @@ export function AppHeader() {
</span>
</div>

<nav
aria-label="产品导航"
className="pointer-events-auto flex min-h-[3.625rem] items-center gap-[3px] rounded-xl border border-[#171817]/14 bg-[#dfe3df] p-[7px_9px]"
>
{productNavigation.map((item) => {
const active = item.isActive(pathname)
<div className="pointer-events-auto flex min-h-[3.625rem] min-w-0 items-center gap-1 rounded-xl border border-[#171817]/14 bg-[#dfe3df] p-[7px]">
<nav aria-label="产品导航" className="flex min-w-0 items-center gap-[3px]">
{productNavigation.map((item) => {
const active = item.isActive(pathname)

return (
<Link
key={item.to}
to={item.to}
aria-label={item.label}
aria-current={active ? 'page' : undefined}
style={{ fontSize: '13px', fontWeight: 600 }}
className={`inline-flex min-h-11 items-center rounded-[0.5625rem] px-2.5 whitespace-nowrap transition-colors focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#284331] ${
active
? 'bg-[#dce9df] text-[#284331]'
: 'text-[#5b655d] hover:bg-[#e7eee8] hover:text-[#26372c]'
}`}
>
{item.compactLabel ? (
<>
<span className="hidden sm:inline">{item.label}</span>
<span className="sm:hidden">{item.compactLabel}</span>
</>
) : (
item.label
)}
</Link>
)
})}
</nav>

return (
<span aria-hidden="true" className="mx-0.5 h-7 w-px shrink-0 bg-[#2d3b31]/14" />

<div aria-label="账号" className="flex min-w-0 items-center gap-1">
{session.state.status === 'booting' ? (
<span
aria-label="正在恢复登录状态"
className="inline-grid min-h-11 min-w-11 place-items-center text-sm text-[#778078] sm:min-w-20"
>
</span>
) : session.state.status === 'guest' ? (
<Link
key={item.to}
to={item.to}
aria-label={item.label}
aria-current={active ? 'page' : undefined}
style={{ fontSize: '13px', fontWeight: 600 }}
className={`inline-flex min-h-[2.125rem] items-center rounded-[0.5625rem] px-2.5 whitespace-nowrap transition-colors focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#284331] ${
active
? 'bg-[#dce9df] text-[#284331]'
: 'text-[#5b655d] hover:bg-[#e7eee8] hover:text-[#26372c]'
}`}
to={accountEntry}
aria-label="登录 / 注册"
className="inline-flex min-h-11 items-center rounded-[0.5625rem] border border-[#2d3b31]/14 bg-white/35 px-3 text-[13px] font-semibold whitespace-nowrap text-[#34483a] transition-colors hover:border-[#2d3b31]/22 hover:bg-[#edf2ed] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#284331]"
>
{item.compactLabel ? (
<>
<span className="hidden sm:inline">{item.label}</span>
<span className="sm:hidden">{item.compactLabel}</span>
</>
) : (
item.label
)}
<span className="hidden sm:inline">登录 / 注册</span>
<span className="sm:hidden">登录</span>
</Link>
)
})}
</nav>
) : (
<div className="flex min-w-0 items-center overflow-hidden rounded-[0.5625rem] border border-[#2d3b31]/14 bg-white/35">
<span
title={session.state.user.email}
className="inline-flex min-h-11 max-w-16 items-center truncate px-3 text-xs font-semibold text-[#34483a] sm:max-w-28"
>
{session.state.user.nickname || session.state.user.email}
</span>
<button
type="button"
onClick={signOut}
aria-label="退出登录"
className="inline-flex min-h-11 min-w-11 items-center justify-center border-l border-[#2d3b31]/12 px-2.5 text-xs font-semibold text-[#68736a] transition-colors hover:bg-[#dce9df] hover:text-[#26372c] focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[#284331]"
>
退出
</button>
</div>
)}
</div>
</div>
</header>
)
}
5 changes: 1 addition & 4 deletions frontend/src/features/account-panel/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,13 +275,10 @@ describe('AppShell account panel host', () => {
it('does not require an auth context while the panel is closed', () => {
render(
<MemoryRouter initialEntries={['/']}>
<AppShell>
<p>当前页面</p>
</AppShell>
<AccountPanel />
</MemoryRouter>,
)

expect(screen.getByText('当前页面')).toBeTruthy()
expect(screen.queryByRole('dialog')).toBeNull()
})

Expand Down
9 changes: 6 additions & 3 deletions frontend/src/pages/playtest/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { MemoryRouter } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { AppRoutes } from '@/app'
import { GuestAuthSession } from '@/test/auth-session'
import { createProjectAssetsBackend } from '@/test/project-assets-backend'

afterEach(() => {
Expand All @@ -26,9 +27,11 @@ function renderPlaytest(path: string, fetchFn?: typeof globalThis.fetch) {
vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test')
vi.stubGlobal('fetch', fetchFn ?? createProjectAssetsBackend().fetch)
return render(
<MemoryRouter initialEntries={[path]}>
<AppRoutes />
</MemoryRouter>,
<GuestAuthSession>
<MemoryRouter initialEntries={[path]}>
<AppRoutes />
</MemoryRouter>
</GuestAuthSession>,
)
}

Expand Down
Loading