mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
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:
parent
f2cf22b3e1
commit
d6913d28aa
12 changed files with 172 additions and 13 deletions
10
web/src/app/layout-header-style.ts
Normal file
10
web/src/app/layout-header-style.ts
Normal 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)
|
||||
}
|
||||
45
web/src/app/layout-main-content.ts
Normal file
45
web/src/app/layout-main-content.ts
Normal 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: '',
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
1
web/src/app/page-shell-style.ts
Normal file
1
web/src/app/page-shell-style.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const APP_SHELL_PAGE_CLASS_NAME = 'space-y-8 animate-fade-up'
|
||||
|
|
@ -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))' }}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
12
web/test/app/layout-header-style.test.ts
Normal file
12
web/test/app/layout-header-style.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
55
web/test/app/layout-main-content.test.ts
Normal file
55
web/test/app/layout-main-content.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
9
web/test/app/page-shell-style.test.ts
Normal file
9
web/test/app/page-shell-style.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -20,5 +20,5 @@
|
|||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue