1
0

feat: 统一品牌标识、关于页面三卡片布局与版本诊断功能

- 统一品牌为 Nex:侧边栏、托盘 tooltip、HTML 标题、favicon (PNG 替代 SVG)
- 重构关于页面为三卡片布局(品牌/版本/链接),版本状态 Tag 绝对定位右上角
- 新增 GET /api/version 后端接口,返回 version/commit/build_time
- 新增前端版本一致性诊断:匹配/不匹配/不可判断三种状态
- 同步 delta specs 到主 specs 并归档变更
This commit is contained in:
2026-05-05 03:28:22 +08:00
parent 9e33e570af
commit 8eea30ea11
42 changed files with 1316 additions and 111 deletions

3
.gitattributes vendored
View File

@@ -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

1
.gitignore vendored
View File

@@ -408,6 +408,7 @@ temp
skills-lock.json
.worktrees
!scripts/build/
backend/bin
# Embedfs generated
embedfs/assets/

View File

@@ -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 参数 > 环境变量 > 配置文件 > 默认值**

View File

@@ -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"}`

View File

@@ -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) {

View File

@@ -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("<html>fallback</html>")},
})
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)
}
}
}

View File

@@ -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("<html>fallback</html>")},
})
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)

View File

@@ -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")
{

View File

@@ -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)
}
}
}

View File

@@ -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(),
})
}

View File

@@ -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"])
}

View File

@@ -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<T>() + 字段转换
│ │ ├── 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` 返回值提示前后端版本是否一致
## 测试策略
### 目录结构
@@ -232,8 +242,9 @@ __tests__/
## 环境变量
| 变量 | 开发环境 | 生产环境 | 说明 |
| --------------- | -------- | -------- | ------------------------------- |
| ------------------ | -------- | -------- | ----------------------------------------- |
| `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 目录

View File

@@ -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()

View File

@@ -2,9 +2,9 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Gateway</title>
<title>Nex</title>
</head>
<body>
<div id="root"></div>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

BIN
frontend/public/icon.png LFS Normal file

Binary file not shown.

View File

@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

View 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',
})
})
})

View File

@@ -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 />)

View 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' })
})
})

View 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()
})
})

View 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')
})
})

View 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')
}

View File

@@ -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 />}

View 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'

View 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,
})
}

View File

@@ -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>
<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' }}>
AI Gateway - API
{APP_DESCRIPTION}
</p>
<a
href='https://github.com/nex/gateway'
target='_blank'
rel='noopener noreferrer'
style={{ marginTop: '1rem', color: 'var(--td-brand-color)' }}
>
https://github.com/nex/gateway
</a>
</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)' }}
>
{versionStatus.label}
</Tag>
</div>
<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>
)
}

View File

@@ -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

View 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: '前后端版本不同,该状态用于部署诊断,不影响当前功能使用。',
}
}

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-04

View File

