feat: 统一品牌标识、关于页面三卡片布局与版本诊断功能
- 统一品牌为 Nex:侧边栏、托盘 tooltip、HTML 标题、favicon (PNG 替代 SVG) - 重构关于页面为三卡片布局(品牌/版本/链接),版本状态 Tag 绝对定位右上角 - 新增 GET /api/version 后端接口,返回 version/commit/build_time - 新增前端版本一致性诊断:匹配/不匹配/不可判断三种状态 - 同步 delta specs 到主 specs 并归档变更
This commit is contained in:
30
frontend/src/__tests__/api/version.test.ts
Normal file
30
frontend/src/__tests__/api/version.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'
|
||||
import { getBackendVersion } from '@/api/version'
|
||||
|
||||
describe('version API', () => {
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }))
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
|
||||
it('fetches backend version and converts build_time to buildTime', async () => {
|
||||
server.use(
|
||||
http.get('http://localhost:3000/api/version', () => {
|
||||
return HttpResponse.json({
|
||||
version: '0.1.0',
|
||||
commit: 'abc1234',
|
||||
build_time: '2026-05-05T00:00:00Z',
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
await expect(getBackendVersion()).resolves.toEqual({
|
||||
version: '0.1.0',
|
||||
commit: 'abc1234',
|
||||
buildTime: '2026-05-05T00:00:00Z',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,25 +1,37 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { BrowserRouter } from 'react-router'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
|
||||
const renderWithRouter = (component: React.ReactNode) => {
|
||||
return render(<BrowserRouter>{component}</BrowserRouter>)
|
||||
return render(<MemoryRouter initialEntries={['/providers']}>{component}</MemoryRouter>)
|
||||
}
|
||||
|
||||
describe('AppLayout', () => {
|
||||
it('renders sidebar with app name', () => {
|
||||
renderWithRouter(<AppLayout />)
|
||||
|
||||
const appNames = screen.getAllByText('AI Gateway')
|
||||
expect(appNames.length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Nex')).toBeInTheDocument()
|
||||
expect(screen.getByAltText('Nex logo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps logo visible when sidebar is collapsed', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithRouter(<AppLayout />)
|
||||
|
||||
await user.click(screen.getByLabelText('收起侧边栏'))
|
||||
|
||||
expect(screen.getByAltText('Nex logo')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Nex')).not.toBeInTheDocument()
|
||||
expect(screen.getByLabelText('展开侧边栏')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders navigation menu items', () => {
|
||||
renderWithRouter(<AppLayout />)
|
||||
|
||||
expect(screen.getByText('供应商管理')).toBeInTheDocument()
|
||||
expect(screen.getByText('用量统计')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('供应商管理').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('用量统计').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('renders settings menu item', () => {
|
||||
@@ -28,6 +40,12 @@ describe('AppLayout', () => {
|
||||
expect(screen.getByText('设置')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders about menu item', () => {
|
||||
renderWithRouter(<AppLayout />)
|
||||
|
||||
expect(screen.getByText('关于')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders content outlet', () => {
|
||||
const { container } = renderWithRouter(<AppLayout />)
|
||||
|
||||
|
||||
33
frontend/src/__tests__/hooks/useVersion.test.tsx
Normal file
33
frontend/src/__tests__/hooks/useVersion.test.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import React from 'react'
|
||||
import { useBackendVersion } from '@/hooks/useVersion'
|
||||
|
||||
const server = setupServer(
|
||||
http.get('/api/version', () => {
|
||||
return HttpResponse.json({ version: '0.1.0', commit: 'abc1234', build_time: '2026-05-05T00:00:00Z' })
|
||||
})
|
||||
)
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen())
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
|
||||
describe('useBackendVersion', () => {
|
||||
it('fetches backend version', async () => {
|
||||
const { result } = renderHook(() => useBackendVersion(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
|
||||
expect(result.current.data).toEqual({ version: '0.1.0', commit: 'abc1234', buildTime: '2026-05-05T00:00:00Z' })
|
||||
})
|
||||
})
|
||||
88
frontend/src/__tests__/pages/About.test.tsx
Normal file
88
frontend/src/__tests__/pages/About.test.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useBackendVersion } from '@/hooks/useVersion'
|
||||
import AboutPage from '@/pages/About'
|
||||
|
||||
vi.mock('@/hooks/useVersion', () => ({
|
||||
useBackendVersion: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/constants/app', () => ({
|
||||
APP_NAME: 'Nex',
|
||||
APP_DESCRIPTION: 'AI Gateway - 统一的大模型 API 网关',
|
||||
APP_WEBSITE: 'https://github.com/nex/gateway',
|
||||
APP_VERSION: '0.1.0',
|
||||
}))
|
||||
|
||||
const mockUseBackendVersion = useBackendVersion as ReturnType<typeof vi.fn>
|
||||
|
||||
describe('AboutPage', () => {
|
||||
beforeEach(() => {
|
||||
mockUseBackendVersion.mockReturnValue({
|
||||
data: { version: '0.1.0', commit: 'abc1234', buildTime: '2026-05-05T00:00:00Z' },
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useBackendVersion>)
|
||||
})
|
||||
|
||||
it('renders brand, description and links', () => {
|
||||
render(<AboutPage />)
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Nex' })).toBeInTheDocument()
|
||||
expect(screen.getByText('AI Gateway - 统一的大模型 API 网关')).toBeInTheDocument()
|
||||
expect(screen.getByText('GitHub')).toHaveAttribute('href', 'https://github.com/nex/gateway')
|
||||
})
|
||||
|
||||
it('shows frontend and backend versions', () => {
|
||||
render(<AboutPage />)
|
||||
|
||||
expect(screen.getByText('前端版本')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('0.1.0').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('abc1234')).toBeInTheDocument()
|
||||
expect(screen.getByText('2026-05-05T00:00:00Z')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows matched status', () => {
|
||||
render(<AboutPage />)
|
||||
|
||||
expect(screen.getByText('版本一致')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows mismatched status', () => {
|
||||
mockUseBackendVersion.mockReturnValue({
|
||||
data: { version: '0.2.0', commit: 'abc1234', buildTime: '2026-05-05T00:00:00Z' },
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useBackendVersion>)
|
||||
|
||||
render(<AboutPage />)
|
||||
|
||||
expect(screen.getByText('版本不一致')).toBeInTheDocument()
|
||||
expect(screen.getByText(/用于部署诊断/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows unknown status for dev backend version', () => {
|
||||
mockUseBackendVersion.mockReturnValue({
|
||||
data: { version: 'dev', commit: 'unknown', buildTime: 'unknown' },
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useBackendVersion>)
|
||||
|
||||
render(<AboutPage />)
|
||||
|
||||
expect(screen.getByText('无法判断版本')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows unavailable status on backend request failure', () => {
|
||||
mockUseBackendVersion.mockReturnValue({
|
||||
data: undefined,
|
||||
isError: true,
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useBackendVersion>)
|
||||
|
||||
render(<AboutPage />)
|
||||
|
||||
expect(screen.getByText('无法获取后端版本')).toBeInTheDocument()
|
||||
expect(screen.getByText(/后端版本接口暂时不可用/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
21
frontend/src/__tests__/utils/version.test.ts
Normal file
21
frontend/src/__tests__/utils/version.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getVersionStatus } from '@/utils/version'
|
||||
|
||||
describe('getVersionStatus', () => {
|
||||
it('returns matched when versions are equal', () => {
|
||||
expect(getVersionStatus('1.2.3', { version: '1.2.3', commit: 'abc', buildTime: 'now' }).kind).toBe('matched')
|
||||
})
|
||||
|
||||
it('returns mismatched when release versions differ', () => {
|
||||
expect(getVersionStatus('1.2.3', { version: '1.2.4', commit: 'abc', buildTime: 'now' }).kind).toBe('mismatched')
|
||||
})
|
||||
|
||||
it('returns unknown for dev or unknown versions', () => {
|
||||
expect(getVersionStatus('dev', { version: '1.2.3', commit: 'abc', buildTime: 'now' }).kind).toBe('unknown')
|
||||
expect(getVersionStatus('1.2.3', { version: 'unknown', commit: 'abc', buildTime: 'now' }).kind).toBe('unknown')
|
||||
})
|
||||
|
||||
it('returns unavailable on request failure', () => {
|
||||
expect(getVersionStatus('1.2.3', undefined, true).kind).toBe('unavailable')
|
||||
})
|
||||
})
|
||||
6
frontend/src/api/version.ts
Normal file
6
frontend/src/api/version.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { BackendVersion } from '@/types'
|
||||
import { request } from './client'
|
||||
|
||||
export async function getBackendVersion(): Promise<BackendVersion> {
|
||||
return request<BackendVersion>('GET', '/api/version')
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ChevronRightIcon,
|
||||
} from 'tdesign-icons-react'
|
||||
import { Layout, Menu, Button } from 'tdesign-react'
|
||||
import { APP_NAME } from '@/constants/app'
|
||||
|
||||
const { MenuItem } = Menu
|
||||
|
||||
@@ -22,7 +23,7 @@ export function AppLayout() {
|
||||
if (location.pathname === '/stats') return '用量统计'
|
||||
if (location.pathname === '/settings') return '设置'
|
||||
if (location.pathname === '/about') return '关于'
|
||||
return 'AI Gateway'
|
||||
return APP_NAME
|
||||
}
|
||||
|
||||
const asideWidth = collapsed ? '64px' : '232px'
|
||||
@@ -52,15 +53,18 @@ export function AppLayout() {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{!collapsed && 'AI Gateway'}
|
||||
<img src='/icon.png' alt={`${APP_NAME} logo`} style={{ width: 28, height: 28 }} />
|
||||
{!collapsed && APP_NAME}
|
||||
</div>
|
||||
}
|
||||
operations={
|
||||
<Button
|
||||
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
|
||||
variant='text'
|
||||
shape='square'
|
||||
icon={collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
||||
|
||||
4
frontend/src/constants/app.ts
Normal file
4
frontend/src/constants/app.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const APP_NAME = 'Nex'
|
||||
export const APP_DESCRIPTION = 'AI Gateway - 统一的大模型 API 网关'
|
||||
export const APP_WEBSITE = 'https://github.com/nex/gateway'
|
||||
export const APP_VERSION = import.meta.env.VITE_APP_VERSION || 'dev'
|
||||
13
frontend/src/hooks/useVersion.ts
Normal file
13
frontend/src/hooks/useVersion.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import * as api from '@/api/version'
|
||||
|
||||
export const versionKeys = {
|
||||
backend: ['version', 'backend'] as const,
|
||||
}
|
||||
|
||||
export function useBackendVersion() {
|
||||
return useQuery({
|
||||
queryKey: versionKeys.backend,
|
||||
queryFn: api.getBackendVersion,
|
||||
})
|
||||
}
|
||||
@@ -1,30 +1,77 @@
|
||||
import { Card } from 'tdesign-react'
|
||||
import { Alert, Card, Descriptions, Link, Space, Tag } from 'tdesign-react'
|
||||
import { APP_DESCRIPTION, APP_NAME, APP_VERSION, APP_WEBSITE } from '@/constants/app'
|
||||
import { useBackendVersion } from '@/hooks/useVersion'
|
||||
import type { VersionStatusKind } from '@/types'
|
||||
import { getVersionStatus } from '@/utils/version'
|
||||
|
||||
const statusTheme: Record<VersionStatusKind, 'success' | 'warning' | 'default'> = {
|
||||
matched: 'success',
|
||||
mismatched: 'warning',
|
||||
unknown: 'default',
|
||||
unavailable: 'warning',
|
||||
}
|
||||
|
||||
export default function AboutPage() {
|
||||
const { data: backendVersion, isError, isLoading } = useBackendVersion()
|
||||
const versionStatus = getVersionStatus(APP_VERSION, backendVersion, isError)
|
||||
|
||||
return (
|
||||
<Card bordered={false}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '4rem 0',
|
||||
}}
|
||||
>
|
||||
<h1 style={{ margin: 0, fontSize: '2rem' }}>Nex</h1>
|
||||
<p style={{ margin: '0.5rem 0 0', color: 'var(--td-text-color-secondary)', fontSize: '1rem' }}>
|
||||
AI Gateway - 统一的大模型 API 网关
|
||||
</p>
|
||||
<a
|
||||
href='https://github.com/nex/gateway'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
style={{ marginTop: '1rem', color: 'var(--td-brand-color)' }}
|
||||
<div style={{ display: 'grid', gap: 'var(--td-comp-margin-l)' }}>
|
||||
<Card bordered={false} hoverShadow>
|
||||
<Space align='center' size='large'>
|
||||
<img src='/icon.png' alt={`${APP_NAME} logo`} style={{ width: 56, height: 56 }} />
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: '2rem' }}>{APP_NAME}</h1>
|
||||
<p style={{ margin: '0.5rem 0 0', color: 'var(--td-text-color-secondary)', fontSize: '1rem' }}>
|
||||
{APP_DESCRIPTION}
|
||||
</p>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Card bordered={false} hoverShadow>
|
||||
{(versionStatus.kind === 'mismatched' || versionStatus.kind === 'unavailable') && (
|
||||
<Alert
|
||||
theme='warning'
|
||||
message={versionStatus.description}
|
||||
style={{ marginBottom: 'var(--td-comp-margin-l)' }}
|
||||
/>
|
||||
)}
|
||||
<Descriptions
|
||||
column={2}
|
||||
itemLayout='vertical'
|
||||
items={[
|
||||
{ label: '前端版本', content: APP_VERSION },
|
||||
{ label: '后端版本', content: isLoading ? '加载中' : backendVersion?.version || 'unknown' },
|
||||
{ label: '后端提交', content: isLoading ? '加载中' : backendVersion?.commit || 'unknown' },
|
||||
{ label: '后端构建时间', content: isLoading ? '加载中' : backendVersion?.buildTime || 'unknown' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
<Tag
|
||||
theme={statusTheme[versionStatus.kind]}
|
||||
variant='light'
|
||||
shape='round'
|
||||
style={{ position: 'absolute', top: 'var(--td-comp-paddingLR-l)', right: 'var(--td-comp-paddingLR-l)' }}
|
||||
>
|
||||
https://github.com/nex/gateway
|
||||
</a>
|
||||
{versionStatus.label}
|
||||
</Tag>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card bordered={false} hoverShadow>
|
||||
<Space breakLine size='large'>
|
||||
<Link href={APP_WEBSITE} target='_blank' theme='primary'>
|
||||
GitHub
|
||||
</Link>
|
||||
<Link href={APP_WEBSITE} target='_blank' theme='primary'>
|
||||
文档
|
||||
</Link>
|
||||
<Link href={`${APP_WEBSITE}/blob/main/LICENSE`} target='_blank' theme='primary'>
|
||||
License
|
||||
</Link>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,20 @@ export interface StatsQueryParams {
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
export interface BackendVersion {
|
||||
version: string
|
||||
commit: string
|
||||
buildTime: string
|
||||
}
|
||||
|
||||
export type VersionStatusKind = 'matched' | 'mismatched' | 'unknown' | 'unavailable'
|
||||
|
||||
export interface VersionStatus {
|
||||
kind: VersionStatusKind
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
code?: string
|
||||
|
||||
42
frontend/src/utils/version.ts
Normal file
42
frontend/src/utils/version.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { BackendVersion, VersionStatus } from '@/types'
|
||||
|
||||
function isUnknownVersion(version: string | undefined): boolean {
|
||||
const normalized = version?.trim().toLowerCase()
|
||||
return !normalized || normalized === 'dev' || normalized === 'unknown'
|
||||
}
|
||||
|
||||
export function getVersionStatus(
|
||||
frontendVersion: string,
|
||||
backendVersion?: BackendVersion,
|
||||
hasError = false
|
||||
): VersionStatus {
|
||||
if (hasError) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
label: '无法获取后端版本',
|
||||
description: '后端版本接口暂时不可用,当前仅展示前端版本。',
|
||||
}
|
||||
}
|
||||
|
||||
if (!backendVersion || isUnknownVersion(frontendVersion) || isUnknownVersion(backendVersion.version)) {
|
||||
return {
|
||||
kind: 'unknown',
|
||||
label: '无法判断版本',
|
||||
description: '当前处于开发构建或版本信息不完整,不判定为版本错误。',
|
||||
}
|
||||
}
|
||||
|
||||
if (frontendVersion === backendVersion.version) {
|
||||
return {
|
||||
kind: 'matched',
|
||||
label: '版本一致',
|
||||
description: '前端和后端来自同一版本构建。',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'mismatched',
|
||||
label: '版本不一致',
|
||||
description: '前后端版本不同,该状态用于部署诊断,不影响当前功能使用。',
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user