feat(web): refine app shell layout polish

变更摘要:

- 优化应用壳布局,统一 search 与 dashboard 的内容宽度与切换时机

- 恢复 search 与 dashboard 页面的上浮入场动画,并保留 sticky header 阴影效果

- 将本次新增前端测试收纳到 web/test/app,并纳入 TypeScript 检查范围

- 同步调整 landing quick start 文案字号与本地开发代理地址

关键文件:

- web/src/app/layout.tsx

- web/src/app/layout-main-content.ts

- web/src/app/page-shell-style.ts

- web/test/app/layout-main-content.test.ts

- web/vite.config.ts
This commit is contained in:
dongmucat 2026-03-19 17:27:56 +08:00 committed by Xudong Sun
parent f2cf22b3e1
commit d6913d28aa
12 changed files with 172 additions and 13 deletions

View file

@ -0,0 +1,10 @@
import { cn } from '@/shared/lib/utils'
export const APP_HEADER_BASE_CLASS_NAME =
'sticky top-0 z-50 flex items-center justify-between border-b bg-white px-6 py-4 transition-shadow duration-200 md:px-12'
export const APP_HEADER_ELEVATED_CLASS_NAME = 'shadow-[0_10px_24px_-20px_rgba(15,23,42,0.32)]'
export function getAppHeaderClassName(isElevated: boolean): string {
return cn(APP_HEADER_BASE_CLASS_NAME, isElevated && APP_HEADER_ELEVATED_CLASS_NAME)
}

View file

@ -0,0 +1,45 @@
export const LANDING_MAIN_CLASS_NAME = 'flex-1 relative z-10'
export const DEFAULT_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-6 py-10 md:px-12'
export const CENTERED_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-4 py-8 sm:px-6 md:px-8 md:py-10 lg:px-10 xl:px-14 2xl:px-20'
export const CENTERED_SEARCH_CONTENT_CLASS_NAME = 'mx-auto w-full max-w-[1200px]'
export const CENTERED_DASHBOARD_CONTENT_CLASS_NAME = 'mx-auto w-full max-w-[1200px]'
interface AppMainContentLayout {
mainClassName: string
contentClassName: string
}
export function resolveAppMainContentPathname(
pathname: string,
resolvedPathname?: string,
): string {
return resolvedPathname ?? pathname
}
export function getAppMainContentLayout(pathname: string): AppMainContentLayout {
if (pathname === '/') {
return {
mainClassName: LANDING_MAIN_CLASS_NAME,
contentClassName: '',
}
}
if (pathname === '/search') {
return {
mainClassName: CENTERED_MAIN_CLASS_NAME,
contentClassName: CENTERED_SEARCH_CONTENT_CLASS_NAME,
}
}
if (pathname === '/dashboard' || pathname.startsWith('/dashboard/')) {
return {
mainClassName: CENTERED_MAIN_CLASS_NAME,
contentClassName: CENTERED_DASHBOARD_CONTENT_CLASS_NAME,
}
}
return {
mainClassName: DEFAULT_MAIN_CLASS_NAME,
contentClassName: '',
}
}

View file