@@ -0,0 +1,105 @@
## Context
Nex 已经具备统一版本源:仓库根目录 `VERSION` 是权威版本,`versionctl sync` 会同步 `frontend/package.json` 和前端 `.env.*` 中的 `VITE_APP_VERSION`Go 二进制构建通过 `-ldflags` 注入 `backend/pkg/buildinfo`。当前缺口在运行时可见性:前端只能知道自身构建版本,无法从后端获取 server/desktop 二进制版本,也无法判断前后端是否来自同一版本。
前端侧边栏当前在 `AppLayout` 中以 `AI Gateway` 作为品牌文字,折叠后 logo 区域为空HTML title 也仍为 `AI Gateway`。About 页面只展示名称、描述和链接,缺少版本、构建信息、状态反馈和现代化信息层次。图标资源方面,前端 public 目录维护独立 SVG favicon 和未使用的 `icons.svg`,而桌面托盘和打包资源已经使用仓库 `assets/icon.png``assets/icon.ico``assets/icon.icns`。desktop 代码中的 `appName/appTooltip` 已经是 `Nex`,但现有 `desktop-app` spec 仍保留旧 tooltip 文案,需要借本次 change 同步。
本变更跨越前端页面、前端 API、后端管理接口、desktop 静态资源服务、README 和 OpenSpec因此需要先固定实现边界避免实现阶段出现多个版本来源或重复图标资源。
## Goals / Non-Goals
**Goals:**
- 统一用户可见应用名称为 `Nex`覆盖前端侧边栏、HTML title 和相关测试断言,`AI Gateway` 作为产品描述保留。
- 侧边栏展开态显示统一图标和 `Nex`,折叠态仍显示统一图标。
- 前端 favicon/public 图标复用仓库 `assets/icon.png`,清理不再使用的 SVG public 图标资源。
- About 页面展示前端版本、后端版本、后端 commit、后端 build_time 和版本匹配状态。
- 后端在 server 和 desktop 模式下都提供 `GET /api/version`
- 明确并落地前端样式优先级TDesign 组件 props、TDesign tokens、SCSS。
- 补充单元测试、组件测试、后端路由测试和必要的 E2E 覆盖。
- 同步更新根 README、frontend README、backend README。
**Non-Goals:**
- 不改变版本权威来源,仍以根目录 `VERSION` 为唯一版本源。
- 不新增前端或后端依赖。
- 不引入用户认证、权限控制或配置项开关。
- 不让 About 页面阻断业务功能;版本不一致仅提示,不禁止使用。
- 不重构所有已有内联样式,只处理本次触达的品牌和 About 页面相关样式。
## Decisions
### Decision: 后端版本信息复用 buildinfo 并通过 `/api/version` 暴露
后端 SHALL 新增 `GET /api/version` 管理接口,响应字段为 `version``commit``build_time`,字段值来自 `buildinfo.Version()``buildinfo.Commit()``buildinfo.BuildTime()`
选择 `/api/version` 的原因:管理接口已经统一使用 `/api/*`,前端 API 客户端也以该路径族访问后端;`/health` 应保持存活检查语义,不混入构建元数据。选择 `buildinfo` 的原因Makefile 已在 server 和 desktop 构建中注入版本、commit 和 buildTime运行时读取 `VERSION` 会破坏发布产物独立性。
备选方案:扩展 `/health`。放弃原因是健康检查会被监控系统频繁调用,混入构建信息容易模糊职责,并且前端语义上需要的是管理信息而不是存活探针。
### Decision: server 和 desktop 分别注册同一个版本接口
server 和 desktop 当前拥有独立 `setupRoutes`,因此两个入口都 MUST 注册 `GET /api/version`。desktop 还需要确保该路径不会落入 SPA fallback 或协议代理路由。
备选方案:只在 server 注册。放弃原因是桌面应用使用独立路由装配和静态资源服务,用户在 desktop 模式下同样需要 About 页面展示后端版本。
### Decision: 前端版本使用 `VITE_APP_VERSION`,不运行时读取 package.json
前端 SHALL 使用 `import.meta.env.VITE_APP_VERSION` 作为自身版本,缺失时降级为 `dev``unknown` 显示。该值由版本同步工具写入 `.env.*`,符合现有构建版本注入规则。
备选方案:在前端运行时请求或打包 `package.json`。放弃原因是会产生第二个版本读取路径,并可能在 desktop 嵌入构建、生产构建和测试环境中产生不一致。
### Decision: About 页面只提示版本一致性,不阻断功能
About 页面 SHALL 根据前端版本和后端版本显示状态:一致、不一致、开发构建无法判断、后端版本获取失败。状态通过 TDesign `Tag` 和必要时的 `Alert` 展示。
判断规则:前端版本等于后端版本时为一致;任一版本为 `dev``unknown`、空值时为无法判断;请求失败时为后端版本获取失败;其余不相等时为不一致。
备选方案:版本不一致时阻断或弹窗提示。放弃原因是版本检查用于部署诊断,不能影响现有配置和代理能力。
### Decision: 图标资源统一到 `assets/icon.png` 并固定运行时路径
前端 public 图标 SHALL 由仓库 `assets/icon.png` 派生,实施时将根目录 `assets/icon.png` 复制为 `frontend/public/icon.png`,前端入口 SHALL 使用 `/icon.png` 作为 PNG favicon 路径。`frontend/public/favicon.svg` 不再作为 favicon 来源,`frontend/public/icons.svg` 经确认未被引用后 SHALL 删除。
Vite 会将 `frontend/public/icon.png` 输出到 dist 根目录,因此 desktop 静态服务 SHALL 显式服务 `/icon.png` 并读取嵌入 dist 中的 `icon.png`。README SHALL 标注 `frontend/public/icon.png` 来源于根目录 `assets/icon.png`,后续更新应用图标时应以根目录资源为准并同步 public 镜像。
备选方案:继续保留 SVG favicon。放弃原因是会继续存在两套应用图标来源与用户要求的统一资源方向不一致。
### Decision: 前端样式优先级写入 README 并指导实现
实现视觉效果时,前端 SHALL 优先使用 TDesign 组件 props例如 `Card``bordered``hoverShadow``headerBordered``Tag``theme``variant``shape``Row/Col` 的响应式 props。组件 props 不足时使用 TDesign tokens例如 `var(--td-text-color-secondary)``var(--td-brand-color)`。只有 props 和 tokens 无法表达布局、响应式或品牌细节时才使用 SCSS。
备选方案:直接新增 SCSS Modules 完成全部视觉。放弃原因是项目已经在 OpenSpec 中要求优先使用 TDesign 样式体系,过多 SCSS 会增加覆盖组件内部样式的风险。
### Decision: About 页面采用信息面板布局
About 页面 SHALL 以三个独立 `Card` 垂直排列呈现:顶部品牌卡片展示图标、`Nex`、产品描述中部版本信息卡片展示前端版本、后端版本、commit、build_time版本状态 Tag 以绝对定位浮动在卡片右上角;下部链接卡片展示外部链接入口。
布局示意:
```text
┌─────────────────────────────────────────────┐
│ Icon Nex │
│ AI Gateway - 统一的大模型 API 网关 │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ 前端版本 后端版本 构建信息 [Tag] │
│ v0.1.0 v0.1.0 commit/time │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ GitHub / 文档 / License │
└─────────────────────────────────────────────┘
```
### Decision: desktop-app spec 同步现有 Nex tooltip
desktop 代码已经通过 `appName = "Nex"``appTooltip = appName` 使用统一应用名称,现有 `desktop-app` spec 中 tooltip 仍写 `AI Gateway`。本次 change 已触达 `desktop-app` capability因此 delta spec SHALL 同步修正托盘 tooltip 要求为 `Nex`,避免归档后主 spec 与当前代码继续漂移。
## Risks / Trade-offs
- `/api/version` 在未通过 Makefile 运行的本地 Go 进程中可能返回 `dev/unknown/unknown` → About 页面将其视为开发构建,显示“无法判断”而不是错误。
- desktop 与 server 路由重复维护,可能漏注册 → 任务中明确分别添加路由测试,覆盖两个入口。
- 前端 public PNG 与根 `assets/icon.png` 可能漂移 → README 明确来源;实现阶段复制 `assets/icon.png``frontend/public/icon.png`,并通过构建验证确认 `/icon.png` 可用。
- 删除 `frontend/public/icons.svg` 可能影响隐藏引用 → 已通过全文搜索未发现引用;实现阶段删除前再次全文确认,删除后运行前端测试和构建验证。
- 样式优先级与现有 README “SCSS Modules” 表述可能冲突 → 本变更同步更新 README以 TDesign props/tokens 优先作为新的明确规则。
- 版本不一致提示可能被误解为严重故障 → 文案应说明该状态用于部署诊断,不影响当前功能使用。

View File

