From 8eea30ea11fbe318019d29dc49621ddf0c3f3a64 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Tue, 5 May 2026 03:28:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=E5=93=81=E7=89=8C?= =?UTF-8?q?=E6=A0=87=E8=AF=86=E3=80=81=E5=85=B3=E4=BA=8E=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E4=B8=89=E5=8D=A1=E7=89=87=E5=B8=83=E5=B1=80=E4=B8=8E=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E8=AF=8A=E6=96=AD=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统一品牌为 Nex:侧边栏、托盘 tooltip、HTML 标题、favicon (PNG 替代 SVG) - 重构关于页面为三卡片布局(品牌/版本/链接),版本状态 Tag 绝对定位右上角 - 新增 GET /api/version 后端接口,返回 version/commit/build_time - 新增前端版本一致性诊断:匹配/不匹配/不可判断三种状态 - 同步 delta specs 到主 specs 并归档变更 --- .gitattributes | 3 +- .gitignore | 1 + README.md | 5 +- backend/README.md | 14 ++ backend/cmd/desktop/main.go | 12 +- backend/cmd/desktop/routes_test.go | 44 +++++++ backend/cmd/desktop/static_test.go | 25 ++++ backend/cmd/server/main.go | 6 +- backend/cmd/server/routes_test.go | 37 ++++++ backend/internal/handler/version_handler.go | 26 ++++ .../internal/handler/version_handler_test.go | 31 +++++ frontend/README.md | 31 +++-- frontend/e2e/navigation.spec.ts | 11 +- frontend/index.html | 4 +- frontend/public/favicon.svg | 1 - frontend/public/icon.png | 3 + frontend/public/icons.svg | 24 ---- frontend/src/__tests__/api/version.test.ts | 30 +++++ .../__tests__/components/AppLayout.test.tsx | 30 ++++- .../src/__tests__/hooks/useVersion.test.tsx | 33 +++++ frontend/src/__tests__/pages/About.test.tsx | 88 +++++++++++++ frontend/src/__tests__/utils/version.test.ts | 21 +++ frontend/src/api/version.ts | 6 + frontend/src/components/AppLayout/index.tsx | 8 +- frontend/src/constants/app.ts | 4 + frontend/src/hooks/useVersion.ts | 13 ++ frontend/src/pages/About/index.tsx | 93 ++++++++++---- frontend/src/types/index.ts | 14 ++ frontend/src/utils/version.ts | 42 ++++++ .../.openspec.yaml | 2 + .../design.md | 105 +++++++++++++++ .../proposal.md | 34 +++++ .../specs/about-page/spec.md | 78 +++++++++++ .../specs/desktop-app/spec.md | 91 +++++++++++++ .../specs/frontend/spec.md | 121 ++++++++++++++++++ .../specs/repository-versioning/spec.md | 54 ++++++++ .../tasks.md | 57 +++++++++ openspec/config.yaml | 2 +- openspec/specs/about-page/spec.md | 74 +++++++++-- openspec/specs/desktop-app/spec.md | 15 ++- openspec/specs/frontend/spec.md | 81 +++++++++--- openspec/specs/repository-versioning/spec.md | 53 ++++++++ 42 files changed, 1316 insertions(+), 111 deletions(-) create mode 100644 backend/cmd/desktop/routes_test.go create mode 100644 backend/cmd/server/routes_test.go create mode 100644 backend/internal/handler/version_handler.go create mode 100644 backend/internal/handler/version_handler_test.go delete mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icon.png delete mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/__tests__/api/version.test.ts create mode 100644 frontend/src/__tests__/hooks/useVersion.test.tsx create mode 100644 frontend/src/__tests__/pages/About.test.tsx create mode 100644 frontend/src/__tests__/utils/version.test.ts create mode 100644 frontend/src/api/version.ts create mode 100644 frontend/src/constants/app.ts create mode 100644 frontend/src/hooks/useVersion.ts create mode 100644 frontend/src/utils/version.ts create mode 100644 openspec/changes/archive/2026-05-05-unify-branding-and-about-version/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-05-unify-branding-and-about-version/design.md create mode 100644 openspec/changes/archive/2026-05-05-unify-branding-and-about-version/proposal.md create mode 100644 openspec/changes/archive/2026-05-05-unify-branding-and-about-version/specs/about-page/spec.md create mode 100644 openspec/changes/archive/2026-05-05-unify-branding-and-about-version/specs/desktop-app/spec.md create mode 100644 openspec/changes/archive/2026-05-05-unify-branding-and-about-version/specs/frontend/spec.md create mode 100644 openspec/changes/archive/2026-05-05-unify-branding-and-about-version/specs/repository-versioning/spec.md create mode 100644 openspec/changes/archive/2026-05-05-unify-branding-and-about-version/tasks.md diff --git a/.gitattributes b/.gitattributes index ba38229..1913b77 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,8 +1,9 @@ * text=auto eol=lf - assets/*.png filter=lfs diff=lfs merge=lfs -text assets/**/*.png filter=lfs diff=lfs merge=lfs -text assets/*.icns filter=lfs diff=lfs merge=lfs -text assets/**/*.icns filter=lfs diff=lfs merge=lfs -text assets/*.ico filter=lfs diff=lfs merge=lfs -text assets/**/*.ico filter=lfs diff=lfs merge=lfs -text +frontend/public/*.png filter=lfs diff=lfs merge=lfs -text +frontend/public/**/*.png filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index 5fd5891..d5bfc7c 100644 --- a/.gitignore +++ b/.gitignore @@ -408,6 +408,7 @@ temp skills-lock.json .worktrees !scripts/build/ +backend/bin # Embedfs generated embedfs/assets/ diff --git a/README.md b/README.md index f5375fa..a432966 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ JSON: {"level":"info","logger":"handler.proxy","msg":"处理请求","method": - **图表库**: Recharts - **路由**: React Router v7 - **数据获取**: TanStack Query v5 -- **样式**: SCSS Modules +- **样式**: TDesign 组件 props 优先,TDesign tokens 次之,SCSS 作为兜底补充 - **测试**: Vitest + React Testing Library + Playwright ## 快速开始 @@ -199,6 +199,9 @@ make server-build 查询参数支持:`provider_id`、`model_name`、`start_date`、`end_date`、`group_by` +#### 版本信息 +- `GET /api/version` - 获取后端构建版本信息(`version`、`commit`、`build_time`),用于前端 About 页面诊断前后端版本一致性 + ## 配置 配置支持多种方式,优先级为:**CLI 参数 > 环境变量 > 配置文件 > 默认值** diff --git a/backend/README.md b/backend/README.md index 77c5d1b..651ed8b 100644 --- a/backend/README.md +++ b/backend/README.md @@ -577,6 +577,20 @@ GET /anthropic/v1/models 查询参数:`provider_id`、`model_name`、`start_date`(YYYY-MM-DD)、`end_date`、`group_by`(provider/model/date) +#### 版本信息 + +- `GET /api/version` - 获取后端构建版本信息 + +响应字段来源于构建阶段注入的 `buildinfo` 元数据: + +```json +{ + "version": "0.1.0", + "commit": "abc1234", + "build_time": "2026-05-05T00:00:00Z" +} +``` + #### 健康检查 - `GET /health` - 返回 `{"status": "ok"}` diff --git a/backend/cmd/desktop/main.go b/backend/cmd/desktop/main.go index c9d290d..20a3e7f 100644 --- a/backend/cmd/desktop/main.go +++ b/backend/cmd/desktop/main.go @@ -130,6 +130,7 @@ func main() { providerHandler := handler.NewProviderHandler(providerService) modelHandler := handler.NewModelHandler(modelService) statsHandler := handler.NewStatsHandler(statsService) + versionHandler := handler.NewVersionHandler() gin.SetMode(gin.ReleaseMode) r := gin.New() @@ -139,7 +140,7 @@ func main() { r.Use(middleware.Logging(zapLogger)) r.Use(middleware.CORS()) - setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler) + setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler) setupStaticFiles(r) server = &http.Server{ @@ -172,9 +173,10 @@ func main() { setupSystray(port) } -func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler) { +func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler, versionHandler *handler.VersionHandler) { r.Any("/openai/*path", withProtocol("openai", proxyHandler.HandleProxy)) r.Any("/anthropic/*path", withProtocol("anthropic", proxyHandler.HandleProxy)) + r.GET("/api/version", versionHandler.GetVersion) providers := r.Group("/api/providers") { @@ -257,13 +259,13 @@ func setupStaticFilesWithFS(r *gin.Engine, distFS fs.FS) { c.Data(200, getContentType(filepath), data) }) - r.GET("/favicon.svg", func(c *gin.Context) { - data, err := fs.ReadFile(distFS, "favicon.svg") + r.GET("/icon.png", func(c *gin.Context) { + data, err := fs.ReadFile(distFS, "icon.png") if err != nil { c.Status(404) return } - c.Data(200, "image/svg+xml", data) + c.Data(200, "image/png", data) }) r.NoRoute(func(c *gin.Context) { diff --git a/backend/cmd/desktop/routes_test.go b/backend/cmd/desktop/routes_test.go new file mode 100644 index 0000000..10b7e98 --- /dev/null +++ b/backend/cmd/desktop/routes_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" + + "nex/backend/internal/handler" + + "github.com/gin-gonic/gin" +) + +func TestSetupRoutes_VersionDoesNotFallback(t *testing.T) { + gin.SetMode(gin.TestMode) + + r := gin.New() + setupRoutes(r, &handler.ProxyHandler{}, &handler.ProviderHandler{}, &handler.ModelHandler{}, &handler.StatsHandler{}, handler.NewVersionHandler()) + setupStaticFilesWithFS(r, fstest.MapFS{ + "index.html": {Data: []byte("fallback")}, + }) + + req := httptest.NewRequest(http.MethodGet, "/api/version", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + if contentType := w.Header().Get("Content-Type"); contentType == "text/html; charset=utf-8" { + t.Fatalf("版本接口不应返回 SPA fallback HTML") + } + + var result map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + for _, key := range []string{"version", "commit", "build_time"} { + if result[key] == "" { + t.Fatalf("响应缺少 %s 字段: %#v", key, result) + } + } +} diff --git a/backend/cmd/desktop/static_test.go b/backend/cmd/desktop/static_test.go index ca67276..28ae5c0 100644 --- a/backend/cmd/desktop/static_test.go +++ b/backend/cmd/desktop/static_test.go @@ -5,6 +5,7 @@ import ( "net/http/httptest" "strings" "testing" + "testing/fstest" "github.com/gin-gonic/gin" ) @@ -100,6 +101,30 @@ func TestSetupStaticFiles(t *testing.T) { t.Log("静态文件服务测试通过") } +func TestSetupStaticFilesWithFS_IconPNG(t *testing.T) { + gin.SetMode(gin.TestMode) + + r := gin.New() + setupStaticFilesWithFS(r, fstest.MapFS{ + "icon.png": {Data: []byte("png")}, + "index.html": {Data: []byte("fallback")}, + }) + + req := httptest.NewRequest("GET", "/icon.png", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望状态码 200, 实际 %d", w.Code) + } + if w.Header().Get("Content-Type") != "image/png" { + t.Fatalf("期望 Content-Type image/png, 实际 %s", w.Header().Get("Content-Type")) + } + if w.Body.String() != "png" { + t.Fatalf("期望返回 PNG 内容,实际 %q", w.Body.String()) + } +} + func TestWithProtocolAndStaticRoutes(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 1071582..15ff664 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -93,6 +93,7 @@ func main() { providerHandler := handler.NewProviderHandler(providerService) modelHandler := handler.NewModelHandler(modelService) statsHandler := handler.NewStatsHandler(statsService) + versionHandler := handler.NewVersionHandler() gin.SetMode(gin.ReleaseMode) r := gin.New() @@ -102,7 +103,7 @@ func main() { r.Use(middleware.Logging(zapLogger)) r.Use(middleware.CORS()) - setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler) + setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler) srv := &http.Server{ Addr: fmt.Sprintf(":%d", cfg.Server.Port), @@ -140,8 +141,9 @@ func main() { zapLogger.Info("服务器已关闭") } -func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler) { +func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler, versionHandler *handler.VersionHandler) { r.Any("/:protocol/*path", proxyHandler.HandleProxy) + r.GET("/api/version", versionHandler.GetVersion) providers := r.Group("/api/providers") { diff --git a/backend/cmd/server/routes_test.go b/backend/cmd/server/routes_test.go new file mode 100644 index 0000000..53eb03e --- /dev/null +++ b/backend/cmd/server/routes_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "nex/backend/internal/handler" + + "github.com/gin-gonic/gin" +) + +func TestSetupRoutes_Version(t *testing.T) { + gin.SetMode(gin.TestMode) + + r := gin.New() + setupRoutes(r, &handler.ProxyHandler{}, &handler.ProviderHandler{}, &handler.ModelHandler{}, &handler.StatsHandler{}, handler.NewVersionHandler()) + + req := httptest.NewRequest(http.MethodGet, "/api/version", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + + var result map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + for _, key := range []string{"version", "commit", "build_time"} { + if result[key] == "" { + t.Fatalf("响应缺少 %s 字段: %#v", key, result) + } + } +} diff --git a/backend/internal/handler/version_handler.go b/backend/internal/handler/version_handler.go new file mode 100644 index 0000000..35d8f76 --- /dev/null +++ b/backend/internal/handler/version_handler.go @@ -0,0 +1,26 @@ +package handler + +import ( + "net/http" + + "nex/backend/pkg/buildinfo" + + "github.com/gin-gonic/gin" +) + +// VersionHandler 提供后端构建版本信息。 +type VersionHandler struct{} + +// NewVersionHandler 创建版本信息处理器。 +func NewVersionHandler() *VersionHandler { + return &VersionHandler{} +} + +// GetVersion 返回构建注入的版本元数据。 +func (h *VersionHandler) GetVersion(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "version": buildinfo.Version(), + "commit": buildinfo.Commit(), + "build_time": buildinfo.BuildTime(), + }) +} diff --git a/backend/internal/handler/version_handler_test.go b/backend/internal/handler/version_handler_test.go new file mode 100644 index 0000000..6c2958d --- /dev/null +++ b/backend/internal/handler/version_handler_test.go @@ -0,0 +1,31 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVersionHandler_GetVersion(t *testing.T) { + gin.SetMode(gin.TestMode) + + h := NewVersionHandler() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/version", nil) + + h.GetVersion(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var result map[string]string + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) + assert.Equal(t, "dev", result["version"]) + assert.Equal(t, "unknown", result["commit"]) + assert.Equal(t, "unknown", result["build_time"]) +} diff --git a/frontend/README.md b/frontend/README.md index a89fb8d..c0c8b7f 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,4 +1,4 @@ -# AI Gateway Frontend +# Nex Frontend AI 网关管理前端,提供供应商配置和用量统计界面。 @@ -11,7 +11,7 @@ AI 网关管理前端,提供供应商配置和用量统计界面。 - **UI 组件库**: TDesign - **路由**: React Router v7 - **数据获取**: TanStack Query v5 -- **样式**: SCSS Modules(禁止使用纯 CSS) +- **样式**: TDesign 组件 props 优先,TDesign tokens 次之,SCSS 作为兜底补充 - **测试**: Vitest + React Testing Library + Playwright - **代码格式化**: Prettier @@ -86,17 +86,20 @@ frontend/ │ │ ├── client.ts # 统一 request() + 字段转换 │ │ ├── providers.ts # Provider CRUD │ │ ├── models.ts # Model CRUD -│ │ └── stats.ts # Stats 查询 +│ │ ├── stats.ts # Stats 查询 +│ │ └── version.ts # 后端版本查询 │ ├── components/ │ │ └── AppLayout/ # 侧边栏导航布局 │ ├── hooks/ # TanStack Query hooks │ │ ├── useProviders.ts │ │ ├── useModels.ts -│ │ └── useStats.ts +│ │ ├── useStats.ts +│ │ └── useVersion.ts │ ├── pages/ │ │ ├── Providers/ # 供应商管理(含内嵌模型管理) │ │ ├── Stats/ # 用量统计 │ │ ├── Settings/ # 设置(开发中) +│ │ ├── About/ # 关于页面(品牌与版本信息) │ │ └── NotFound.tsx │ ├── routes/ │ │ └── index.tsx # 路由配置 @@ -111,6 +114,7 @@ frontend/ │ ├── main.tsx │ └── index.scss ├── e2e/ # Playwright E2E 测试 +├── public/ # 静态资源(icon.png 来源于 ../assets/icon.png) ├── vitest.config.ts ├── playwright.config.ts ├── tsconfig.json @@ -200,6 +204,12 @@ bun run test:e2e - 按模型筛选 - 按日期范围筛选(DatePicker.RangePicker) +### 关于页面 + +- 展示应用名称、产品描述和项目链接 +- 展示前端版本、后端版本、后端 commit 和构建时间 +- 根据 `VITE_APP_VERSION` 与 `GET /api/version` 返回值提示前后端版本是否一致 + ## 测试策略 ### 目录结构 @@ -231,9 +241,10 @@ __tests__/ ## 环境变量 -| 变量 | 开发环境 | 生产环境 | 说明 | -| --------------- | -------- | -------- | ------------------------------- | -| `VITE_API_BASE` | (空) | `/api` | API 基础路径,空则走 Vite proxy | +| 变量 | 开发环境 | 生产环境 | 说明 | +| ------------------ | -------- | -------- | ----------------------------------------- | +| `VITE_API_BASE` | (空) | `/api` | API 基础路径,空则走 Vite proxy | +| `VITE_APP_VERSION` | `0.1.0` | `0.1.0` | 前端构建版本,由 `make version-sync` 同步 | **E2E 测试特有**: @@ -242,9 +253,11 @@ __tests__/ ## 开发规范 -- 所有样式使用 SCSS,禁止使用纯 CSS 文件 -- 组件级样式使用 SCSS Modules(\*.module.scss) +- 样式优先使用 TDesign 组件 props(如 `hoverShadow`、`headerBordered`、`variant`、`shape`、`gutter`) +- 组件 props 无法表达时使用 TDesign tokens(`var(--td-*)`) +- 仅当 props 和 tokens 无法满足布局、响应式或品牌视觉需求时使用 SCSS,禁止使用纯 CSS 文件 - 图标优先使用 TDesign 图标(tdesign-icons-react) +- 应用 favicon 使用 `frontend/public/icon.png`,该文件来源于仓库根目录 `assets/icon.png` - TypeScript strict 模式,禁止 any 类型 - API 层自动处理 snake_case ↔ camelCase 字段转换 - 使用路径别名 `@/` 引用 src 目录 diff --git a/frontend/e2e/navigation.spec.ts b/frontend/e2e/navigation.spec.ts index 5f8d925..e49107f 100644 --- a/frontend/e2e/navigation.spec.ts +++ b/frontend/e2e/navigation.spec.ts @@ -12,7 +12,7 @@ test.describe('侧边栏', () => { }) test('应显示应用名称', async ({ page }) => { - await expect(page.locator('aside').getByText('AI Gateway')).toBeVisible() + await expect(page.locator('aside').getByText('Nex')).toBeVisible() }) test('应显示导航菜单项', async ({ page }) => { @@ -41,6 +41,15 @@ test.describe('页面导航', () => { await expect(page.getByRole('heading', { name: '供应商管理' })).toBeVisible() }) + test('应能切换到关于页面并显示版本信息', async ({ page }) => { + await page.locator('aside').getByText('关于').click() + + await expect(page.getByRole('heading', { name: '关于' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Nex' })).toBeVisible() + await expect(page.getByText('前端版本')).toBeVisible() + await expect(page.getByText('后端版本')).toBeVisible() + }) + test('应在刷新后保持当前页面', async ({ page }) => { await page.locator('aside').getByText('用量统计').click() await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible() diff --git a/frontend/index.html b/frontend/index.html index d2f2908..ca22465 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,9 @@ - + - AI Gateway + Nex
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg deleted file mode 100644 index 6893eb1..0000000 --- a/frontend/public/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/icon.png b/frontend/public/icon.png new file mode 100644 index 0000000..9a9c0e5 --- /dev/null +++ b/frontend/public/icon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:714d345ce0db4c608bbdd9864eac25670e26ea940733c2de90aafbb49d851227 +size 970500 diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg deleted file mode 100644 index e952219..0000000 --- a/frontend/public/icons.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/__tests__/api/version.test.ts b/frontend/src/__tests__/api/version.test.ts new file mode 100644 index 0000000..112444b --- /dev/null +++ b/frontend/src/__tests__/api/version.test.ts @@ -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', + }) + }) +}) diff --git a/frontend/src/__tests__/components/AppLayout.test.tsx b/frontend/src/__tests__/components/AppLayout.test.tsx index b454842..b70e42e 100644 --- a/frontend/src/__tests__/components/AppLayout.test.tsx +++ b/frontend/src/__tests__/components/AppLayout.test.tsx @@ -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({component}) + return render({component}) } describe('AppLayout', () => { it('renders sidebar with app name', () => { renderWithRouter() - 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() + + 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() - 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() + + expect(screen.getByText('关于')).toBeInTheDocument() + }) + it('renders content outlet', () => { const { container } = renderWithRouter() diff --git a/frontend/src/__tests__/hooks/useVersion.test.tsx b/frontend/src/__tests__/hooks/useVersion.test.tsx new file mode 100644 index 0000000..ada96d7 --- /dev/null +++ b/frontend/src/__tests__/hooks/useVersion.test.tsx @@ -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 {children} + } +} + +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' }) + }) +}) diff --git a/frontend/src/__tests__/pages/About.test.tsx b/frontend/src/__tests__/pages/About.test.tsx new file mode 100644 index 0000000..9859895 --- /dev/null +++ b/frontend/src/__tests__/pages/About.test.tsx @@ -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 + +describe('AboutPage', () => { + beforeEach(() => { + mockUseBackendVersion.mockReturnValue({ + data: { version: '0.1.0', commit: 'abc1234', buildTime: '2026-05-05T00:00:00Z' }, + isError: false, + isLoading: false, + } as ReturnType) + }) + + it('renders brand, description and links', () => { + render() + + 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() + + 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() + + 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) + + render() + + 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) + + render() + + expect(screen.getByText('无法判断版本')).toBeInTheDocument() + }) + + it('shows unavailable status on backend request failure', () => { + mockUseBackendVersion.mockReturnValue({ + data: undefined, + isError: true, + isLoading: false, + } as ReturnType) + + render() + + expect(screen.getByText('无法获取后端版本')).toBeInTheDocument() + expect(screen.getByText(/后端版本接口暂时不可用/)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/__tests__/utils/version.test.ts b/frontend/src/__tests__/utils/version.test.ts new file mode 100644 index 0000000..51df363 --- /dev/null +++ b/frontend/src/__tests__/utils/version.test.ts @@ -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') + }) +}) diff --git a/frontend/src/api/version.ts b/frontend/src/api/version.ts new file mode 100644 index 0000000..fc7f010 --- /dev/null +++ b/frontend/src/api/version.ts @@ -0,0 +1,6 @@ +import type { BackendVersion } from '@/types' +import { request } from './client' + +export async function getBackendVersion(): Promise { + return request('GET', '/api/version') +} diff --git a/frontend/src/components/AppLayout/index.tsx b/frontend/src/components/AppLayout/index.tsx index f860d2e..185f69a 100644 --- a/frontend/src/components/AppLayout/index.tsx +++ b/frontend/src/components/AppLayout/index.tsx @@ -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'} + {`${APP_NAME} + {!collapsed && APP_NAME} } operations={