@ -1,9 +1,11 @@
import { Suspense } from 'react'
import { Suspense, useEffect, useState } from 'react'
import { Outlet, Link, useRouterState } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useAuth } from '@/features/auth/use-auth'
import { LanguageSwitcher } from '@/shared/components/language-switcher'
import { UserMenu } from '@/shared/components/user-menu'
import { getAppHeaderClassName } from './layout-header-style'
import { getAppMainContentLayout, resolveAppMainContentPathname } from './layout-main-content'
/**
* Application shell shared by all routed pages.
@ -13,8 +15,29 @@ import { UserMenu } from '@/shared/components/user-menu'
*/
export function Layout() {
const { t } = useTranslation()
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { pathname, resolvedPathname } = useRouterState({
select: (s) => ({
pathname: s.location.pathname,
resolvedPathname: s.resolvedLocation?.pathname,
}),
})
const { user, isLoading } = useAuth()
const [isHeaderElevated, setIsHeaderElevated] = useState(false)
const contentLayoutPathname = resolveAppMainContentPathname(pathname, resolvedPathname)
const mainContentLayout = getAppMainContentLayout(contentLayoutPathname)
useEffect(() => {
const updateHeaderElevation = () => {
setIsHeaderElevated(window.scrollY > 0)
}
updateHeaderElevation()
window.addEventListener('scroll', updateHeaderElevation, { passive: true })
return () => {
window.removeEventListener('scroll', updateHeaderElevation)
}
}, [])
const navItems: Array<{
label: string
@ -36,7 +59,7 @@ export function Layout() {
}
return (
<div className="min-h-screen flex flex-col relative overflow-x-hidden" style={{ background: 'var(--bg-page, hsl(var(--background)))' }}>
<div className="min-h-screen flex flex-col relative overflow-x-clip" style={{ background: 'var(--bg-page, hsl(var(--background)))' }}>
{/* Decorative gradient orb */}
<div
className="absolute top-0 right-0 w-[600px] h-[500px] rounded-full opacity-90 pointer-events-none z-0"
@ -47,7 +70,7 @@ export function Layout() {
/>
{/* Header */}
<header className="relative z-50 flex items-center justify-between px-6 py-4 md:px-12 bg-white border-b" style={{ borderColor: 'hsl(var(--border))' }}>
<header className={getAppHeaderClassName(isHeaderElevated)} style={{ borderColor: 'hsl(var(--border))' }}>
<Link to="/" className="text-xl font-semibold tracking-tight text-brand-gradient">
SkillHub
</Link>
@ -64,7 +87,7 @@ export function Layout() {
className={
active
? 'px-4 py-1.5 rounded-full bg-brand-gradient text-white shadow-sm'
: 'hover:opacity-80 transition-opacity'
: 'hover:opacity-80 transition-opacity duration-150'
}
>
{item.label}
@ -90,7 +113,7 @@ export function Layout() {
</header>
{/* Main content */}
<main className={`flex-1 relative z-10${pathname === '/' ? '' : ' px-6 md:px-12 py-10'}`}>
<main className={mainContentLayout.mainClassName}>
<Suspense
fallback={
<div className="space-y-4 animate-fade-up">
@ -100,7 +123,9 @@ export function Layout() {
</div>
}
>
<Outlet />
<div className={mainContentLayout.contentClassName}>
<Outlet />
</div>
</Suspense>
</main>

View file

@ -0,0 +1 @@
export const APP_SHELL_PAGE_CLASS_NAME = 'space-y-8 animate-fade-up'

View file

@ -7,6 +7,7 @@ import { canViewGovernanceCenter } from '@/shared/lib/governance-access'
import { getHeadlineVersion } from '@/shared/lib/skill-lifecycle'
import { TokenList } from '@/features/token/token-list'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style'
import { limitPreviewItems } from './dashboard-preview'
const DASHBOARD_PREVIEW_LIMIT = 5
@ -26,7 +27,7 @@ export function DashboardPage() {
const skillPreview = limitPreviewItems<SkillSummary>(skillPage?.items ?? [], DASHBOARD_PREVIEW_LIMIT)
return (
<div className="space-y-8 animate-fade-up">
<div className={APP_SHELL_PAGE_CLASS_NAME}>
<div>
<h1 className="text-4xl font-bold" style={{ color: 'hsl(var(--foreground))' }}>{t('dashboard.title')}</h1>
<p className="mt-2 text-lg" style={{ color: 'hsl(var(--text-secondary))' }}>

View file

@ -12,6 +12,7 @@ import { Pagination } from '@/shared/components/pagination'
import { useMyStars, useSearchSkills } from '@/shared/hooks/use-skill-queries'
import { normalizeSearchQuery } from '@/shared/lib/search-query'
import { Button } from '@/shared/ui/button'
import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style'
const PAGE_SIZE = 12
@ -149,7 +150,7 @@ export function SearchPage() {
const resultCount = starredOnly ? filteredStarredSkills.length : (data?.total ?? 0)
return (
<div className="space-y-8 animate-fade-up">
<div className={APP_SHELL_PAGE_CLASS_NAME}>
{/* Search Bar */}
<div className="max-w-3xl mx-auto">
<SearchBar

View file

@ -108,7 +108,7 @@ export function LandingQuickStartSection() {
<div className="px-4 pb-4 pt-8 md:px-8 md:pb-6 md:pt-9">
<p
className="mx-auto mb-6 max-w-xl text-center text-xl font-medium leading-relaxed md:text-[1.75rem]"
className="mx-auto mb-6 max-w-xl text-center text-base font-medium leading-relaxed md:text-lg"
style={{ color: 'hsl(var(--foreground))' }}
>
{currentTab.description}

View file

@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { APP_HEADER_ELEVATED_CLASS_NAME, getAppHeaderClassName } from '../../src/app/layout-header-style'
describe('getAppHeaderClassName', () => {
it('keeps the header flat before the page starts scrolling', () => {
expect(getAppHeaderClassName(false)).not.toContain(APP_HEADER_ELEVATED_CLASS_NAME)
})
it('adds a subtle drop shadow after the header becomes sticky', () => {
expect(getAppHeaderClassName(true)).toContain(APP_HEADER_ELEVATED_CLASS_NAME)
})
})

View file

@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import {
CENTERED_DASHBOARD_CONTENT_CLASS_NAME,
CENTERED_MAIN_CLASS_NAME,
CENTERED_SEARCH_CONTENT_CLASS_NAME,
DEFAULT_MAIN_CLASS_NAME,
getAppMainContentLayout,
resolveAppMainContentPathname,
} from '../../src/app/layout-main-content'
describe('getAppMainContentLayout', () => {
it('keeps the landing page full width without the app-shell padding wrapper', () => {
expect(getAppMainContentLayout('/')).toEqual({
mainClassName: 'flex-1 relative z-10',
contentClassName: '',
})
})
it('centers the search page content with roomier responsive gutters', () => {
const layout = getAppMainContentLayout('/search')
expect(layout).toEqual({
mainClassName: CENTERED_MAIN_CLASS_NAME,
contentClassName: CENTERED_SEARCH_CONTENT_CLASS_NAME,
})
expect(layout.contentClassName).toContain('max-w-[1200px]')
})
it('centers all dashboard sub-pages within a slightly narrower content frame', () => {
const layout = getAppMainContentLayout('/dashboard/skills')
expect(layout).toEqual({
mainClassName: CENTERED_MAIN_CLASS_NAME,
contentClassName: CENTERED_DASHBOARD_CONTENT_CLASS_NAME,
})
expect(layout.contentClassName).toContain('max-w-[1200px]')
})
it('leaves other non-landing routes on the default full-width app content layout', () => {
expect(getAppMainContentLayout('/space/acme/demo')).toEqual({
mainClassName: DEFAULT_MAIN_CLASS_NAME,
contentClassName: '',
})
})
})
describe('resolveAppMainContentPathname', () => {
it('sticks with the last resolved route while the next page is still pending', () => {
expect(resolveAppMainContentPathname('/dashboard', '/search')).toBe('/search')
})
it('falls back to the live location when no resolved route is available yet', () => {
expect(resolveAppMainContentPathname('/search')).toBe('/search')
})
})

View file

@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest'
import { APP_SHELL_PAGE_CLASS_NAME } from '../../src/app/page-shell-style'
describe('APP_SHELL_PAGE_CLASS_NAME', () => {
it('keeps the upward float-in animation on stable app-shell pages', () => {
expect(APP_SHELL_PAGE_CLASS_NAME).toContain('space-y-8')
expect(APP_SHELL_PAGE_CLASS_NAME).toContain('animate-fade-up')
})
})

View file

@ -20,5 +20,5 @@
"@/*": ["./src/*"]
}
},
"include": ["src"]
"include": ["src", "test"]
}

View file

@ -17,11 +17,11 @@ export default defineConfig({
},
proxy: {
'/api': {
target: 'http://localhost:8080',
target: 'http://10.1.203.231:8080',
changeOrigin: true,
},
'/oauth2': {
target: 'http://localhost:8080',
target: 'http://10.1.203.231:8080',
changeOrigin: true,
},
},