@@ -0,0 +1,34 @@
## Why
当前前端侧边栏仍显示旧的 `AI Gateway` 文案折叠后品牌区域为空About 页面信息展示较简陋,无法展示和判断前后端构建版本是否匹配。同时前端 public 图标资源与仓库统一的 `assets/icon.png` 不一致,并存在未使用的 SVG 图标资源,增加了品牌和资源维护成本。
## What Changes
- 将前端侧边栏品牌统一为 `Nex`,展开时显示统一图标和应用名称,折叠时仍显示图标。
- 将前端 favicon/public 图标统一复用仓库 `assets/icon.png`,运行时统一使用 `/icon.png`;替换当前 `/favicon.svg` 引用并清理未使用的 `frontend/public/icons.svg` 资源。
- 将前端 HTML 标题等用户可见应用名称同步为 `Nex`,保留 `AI Gateway` 作为产品描述。
- 重新设计 About 页面信息结构,使用三个独立卡片分别展示品牌、版本信息和外部链接,版本状态 Tag 浮动在版本信息卡片右上角。
- 新增后端管理接口 `GET /api/version`,暴露构建注入的 `version``commit``build_time`,供前端判断前后端版本一致性。
- 在 server 和 desktop 两种启动模式下都注册版本接口,并确保 desktop 静态资源路由支持新的 PNG 图标路径。
- 明确前端样式优先级TDesign 组件 props 优先,其次使用 TDesign tokens最后才在无法通过前两者表达时使用 SCSS并同步更新 README。
- 为菜单品牌、About 页面、版本接口、版本匹配状态和资源清理补充测试。
## Capabilities
### New Capabilities
无。
### Modified Capabilities
- `frontend`: 侧边栏品牌标识、折叠态 logo、HTML 标题、前端样式优先级和 public 图标资源使用规则发生变化。
- `about-page`: About 页面从简单品牌信息扩展为现代化信息面板,并展示前后端版本与匹配状态。
- `repository-versioning`: 后端需要通过管理接口暴露构建版本信息,前端需要使用构建注入版本与后端版本进行一致性判断。
- `desktop-app`: desktop 模式需要支持新的 PNG 图标静态资源路径,保证版本接口作为 API 路由处理,并同步现有 `Nex` 托盘 tooltip 规范。
## Impact
- 前端:`frontend/src/components/AppLayout``frontend/src/pages/About``frontend/src/api``frontend/src/hooks``frontend/src/types``frontend/index.html``frontend/public`、前端测试和 E2E 测试。
- 后端:`backend/internal/handler``backend/cmd/server``backend/cmd/desktop`、后端测试。
- 文档与规范:根 `README.md``frontend/README.md``backend/README.md`、相关 OpenSpec specs。
- API新增 `GET /api/version` 管理接口;不引入新依赖。

View File

@@ -0,0 +1,78 @@
## MODIFIED Requirements
### Requirement: 关于页面
前端 SHALL 提供现代化关于页面,使用 TDesign 组件展示项目品牌信息、项目链接、前端版本、后端版本和版本匹配状态。
#### Scenario: 显示关于页面
- **WHEN** 用户访问 `/about` 路径
- **THEN** 前端 SHALL 显示关于页面
- **THEN** 页面 SHALL 展示应用名称 `Nex`
- **THEN** 页面 SHALL 展示应用描述 `AI Gateway - 统一的大模型 API 网关`
- **THEN** 页面 SHALL 展示项目链接 `https://github.com/nex/gateway`
#### Scenario: 页面布局
- **WHEN** 渲染关于页面
- **THEN** 页面 SHALL 使用三个独立 TDesign Card 组件分别承载品牌区、版本信息区和链接区
- **THEN** 三个 Card SHALL 使用 grid 布局垂直排列
- **THEN** 每个 Card SHALL 设置 `bordered={false}``hoverShadow`
- **THEN** Card SHALL 使用 TDesign 组件 props 和 tokens 完成主要视觉效果
#### Scenario: 品牌卡片
- **WHEN** 渲染品牌卡片
- **THEN** 卡片 SHALL 展示应用图标、应用名称 `Nex` 和产品描述
#### Scenario: 版本信息卡片
- **WHEN** 渲染版本信息卡片
- **THEN** 版本状态 Tag SHALL 以绝对定位浮动在卡片右上角,不占据内容布局空间
- **THEN** 版本状态 Tag SHALL 使用 TDesign Tag 的 `theme``variant``shape` props
- **THEN** 卡片 SHALL 展示前端版本、后端版本、后端提交和后端构建时间
#### Scenario: 链接卡片
- **WHEN** 渲染链接卡片
- **THEN** 卡片 SHALL 展示项目外部链接
#### Scenario: 展示前端版本
- **WHEN** 渲染关于页面
- **THEN** 页面 SHALL 显示前端版本号
- **THEN** 前端版本号 SHALL 来源于构建注入的 `VITE_APP_VERSION`
- **THEN** 当前端版本号缺失时页面 SHALL 显示开发或未知版本状态
#### Scenario: 展示后端版本
- **WHEN** 渲染关于页面且后端版本接口请求成功
- **THEN** 页面 SHALL 显示后端 `version`
- **THEN** 页面 SHALL 显示后端 `commit`
- **THEN** 页面 SHALL 显示后端 `build_time`
#### Scenario: 判断版本一致
- **WHEN** 前端版本号与后端版本号相同
- **THEN** 页面 SHALL 显示版本一致状态
- **THEN** 版本状态 SHALL 使用 TDesign Tag 展示成功语义
#### Scenario: 判断版本不一致
- **WHEN** 前端版本号与后端版本号不同且两者均为可判断的发布版本
- **THEN** 页面 SHALL 显示版本不一致状态
- **THEN** 页面 SHALL 展示提示信息说明该状态用于部署诊断
- **THEN** 页面 SHALL NOT 阻断用户使用其他功能
#### Scenario: 后端版本无法判断
- **WHEN** 后端版本号为 `dev``unknown` 或空值
- **THEN** 页面 SHALL 显示开发构建或无法判断状态
- **THEN** 页面 SHALL NOT 将该状态显示为版本错误
#### Scenario: 后端版本获取失败
- **WHEN** 请求后端版本接口失败
- **THEN** 页面 SHALL 显示无法获取后端版本的状态
- **THEN** 页面 SHALL 保留前端版本信息
- **THEN** 页面 SHALL NOT 因版本接口失败而崩溃

View File

@@ -0,0 +1,91 @@
## MODIFIED Requirements
### Requirement: 系统托盘
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择。
#### Scenario: 托盘图标显示
- **WHEN** 桌面应用启动成功
- **THEN** 系统根据平台加载正确的图标格式
- **AND** 在 Windows 上加载 ICO 格式图标(`assets/icon.ico`
- **AND** 在 macOS 和 Linux 上加载 PNG 格式图标(`assets/icon.png`
- **AND** 托盘图标 tooltip 显示 `Nex`
#### Scenario: 托盘菜单显示
- **WHEN** 用户点击托盘图标(左键或右键)
- **THEN** 显示托盘菜单
- **AND** 菜单包含"打开管理界面"选项
- **AND** 菜单包含"状态: 运行中"选项(禁用状态)
- **AND** 菜单包含"端口: 9826"选项(禁用状态)
- **AND** 菜单包含"退出"选项
#### Scenario: 打开管理界面
- **WHEN** 用户点击托盘菜单"打开管理界面"
- **THEN** 系统在浏览器中打开 `http://localhost:9826`
#### Scenario: 浏览器打开失败
- **WHEN** 系统无法打开浏览器(浏览器未安装等)
- **THEN** 托盘菜单仍可正常使用
- **AND** 用户可手动访问 `http://localhost:9826`
#### Scenario: 退出应用
- **WHEN** 用户点击托盘菜单"退出"
- **THEN** 系统优雅关闭后端服务
- **AND** 托盘图标消失
- **AND** 应用进程退出
### Requirement: 静态文件服务
系统 SHALL 通过 Gin 同时服务 API、协议代理和前端静态资源。
#### Scenario: API 请求路由
- **WHEN** 请求路径以 `/api/``/health` 开头
- **THEN** 请求由现有业务 handler 处理或返回 API 风格 404
#### Scenario: 版本接口路由
- **WHEN** desktop 模式收到 `GET /api/version` 请求
- **THEN** 请求 SHALL 由版本信息 handler 处理
- **THEN** 响应 SHALL 为 API JSON 响应
- **THEN** 请求 SHALL NOT 返回前端 `index.html`
#### Scenario: 协议代理请求路由
- **WHEN** 请求路径以 `/openai/``/anthropic/` 开头
- **THEN** 请求 SHALL 被视为协议代理请求或返回 API 风格 404
- **THEN** 请求 SHALL NOT 返回前端 `index.html`
#### Scenario: OpenAI 代理路由
- **WHEN** desktop 模式收到 `/openai/v1/chat/completions` 请求
- **THEN** 请求 SHALL 进入 ProxyHandler
- **THEN** ProxyHandler SHALL 获取 clientProtocol 为 `openai`
#### Scenario: Anthropic 代理路由
- **WHEN** desktop 模式收到 `/anthropic/v1/messages` 请求
- **THEN** 请求 SHALL 进入 ProxyHandler
- **THEN** ProxyHandler SHALL 获取 clientProtocol 为 `anthropic`
#### Scenario: 静态资源路由
- **WHEN** 请求路径为 `/assets/*`
- **THEN** 返回嵌入的前端静态资源文件
- **THEN** 请求 SHALL NOT 被协议代理路由处理
#### Scenario: PNG Favicon 路由
- **WHEN** 请求路径为 `/icon.png`
- **THEN** 返回来源于统一应用图标的 PNG favicon 资源
- **THEN** 请求 SHALL NOT 被协议代理路由处理
#### Scenario: SPA 路由回退
- **WHEN** 请求路径不匹配任何 API、协议代理或静态资源路由
- **THEN** 返回 `index.html`(支持前端 SPA 路由)

View File

@@ -0,0 +1,121 @@
## MODIFIED Requirements
### Requirement: 样式体系
前端样式 SHALL 优先使用 TDesign 组件 props其次使用 TDesign tokens最后在前两者无法表达所需效果时使用 SCSS 作为补充工具。
#### Scenario: TDesign 组件 Props 优先
- **WHEN** 实现组件视觉效果
- **THEN** 前端 SHALL 优先使用 TDesign 组件的视觉增强 Props如 color、trend、hoverShadow、stripe、variant、shape、headerBordered、gutter 等)
- **THEN** 前端 SHALL NOT 通过 CSS 类名覆盖组件内部样式
#### Scenario: TDesign Tokens 作为第二优先级
- **WHEN** 组件 props 无法完整表达颜色、边框、背景、间距等视觉细节
- **THEN** 前端 SHALL 使用 TDesign CSS Token 引用(`var(--td-*)`)表达样式
- **THEN** 前端 SHALL NOT 在布局样式中硬编码 `#fff``#e7e7e7``#999` 等颜色值
#### Scenario: CSS Variables 主题微调
- **WHEN** 需要调整全局视觉风格
- **THEN** 前端 SHALL 通过 `:root` 中声明 TDesign CSS Variables`--td-*`)进行覆盖
- **THEN** 前端 SHALL NOT 使用 `!important` 或高优先级选择器覆盖组件样式
#### Scenario: SCSS 兜底使用
- **WHEN** TDesign 组件 props 和 TDesign tokens 均无法满足布局、响应式或品牌视觉需求
- **THEN** 前端 MAY 使用 SCSS 作为补充
- **THEN** SCSS SHALL 只承载必要的补充样式
- **THEN** 前端 SHALL NOT 使用纯 CSS 文件(*.css
### Requirement: 提供响应式布局
前端 SHALL 使用 TDesign Layout 提供侧边栏导航布局。
#### Scenario: 桌面布局
- **WHEN** 在桌面屏幕上查看前端
- **THEN** 布局 SHALL 使用 TDesign `Layout.Aside` + `Menu`
- **THEN** 侧边栏 SHALL 显示导航菜单,包含图标和文字标签
- **THEN** 侧边栏 SHALL 使用固定宽度 232px
- **THEN** Menu 组件 SHALL 使用 `logo` prop 显示品牌标识
- **THEN** Menu 组件 SHALL 使用 `operations` prop 在底部显示操作区域
- **THEN** Menu 组件 SHALL 支持 `collapsed` 折叠功能
#### Scenario: 侧边栏折叠布局
- **WHEN** 用户折叠侧边栏
- **THEN** 侧边栏 SHALL 使用折叠宽度 64px
- **THEN** Menu logo 区域 SHALL 保留应用图标
- **THEN** Menu logo 区域 SHALL 隐藏应用名称文字
- **THEN** Menu logo 区域 SHALL NOT 显示为空白
#### Scenario: 页面内容区域
- **WHEN** 显示页面内容
- **THEN** 内容区域 SHALL 在 `Layout.Content` 中渲染
- **THEN** 页面之间 SHALL 通过 React Router Outlet 渲染
#### Scenario: Header 区域
- **WHEN** 渲染页面 Header
- **THEN** Header SHALL 仅显示当前页面标题
- **THEN** Header SHALL 不包含导航菜单
- **THEN** Header 背景色 SHALL 使用 `var(--td-bg-color-container)` Token
- **THEN** Header 底部分割线 SHALL 使用 `var(--td-component-stroke)` Token
### Requirement: 提供侧边栏导航
前端 SHALL 使用 TDesign `Layout.Aside` 提供侧边栏导航。
#### Scenario: 侧边栏内容
- **WHEN** 渲染侧边栏
- **THEN** 侧边栏顶部 SHALL 显示统一应用图标和应用名称 `Nex`
- **THEN** 侧边栏 SHALL NOT 显示旧品牌文字 `AI Gateway` 作为应用名称
- **THEN** 侧边栏 SHALL 包含导航菜单
- **THEN** 导航菜单项 SHALL 包含供应商管理ServerIcon 图标、用量统计ChartLineIcon 图标、设置SettingIcon 图标、关于InfoCircleIcon 图标)
#### Scenario: 侧边栏折叠品牌显示
- **WHEN** 侧边栏处于折叠状态
- **THEN** 侧边栏顶部 SHALL 显示统一应用图标
- **THEN** 侧边栏顶部 SHALL 隐藏 `Nex` 文案
- **THEN** 侧边栏顶部 SHALL NOT 为空白
#### Scenario: 导航菜单交互
- **WHEN** 用户点击导航中的"供应商管理"
- **THEN** 前端 SHALL 导航到 `/providers` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"用量统计"
- **THEN** 前端 SHALL 导航到 `/stats` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"设置"
- **THEN** 前端 SHALL 导航到 `/settings` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"关于"
- **THEN** 前端 SHALL 导航到 `/about` 并高亮当前菜单项
## ADDED Requirements
### Requirement: 统一 public 图标资源
前端 SHALL 使用仓库统一应用图标作为 public favicon 和品牌图标来源。
#### Scenario: 使用 PNG favicon
- **WHEN** 前端页面加载 HTML 入口
- **THEN** 页面 SHALL 使用 `/icon.png` 作为 PNG favicon 路径
- **THEN** `frontend/public/icon.png` SHALL 来源于仓库根目录 `assets/icon.png`
- **THEN** 页面 SHALL NOT 引用独立维护的 SVG favicon
#### Scenario: HTML 标题使用统一应用名称
- **WHEN** 前端页面加载 HTML 入口
- **THEN** 页面标题 SHALL 使用 `Nex` 作为应用名称
- **THEN** 页面标题 SHALL NOT 使用旧应用名称 `AI Gateway`
#### Scenario: 清理未使用 public SVG 图标
- **WHEN** public 目录中的 SVG 图标资源没有被前端代码、HTML 或 desktop 静态服务引用
- **THEN** 前端 SHALL 删除该未使用 SVG 图标资源
- **THEN** 前端 SHALL NOT 保留未使用的 `frontend/public/icons.svg`

View File

@@ -0,0 +1,54 @@
## ADDED Requirements
### Requirement: 后端运行时版本查询
系统 SHALL 通过管理接口暴露后端运行时构建版本信息,供前端和用户诊断前后端版本一致性。
#### Scenario: 查询后端版本信息
- **WHEN** 客户端请求 `GET /api/version`
- **THEN** 后端 SHALL 返回 HTTP 200
- **THEN** 响应 JSON SHALL 包含 `version` 字段
- **THEN** 响应 JSON SHALL 包含 `commit` 字段
- **THEN** 响应 JSON SHALL 包含 `build_time` 字段
#### Scenario: 版本信息来源于构建注入
- **WHEN** 后端返回版本信息
- **THEN** `version` SHALL 来源于 `buildinfo.Version()`
- **THEN** `commit` SHALL 来源于 `buildinfo.Commit()`
- **THEN** `build_time` SHALL 来源于 `buildinfo.BuildTime()`
- **THEN** 后端 SHALL NOT 在运行时读取仓库 `VERSION` 文件作为接口响应来源
#### Scenario: 本地开发构建降级值
- **WHEN** 后端未通过构建参数注入版本元数据
- **THEN** 后端版本接口 SHALL 返回 buildinfo 的默认降级值
- **THEN** 前端 SHALL 能够展示该降级值而不崩溃
### Requirement: 前后端版本一致性诊断
系统 SHALL 支持前端使用自身构建版本和后端运行时版本进行一致性诊断。
#### Scenario: 前端读取构建版本
- **WHEN** 前端渲染版本信息
- **THEN** 前端 SHALL 使用 `VITE_APP_VERSION` 作为前端版本号
- **THEN** `VITE_APP_VERSION` SHALL 继续由版本同步流程保持与 `VERSION` 一致
#### Scenario: 诊断版本匹配
- **WHEN** 前端版本号和后端版本号均可判断且完全相同
- **THEN** 前端 SHALL 将版本状态判定为一致
#### Scenario: 诊断版本不匹配
- **WHEN** 前端版本号和后端版本号均可判断且不相同
- **THEN** 前端 SHALL 将版本状态判定为不一致
- **THEN** 前端 SHALL 将该状态作为诊断提示展示
#### Scenario: 诊断版本不可判断
- **WHEN** 任一版本号为空、`dev``unknown`
- **THEN** 前端 SHALL 将版本状态判定为无法判断
- **THEN** 前端 SHALL NOT 将该状态判定为版本不一致

View File

@@ -0,0 +1,57 @@
## 1. 后端版本接口
- [x] 1.1 新增后端版本信息 handler响应 `version``commit``build_time` 并复用 `buildinfo`
- [x] 1.2 在 server 入口注册 `GET /api/version`
- [x] 1.3 在 desktop 入口注册 `GET /api/version`
- [x] 1.4 为版本 handler 增加单元测试覆盖响应字段和默认降级值
- [x] 1.5 为 server 路由增加测试覆盖 `GET /api/version`
- [x] 1.6 为 desktop 路由增加测试覆盖 `GET /api/version` 不落入 SPA fallback
## 2. 前端版本 API 与状态判断
- [x] 2.1 新增前端版本信息类型定义,包含后端 `version``commit``buildTime`
- [x] 2.2 新增前端版本 API 调用 `GET /api/version`
- [x] 2.3 新增 TanStack Query hook 获取后端版本信息
- [x] 2.4 集中定义前端 `APP_NAME`、产品描述和 `VITE_APP_VERSION` 降级值
- [x] 2.5 实现前后端版本状态判断逻辑,覆盖一致、不一致、无法判断和请求失败
- [x] 2.6 为版本 API 和版本状态判断增加前端单元测试
## 3. 品牌与图标资源
- [x] 3.1 将侧边栏品牌名称从 `AI Gateway` 替换为 `Nex`
- [x] 3.2 使用统一应用图标渲染侧边栏 logo折叠态保留图标并隐藏文字
- [x] 3.3 为侧边栏折叠按钮补充清晰的无障碍标签
- [x] 3.4 将根目录 `assets/icon.png` 复制为 `frontend/public/icon.png`
- [x] 3.5 将前端 HTML favicon 路径改为 `/icon.png` 并将 HTML title 改为 `Nex`
- [x] 3.6 将 desktop 静态文件服务的 `/favicon.svg` 路由替换为 `/icon.png` 路由
- [x] 3.7 删除未使用的 `frontend/public/icons.svg` 和旧 SVG favicon 资源
- [x] 3.8 全文确认不存在未处理的 `icons.svg``favicon.svg` 引用
- [x] 3.9 更新 AppLayout 组件测试覆盖展开态 `Nex`、折叠态图标和 About 菜单项
- [x] 3.10 更新导航 E2E 中旧 `AI Gateway` 品牌断言为 `Nex`
## 4. About 页面改造
- [x] 4.1 重构 About 页面为品牌卡片、版本信息卡片和链接卡片三卡布局
- [x] 4.2 使用 TDesign Card、Tag、Descriptions、Alert、Row/Col 等组件 props 完成主要视觉结构
- [x] 4.3 在 About 页面展示前端版本、后端版本、commit 和 build_time
- [x] 4.4 在 About 页面展示版本一致、不一致、无法判断和请求失败状态
- [x] 4.5 确保版本不一致或接口失败时 About 页面不崩溃且不阻断其他功能
- [x] 4.6 为 About 页面增加组件测试覆盖布局、版本展示和所有版本状态
- [x] 4.7 为 About 页面补充必要的 E2E 导航与版本区域可见性测试
## 5. 文档同步
- [x] 5.1 更新根 README 的管理接口列表,加入 `GET /api/version`
- [x] 5.2 更新根 README 的前端样式技术栈说明,体现 TDesign props、TDesign tokens、SCSS 优先级
- [x] 5.3 更新 backend README 的管理接口文档,说明版本响应字段来源
- [x] 5.4 更新 frontend README 的环境变量说明,加入 `VITE_APP_VERSION`
- [x] 5.5 更新 frontend README 的样式优先级为 TDesign props、TDesign tokens、SCSS
- [x] 5.6 更新 frontend README 的项目结构、About 页面说明和 public 图标来源说明
- [x] 5.7 更新 desktop 相关文档说明 PNG favicon、`/icon.png` 静态路由和 API 版本接口路由
## 6. 验证
- [x] 6.1 运行后端测试,确保 handler、server 和 desktop 路由测试通过
- [x] 6.2 运行前端测试,确保 API、hook、AppLayout 和 About 页面测试通过
- [x] 6.3 运行前端检查和构建,确保 Bun/Vite 构建能加载 PNG favicon
- [x] 6.4 运行 OpenSpec 校验,确保 proposal、design、specs 和 tasks 状态可实施

View File

@@ -8,7 +8,7 @@ context: |
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
- backend是使用go开发的后端阅读backend/README.md了解项目架构优先使用公共组件实现功能逻辑优先级官方库>主流三方库>项目公共工具>自行实现)
- frontend是基于bun+vite+typescript开发的前端使用bun作为唯一包管理器严禁使用pnpm、npm
- frontend是基于bun+vite+typescript开发的前端使用bun作为唯一包管理器严禁使用pnpm、npm使用bunx运行工具严禁使用npx、pnpx
- Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
- 禁止创建git操作task
- 积极使用subagents精心设计并行任务节省上下文空间加速任务执行

View File

@@ -2,25 +2,83 @@
## Purpose
TBD - 提供关于页面展示项目品牌信息
TBD - 提供关于页面展示项目品牌信息、版本信息和外部链接
## Requirements
### Requirement: 关于页面
前端 SHALL 提供关于页面,使用 TDesign Card 组件居中展示项目品牌信息(应用名称、描述、项目链接)
前端 SHALL 提供现代化关于页面,使用 TDesign 组件展示项目品牌信息、项目链接、前端版本、后端版本和版本匹配状态
#### Scenario: 显示关于页面
- **WHEN** 用户访问 `/about` 路径
- **THEN** 前端 SHALL 显示关于页面
- **THEN** 页面 SHALL 展示应用名称"Nex"
- **THEN** 页面 SHALL 展示应用描述"AI Gateway - 统一的大模型 API 网关"
- **THEN** 页面 SHALL 展示项目链接"https://github.com/nex/gateway"
- **THEN** 页面 SHALL 展示应用名称 `Nex`
- **THEN** 页面 SHALL 展示应用描述 `AI Gateway - 统一的大模型 API 网关`
- **THEN** 页面 SHALL 展示项目链接 `https://github.com/nex/gateway`
#### Scenario: 页面布局
- **WHEN** 渲染关于页面
- **THEN** 页面 SHALL 使用 TDesign Card 组件作为容器
- **THEN** Card SHALL 设置 `bordered={false}`
- **THEN** 内容 SHALL 居中展示
- **THEN** 页面 SHALL 使用三个独立 TDesign Card 组件分别承载品牌区、版本信息区和链接区
- **THEN** 三个 Card SHALL 使用 grid 布局垂直排列
- **THEN** 每个 Card SHALL 设置 `bordered={false}``hoverShadow`
- **THEN** Card SHALL 使用 TDesign 组件 props 和 tokens 完成主要视觉效果
#### Scenario: 品牌卡片
- **WHEN** 渲染品牌卡片
- **THEN** 卡片 SHALL 展示应用图标、应用名称 `Nex` 和产品描述
#### Scenario: 版本信息卡片
- **WHEN** 渲染版本信息卡片
- **THEN** 版本状态 Tag SHALL 以绝对定位浮动在卡片右上角,不占据内容布局空间
- **THEN** 版本状态 Tag SHALL 使用 TDesign Tag 的 `theme``variant``shape` props
- **THEN** 卡片 SHALL 展示前端版本、后端版本、后端提交和后端构建时间
#### Scenario: 链接卡片
- **WHEN** 渲染链接卡片
- **THEN** 卡片 SHALL 展示项目外部链接
#### Scenario: 展示前端版本
- **WHEN** 渲染关于页面
- **THEN** 页面 SHALL 显示前端版本号
- **THEN** 前端版本号 SHALL 来源于构建注入的 `VITE_APP_VERSION`
- **THEN** 当前端版本号缺失时页面 SHALL 显示开发或未知版本状态
#### Scenario: 展示后端版本
- **WHEN** 渲染关于页面且后端版本接口请求成功
- **THEN** 页面 SHALL 显示后端 `version`
- **THEN** 页面 SHALL 显示后端 `commit`
- **THEN** 页面 SHALL 显示后端 `build_time`
#### Scenario: 判断版本一致
- **WHEN** 前端版本号与后端版本号相同
- **THEN** 页面 SHALL 显示版本一致状态
- **THEN** 版本状态 SHALL 使用 TDesign Tag 展示成功语义
#### Scenario: 判断版本不一致
- **WHEN** 前端版本号与后端版本号不同且两者均为可判断的发布版本
- **THEN** 页面 SHALL 显示版本不一致状态
- **THEN** 页面 SHALL 展示提示信息说明该状态用于部署诊断
- **THEN** 页面 SHALL NOT 阻断用户使用其他功能
#### Scenario: 后端版本无法判断
- **WHEN** 后端版本号为 `dev``unknown` 或空值
- **THEN** 页面 SHALL 显示开发构建或无法判断状态
- **THEN** 页面 SHALL NOT 将该状态显示为版本错误
#### Scenario: 后端版本获取失败
- **WHEN** 请求后端版本接口失败
- **THEN** 页面 SHALL 显示无法获取后端版本的状态
- **THEN** 页面 SHALL 保留前端版本信息
- **THEN** 页面 SHALL NOT 因版本接口失败而崩溃

View File

@@ -42,7 +42,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
- **THEN** 系统根据平台加载正确的图标格式
- **AND** 在 Windows 上加载 ICO 格式图标(`assets/icon.ico`
- **AND** 在 macOS 和 Linux 上加载 PNG 格式图标(`assets/icon.png`
- **AND** 托盘图标 tooltip 显示"AI Gateway"
- **AND** 托盘图标 tooltip 显示 `Nex`
#### Scenario: 托盘菜单显示
@@ -80,6 +80,13 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
- **WHEN** 请求路径以 `/api/``/health` 开头
- **THEN** 请求由现有业务 handler 处理或返回 API 风格 404
#### Scenario: 版本接口路由
- **WHEN** desktop 模式收到 `GET /api/version` 请求
- **THEN** 请求 SHALL 由版本信息 handler 处理
- **THEN** 响应 SHALL 为 API JSON 响应
- **THEN** 请求 SHALL NOT 返回前端 `index.html`
#### Scenario: 协议代理请求路由
- **WHEN** 请求路径以 `/openai/``/anthropic/` 开头
@@ -104,10 +111,10 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
- **THEN** 返回嵌入的前端静态资源文件
- **THEN** 请求 SHALL NOT 被协议代理路由处理
#### Scenario: Favicon 路由
#### Scenario: PNG Favicon 路由
- **WHEN** 请求路径为 `/favicon.svg`
- **THEN** 返回嵌入的前端 favicon 资源
- **WHEN** 请求路径为 `/icon.png`
- **THEN** 返回来源于统一应用图标的 PNG favicon 资源
- **THEN** 请求 SHALL NOT 被协议代理路由处理
#### Scenario: SPA 路由回退

View File

@@ -8,31 +8,31 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
### Requirement: 样式体系
前端样式 SHALL 优先使用 TDesign 样式体系,SCSS 作为补充工具。
前端样式 SHALL 优先使用 TDesign 组件 props其次使用 TDesign tokens最后在前两者无法表达所需效果时使用 SCSS 作为补充工具。
#### Scenario: TDesign 组件 Props 优先
- **WHEN** 实现组件视觉效果
- **THEN** 前端 SHALL 优先使用 TDesign 组件的视觉增强 Props如 color、trend、hoverShadow、stripe、variant、shape 等)
- **THEN** 前端 SHALL 优先使用 TDesign 组件的视觉增强 Props如 color、trend、hoverShadow、stripe、variant、shape、headerBordered、gutter 等)
- **THEN** 前端 SHALL NOT 通过 CSS 类名覆盖组件内部样式
#### Scenario: TDesign Tokens 作为第二优先级
- **WHEN** 组件 props 无法完整表达颜色、边框、背景、间距等视觉细节
- **THEN** 前端 SHALL 使用 TDesign CSS Token 引用(`var(--td-*)`)表达样式
- **THEN** 前端 SHALL NOT 在布局样式中硬编码 `#fff``#e7e7e7``#999` 等颜色值
#### Scenario: CSS Variables 主题微调
- **WHEN** 需要调整全局视觉风格
- **THEN** 前端 SHALL 通过 \`:root\` 中声明 TDesign CSS Variables\`--td-*\`)进行覆盖
- **THEN** 前端 SHALL NOT 使用 \`!important\` 或高优先级选择器覆盖组件样式
- **THEN** 前端 SHALL 通过 `:root` 中声明 TDesign CSS Variables`--td-*`)进行覆盖
- **THEN** 前端 SHALL NOT 使用 `!important` 或高优先级选择器覆盖组件样式
#### Scenario: 布局样式 Token 化
#### Scenario: SCSS 兜底使用
- **WHEN** 编写布局级 inline style
- **THEN** 前端 SHALL 使用 TDesign CSS Token 引用(\`var(--td-*)\`)替代硬编码颜色值
- **THEN** 前端 SHALL NOT 在布局样式中硬编码 \`#fff\`、\`#e7e7e7\`、\`#999\` 等颜色值
#### Scenario: SCSS 补充使用
- **WHEN** TDesign 样式体系无法满足需求
- **WHEN** TDesign 组件 props 和 TDesign tokens 均无法满足布局、响应式或品牌视觉需求
- **THEN** 前端 MAY 使用 SCSS 作为补充
- **THEN** SCSS 文件 SHALL 仅用于 \`:root\` 级别的 CSS Variables 声明和全局 reset
- **THEN** SCSS SHALL 只承载必要的补充样式
- **THEN** 前端 SHALL NOT 使用纯 CSS 文件(*.css
@@ -231,12 +231,20 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
#### Scenario: 桌面布局
- **WHEN** 在桌面屏幕上查看前端
- **THEN** 布局 SHALL 使用 TDesign \`Layout.Aside\` + \`Menu\`
- **THEN** 布局 SHALL 使用 TDesign `Layout.Aside` + `Menu`
- **THEN** 侧边栏 SHALL 显示导航菜单,包含图标和文字标签
- **THEN** 侧边栏 SHALL 使用固定宽度 232px
- **THEN** Menu 组件 SHALL 使用 \`logo\` prop 显示品牌标识
- **THEN** Menu 组件 SHALL 使用 \`operations\` prop 在底部显示操作区域
- **THEN** Menu 组件 SHALL 支持 \`collapsed\` 折叠功能
- **THEN** Menu 组件 SHALL 使用 `logo` prop 显示品牌标识
- **THEN** Menu 组件 SHALL 使用 `operations` prop 在底部显示操作区域
- **THEN** Menu 组件 SHALL 支持 `collapsed` 折叠功能
#### Scenario: 侧边栏折叠布局
- **WHEN** 用户折叠侧边栏
- **THEN** 侧边栏 SHALL 使用折叠宽度 64px
- **THEN** Menu logo 区域 SHALL 保留应用图标
- **THEN** Menu logo 区域 SHALL 隐藏应用名称文字
- **THEN** Menu logo 区域 SHALL NOT 显示为空白
#### Scenario: 页面内容区域
@@ -402,21 +410,29 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
### Requirement: 提供侧边栏导航
前端 SHALL 使用 TDesign \`Layout.Aside\` 提供侧边栏导航。
前端 SHALL 使用 TDesign `Layout.Aside` 提供侧边栏导航。
#### Scenario: 侧边栏内容
- **WHEN** 渲染侧边栏
- **THEN** 侧边栏顶部 SHALL 显示应用名称/Logo
- **THEN** 侧边栏顶部 SHALL 显示统一应用图标和应用名称 `Nex`
- **THEN** 侧边栏 SHALL NOT 显示旧品牌文字 `AI Gateway` 作为应用名称
- **THEN** 侧边栏 SHALL 包含导航菜单
- **THEN** 导航菜单项 SHALL 包含供应商管理ServerIcon 图标、用量统计ChartLineIcon 图标、设置SettingIcon 图标、关于InfoCircleIcon 图标)
#### Scenario: 侧边栏折叠品牌显示
- **WHEN** 侧边栏处于折叠状态
- **THEN** 侧边栏顶部 SHALL 显示统一应用图标
- **THEN** 侧边栏顶部 SHALL 隐藏 `Nex` 文案
- **THEN** 侧边栏顶部 SHALL NOT 为空白
#### Scenario: 导航菜单交互
- **WHEN** 用户点击导航中的"供应商管理"
- **THEN** 前端 SHALL 导航到 \`/providers\` 并高亮当前菜单项
- **THEN** 前端 SHALL 导航到 `/providers` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"用量统计"
- **THEN** 前端 SHALL 导航到 \`/stats\` 并高亮当前菜单项
- **THEN** 前端 SHALL 导航到 `/stats` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"设置"
- **THEN** 前端 SHALL 导航到 `/settings` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"关于"
@@ -583,3 +599,26 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **THEN** 前端 SHALL 显示全局错误提示
- **THEN** 错误消息 SHALL 具有描述性
### Requirement: 统一 public 图标资源
前端 SHALL 使用仓库统一应用图标作为 public favicon 和品牌图标来源。
#### Scenario: 使用 PNG favicon
- **WHEN** 前端页面加载 HTML 入口
- **THEN** 页面 SHALL 使用 `/icon.png` 作为 PNG favicon 路径
- **THEN** `frontend/public/icon.png` SHALL 来源于仓库根目录 `assets/icon.png`
- **THEN** 页面 SHALL NOT 引用独立维护的 SVG favicon
#### Scenario: HTML 标题使用统一应用名称
- **WHEN** 前端页面加载 HTML 入口
- **THEN** 页面标题 SHALL 使用 `Nex` 作为应用名称
- **THEN** 页面标题 SHALL NOT 使用旧应用名称 `AI Gateway`
#### Scenario: 清理未使用 public SVG 图标
- **WHEN** public 目录中的 SVG 图标资源没有被前端代码、HTML 或 desktop 静态服务引用
- **THEN** 前端 SHALL 删除该未使用 SVG 图标资源
- **THEN** 前端 SHALL NOT 保留未使用的 `frontend/public/icons.svg`

View File

@@ -66,3 +66,56 @@
- **WHEN** 执行前端生产构建
- **THEN** 构建流程 SHALL 注入 `VITE_APP_VERSION`
- **AND** 该值 SHALL 等于 `VERSION` 中的版本号
### Requirement: 后端运行时版本查询
系统 SHALL 通过管理接口暴露后端运行时构建版本信息,供前端和用户诊断前后端版本一致性。
#### Scenario: 查询后端版本信息
- **WHEN** 客户端请求 `GET /api/version`
- **THEN** 后端 SHALL 返回 HTTP 200
- **THEN** 响应 JSON SHALL 包含 `version` 字段
- **THEN** 响应 JSON SHALL 包含 `commit` 字段
- **THEN** 响应 JSON SHALL 包含 `build_time` 字段
#### Scenario: 版本信息来源于构建注入
- **WHEN** 后端返回版本信息
- **THEN** `version` SHALL 来源于 `buildinfo.Version()`
- **THEN** `commit` SHALL 来源于 `buildinfo.Commit()`
- **THEN** `build_time` SHALL 来源于 `buildinfo.BuildTime()`
- **THEN** 后端 SHALL NOT 在运行时读取仓库 `VERSION` 文件作为接口响应来源
#### Scenario: 本地开发构建降级值
- **WHEN** 后端未通过构建参数注入版本元数据
- **THEN** 后端版本接口 SHALL 返回 buildinfo 的默认降级值
- **THEN** 前端 SHALL 能够展示该降级值而不崩溃
### Requirement: 前后端版本一致性诊断
系统 SHALL 支持前端使用自身构建版本和后端运行时版本进行一致性诊断。
#### Scenario: 前端读取构建版本
- **WHEN** 前端渲染版本信息
- **THEN** 前端 SHALL 使用 `VITE_APP_VERSION` 作为前端版本号
- **THEN** `VITE_APP_VERSION` SHALL 继续由版本同步流程保持与 `VERSION` 一致
#### Scenario: 诊断版本匹配
- **WHEN** 前端版本号和后端版本号均可判断且完全相同
- **THEN** 前端 SHALL 将版本状态判定为一致
#### Scenario: 诊断版本不匹配
- **WHEN** 前端版本号和后端版本号均可判断且不相同
- **THEN** 前端 SHALL 将版本状态判定为不一致
- **THEN** 前端 SHALL 将该状态作为诊断提示展示
#### Scenario: 诊断版本不可判断
- **WHEN** 任一版本号为空、`dev``unknown`
- **THEN** 前端 SHALL 将版本状态判定为无法判断
- **THEN** 前端 SHALL NOT 将该状态判定为版本不一致