Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 394025c8ea | |||
| 34bd749741 | |||
| 290f299e22 | |||
| 859dec8ada | |||
| 993c0a72d6 | |||
| c9c3a84b33 | |||
| 6de7a2d2e1 | |||
| 6181923d8d | |||
| 235efb0e62 | |||
| 6b1af27ea2 | |||
| 32f48777f3 | |||
| bc7a7c6e81 | |||
| 3cd0458c2c | |||
| 8eea30ea11 | |||
| 9e33e570af | |||
| 7653385838 |
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -1,8 +1,9 @@
|
|||||||
* text=auto eol=lf
|
* text=auto eol=lf
|
||||||
|
|
||||||
assets/*.png filter=lfs diff=lfs merge=lfs -text
|
assets/*.png filter=lfs diff=lfs merge=lfs -text
|
||||||
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/**/*.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
|
||||||
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
|
||||||
|
|||||||
219
.github/workflows/release.yml
vendored
219
.github/workflows/release.yml
vendored
@@ -18,42 +18,122 @@ jobs:
|
|||||||
version: ${{ steps.version.outputs.version }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
lfs: true
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.work
|
go-version-file: go.work
|
||||||
|
cache-dependency-path: |
|
||||||
|
backend/go.sum
|
||||||
|
versionctl/go.sum
|
||||||
|
|
||||||
- name: Verify tag and VERSION
|
- name: Verify tag and VERSION
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
version=$(go run ./backend/cmd/versionctl print)
|
version=$(go run ./versionctl print)
|
||||||
go run ./backend/cmd/versionctl verify-tag "${GITHUB_REF_NAME}"
|
go run ./versionctl verify-tag "${GITHUB_REF_NAME}"
|
||||||
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
|
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
build-linux:
|
build-web:
|
||||||
name: Build Linux Assets
|
name: Build Web Asset
|
||||||
needs: prepare
|
needs: prepare
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
lfs: true
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.work
|
go-version-file: go.work
|
||||||
|
cache-dependency-path: |
|
||||||
|
backend/go.sum
|
||||||
|
versionctl/go.sum
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Install Linux desktop build dependencies
|
- name: Preflight web release toolchain
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
command -v go
|
||||||
|
go version
|
||||||
|
command -v bun
|
||||||
|
bun --version
|
||||||
|
make release-assets-check
|
||||||
|
|
||||||
|
- name: Build web release asset
|
||||||
|
run: make release-assets-web
|
||||||
|
|
||||||
|
- name: Upload web release asset
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: release-web
|
||||||
|
path: build/release/*
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
name: Build Linux ${{ matrix.arch }} Assets
|
||||||
|
needs: prepare
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- arch: arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
lfs: true
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.work
|
||||||
|
cache-dependency-path: |
|
||||||
|
backend/go.sum
|
||||||
|
versionctl/go.sum
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Install Linux desktop and package dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libayatana-appindicator3-dev libgtk-3-dev
|
sudo apt-get install -y curl file libayatana-appindicator3-dev libgtk-3-dev rpm
|
||||||
|
|
||||||
|
- name: Preflight Linux release toolchain
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
printf 'runner arch: %s\n' "$(uname -m)"
|
||||||
|
command -v go
|
||||||
|
go version
|
||||||
|
command -v bun
|
||||||
|
bun --version
|
||||||
|
command -v gcc
|
||||||
|
gcc --version
|
||||||
|
command -v pkg-config
|
||||||
|
pkg-config --modversion ayatana-appindicator3-0.1
|
||||||
|
pkg-config --modversion gtk+-3.0
|
||||||
|
command -v curl
|
||||||
|
command -v dpkg-deb
|
||||||
|
dpkg-deb --version
|
||||||
|
command -v rpmbuild
|
||||||
|
rpmbuild --version
|
||||||
|
make release-assets-check
|
||||||
|
|
||||||
- name: Build Linux release assets
|
- name: Build Linux release assets
|
||||||
run: make release-assets-linux
|
run: make release-assets-linux
|
||||||
@@ -61,23 +141,50 @@ jobs:
|
|||||||
- name: Upload Linux release assets
|
- name: Upload Linux release assets
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release-linux
|
name: release-linux-${{ matrix.arch }}
|
||||||
path: build/release/*
|
path: build/release/*
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
name: Build Windows Assets
|
name: Build Windows ${{ matrix.arch }} Assets
|
||||||
needs: prepare
|
needs: prepare
|
||||||
runs-on: windows-latest
|
runs-on: ${{ matrix.runner }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: amd64
|
||||||
|
runner: windows-latest
|
||||||
|
msystem: MINGW64
|
||||||
|
cc: gcc
|
||||||
|
cxx: g++
|
||||||
|
packages: >-
|
||||||
|
make
|
||||||
|
mingw-w64-x86_64-gcc
|
||||||
|
- arch: arm64
|
||||||
|
runner: windows-11-arm
|
||||||
|
msystem: CLANGARM64
|
||||||
|
cc: clang
|
||||||
|
cxx: clang++
|
||||||
|
packages: >-
|
||||||
|
make
|
||||||
|
mingw-w64-clang-aarch64-clang
|
||||||
|
mingw-w64-clang-aarch64-llvm
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
lfs: true
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.work
|
go-version-file: go.work
|
||||||
|
cache-dependency-path: |
|
||||||
|
backend/go.sum
|
||||||
|
versionctl/go.sum
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
@@ -85,39 +192,98 @@ jobs:
|
|||||||
- name: Setup MSYS2 toolchain
|
- name: Setup MSYS2 toolchain
|
||||||
uses: msys2/setup-msys2@v2
|
uses: msys2/setup-msys2@v2
|
||||||
with:
|
with:
|
||||||
|
msystem: ${{ matrix.msystem }}
|
||||||
|
path-type: inherit
|
||||||
update: true
|
update: true
|
||||||
install: >-
|
install: ${{ matrix.packages }}
|
||||||
make
|
|
||||||
mingw-w64-x86_64-gcc
|
- name: Preflight Windows release toolchain
|
||||||
|
shell: msys2 {0}
|
||||||
|
env:
|
||||||
|
CC: ${{ matrix.cc }}
|
||||||
|
CXX: ${{ matrix.cxx }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
command -v go
|
||||||
|
go version
|
||||||
|
command -v bun
|
||||||
|
bun --version
|
||||||
|
command -v make
|
||||||
|
make --version
|
||||||
|
command -v "$CC"
|
||||||
|
"$CC" --version
|
||||||
|
command -v "$CXX"
|
||||||
|
"$CXX" --version
|
||||||
|
if [ "${{ matrix.arch }}" = "arm64" ]; then
|
||||||
|
if command -v llvm-windres >/dev/null 2>&1; then
|
||||||
|
llvm-windres --version
|
||||||
|
else
|
||||||
|
command -v windres
|
||||||
|
windres --version
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
command -v windres
|
||||||
|
windres --version
|
||||||
|
fi
|
||||||
|
if command -v powershell.exe >/dev/null 2>&1; then
|
||||||
|
powershell.exe -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'
|
||||||
|
else
|
||||||
|
command -v powershell
|
||||||
|
powershell -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'
|
||||||
|
fi
|
||||||
|
make release-assets-check
|
||||||
|
|
||||||
- name: Build Windows release assets
|
- name: Build Windows release assets
|
||||||
shell: msys2 {0}
|
shell: msys2 {0}
|
||||||
|
env:
|
||||||
|
CC: ${{ matrix.cc }}
|
||||||
|
CXX: ${{ matrix.cxx }}
|
||||||
run: make release-assets-windows
|
run: make release-assets-windows
|
||||||
|
|
||||||
- name: Upload Windows release assets
|
- name: Upload Windows release assets
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release-windows
|
name: release-windows-${{ matrix.arch }}
|
||||||
path: build/release/*
|
path: build/release/*
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
name: Build macOS Assets
|
name: Build macOS Assets
|
||||||
needs: prepare
|
needs: prepare
|
||||||
runs-on: macos-latest
|
runs-on: macos-15
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
lfs: true
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.work
|
go-version-file: go.work
|
||||||
|
cache-dependency-path: |
|
||||||
|
backend/go.sum
|
||||||
|
versionctl/go.sum
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Preflight macOS release toolchain
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
printf 'runner arch: %s\n' "$(uname -m)"
|
||||||
|
command -v go
|
||||||
|
go version
|
||||||
|
command -v bun
|
||||||
|
bun --version
|
||||||
|
command -v ditto
|
||||||
|
command -v hdiutil
|
||||||
|
xcrun --find lipo
|
||||||
|
xcrun --find vtool
|
||||||
|
make release-assets-check
|
||||||
|
|
||||||
- name: Build macOS release assets
|
- name: Build macOS release assets
|
||||||
run: make release-assets-macos
|
run: make release-assets-macos
|
||||||
|
|
||||||
@@ -126,14 +292,18 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: release-macos
|
name: release-macos
|
||||||
path: build/release/*
|
path: build/release/*
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
draft-release:
|
draft-release:
|
||||||
name: Create Draft Release
|
name: Create Draft Release
|
||||||
needs: [prepare, build-linux, build-windows, build-macos]
|
needs: [prepare, build-web, build-linux, build-windows, build-macos]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Download release assets
|
- name: Download release assets
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -141,6 +311,9 @@ jobs:
|
|||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
path: dist
|
path: dist
|
||||||
|
|
||||||
|
- name: Generate checksums
|
||||||
|
run: make release-assets-checksums RELEASE_DIR=dist
|
||||||
|
|
||||||
- name: Publish draft release
|
- name: Publish draft release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -408,6 +408,7 @@ temp
|
|||||||
skills-lock.json
|
skills-lock.json
|
||||||
.worktrees
|
.worktrees
|
||||||
!scripts/build/
|
!scripts/build/
|
||||||
|
backend/bin
|
||||||
|
|
||||||
# Embedfs generated
|
# Embedfs generated
|
||||||
embedfs/assets/
|
embedfs/assets/
|
||||||
|
|||||||
335
Makefile
335
Makefile
@@ -1,37 +1,62 @@
|
|||||||
.PHONY: \
|
.PHONY: \
|
||||||
lint test clean \
|
lint test clean \
|
||||||
version-sync version-check \
|
version-sync version-check version-bump \
|
||||||
server-run server-build server-lint server-test server-clean \
|
server-run server-build server-lint server-test server-clean \
|
||||||
desktop-build-mac desktop-build-win desktop-build-linux \
|
desktop-build-mac desktop-build-win desktop-build-linux \
|
||||||
desktop-lint desktop-test desktop-clean \
|
desktop-lint desktop-test desktop-clean \
|
||||||
release-assets-linux release-assets-windows release-assets-macos \
|
release-assets-check release-assets-web release-assets-linux release-assets-windows release-assets-macos release-assets-checksums \
|
||||||
|
release-assets-server-linux release-assets-server-windows release-assets-server-macos \
|
||||||
|
release-assets-desktop-linux release-assets-desktop-windows release-assets-desktop-macos \
|
||||||
_backend-lint _backend-test _backend-clean _backend-build \
|
_backend-lint _backend-test _backend-clean _backend-build \
|
||||||
|
_versionctl-lint _versionctl-test \
|
||||||
_frontend-install _frontend-build _frontend-check _frontend-test _frontend-dev _frontend-clean \
|
_frontend-install _frontend-build _frontend-check _frontend-test _frontend-dev _frontend-clean \
|
||||||
_desktop-test _desktop-clean _desktop-prepare-frontend _desktop-prepare-embedfs _desktop-prepare-windows-resource \
|
_desktop-test _desktop-clean _desktop-prepare-frontend _desktop-prepare-embedfs _desktop-prepare-windows-resource \
|
||||||
_server-run-backend _server-run-frontend
|
_server-run-backend _server-run-frontend \
|
||||||
|
_check-linux-target-arch _check-windows-target-arch _ensure-appimagetool \
|
||||||
|
_package-linux-tar _package-linux-appimage _package-linux-deb _package-linux-rpm \
|
||||||
|
_package-macos-zip _package-macos-dmg
|
||||||
|
|
||||||
VERSION := $(shell go run ./backend/cmd/versionctl print)
|
# Delay shell lookups until a target needs them, then cache the result for this make run.
|
||||||
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || printf 'unknown')
|
lazy_shell = $(or $($(1)),$(eval $(1) := $(shell $(2)))$($(1)))
|
||||||
BUILD_TIME ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
||||||
GO_LDFLAGS := -X nex/backend/pkg/buildinfo.version=$(VERSION) -X nex/backend/pkg/buildinfo.commit=$(GIT_COMMIT) -X nex/backend/pkg/buildinfo.buildTime=$(BUILD_TIME)
|
VERSION = $(call lazy_shell,_VERSION,go run ./versionctl print)
|
||||||
GO_LDFLAGS_WIN := $(GO_LDFLAGS) -H=windowsgui
|
GIT_COMMIT ?= $(call lazy_shell,_GIT_COMMIT,git rev-parse --short HEAD 2>/dev/null || printf 'unknown')
|
||||||
RELEASE_DIR := build/release
|
BUILD_TIME ?= $(call lazy_shell,_BUILD_TIME,date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
SERVER_LINUX_ASSET := $(shell go run ./backend/cmd/versionctl asset-name server linux amd64)
|
TARGET_ARCH ?= $(call lazy_shell,_TARGET_ARCH,go env GOARCH)
|
||||||
SERVER_WINDOWS_ASSET := $(shell go run ./backend/cmd/versionctl asset-name server windows amd64)
|
GO_LDFLAGS = -X nex/backend/pkg/buildinfo.version=$(VERSION) -X nex/backend/pkg/buildinfo.commit=$(GIT_COMMIT) -X nex/backend/pkg/buildinfo.buildTime=$(BUILD_TIME)
|
||||||
SERVER_DARWIN_AMD64_ASSET := $(shell go run ./backend/cmd/versionctl asset-name server darwin amd64)
|
GO_LDFLAGS_WIN = $(GO_LDFLAGS) -H=windowsgui
|
||||||
SERVER_DARWIN_ARM64_ASSET := $(shell go run ./backend/cmd/versionctl asset-name server darwin arm64)
|
RELEASE_DIR ?= build/release
|
||||||
DESKTOP_LINUX_ASSET := $(shell go run ./backend/cmd/versionctl asset-name desktop linux)
|
LINUX_DESKTOP_BINARY = build/nex-linux-$(TARGET_ARCH)
|
||||||
DESKTOP_WINDOWS_ASSET := $(shell go run ./backend/cmd/versionctl asset-name desktop windows)
|
WINDOWS_DESKTOP_BINARY = build/nex-win-$(TARGET_ARCH).exe
|
||||||
DESKTOP_MACOS_ASSET := $(shell go run ./backend/cmd/versionctl asset-name desktop macos)
|
WINDOWS_SERVER_BINARY = build/nex-server-windows-$(TARGET_ARCH).exe
|
||||||
|
WINDRES ?= windres
|
||||||
|
|
||||||
|
ifeq ($(TARGET_ARCH),arm64)
|
||||||
|
APPIMAGE_ARCH := aarch64
|
||||||
|
DEB_ARCH := arm64
|
||||||
|
RPM_ARCH := aarch64
|
||||||
|
WINDOWS_WINDRES_FORMAT := pe-aarch64
|
||||||
|
WINDOWS_RESOURCE := rsrc_windows_arm64.syso
|
||||||
|
else
|
||||||
|
APPIMAGE_ARCH := x86_64
|
||||||
|
DEB_ARCH := amd64
|
||||||
|
RPM_ARCH := x86_64
|
||||||
|
WINDOWS_WINDRES_FORMAT := pe-x86-64
|
||||||
|
WINDOWS_RESOURCE := rsrc_windows_amd64.syso
|
||||||
|
endif
|
||||||
|
|
||||||
|
APPIMAGETOOL_PATH := build/tools/appimagetool-$(APPIMAGE_ARCH).AppImage
|
||||||
|
APPIMAGETOOL_URL ?= https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-$(APPIMAGE_ARCH).AppImage
|
||||||
|
APPIMAGETOOL ?= $(APPIMAGETOOL_PATH)
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 全局命令
|
# 全局命令
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
lint: _backend-lint _frontend-check
|
lint: _backend-lint _frontend-check _versionctl-lint
|
||||||
@printf 'Lint complete\n'
|
@printf 'Lint complete\n'
|
||||||
|
|
||||||
test: _backend-test _frontend-test _desktop-test
|
test: _backend-test _frontend-test _desktop-test _versionctl-test
|
||||||
@printf 'All tests passed\n'
|
@printf 'All tests passed\n'
|
||||||
|
|
||||||
clean: _backend-clean _frontend-clean _desktop-clean
|
clean: _backend-clean _frontend-clean _desktop-clean
|
||||||
@@ -42,10 +67,19 @@ clean: _backend-clean _frontend-clean _desktop-clean
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
version-sync:
|
version-sync:
|
||||||
go run ./backend/cmd/versionctl sync
|
go run ./versionctl sync
|
||||||
|
|
||||||
version-check:
|
version-check:
|
||||||
go run ./backend/cmd/versionctl check
|
go run ./versionctl check
|
||||||
|
|
||||||
|
version-bump: BUMP ?= patch
|
||||||
|
version-bump:
|
||||||
|
$(eval _BUMP_ARG := $(if $(SET_VERSION),$(SET_VERSION),$(BUMP)))
|
||||||
|
$(eval _NEW_VERSION := $(shell go run ./versionctl bump $(_BUMP_ARG)))
|
||||||
|
git add VERSION frontend/
|
||||||
|
git commit -m "chore: 版本升迁 v$(_NEW_VERSION)"
|
||||||
|
git tag "v$(_NEW_VERSION)"
|
||||||
|
@printf '版本升迁完成: v%s\n' "$(_NEW_VERSION)"
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Server 模式
|
# Server 模式
|
||||||
@@ -81,37 +115,37 @@ desktop-build-mac: version-check _desktop-prepare-frontend _desktop-prepare-embe
|
|||||||
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-mac-arm64 ./cmd/desktop
|
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-mac-arm64 ./cmd/desktop
|
||||||
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-mac-amd64 ./cmd/desktop
|
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-mac-amd64 ./cmd/desktop
|
||||||
lipo -create build/nex-mac-arm64 build/nex-mac-amd64 -output build/nex-mac-universal
|
lipo -create build/nex-mac-arm64 build/nex-mac-amd64 -output build/nex-mac-universal
|
||||||
|
lipo -info build/nex-mac-universal | grep -q 'x86_64 arm64'
|
||||||
|
rm -f build/nex-mac-arm64 build/nex-mac-amd64
|
||||||
@printf 'Packaging macOS app bundle...\n'
|
@printf 'Packaging macOS app bundle...\n'
|
||||||
|
rm -rf build/Nex.app
|
||||||
mkdir -p build/Nex.app/Contents/MacOS build/Nex.app/Contents/Resources
|
mkdir -p build/Nex.app/Contents/MacOS build/Nex.app/Contents/Resources
|
||||||
cp build/nex-mac-universal build/Nex.app/Contents/MacOS/nex
|
cp build/nex-mac-universal build/Nex.app/Contents/MacOS/nex
|
||||||
@if [ -f assets/icon.icns ]; then \
|
@if [ -f assets/icon.icns ]; then \
|
||||||
cp assets/icon.icns build/Nex.app/Contents/Resources/; \
|
cp assets/icon.icns build/Nex.app/Contents/Resources/; \
|
||||||
else \
|
else \
|
||||||
printf 'Missing assets/icon.icns\n'; \
|
printf 'Missing assets/icon.icns\n'; \
|
||||||
|
exit 1; \
|
||||||
fi
|
fi
|
||||||
@MIN_MACOS_VERSION=$$(vtool -show-build build/nex-mac-universal | awk '/minos / {print $$2; exit}'); \
|
@MIN_MACOS_VERSION=$$(vtool -show-build build/nex-mac-universal | awk '/minos / {print $$2; exit}'); \
|
||||||
if [ -z "$$MIN_MACOS_VERSION" ]; then \
|
if [ -z "$$MIN_MACOS_VERSION" ]; then \
|
||||||
printf 'Unable to read macOS minimum version\n'; \
|
printf 'Unable to read macOS minimum version\n'; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
fi; \
|
fi; \
|
||||||
go run ./backend/cmd/versionctl macos-plist "$$MIN_MACOS_VERSION" > build/Nex.app/Contents/Info.plist
|
go run ./versionctl macos-plist "$$MIN_MACOS_VERSION" > build/Nex.app/Contents/Info.plist
|
||||||
chmod +x build/Nex.app/Contents/MacOS/nex
|
chmod +x build/Nex.app/Contents/MacOS/nex
|
||||||
@printf 'macOS desktop build complete\n'
|
@printf 'macOS desktop build complete\n'
|
||||||
|
|
||||||
desktop-build-win: version-check _desktop-prepare-frontend _desktop-prepare-embedfs _desktop-prepare-windows-resource
|
desktop-build-win: version-check _desktop-prepare-frontend _desktop-prepare-embedfs _desktop-prepare-windows-resource _check-windows-target-arch
|
||||||
@printf 'Building Windows desktop...\n'
|
@printf 'Building Windows desktop $(TARGET_ARCH)...\n'
|
||||||
ifeq ($(OS),Windows_NT)
|
|
||||||
powershell -NoProfile -Command "New-Item -ItemType Directory -Path 'build' -Force | Out-Null"
|
|
||||||
cd backend && set "CGO_ENABLED=1"&& set "GOOS=windows"&& set "GOARCH=amd64"&& go build -ldflags "$(GO_LDFLAGS_WIN)" -o ../build/nex-win-amd64.exe ./cmd/desktop
|
|
||||||
else
|
|
||||||
mkdir -p build
|
mkdir -p build
|
||||||
cd backend && CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS_WIN)" -o ../build/nex-win-amd64.exe ./cmd/desktop
|
cd backend && CGO_ENABLED=1 GOOS=windows GOARCH=$(TARGET_ARCH) go build -ldflags "$(GO_LDFLAGS_WIN)" -o ../$(WINDOWS_DESKTOP_BINARY) ./cmd/desktop
|
||||||
endif
|
|
||||||
@printf 'Windows desktop build complete\n'
|
@printf 'Windows desktop build complete\n'
|
||||||
|
|
||||||
desktop-build-linux: version-check _desktop-prepare-frontend _desktop-prepare-embedfs
|
desktop-build-linux: version-check _desktop-prepare-frontend _desktop-prepare-embedfs _check-linux-target-arch
|
||||||
@printf 'Building Linux desktop...\n'
|
@printf 'Building Linux desktop $(TARGET_ARCH)...\n'
|
||||||
cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-linux-amd64 ./cmd/desktop
|
mkdir -p build
|
||||||
|
cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=$(TARGET_ARCH) go build -ldflags "$(GO_LDFLAGS)" -o ../$(LINUX_DESKTOP_BINARY) ./cmd/desktop
|
||||||
@printf 'Linux desktop build complete\n'
|
@printf 'Linux desktop build complete\n'
|
||||||
|
|
||||||
desktop-lint: _backend-lint _frontend-check
|
desktop-lint: _backend-lint _frontend-check
|
||||||
@@ -127,75 +161,216 @@ _desktop-test:
|
|||||||
cd backend && go test ./cmd/desktop/... -v
|
cd backend && go test ./cmd/desktop/... -v
|
||||||
|
|
||||||
_desktop-clean:
|
_desktop-clean:
|
||||||
rm -rf build/ embedfs/assets embedfs/frontend-dist backend/cmd/desktop/rsrc_windows_amd64.syso
|
rm -rf build/ embedfs/assets embedfs/frontend-dist backend/cmd/desktop/rsrc_windows_amd64.syso backend/cmd/desktop/rsrc_windows_arm64.syso
|
||||||
|
|
||||||
_desktop-prepare-frontend: _frontend-install
|
_desktop-prepare-frontend: _frontend-install
|
||||||
@printf 'Preparing frontend for desktop...\n'
|
@printf 'Preparing frontend for desktop...\n'
|
||||||
ifeq ($(OS),Windows_NT)
|
|
||||||
powershell -NoProfile -Command "Copy-Item -LiteralPath 'frontend/.env.desktop' -Destination 'frontend/.env.production.local' -Force"
|
|
||||||
cd frontend && bun run build
|
|
||||||
powershell -NoProfile -Command "Remove-Item -LiteralPath 'frontend/.env.production.local' -Force -ErrorAction SilentlyContinue"
|
|
||||||
else
|
|
||||||
cd frontend && cp .env.desktop .env.production.local
|
cd frontend && cp .env.desktop .env.production.local
|
||||||
cd frontend && bun run build
|
cd frontend && bun run build
|
||||||
rm -f frontend/.env.production.local
|
rm -f frontend/.env.production.local
|
||||||
endif
|
|
||||||
|
|
||||||
_desktop-prepare-embedfs:
|
_desktop-prepare-embedfs:
|
||||||
@printf 'Preparing embedded filesystem...\n'
|
@printf 'Preparing embedded filesystem...\n'
|
||||||
ifeq ($(OS),Windows_NT)
|
|
||||||
powershell -NoProfile -Command "Remove-Item -LiteralPath 'embedfs/assets' -Recurse -Force -ErrorAction SilentlyContinue; Remove-Item -LiteralPath 'embedfs/frontend-dist' -Recurse -Force -ErrorAction SilentlyContinue; Copy-Item -LiteralPath 'assets' -Destination 'embedfs/assets' -Recurse; Copy-Item -LiteralPath 'frontend/dist' -Destination 'embedfs/frontend-dist' -Recurse"
|
|
||||||
else
|
|
||||||
rm -rf embedfs/assets embedfs/frontend-dist
|
rm -rf embedfs/assets embedfs/frontend-dist
|
||||||
cp -r assets embedfs/assets
|
cp -r assets embedfs/assets
|
||||||
cp -r frontend/dist embedfs/frontend-dist
|
cp -r frontend/dist embedfs/frontend-dist
|
||||||
endif
|
|
||||||
|
|
||||||
_desktop-prepare-windows-resource:
|
_desktop-prepare-windows-resource: _check-windows-target-arch
|
||||||
@printf 'Preparing Windows executable icon...\n'
|
@printf 'Preparing Windows $(TARGET_ARCH) executable icon...\n'
|
||||||
ifeq ($(OS),Windows_NT)
|
@if [ "$(TARGET_ARCH)" = "arm64" ] && [ "$(WINDRES)" = "windres" ] && command -v llvm-windres >/dev/null 2>&1; then \
|
||||||
cd backend/cmd/desktop && windres -O coff -F pe-x86-64 -i icon_windows.rc -o rsrc_windows_amd64.syso
|
WINDRES_CMD=llvm-windres; \
|
||||||
else
|
|
||||||
@if command -v x86_64-w64-mingw32-windres >/dev/null 2>&1; then \
|
|
||||||
cd backend/cmd/desktop && x86_64-w64-mingw32-windres -O coff -F pe-x86-64 -i icon_windows.rc -o rsrc_windows_amd64.syso; \
|
|
||||||
elif command -v windres >/dev/null 2>&1; then \
|
|
||||||
cd backend/cmd/desktop && windres -O coff -F pe-x86-64 -i icon_windows.rc -o rsrc_windows_amd64.syso; \
|
|
||||||
else \
|
else \
|
||||||
printf 'Missing windres for Windows icon resource generation\n'; \
|
WINDRES_CMD="$(WINDRES)"; \
|
||||||
exit 1; \
|
fi; \
|
||||||
fi
|
command -v "$$WINDRES_CMD" >/dev/null 2>&1 || { printf 'Missing windres tool: %s\n' "$$WINDRES_CMD"; exit 1; }; \
|
||||||
endif
|
cd backend/cmd/desktop && "$$WINDRES_CMD" -O coff -F $(WINDOWS_WINDRES_FORMAT) -i icon_windows.rc -o $(WINDOWS_RESOURCE)
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 发布资产
|
# 发布资产
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
release-assets-linux: version-check desktop-build-linux
|
release-assets-check:
|
||||||
|
go run ./versionctl release-assets-check
|
||||||
|
@printf 'Release assets check passed\n'
|
||||||
|
|
||||||
|
release-assets-web: version-check release-assets-check _frontend-build
|
||||||
rm -rf "$(RELEASE_DIR)"
|
rm -rf "$(RELEASE_DIR)"
|
||||||
mkdir -p "$(RELEASE_DIR)"
|
mkdir -p "$(RELEASE_DIR)"
|
||||||
cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-linux-amd64 ./cmd/server
|
asset=$$(go run ./versionctl asset-name web tar.gz); \
|
||||||
tar -C build -czf "$(RELEASE_DIR)/$(SERVER_LINUX_ASSET)" nex-server-linux-amd64
|
tar -C frontend -czf "$(RELEASE_DIR)/$$asset" dist
|
||||||
tar -C build -czf "$(RELEASE_DIR)/$(DESKTOP_LINUX_ASSET)" nex-linux-amd64
|
|
||||||
|
|
||||||
release-assets-windows: version-check desktop-build-win
|
release-assets-linux: version-check release-assets-check _check-linux-target-arch
|
||||||
ifeq ($(OS),Windows_NT)
|
|
||||||
powershell -NoProfile -Command "Remove-Item -LiteralPath '$(RELEASE_DIR)' -Recurse -Force -ErrorAction SilentlyContinue; New-Item -ItemType Directory -Path '$(RELEASE_DIR)' -Force | Out-Null"
|
|
||||||
cd backend && set "CGO_ENABLED=1"&& set "GOOS=windows"&& set "GOARCH=amd64"&& go build -ldflags "$(GO_LDFLAGS_WIN)" -o ../build/nex-server-win-amd64.exe ./cmd/server
|
|
||||||
powershell -NoProfile -Command "Compress-Archive -LiteralPath 'build/nex-server-win-amd64.exe' -DestinationPath '$(RELEASE_DIR)/$(SERVER_WINDOWS_ASSET)' -Force"
|
|
||||||
powershell -NoProfile -Command "Compress-Archive -LiteralPath 'build/nex-win-amd64.exe' -DestinationPath '$(RELEASE_DIR)/$(DESKTOP_WINDOWS_ASSET)' -Force"
|
|
||||||
else
|
|
||||||
@printf 'release-assets-windows requires Windows\n'
|
|
||||||
@exit 1
|
|
||||||
endif
|
|
||||||
|
|
||||||
release-assets-macos: version-check desktop-build-mac
|
|
||||||
rm -rf "$(RELEASE_DIR)"
|
rm -rf "$(RELEASE_DIR)"
|
||||||
mkdir -p "$(RELEASE_DIR)"
|
mkdir -p "$(RELEASE_DIR)"
|
||||||
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-darwin-amd64 ./cmd/server
|
@$(MAKE) release-assets-server-linux TARGET_ARCH=$(TARGET_ARCH) RELEASE_DIR="$(RELEASE_DIR)"
|
||||||
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-darwin-arm64 ./cmd/server
|
@$(MAKE) release-assets-desktop-linux TARGET_ARCH=$(TARGET_ARCH) RELEASE_DIR="$(RELEASE_DIR)"
|
||||||
tar -C build -czf "$(RELEASE_DIR)/$(SERVER_DARWIN_AMD64_ASSET)" nex-server-darwin-amd64
|
|
||||||
tar -C build -czf "$(RELEASE_DIR)/$(SERVER_DARWIN_ARM64_ASSET)" nex-server-darwin-arm64
|
release-assets-windows: version-check release-assets-check _check-windows-target-arch
|
||||||
ditto -c -k --keepParent build/Nex.app "$(RELEASE_DIR)/$(DESKTOP_MACOS_ASSET)"
|
rm -rf "$(RELEASE_DIR)"
|
||||||
|
mkdir -p "$(RELEASE_DIR)"
|
||||||
|
@$(MAKE) release-assets-server-windows TARGET_ARCH=$(TARGET_ARCH) RELEASE_DIR="$(RELEASE_DIR)"
|
||||||
|
@$(MAKE) release-assets-desktop-windows TARGET_ARCH=$(TARGET_ARCH) RELEASE_DIR="$(RELEASE_DIR)"
|
||||||
|
|
||||||
|
release-assets-macos: version-check release-assets-check
|
||||||
|
rm -rf "$(RELEASE_DIR)"
|
||||||
|
mkdir -p "$(RELEASE_DIR)"
|
||||||
|
@$(MAKE) release-assets-server-macos RELEASE_DIR="$(RELEASE_DIR)"
|
||||||
|
@$(MAKE) release-assets-desktop-macos RELEASE_DIR="$(RELEASE_DIR)"
|
||||||
|
|
||||||
|
release-assets-server-linux: version-check _check-linux-target-arch
|
||||||
|
mkdir -p build "$(RELEASE_DIR)"
|
||||||
|
cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=$(TARGET_ARCH) go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-linux-$(TARGET_ARCH) ./cmd/server
|
||||||
|
asset=$$(go run ./versionctl asset-name server linux $(TARGET_ARCH) tar.gz); \
|
||||||
|
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-linux-$(TARGET_ARCH)
|
||||||
|
|
||||||
|
release-assets-server-windows: version-check _check-windows-target-arch
|
||||||
|
mkdir -p build "$(RELEASE_DIR)"
|
||||||
|
cd backend && CGO_ENABLED=1 GOOS=windows GOARCH=$(TARGET_ARCH) go build -ldflags "$(GO_LDFLAGS)" -o ../$(WINDOWS_SERVER_BINARY) ./cmd/server
|
||||||
|
asset=$$(go run ./versionctl asset-name server windows $(TARGET_ARCH) zip); \
|
||||||
|
if command -v powershell.exe >/dev/null 2>&1; then POWERSHELL=powershell.exe; else POWERSHELL=powershell; fi; \
|
||||||
|
"$$POWERSHELL" -NoProfile -Command "Compress-Archive -LiteralPath '$(WINDOWS_SERVER_BINARY)' -DestinationPath '$(RELEASE_DIR)/$$asset' -Force"
|
||||||
|
|
||||||
|
release-assets-server-macos: version-check
|
||||||
|
mkdir -p build "$(RELEASE_DIR)"
|
||||||
|
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-macos-amd64 ./cmd/server
|
||||||
|
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-macos-arm64 ./cmd/server
|
||||||
|
lipo -create build/nex-server-macos-amd64 build/nex-server-macos-arm64 -output build/nex-server-macos-universal
|
||||||
|
lipo -info build/nex-server-macos-universal | grep -q 'x86_64 arm64'
|
||||||
|
asset=$$(go run ./versionctl asset-name server macos amd64 tar.gz); \
|
||||||
|
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-macos-amd64
|
||||||
|
asset=$$(go run ./versionctl asset-name server macos arm64 tar.gz); \
|
||||||
|
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-macos-arm64
|
||||||
|
asset=$$(go run ./versionctl asset-name server macos universal tar.gz); \
|
||||||
|
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-macos-universal
|
||||||
|
rm -f build/nex-server-macos-amd64 build/nex-server-macos-arm64 build/nex-server-macos-universal
|
||||||
|
|
||||||
|
release-assets-desktop-linux: version-check release-assets-check desktop-build-linux _package-linux-tar _package-linux-appimage _package-linux-deb _package-linux-rpm
|
||||||
|
|
||||||
|
release-assets-desktop-windows: version-check release-assets-check desktop-build-win
|
||||||
|
mkdir -p "$(RELEASE_DIR)"
|
||||||
|
asset=$$(go run ./versionctl asset-name desktop windows $(TARGET_ARCH) zip); \
|
||||||
|
if command -v powershell.exe >/dev/null 2>&1; then POWERSHELL=powershell.exe; else POWERSHELL=powershell; fi; \
|
||||||
|
"$$POWERSHELL" -NoProfile -Command "Compress-Archive -LiteralPath '$(WINDOWS_DESKTOP_BINARY)' -DestinationPath '$(RELEASE_DIR)/$$asset' -Force"
|
||||||
|
|
||||||
|
release-assets-desktop-macos: version-check release-assets-check desktop-build-mac _package-macos-zip _package-macos-dmg
|
||||||
|
rm -rf build/Nex.app build/dmg
|
||||||
|
|
||||||
|
release-assets-checksums:
|
||||||
|
@cd "$(RELEASE_DIR)" && \
|
||||||
|
rm -f SHA256SUMS && \
|
||||||
|
for asset in *; do \
|
||||||
|
[ -f "$$asset" ] || continue; \
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then \
|
||||||
|
sha256sum "$$asset"; \
|
||||||
|
elif command -v shasum >/dev/null 2>&1; then \
|
||||||
|
shasum -a 256 "$$asset"; \
|
||||||
|
else \
|
||||||
|
printf 'Missing sha256sum or shasum\n' >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
done > SHA256SUMS && \
|
||||||
|
test -s SHA256SUMS
|
||||||
|
|
||||||
|
_check-linux-target-arch:
|
||||||
|
@if [ "$(TARGET_ARCH)" != "amd64" ] && [ "$(TARGET_ARCH)" != "arm64" ]; then \
|
||||||
|
printf 'Unsupported Linux TARGET_ARCH: %s\n' "$(TARGET_ARCH)"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
_check-windows-target-arch:
|
||||||
|
@if [ "$(TARGET_ARCH)" != "amd64" ] && [ "$(TARGET_ARCH)" != "arm64" ]; then \
|
||||||
|
printf 'Unsupported Windows TARGET_ARCH: %s\n' "$(TARGET_ARCH)"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
_ensure-appimagetool:
|
||||||
|
@mkdir -p build/tools
|
||||||
|
@if [ ! -x "$(APPIMAGETOOL)" ]; then \
|
||||||
|
printf 'Downloading appimagetool for %s...\n' "$(APPIMAGE_ARCH)"; \
|
||||||
|
command -v curl >/dev/null 2>&1 || { printf 'Missing curl for appimagetool download\n'; exit 1; }; \
|
||||||
|
curl -L "$(APPIMAGETOOL_URL)" -o "$(APPIMAGETOOL)"; \
|
||||||
|
chmod +x "$(APPIMAGETOOL)"; \
|
||||||
|
fi; \
|
||||||
|
printf 'Using appimagetool: %s\n' "$(APPIMAGETOOL)"
|
||||||
|
|
||||||
|
_package-linux-tar:
|
||||||
|
mkdir -p "$(RELEASE_DIR)"
|
||||||
|
asset=$$(go run ./versionctl asset-name desktop linux $(TARGET_ARCH) tar.gz); \
|
||||||
|
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-linux-$(TARGET_ARCH)
|
||||||
|
|
||||||
|
_package-linux-appimage: _ensure-appimagetool
|
||||||
|
mkdir -p "$(RELEASE_DIR)"
|
||||||
|
appdir="build/appimage/nex-$(TARGET_ARCH).AppDir"; \
|
||||||
|
rm -rf "$$appdir"; \
|
||||||
|
mkdir -p "$$appdir/usr/bin" "$$appdir/usr/share/applications" "$$appdir/usr/share/icons"; \
|
||||||
|
install -m 0755 "$(LINUX_DESKTOP_BINARY)" "$$appdir/usr/bin/nex"; \
|
||||||
|
install -m 0644 packaging/linux/nex.desktop "$$appdir/nex.desktop"; \
|
||||||
|
install -m 0644 packaging/linux/nex.desktop "$$appdir/usr/share/applications/nex.desktop"; \
|
||||||
|
install -m 0755 packaging/linux/AppRun "$$appdir/AppRun"; \
|
||||||
|
cp -R assets/icons/hicolor "$$appdir/usr/share/icons/"; \
|
||||||
|
cp assets/icon.png "$$appdir/nex.png"; \
|
||||||
|
asset=$$(go run ./versionctl asset-name desktop linux $(TARGET_ARCH) AppImage); \
|
||||||
|
ARCH=$(APPIMAGE_ARCH) APPIMAGE_EXTRACT_AND_RUN=1 "$(APPIMAGETOOL)" "$$appdir" "$(RELEASE_DIR)/$$asset"; \
|
||||||
|
chmod +x "$(RELEASE_DIR)/$$asset"; \
|
||||||
|
test -s "$(RELEASE_DIR)/$$asset"
|
||||||
|
|
||||||
|
_package-linux-deb:
|
||||||
|
mkdir -p "$(RELEASE_DIR)"
|
||||||
|
pkgdir="build/pkg/deb/nex-$(TARGET_ARCH)"; \
|
||||||
|
rm -rf "$$pkgdir"; \
|
||||||
|
mkdir -p "$$pkgdir/DEBIAN" "$$pkgdir/usr/bin" "$$pkgdir/usr/share/applications" "$$pkgdir/usr/share/icons"; \
|
||||||
|
install -m 0755 "$(LINUX_DESKTOP_BINARY)" "$$pkgdir/usr/bin/nex"; \
|
||||||
|
install -m 0644 packaging/linux/nex.desktop "$$pkgdir/usr/share/applications/nex.desktop"; \
|
||||||
|
cp -R assets/icons/hicolor "$$pkgdir/usr/share/icons/"; \
|
||||||
|
printf '%s\n' \
|
||||||
|
'Package: nex' \
|
||||||
|
'Version: $(VERSION)' \
|
||||||
|
'Section: utils' \
|
||||||
|
'Priority: optional' \
|
||||||
|
'Architecture: $(DEB_ARCH)' \
|
||||||
|
'Maintainer: Nex Maintainers <noreply@example.com>' \
|
||||||
|
'Depends: libgtk-3-0, libayatana-appindicator3-1, xdg-utils' \
|
||||||
|
'Description: AI Gateway desktop application' \
|
||||||
|
' Nex is an AI Gateway desktop application.' \
|
||||||
|
> "$$pkgdir/DEBIAN/control"; \
|
||||||
|
asset=$$(go run ./versionctl asset-name desktop linux $(TARGET_ARCH) deb); \
|
||||||
|
dpkg-deb --build --root-owner-group "$$pkgdir" "$(RELEASE_DIR)/$$asset"; \
|
||||||
|
dpkg-deb -I "$(RELEASE_DIR)/$$asset" >/dev/null
|
||||||
|
|
||||||
|
_package-linux-rpm:
|
||||||
|
mkdir -p "$(RELEASE_DIR)"
|
||||||
|
topdir="$(abspath build/rpmbuild-$(TARGET_ARCH))"; \
|
||||||
|
rm -rf "$$topdir"; \
|
||||||
|
mkdir -p "$$topdir/BUILD" "$$topdir/BUILDROOT" "$$topdir/RPMS" "$$topdir/SOURCES" "$$topdir/SPECS" "$$topdir/SRPMS"; \
|
||||||
|
rpmbuild -bb --target "$(RPM_ARCH)" \
|
||||||
|
--define "_topdir $$topdir" \
|
||||||
|
--define "nex_version $(VERSION)" \
|
||||||
|
--define "nex_binary $(abspath $(LINUX_DESKTOP_BINARY))" \
|
||||||
|
--define "nex_desktop_file $(abspath packaging/linux/nex.desktop)" \
|
||||||
|
--define "nex_icons_dir $(abspath assets/icons/hicolor)" \
|
||||||
|
packaging/linux/nex.spec; \
|
||||||
|
rpm_file=$$(find "$$topdir/RPMS" -type f -name '*.rpm' | sort | tail -n 1); \
|
||||||
|
test -n "$$rpm_file"; \
|
||||||
|
asset=$$(go run ./versionctl asset-name desktop linux $(TARGET_ARCH) rpm); \
|
||||||
|
cp "$$rpm_file" "$(RELEASE_DIR)/$$asset"; \
|
||||||
|
rpm -qip "$(RELEASE_DIR)/$$asset" >/dev/null
|
||||||
|
|
||||||
|
_package-macos-zip:
|
||||||
|
mkdir -p "$(RELEASE_DIR)"
|
||||||
|
asset=$$(go run ./versionctl asset-name desktop macos universal zip); \
|
||||||
|
ditto -c -k --keepParent build/Nex.app "$(RELEASE_DIR)/$$asset"
|
||||||
|
|
||||||
|
_package-macos-dmg:
|
||||||
|
mkdir -p "$(RELEASE_DIR)"
|
||||||
|
dmgdir="build/dmg/Nex"; \
|
||||||
|
rm -rf "$$dmgdir"; \
|
||||||
|
mkdir -p "$$dmgdir"; \
|
||||||
|
cp -R build/Nex.app "$$dmgdir/Nex.app"; \
|
||||||
|
ln -s /Applications "$$dmgdir/Applications"; \
|
||||||
|
asset=$$(go run ./versionctl asset-name desktop macos universal dmg); \
|
||||||
|
hdiutil create -volname Nex -srcfolder "$$dmgdir" -ov -format UDZO "$(RELEASE_DIR)/$$asset"; \
|
||||||
|
hdiutil verify "$(RELEASE_DIR)/$$asset" >/dev/null && \
|
||||||
|
rm -rf "$$dmgdir"
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 共享 helper targets
|
# 共享 helper targets
|
||||||
@@ -213,6 +388,12 @@ _backend-test:
|
|||||||
_backend-clean:
|
_backend-clean:
|
||||||
@$(MAKE) -C backend clean
|
@$(MAKE) -C backend clean
|
||||||
|
|
||||||
|
_versionctl-lint:
|
||||||
|
@$(MAKE) -C versionctl lint
|
||||||
|
|
||||||
|
_versionctl-test:
|
||||||
|
@$(MAKE) -C versionctl test
|
||||||
|
|
||||||
_frontend-install:
|
_frontend-install:
|
||||||
cd frontend && bun install
|
cd frontend && bun install
|
||||||
|
|
||||||
|
|||||||
84
README.md
84
README.md
@@ -39,6 +39,8 @@ nex/
|
|||||||
│ ├── icon.icns # macOS 应用图标
|
│ ├── icon.icns # macOS 应用图标
|
||||||
│ └── icon.ico # Windows 应用图标
|
│ └── icon.ico # Windows 应用图标
|
||||||
│
|
│
|
||||||
|
├── packaging/ # 桌面发布包元数据(Linux desktop entry、RPM spec 等)
|
||||||
|
│
|
||||||
└── README.md # 本文件
|
└── README.md # 本文件
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -91,7 +93,7 @@ JSON: {"level":"info","logger":"handler.proxy","msg":"处理请求","method":
|
|||||||
- **图表库**: Recharts
|
- **图表库**: Recharts
|
||||||
- **路由**: React Router v7
|
- **路由**: React Router v7
|
||||||
- **数据获取**: TanStack Query v5
|
- **数据获取**: TanStack Query v5
|
||||||
- **样式**: SCSS Modules
|
- **样式**: TDesign 组件 props 优先,TDesign tokens 次之,SCSS 作为兜底补充
|
||||||
- **测试**: Vitest + React Testing Library + Playwright
|
- **测试**: Vitest + React Testing Library + Playwright
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
@@ -107,12 +109,18 @@ make desktop-build-mac
|
|||||||
# Windows
|
# Windows
|
||||||
make desktop-build-win
|
make desktop-build-win
|
||||||
|
|
||||||
|
# Windows arm64
|
||||||
|
make desktop-build-win TARGET_ARCH=arm64
|
||||||
|
|
||||||
# Linux
|
# Linux
|
||||||
make desktop-build-linux
|
make desktop-build-linux
|
||||||
|
|
||||||
|
# Linux arm64
|
||||||
|
make desktop-build-linux TARGET_ARCH=arm64
|
||||||
```
|
```
|
||||||
|
|
||||||
**使用桌面应用**:
|
**使用桌面应用**:
|
||||||
- 双击启动应用(macOS: Nex.app,Windows: nex-win-amd64.exe,Linux: nex-linux-amd64)
|
- 双击启动应用(macOS: Nex.app,Windows: nex-win-amd64.exe / nex-win-arm64.exe,Linux: nex-linux-amd64 / nex-linux-arm64)
|
||||||
- 系统托盘图标出现,浏览器自动打开管理界面
|
- 系统托盘图标出现,浏览器自动打开管理界面
|
||||||
- 点击托盘图标显示菜单,可打开管理界面或退出
|
- 点击托盘图标显示菜单,可打开管理界面或退出
|
||||||
- 关闭浏览器后服务继续运行,可通过托盘重新打开
|
- 关闭浏览器后服务继续运行,可通过托盘重新打开
|
||||||
@@ -120,8 +128,10 @@ make desktop-build-linux
|
|||||||
**注意事项**:
|
**注意事项**:
|
||||||
- 桌面应用需要 CGO 支持
|
- 桌面应用需要 CGO 支持
|
||||||
- macOS: 自带 Xcode Command Line Tools
|
- macOS: 自带 Xcode Command Line Tools
|
||||||
- Linux: 自带 gcc,部分桌面环境需要 `libappindicator3-dev`
|
- Linux 构建: 需要 gcc、pkg-config、GTK3 开发包和 Ayatana AppIndicator 开发包(Ubuntu/Debian: `libgtk-3-dev`、`libayatana-appindicator3-dev`)
|
||||||
- Windows: 需要 MinGW-w64 或在 Windows 环境构建
|
- Linux 运行: 需要 GTK3、Ayatana AppIndicator 和 xdg-utils;AppImage 也依赖系统提供 AppImage runtime/FUSE 能力,不承诺完全自包含
|
||||||
|
- Windows: 需要对应架构的 MinGW-w64/MSYS2 工具链,desktop 使用 GUI linker flags 隐藏控制台窗口
|
||||||
|
- macOS DMG: 发布包暂不签名、不 notarize,首次打开可能出现 Gatekeeper 提示
|
||||||
|
|
||||||
**Linux 桌面环境兼容性**:
|
**Linux 桌面环境兼容性**:
|
||||||
- GNOME: 需要 AppIndicator 扩展
|
- GNOME: 需要 AppIndicator 扩展
|
||||||
@@ -151,6 +161,40 @@ make server-run
|
|||||||
make server-build
|
make server-build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Release 产物
|
||||||
|
|
||||||
|
发布流程由 Git tag `vX.Y.Z` 触发,GitHub Actions 会创建 Draft Release 并上传 server、web 和 desktop 三类产物,同时生成 `SHA256SUMS`。
|
||||||
|
|
||||||
|
**server 产物**(不内置 Web 管理界面):
|
||||||
|
|
||||||
|
| 平台 | 产物 |
|
||||||
|
|------|------|
|
||||||
|
| Linux amd64 | `nex-server_<version>_linux_amd64.tar.gz` |
|
||||||
|
| Linux arm64 | `nex-server_<version>_linux_arm64.tar.gz` |
|
||||||
|
| macOS amd64 | `nex-server_<version>_macos_amd64.tar.gz` |
|
||||||
|
| macOS arm64 | `nex-server_<version>_macos_arm64.tar.gz` |
|
||||||
|
| macOS universal | `nex-server_<version>_macos_universal.tar.gz` |
|
||||||
|
| Windows amd64 | `nex-server_<version>_windows_amd64.zip` |
|
||||||
|
| Windows arm64 | `nex-server_<version>_windows_arm64.zip` |
|
||||||
|
|
||||||
|
**web 产物**:
|
||||||
|
|
||||||
|
| 内容 | 产物 |
|
||||||
|
|------|------|
|
||||||
|
| `frontend/dist` | `nex-web_<version>.tar.gz` |
|
||||||
|
|
||||||
|
**desktop 产物**:
|
||||||
|
|
||||||
|
| 平台 | 产物 |
|
||||||
|
|------|------|
|
||||||
|
| Linux amd64 | `nex-desktop_<version>_linux_amd64.tar.gz`、`.AppImage`、`.deb`、`.rpm` |
|
||||||
|
| Linux arm64 | `nex-desktop_<version>_linux_arm64.tar.gz`、`.AppImage`、`.deb`、`.rpm` |
|
||||||
|
| macOS universal | `nex-desktop_<version>_macos_universal.zip`、`nex-desktop_<version>_macos_universal.dmg` |
|
||||||
|
| Windows amd64 | `nex-desktop_<version>_windows_amd64.zip` |
|
||||||
|
| Windows arm64 | `nex-desktop_<version>_windows_arm64.zip` |
|
||||||
|
|
||||||
|
Linux deb 包声明 `libgtk-3-0`、`libayatana-appindicator3-1`、`xdg-utils` 运行依赖;rpm 包声明 `gtk3`、`libayatana-appindicator-gtk3`、`xdg-utils` 运行依赖。Rocky Linux 9 等发行版可能需要启用 EPEL 才能解析 Ayatana AppIndicator 依赖。
|
||||||
|
|
||||||
## API 接口
|
## API 接口
|
||||||
|
|
||||||
### 代理接口(对外部应用)
|
### 代理接口(对外部应用)
|
||||||
@@ -199,6 +243,9 @@ make server-build
|
|||||||
|
|
||||||
查询参数支持:`provider_id`、`model_name`、`start_date`、`end_date`、`group_by`
|
查询参数支持:`provider_id`、`model_name`、`start_date`、`end_date`、`group_by`
|
||||||
|
|
||||||
|
#### 版本信息
|
||||||
|
- `GET /api/version` - 获取后端构建版本信息(`version`、`commit`、`build_time`),用于前端 About 页面诊断前后端版本一致性
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
配置支持多种方式,优先级为:**CLI 参数 > 环境变量 > 配置文件 > 默认值**
|
配置支持多种方式,优先级为:**CLI 参数 > 环境变量 > 配置文件 > 默认值**
|
||||||
@@ -318,27 +365,24 @@ make desktop-clean # 清理 desktop 产物
|
|||||||
|
|
||||||
### 本地版本演进
|
### 本地版本演进
|
||||||
|
|
||||||
1. 手工修改根目录 `VERSION` 为新的 `x.y.z`
|
```bash
|
||||||
2. 同步镜像文件:
|
# 递增版本(自动 sync + check + commit + tag)
|
||||||
|
make version-bump BUMP=minor
|
||||||
|
|
||||||
|
# 或指定具体版本号
|
||||||
|
make version-bump SET_VERSION=1.0.0
|
||||||
|
|
||||||
|
# 推送到远程
|
||||||
|
git push --follow-tags
|
||||||
|
```
|
||||||
|
|
||||||
|
手动同步和校验:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make version-sync
|
make version-sync
|
||||||
```
|
|
||||||
|
|
||||||
3. 校验版本一致性:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make version-check
|
make version-check
|
||||||
```
|
```
|
||||||
|
|
||||||
4. 提交版本变更后,创建发布 tag:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git tag -a vX.Y.Z -m "Release vX.Y.Z"
|
|
||||||
git push origin main
|
|
||||||
git push origin vX.Y.Z
|
|
||||||
```
|
|
||||||
|
|
||||||
### 本地生成发布资产
|
### 本地生成发布资产
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -357,6 +401,8 @@ make release-assets-macos
|
|||||||
### GitHub Draft Release
|
### GitHub Draft Release
|
||||||
|
|
||||||
- 推送 `vX.Y.Z` tag 后,`.github/workflows/release.yml` 会自动执行发布流水线
|
- 推送 `vX.Y.Z` tag 后,`.github/workflows/release.yml` 会自动执行发布流水线
|
||||||
|
- 三个平台 job 会在正式构建前先检查 `go`、`bun` 和各自的平台打包工具链,缺失时快速失败并在日志中输出诊断信息
|
||||||
|
- Windows 发布 job 在 `MSYS2 / MINGW64` shell 中执行,并继承 `setup-go` / `setup-bun` 准备好的工具链路径
|
||||||
- 流水线会先校验 tag 与 `VERSION` 一致,再构建以下资产并上传到 GitHub Draft Release:
|
- 流水线会先校验 tag 与 `VERSION` 一致,再构建以下资产并上传到 GitHub Draft Release:
|
||||||
- Linux server
|
- Linux server
|
||||||
- Windows server
|
- Windows server
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
migrate-up migrate-down migrate-status migrate-create \
|
migrate-up migrate-down migrate-status migrate-create \
|
||||||
mysql-up mysql-down mysql-test mysql-test-quick
|
mysql-up mysql-down mysql-test mysql-test-quick
|
||||||
|
|
||||||
VERSION := $(shell go run ./cmd/versionctl print)
|
VERSION := $(shell go run ../versionctl print)
|
||||||
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || printf 'unknown')
|
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || printf 'unknown')
|
||||||
BUILD_TIME ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
BUILD_TIME ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
GO_LDFLAGS := -X nex/backend/pkg/buildinfo.version=$(VERSION) -X nex/backend/pkg/buildinfo.commit=$(GIT_COMMIT) -X nex/backend/pkg/buildinfo.buildTime=$(BUILD_TIME)
|
GO_LDFLAGS := -X nex/backend/pkg/buildinfo.version=$(VERSION) -X nex/backend/pkg/buildinfo.commit=$(GIT_COMMIT) -X nex/backend/pkg/buildinfo.buildTime=$(BUILD_TIME)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func NewProxyHandler(..., logger *zap.Logger) *ProxyHandler {
|
|||||||
使用 `pkg/logger/field.go` 中定义的字段构造函数:
|
使用 `pkg/logger/field.go` 中定义的字段构造函数:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
logger.Info("请求开始",
|
logger.Debug("请求开始",
|
||||||
pkglogger.Method("POST"),
|
pkglogger.Method("POST"),
|
||||||
pkglogger.Path("/v1/chat"),
|
pkglogger.Path("/v1/chat"),
|
||||||
pkglogger.RequestID("xxx"),
|
pkglogger.RequestID("xxx"),
|
||||||
@@ -577,6 +577,20 @@ GET /anthropic/v1/models
|
|||||||
|
|
||||||
查询参数:`provider_id`、`model_name`、`start_date`(YYYY-MM-DD)、`end_date`、`group_by`(provider/model/date)
|
查询参数:`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"}`
|
- `GET /health` - 返回 `{"status": "ok"}`
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ func main() {
|
|||||||
providerHandler := handler.NewProviderHandler(providerService)
|
providerHandler := handler.NewProviderHandler(providerService)
|
||||||
modelHandler := handler.NewModelHandler(modelService)
|
modelHandler := handler.NewModelHandler(modelService)
|
||||||
statsHandler := handler.NewStatsHandler(statsService)
|
statsHandler := handler.NewStatsHandler(statsService)
|
||||||
|
versionHandler := handler.NewVersionHandler()
|
||||||
|
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
@@ -139,7 +140,7 @@ func main() {
|
|||||||
r.Use(middleware.Logging(zapLogger))
|
r.Use(middleware.Logging(zapLogger))
|
||||||
r.Use(middleware.CORS())
|
r.Use(middleware.CORS())
|
||||||
|
|
||||||
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler)
|
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler)
|
||||||
setupStaticFiles(r)
|
setupStaticFiles(r)
|
||||||
|
|
||||||
server = &http.Server{
|
server = &http.Server{
|
||||||
@@ -172,9 +173,10 @@ func main() {
|
|||||||
setupSystray(port)
|
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("/openai/*path", withProtocol("openai", proxyHandler.HandleProxy))
|
||||||
r.Any("/anthropic/*path", withProtocol("anthropic", proxyHandler.HandleProxy))
|
r.Any("/anthropic/*path", withProtocol("anthropic", proxyHandler.HandleProxy))
|
||||||
|
r.GET("/api/version", versionHandler.GetVersion)
|
||||||
|
|
||||||
providers := r.Group("/api/providers")
|
providers := r.Group("/api/providers")
|
||||||
{
|
{
|
||||||
@@ -257,13 +259,13 @@ func setupStaticFilesWithFS(r *gin.Engine, distFS fs.FS) {
|
|||||||
c.Data(200, getContentType(filepath), data)
|
c.Data(200, getContentType(filepath), data)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.GET("/favicon.svg", func(c *gin.Context) {
|
r.GET("/icon.png", func(c *gin.Context) {
|
||||||
data, err := fs.ReadFile(distFS, "favicon.svg")
|
data, err := fs.ReadFile(distFS, "icon.png")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Status(404)
|
c.Status(404)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Data(200, "image/svg+xml", data)
|
c.Data(200, "image/png", data)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.NoRoute(func(c *gin.Context) {
|
r.NoRoute(func(c *gin.Context) {
|
||||||
|
|||||||
44
backend/cmd/desktop/routes_test.go
Normal file
44
backend/cmd/desktop/routes_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -100,6 +101,30 @@ func TestSetupStaticFiles(t *testing.T) {
|
|||||||
t.Log("静态文件服务测试通过")
|
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) {
|
func TestWithProtocolAndStaticRoutes(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ func main() {
|
|||||||
providerHandler := handler.NewProviderHandler(providerService)
|
providerHandler := handler.NewProviderHandler(providerService)
|
||||||
modelHandler := handler.NewModelHandler(modelService)
|
modelHandler := handler.NewModelHandler(modelService)
|
||||||
statsHandler := handler.NewStatsHandler(statsService)
|
statsHandler := handler.NewStatsHandler(statsService)
|
||||||
|
versionHandler := handler.NewVersionHandler()
|
||||||
|
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
@@ -102,7 +103,7 @@ func main() {
|
|||||||
r.Use(middleware.Logging(zapLogger))
|
r.Use(middleware.Logging(zapLogger))
|
||||||
r.Use(middleware.CORS())
|
r.Use(middleware.CORS())
|
||||||
|
|
||||||
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler)
|
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
|
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
|
||||||
@@ -140,8 +141,9 @@ func main() {
|
|||||||
zapLogger.Info("服务器已关闭")
|
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.Any("/:protocol/*path", proxyHandler.HandleProxy)
|
||||||
|
r.GET("/api/version", versionHandler.GetVersion)
|
||||||
|
|
||||||
providers := r.Group("/api/providers")
|
providers := r.Group("/api/providers")
|
||||||
{
|
{
|
||||||
|
|||||||
37
backend/cmd/server/routes_test.go
Normal file
37
backend/cmd/server/routes_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ func Logging(logger *zap.Logger) gin.HandlerFunc {
|
|||||||
if id, ok := requestID.(string); ok {
|
if id, ok := requestID.(string); ok {
|
||||||
requestIDStr = id
|
requestIDStr = id
|
||||||
}
|
}
|
||||||
logger.Info("请求开始",
|
logger.Debug("请求开始",
|
||||||
pkglogger.Method(c.Request.Method),
|
pkglogger.Method(c.Request.Method),
|
||||||
pkglogger.Path(path),
|
pkglogger.Path(path),
|
||||||
pkglogger.Query(query),
|
pkglogger.Query(query),
|
||||||
@@ -33,7 +33,7 @@ func Logging(logger *zap.Logger) gin.HandlerFunc {
|
|||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
statusCode := c.Writer.Status()
|
statusCode := c.Writer.Status()
|
||||||
|
|
||||||
logger.Info("请求结束",
|
logger.Debug("请求结束",
|
||||||
pkglogger.StatusCode(statusCode),
|
pkglogger.StatusCode(statusCode),
|
||||||
pkglogger.Method(c.Request.Method),
|
pkglogger.Method(c.Request.Method),
|
||||||
pkglogger.Path(path),
|
pkglogger.Path(path),
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
"go.uber.org/zap/zaptest/observer"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -65,6 +67,61 @@ func TestLogging(t *testing.T) {
|
|||||||
assert.Equal(t, 200, w.Code)
|
assert.Equal(t, 200, w.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLogging_DoesNotLogLifecycleAtInfoLevel(t *testing.T) {
|
||||||
|
core, logs := observer.New(zapcore.InfoLevel)
|
||||||
|
logger := zap.New(core)
|
||||||
|
|
||||||
|
w := serveLoggingRequest(logger)
|
||||||
|
|
||||||
|
assert.Equal(t, 200, w.Code)
|
||||||
|
assert.Empty(t, logs.FilterMessage("请求开始").All())
|
||||||
|
assert.Empty(t, logs.FilterMessage("请求结束").All())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogging_LogsLifecycleAtDebugLevel(t *testing.T) {
|
||||||
|
core, logs := observer.New(zapcore.DebugLevel)
|
||||||
|
logger := zap.New(core)
|
||||||
|
|
||||||
|
w := serveLoggingRequest(logger)
|
||||||
|
|
||||||
|
assert.Equal(t, 200, w.Code)
|
||||||
|
startLogs := logs.FilterMessage("请求开始").All()
|
||||||
|
endLogs := logs.FilterMessage("请求结束").All()
|
||||||
|
if assert.Len(t, startLogs, 1) {
|
||||||
|
fields := startLogs[0].ContextMap()
|
||||||
|
assert.Equal(t, "GET", fields["method"])
|
||||||
|
assert.Equal(t, "/test", fields["path"])
|
||||||
|
assert.Equal(t, "key=value", fields["query"])
|
||||||
|
assert.Equal(t, "existing-id-123", fields["request_id"])
|
||||||
|
assert.NotEmpty(t, fields["client_ip"])
|
||||||
|
}
|
||||||
|
if assert.Len(t, endLogs, 1) {
|
||||||
|
fields := endLogs[0].ContextMap()
|
||||||
|
assert.Equal(t, int64(200), fields["status"])
|
||||||
|
assert.Equal(t, "GET", fields["method"])
|
||||||
|
assert.Equal(t, "/test", fields["path"])
|
||||||
|
assert.Equal(t, int64(2), fields["body_size"])
|
||||||
|
assert.Equal(t, "existing-id-123", fields["request_id"])
|
||||||
|
assert.Contains(t, fields, "latency")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveLoggingRequest(logger *zap.Logger) *httptest.ResponseRecorder {
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(RequestID())
|
||||||
|
r.Use(Logging(logger))
|
||||||
|
r.GET("/test", func(c *gin.Context) {
|
||||||
|
c.String(200, "ok")
|
||||||
|
})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("GET", "/test?key=value", nil)
|
||||||
|
req.Header.Set("X-Request-ID", "existing-id-123")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
func TestRecovery_NoPanic(t *testing.T) {
|
func TestRecovery_NoPanic(t *testing.T) {
|
||||||
logger := zap.NewNop()
|
logger := zap.NewNop()
|
||||||
|
|
||||||
|
|||||||
26
backend/internal/handler/version_handler.go
Normal file
26
backend/internal/handler/version_handler.go
Normal 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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
31
backend/internal/handler/version_handler_test.go
Normal file
31
backend/internal/handler/version_handler_test.go
Normal 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"])
|
||||||
|
}
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
package projectversion
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParse(t *testing.T) {
|
|
||||||
t.Run("valid", func(t *testing.T) {
|
|
||||||
version, err := Parse("1.2.3")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, Version{Major: 1, Minor: 2, Patch: 3}, version)
|
|
||||||
assert.Equal(t, "1.2.3", version.String())
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("invalid", func(t *testing.T) {
|
|
||||||
invalidValues := []string{"", "1.2", "1.2.3.4", "v1.2.3", "01.2.3", "1.02.3"}
|
|
||||||
for _, tc := range invalidValues {
|
|
||||||
_, err := Parse(tc)
|
|
||||||
assert.Error(t, err, "%q 应校验失败", tc)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdatePackageJSONVersion(t *testing.T) {
|
|
||||||
content := "{\n \"name\": \"frontend\",\n \"version\": \"0.0.0\"\n}\n"
|
|
||||||
updated, err := UpdatePackageJSONVersion(content, "1.2.3")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, updated, `"version": "1.2.3"`)
|
|
||||||
|
|
||||||
version, err := ReadPackageJSONVersion(updated)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "1.2.3", version)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpsertEnvVar(t *testing.T) {
|
|
||||||
updated := UpsertEnvVar("VITE_API_BASE=/api\n", "VITE_APP_VERSION", "1.2.3")
|
|
||||||
assert.Contains(t, updated, "VITE_API_BASE=/api\n")
|
|
||||||
assert.Contains(t, updated, "VITE_APP_VERSION=1.2.3\n")
|
|
||||||
|
|
||||||
updated = UpsertEnvVar(updated, "VITE_APP_VERSION", "2.0.0")
|
|
||||||
value, ok := ReadEnvVar(updated, "VITE_APP_VERSION")
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, "2.0.0", value)
|
|
||||||
assert.Equal(t, 1, strings.Count(updated, "VITE_APP_VERSION="))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSyncAndCheck(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(root, "VERSION"), []byte("1.2.3\n"), 0o644))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "frontend"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", "package.json"), []byte("{\n \"name\": \"frontend\",\n \"version\": \"0.0.0\"\n}\n"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", ".env.production"), []byte("VITE_API_BASE=/api\n"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", ".env.development"), []byte("VITE_API_BASE=\n"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", ".env.desktop"), []byte("VITE_API_BASE=\n"), 0o644))
|
|
||||||
|
|
||||||
require.NoError(t, Sync(root))
|
|
||||||
require.NoError(t, Check(root))
|
|
||||||
|
|
||||||
packageJSONContent, err := os.ReadFile(filepath.Join(root, "frontend", "package.json"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, string(packageJSONContent), `"version": "1.2.3"`)
|
|
||||||
|
|
||||||
for _, relPath := range frontendVersionFiles {
|
|
||||||
content, readErr := os.ReadFile(filepath.Join(root, relPath))
|
|
||||||
require.NoError(t, readErr)
|
|
||||||
assert.Contains(t, string(content), "VITE_APP_VERSION=1.2.3\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVerifyTag(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(root, "VERSION"), []byte("1.2.3\n"), 0o644))
|
|
||||||
|
|
||||||
require.NoError(t, VerifyTag(root, "v1.2.3"))
|
|
||||||
assert.Error(t, VerifyTag(root, "1.2.3"))
|
|
||||||
assert.Error(t, VerifyTag(root, "v1.2.4"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAssetNames(t *testing.T) {
|
|
||||||
linuxServer, err := ServerAssetName("1.2.3", "linux", "amd64")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "nex-server_1.2.3_linux_amd64.tar.gz", linuxServer)
|
|
||||||
|
|
||||||
macServer, err := ServerAssetName("1.2.3", "darwin", "arm64")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "nex-server_1.2.3_darwin_arm64.tar.gz", macServer)
|
|
||||||
|
|
||||||
macDesktop, err := DesktopAssetName("1.2.3", "macos")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "Nex_1.2.3_macOS_universal.zip", macDesktop)
|
|
||||||
|
|
||||||
_, err = DesktopAssetName("1.2.3", "ios")
|
|
||||||
assert.Error(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDesktopInfoPlist(t *testing.T) {
|
|
||||||
plist, err := DesktopInfoPlist("1.2.3", "13.0")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, plist, "<key>CFBundleShortVersionString</key>\n <string>1.2.3</string>")
|
|
||||||
assert.Contains(t, plist, "<key>CFBundleVersion</key>\n <string>1.2.3</string>")
|
|
||||||
assert.Contains(t, plist, "<key>LSMinimumSystemVersion</key>\n <string>13.0</string>")
|
|
||||||
|
|
||||||
_, err = DesktopInfoPlist("1.2", "13.0")
|
|
||||||
assert.Error(t, err)
|
|
||||||
_, err = DesktopInfoPlist("1.2.3", "")
|
|
||||||
assert.Error(t, err)
|
|
||||||
}
|
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
VITE_API_BASE=
|
VITE_API_BASE=
|
||||||
VITE_APP_VERSION=0.1.0
|
VITE_APP_VERSION=0.1.5
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
VITE_API_BASE=
|
VITE_API_BASE=
|
||||||
VITE_APP_VERSION=0.1.0
|
VITE_APP_VERSION=0.1.5
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
VITE_API_BASE=/api
|
VITE_API_BASE=/api
|
||||||
VITE_APP_VERSION=0.1.0
|
VITE_APP_VERSION=0.1.5
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# AI Gateway Frontend
|
# Nex Frontend
|
||||||
|
|
||||||
AI 网关管理前端,提供供应商配置和用量统计界面。
|
AI 网关管理前端,提供供应商配置和用量统计界面。
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ AI 网关管理前端,提供供应商配置和用量统计界面。
|
|||||||
- **UI 组件库**: TDesign
|
- **UI 组件库**: TDesign
|
||||||
- **路由**: React Router v7
|
- **路由**: React Router v7
|
||||||
- **数据获取**: TanStack Query v5
|
- **数据获取**: TanStack Query v5
|
||||||
- **样式**: SCSS Modules(禁止使用纯 CSS)
|
- **样式**: TDesign 组件 props 优先,TDesign tokens 次之,SCSS 作为兜底补充
|
||||||
- **测试**: Vitest + React Testing Library + Playwright
|
- **测试**: Vitest + React Testing Library + Playwright
|
||||||
- **代码格式化**: Prettier
|
- **代码格式化**: Prettier
|
||||||
|
|
||||||
@@ -86,17 +86,20 @@ frontend/
|
|||||||
│ │ ├── client.ts # 统一 request<T>() + 字段转换
|
│ │ ├── client.ts # 统一 request<T>() + 字段转换
|
||||||
│ │ ├── providers.ts # Provider CRUD
|
│ │ ├── providers.ts # Provider CRUD
|
||||||
│ │ ├── models.ts # Model CRUD
|
│ │ ├── models.ts # Model CRUD
|
||||||
│ │ └── stats.ts # Stats 查询
|
│ │ ├── stats.ts # Stats 查询
|
||||||
|
│ │ └── version.ts # 后端版本查询
|
||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ │ └── AppLayout/ # 侧边栏导航布局
|
│ │ └── AppLayout/ # 侧边栏导航布局
|
||||||
│ ├── hooks/ # TanStack Query hooks
|
│ ├── hooks/ # TanStack Query hooks
|
||||||
│ │ ├── useProviders.ts
|
│ │ ├── useProviders.ts
|
||||||
│ │ ├── useModels.ts
|
│ │ ├── useModels.ts
|
||||||
│ │ └── useStats.ts
|
│ │ ├── useStats.ts
|
||||||
|
│ │ └── useVersion.ts
|
||||||
│ ├── pages/
|
│ ├── pages/
|
||||||
│ │ ├── Providers/ # 供应商管理(含内嵌模型管理)
|
│ │ ├── Providers/ # 供应商管理(含内嵌模型管理)
|
||||||
│ │ ├── Stats/ # 用量统计
|
│ │ ├── Stats/ # 用量统计
|
||||||
│ │ ├── Settings/ # 设置(开发中)
|
│ │ ├── Settings/ # 设置(开发中)
|
||||||
|
│ │ ├── About/ # 关于页面(品牌与版本信息)
|
||||||
│ │ └── NotFound.tsx
|
│ │ └── NotFound.tsx
|
||||||
│ ├── routes/
|
│ ├── routes/
|
||||||
│ │ └── index.tsx # 路由配置
|
│ │ └── index.tsx # 路由配置
|
||||||
@@ -111,6 +114,7 @@ frontend/
|
|||||||
│ ├── main.tsx
|
│ ├── main.tsx
|
||||||
│ └── index.scss
|
│ └── index.scss
|
||||||
├── e2e/ # Playwright E2E 测试
|
├── e2e/ # Playwright E2E 测试
|
||||||
|
├── public/ # 静态资源(icon.png 来源于 ../assets/icon.png)
|
||||||
├── vitest.config.ts
|
├── vitest.config.ts
|
||||||
├── playwright.config.ts
|
├── playwright.config.ts
|
||||||
├── tsconfig.json
|
├── tsconfig.json
|
||||||
@@ -200,6 +204,12 @@ bun run test:e2e
|
|||||||
- 按模型筛选
|
- 按模型筛选
|
||||||
- 按日期范围筛选(DatePicker.RangePicker)
|
- 按日期范围筛选(DatePicker.RangePicker)
|
||||||
|
|
||||||
|
### 关于页面
|
||||||
|
|
||||||
|
- 展示应用名称、产品描述和项目链接
|
||||||
|
- 展示前端版本、后端版本、后端 commit 和构建时间
|
||||||
|
- 根据 `VITE_APP_VERSION` 与 `GET /api/version` 返回值提示前后端版本是否一致
|
||||||
|
|
||||||
## 测试策略
|
## 测试策略
|
||||||
|
|
||||||
### 目录结构
|
### 目录结构
|
||||||
@@ -232,8 +242,9 @@ __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 测试特有**:
|
**E2E 测试特有**:
|
||||||
|
|
||||||
@@ -242,9 +253,11 @@ __tests__/
|
|||||||
|
|
||||||
## 开发规范
|
## 开发规范
|
||||||
|
|
||||||
- 所有样式使用 SCSS,禁止使用纯 CSS 文件
|
- 样式优先使用 TDesign 组件 props(如 `hoverShadow`、`headerBordered`、`variant`、`shape`、`gutter`)
|
||||||
- 组件级样式使用 SCSS Modules(\*.module.scss)
|
- 组件 props 无法表达时使用 TDesign tokens(`var(--td-*)`)
|
||||||
|
- 仅当 props 和 tokens 无法满足布局、响应式或品牌视觉需求时使用 SCSS,禁止使用纯 CSS 文件
|
||||||
- 图标优先使用 TDesign 图标(tdesign-icons-react)
|
- 图标优先使用 TDesign 图标(tdesign-icons-react)
|
||||||
|
- 应用 favicon 使用 `frontend/public/icon.png`,该文件来源于仓库根目录 `assets/icon.png`
|
||||||
- TypeScript strict 模式,禁止 any 类型
|
- TypeScript strict 模式,禁止 any 类型
|
||||||
- API 层自动处理 snake_case ↔ camelCase 字段转换
|
- API 层自动处理 snake_case ↔ camelCase 字段转换
|
||||||
- 使用路径别名 `@/` 引用 src 目录
|
- 使用路径别名 `@/` 引用 src 目录
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ test.describe('侧边栏', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('应显示应用名称', async ({ page }) => {
|
test('应显示应用名称', async ({ page }) => {
|
||||||
await expect(page.locator('aside').getByText('AI Gateway')).toBeVisible()
|
await expect(page.locator('aside').getByText('Nex')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('应显示导航菜单项', async ({ page }) => {
|
test('应显示导航菜单项', async ({ page }) => {
|
||||||
@@ -41,6 +41,15 @@ test.describe('页面导航', () => {
|
|||||||
await expect(page.getByRole('heading', { name: '供应商管理' })).toBeVisible()
|
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 }) => {
|
test('应在刷新后保持当前页面', async ({ page }) => {
|
||||||
await page.locator('aside').getByText('用量统计').click()
|
await page.locator('aside').getByText('用量统计').click()
|
||||||
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible()
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AI Gateway</title>
|
<title>Nex</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.5",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
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
BIN
frontend/public/icon.png
LFS
Normal file
Binary file not shown.
@@ -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 |
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 { 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 { describe, it, expect } from 'vitest'
|
||||||
import { AppLayout } from '@/components/AppLayout'
|
import { AppLayout } from '@/components/AppLayout'
|
||||||
|
|
||||||
const renderWithRouter = (component: React.ReactNode) => {
|
const renderWithRouter = (component: React.ReactNode) => {
|
||||||
return render(<BrowserRouter>{component}</BrowserRouter>)
|
return render(<MemoryRouter initialEntries={['/providers']}>{component}</MemoryRouter>)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('AppLayout', () => {
|
describe('AppLayout', () => {
|
||||||
it('renders sidebar with app name', () => {
|
it('renders sidebar with app name', () => {
|
||||||
renderWithRouter(<AppLayout />)
|
renderWithRouter(<AppLayout />)
|
||||||
|
|
||||||
const appNames = screen.getAllByText('AI Gateway')
|
expect(screen.getByText('Nex')).toBeInTheDocument()
|
||||||
expect(appNames.length).toBeGreaterThan(0)
|
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', () => {
|
it('renders navigation menu items', () => {
|
||||||
renderWithRouter(<AppLayout />)
|
renderWithRouter(<AppLayout />)
|
||||||
|
|
||||||
expect(screen.getByText('供应商管理')).toBeInTheDocument()
|
expect(screen.getAllByText('供应商管理').length).toBeGreaterThan(0)
|
||||||
expect(screen.getByText('用量统计')).toBeInTheDocument()
|
expect(screen.getAllByText('用量统计').length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders settings menu item', () => {
|
it('renders settings menu item', () => {
|
||||||
@@ -28,6 +40,12 @@ describe('AppLayout', () => {
|
|||||||
expect(screen.getByText('设置')).toBeInTheDocument()
|
expect(screen.getByText('设置')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders about menu item', () => {
|
||||||
|
renderWithRouter(<AppLayout />)
|
||||||
|
|
||||||
|
expect(screen.getByText('关于')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('renders content outlet', () => {
|
it('renders content outlet', () => {
|
||||||
const { container } = renderWithRouter(<AppLayout />)
|
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,
|
ChevronRightIcon,
|
||||||
} from 'tdesign-icons-react'
|
} from 'tdesign-icons-react'
|
||||||
import { Layout, Menu, Button } from 'tdesign-react'
|
import { Layout, Menu, Button } from 'tdesign-react'
|
||||||
|
import { APP_NAME } from '@/constants/app'
|
||||||
|
|
||||||
const { MenuItem } = Menu
|
const { MenuItem } = Menu
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ export function AppLayout() {
|
|||||||
if (location.pathname === '/stats') return '用量统计'
|
if (location.pathname === '/stats') return '用量统计'
|
||||||
if (location.pathname === '/settings') return '设置'
|
if (location.pathname === '/settings') return '设置'
|
||||||
if (location.pathname === '/about') return '关于'
|
if (location.pathname === '/about') return '关于'
|
||||||
return 'AI Gateway'
|
return APP_NAME
|
||||||
}
|
}
|
||||||
|
|
||||||
const asideWidth = collapsed ? '64px' : '232px'
|
const asideWidth = collapsed ? '64px' : '232px'
|
||||||
@@ -52,15 +53,18 @@ export function AppLayout() {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
gap: 10,
|
||||||
fontSize: '1.25rem',
|
fontSize: '1.25rem',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!collapsed && 'AI Gateway'}
|
<img src='/icon.png' alt={`${APP_NAME} logo`} style={{ width: 28, height: 28 }} />
|
||||||
|
{!collapsed && APP_NAME}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
operations={
|
operations={
|
||||||
<Button
|
<Button
|
||||||
|
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
|
||||||
variant='text'
|
variant='text'
|
||||||
shape='square'
|
shape='square'
|
||||||
icon={collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
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() {
|
export default function AboutPage() {
|
||||||
|
const { data: backendVersion, isError, isLoading } = useBackendVersion()
|
||||||
|
const versionStatus = getVersionStatus(APP_VERSION, backendVersion, isError)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card bordered={false}>
|
<div style={{ display: 'grid', gap: 'var(--td-comp-margin-l)' }}>
|
||||||
<div
|
<Card bordered={false} hoverShadow>
|
||||||
style={{
|
<Space align='center' size='large'>
|
||||||
display: 'flex',
|
<img src='/icon.png' alt={`${APP_NAME} logo`} style={{ width: 56, height: 56 }} />
|
||||||
flexDirection: 'column',
|
<div>
|
||||||
alignItems: 'center',
|
<h1 style={{ margin: 0, fontSize: '2rem' }}>{APP_NAME}</h1>
|
||||||
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' }}>
|
<p style={{ margin: '0.5rem 0 0', color: 'var(--td-text-color-secondary)', fontSize: '1rem' }}>
|
||||||
AI Gateway - 统一的大模型 API 网关
|
{APP_DESCRIPTION}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
</Space>
|
||||||
</Card>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,20 @@ export interface StatsQueryParams {
|
|||||||
endDate?: string
|
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 {
|
export class ApiError extends Error {
|
||||||
status: number
|
status: number
|
||||||
code?: string
|
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: '前后端版本不同,该状态用于部署诊断,不影响当前功能使用。',
|
||||||
|
}
|
||||||
|
}
|
||||||
1
go.work
1
go.work
@@ -3,4 +3,5 @@ go 1.26.2
|
|||||||
use (
|
use (
|
||||||
backend
|
backend
|
||||||
embedfs
|
embedfs
|
||||||
|
versionctl
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQ
|
|||||||
cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
|
cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
|
||||||
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
|
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
|
||||||
cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng=
|
cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng=
|
||||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
|
||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
|
||||||
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
|
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
|
||||||
github.com/ClickHouse/clickhouse-go/v2 v2.43.0/go.mod h1:o6jf7JM/zveWC/PP277BLxjHy5KjnGX/jfljhM4s34g=
|
github.com/ClickHouse/clickhouse-go/v2 v2.43.0/go.mod h1:o6jf7JM/zveWC/PP277BLxjHy5KjnGX/jfljhM4s34g=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
@@ -14,6 +12,7 @@ github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmO
|
|||||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/cristalhq/acmd v0.12.0/go.mod h1:LG5oa43pE/BbxtfMoImHCQN++0Su7dzipdgBjMCBVDQ=
|
github.com/cristalhq/acmd v0.12.0/go.mod h1:LG5oa43pE/BbxtfMoImHCQN++0Su7dzipdgBjMCBVDQ=
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||||
@@ -26,8 +25,6 @@ github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6v
|
|||||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
|
||||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||||
@@ -48,6 +45,7 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
|
|||||||
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||||
github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk=
|
github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk=
|
||||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||||
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
||||||
@@ -66,8 +64,10 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v
|
|||||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||||
github.com/phayes/checkstyle v0.0.0-20170904204023-bfd46e6a821d/go.mod h1:3OzsM7FXDQlpCiw2j81fOmAwQLnZnLGXVKUzeKQXIAw=
|
github.com/phayes/checkstyle v0.0.0-20170904204023-bfd46e6a821d/go.mod h1:3OzsM7FXDQlpCiw2j81fOmAwQLnZnLGXVKUzeKQXIAw=
|
||||||
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||||
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/quasilyte/go-ruleguard/rules v0.0.0-20211022131956-028d6511ab71/go.mod h1:4cgAphtvu7Ftv7vOT2ZOYhC6CvBxZixcasr8qIOTA50=
|
github.com/quasilyte/go-ruleguard/rules v0.0.0-20211022131956-028d6511ab71/go.mod h1:4cgAphtvu7Ftv7vOT2ZOYhC6CvBxZixcasr8qIOTA50=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA=
|
github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA=
|
||||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-04
|
||||||
@@ -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 优先作为新的明确规则。
|
||||||
|
- 版本不一致提示可能被误解为严重故障 → 文案应说明该状态用于部署诊断,不影响当前功能使用。
|
||||||
@@ -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` 管理接口;不引入新依赖。
|
||||||
@@ -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 因版本接口失败而崩溃
|
||||||
@@ -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 路由)
|
||||||
@@ -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`
|
||||||
@@ -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 将该状态判定为版本不一致
|
||||||
@@ -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 状态可实施
|
||||||
@@ -8,7 +8,7 @@ context: |
|
|||||||
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
|
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
|
||||||
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
|
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
|
||||||
- backend是使用go开发的后端,阅读backend/README.md了解项目架构,优先使用公共组件实现功能逻辑(优先级:官方库>主流三方库>项目公共工具>自行实现)
|
- 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提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
|
||||||
- 禁止创建git操作task
|
- 禁止创建git操作task
|
||||||
- 积极使用subagents精心设计并行任务,节省上下文空间,加速任务执行
|
- 积极使用subagents精心设计并行任务,节省上下文空间,加速任务执行
|
||||||
|
|||||||
@@ -2,25 +2,83 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
TBD - 提供关于页面展示项目品牌信息
|
TBD - 提供关于页面展示项目品牌信息、版本信息和外部链接
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Requirement: 关于页面
|
### Requirement: 关于页面
|
||||||
|
|
||||||
前端 SHALL 提供关于页面,使用 TDesign Card 组件居中展示项目品牌信息(应用名称、描述、项目链接)。
|
前端 SHALL 提供现代化关于页面,使用 TDesign 组件展示项目品牌信息、项目链接、前端版本、后端版本和版本匹配状态。
|
||||||
|
|
||||||
#### Scenario: 显示关于页面
|
#### Scenario: 显示关于页面
|
||||||
|
|
||||||
- **WHEN** 用户访问 `/about` 路径
|
- **WHEN** 用户访问 `/about` 路径
|
||||||
- **THEN** 前端 SHALL 显示关于页面
|
- **THEN** 前端 SHALL 显示关于页面
|
||||||
- **THEN** 页面 SHALL 展示应用名称"Nex"
|
- **THEN** 页面 SHALL 展示应用名称 `Nex`
|
||||||
- **THEN** 页面 SHALL 展示应用描述"AI Gateway - 统一的大模型 API 网关"
|
- **THEN** 页面 SHALL 展示应用描述 `AI Gateway - 统一的大模型 API 网关`
|
||||||
- **THEN** 页面 SHALL 展示项目链接"https://github.com/nex/gateway"
|
- **THEN** 页面 SHALL 展示项目链接 `https://github.com/nex/gateway`
|
||||||
|
|
||||||
#### Scenario: 页面布局
|
#### Scenario: 页面布局
|
||||||
|
|
||||||
- **WHEN** 渲染关于页面
|
- **WHEN** 渲染关于页面
|
||||||
- **THEN** 页面 SHALL 使用 TDesign Card 组件作为容器
|
- **THEN** 页面 SHALL 使用三个独立 TDesign Card 组件分别承载品牌区、版本信息区和链接区
|
||||||
- **THEN** Card SHALL 设置 `bordered={false}`
|
- **THEN** 三个 Card SHALL 使用 grid 布局垂直排列
|
||||||
- **THEN** 内容 SHALL 居中展示
|
- **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 因版本接口失败而崩溃
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
|||||||
- **THEN** 系统根据平台加载正确的图标格式
|
- **THEN** 系统根据平台加载正确的图标格式
|
||||||
- **AND** 在 Windows 上加载 ICO 格式图标(`assets/icon.ico`)
|
- **AND** 在 Windows 上加载 ICO 格式图标(`assets/icon.ico`)
|
||||||
- **AND** 在 macOS 和 Linux 上加载 PNG 格式图标(`assets/icon.png`)
|
- **AND** 在 macOS 和 Linux 上加载 PNG 格式图标(`assets/icon.png`)
|
||||||
- **AND** 托盘图标 tooltip 显示"AI Gateway"
|
- **AND** 托盘图标 tooltip 显示 `Nex`
|
||||||
|
|
||||||
#### Scenario: 托盘菜单显示
|
#### Scenario: 托盘菜单显示
|
||||||
|
|
||||||
@@ -80,6 +80,13 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
|||||||
- **WHEN** 请求路径以 `/api/` 或 `/health` 开头
|
- **WHEN** 请求路径以 `/api/` 或 `/health` 开头
|
||||||
- **THEN** 请求由现有业务 handler 处理或返回 API 风格 404
|
- **THEN** 请求由现有业务 handler 处理或返回 API 风格 404
|
||||||
|
|
||||||
|
#### Scenario: 版本接口路由
|
||||||
|
|
||||||
|
- **WHEN** desktop 模式收到 `GET /api/version` 请求
|
||||||
|
- **THEN** 请求 SHALL 由版本信息 handler 处理
|
||||||
|
- **THEN** 响应 SHALL 为 API JSON 响应
|
||||||
|
- **THEN** 请求 SHALL NOT 返回前端 `index.html`
|
||||||
|
|
||||||
#### Scenario: 协议代理请求路由
|
#### Scenario: 协议代理请求路由
|
||||||
|
|
||||||
- **WHEN** 请求路径以 `/openai/` 或 `/anthropic/` 开头
|
- **WHEN** 请求路径以 `/openai/` 或 `/anthropic/` 开头
|
||||||
@@ -104,10 +111,10 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
|||||||
- **THEN** 返回嵌入的前端静态资源文件
|
- **THEN** 返回嵌入的前端静态资源文件
|
||||||
- **THEN** 请求 SHALL NOT 被协议代理路由处理
|
- **THEN** 请求 SHALL NOT 被协议代理路由处理
|
||||||
|
|
||||||
#### Scenario: Favicon 路由
|
#### Scenario: PNG Favicon 路由
|
||||||
|
|
||||||
- **WHEN** 请求路径为 `/favicon.svg`
|
- **WHEN** 请求路径为 `/icon.png`
|
||||||
- **THEN** 返回嵌入的前端 favicon 资源
|
- **THEN** 返回来源于统一应用图标的 PNG favicon 资源
|
||||||
- **THEN** 请求 SHALL NOT 被协议代理路由处理
|
- **THEN** 请求 SHALL NOT 被协议代理路由处理
|
||||||
|
|
||||||
#### Scenario: SPA 路由回退
|
#### Scenario: SPA 路由回退
|
||||||
@@ -132,29 +139,92 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
|||||||
|
|
||||||
### Requirement: 跨平台构建
|
### Requirement: 跨平台构建
|
||||||
|
|
||||||
系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台分离,中间构建产物文件名 SHALL 保持 `nex-{os}-{arch}[.exe]` 格式,最终桌面发布资产文件名 SHALL 包含统一版本号和平台标识。
|
系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台和架构分离或参数化,中间构建产物文件名 SHALL 保持可区分目标平台和架构,最终桌面发布资产文件名 SHALL 包含统一版本号、组件、平台、架构和格式信息。
|
||||||
|
|
||||||
#### Scenario: macOS 构建
|
#### Scenario: macOS 构建
|
||||||
|
|
||||||
- **WHEN** 执行 `desktop-build-mac` 构建命令且当前版本为 `1.2.3`
|
- **WHEN** 执行 macOS desktop 构建命令且当前版本为 `1.2.3`
|
||||||
- **THEN** 生成 `nex-mac-arm64` 和 `nex-mac-amd64` 可执行文件
|
- **THEN** 系统 SHALL 生成 macOS arm64 和 amd64 桌面可执行文件
|
||||||
- **AND** 系统生成可打包为 `.app` bundle 的 macOS 桌面产物
|
- **AND** 系统 SHALL 使用 `lipo` 生成 macOS universal 桌面可执行文件
|
||||||
- **AND** 最终发布资产文件名 SHALL 包含 `1.2.3` 和 `macOS` 平台标识
|
- **AND** 系统 SHALL 生成可打包为 `.app` bundle 的 macOS desktop 产物
|
||||||
|
- **AND** 最终 macOS desktop 发布资产文件名 SHALL 包含 `1.2.3`、`macos` 和 `universal`
|
||||||
|
|
||||||
#### Scenario: Windows 构建
|
#### Scenario: Windows 构建
|
||||||
|
|
||||||
- **WHEN** 执行 `desktop-build-win` 构建命令且当前版本为 `1.2.3`
|
- **WHEN** 执行 Windows desktop 构建命令且当前版本为 `1.2.3`
|
||||||
- **THEN** 系统生成 Windows 桌面可执行文件
|
- **THEN** 系统 SHALL 生成 Windows amd64 和 arm64 desktop 可执行文件
|
||||||
- **AND** 生成 `nex-win-amd64.exe` 可执行文件
|
- **AND** Windows desktop 构建 SHALL 使用 `-H=windowsgui` linker flag 隐藏控制台窗口
|
||||||
- **AND** 使用 `-H=windowsgui` linker flag 隐藏控制台窗口
|
- **AND** 最终 Windows desktop 发布资产文件名 SHALL 包含 `1.2.3`、`windows` 和对应架构标识
|
||||||
- **AND** 最终发布资产文件名 SHALL 包含 `1.2.3` 和 `windows` 平台标识
|
|
||||||
|
|
||||||
#### Scenario: Linux 构建
|
#### Scenario: Linux 构建
|
||||||
|
|
||||||
- **WHEN** 执行 `desktop-build-linux` 构建命令且当前版本为 `1.2.3`
|
- **WHEN** 执行 Linux desktop 构建命令且当前版本为 `1.2.3`
|
||||||
- **THEN** 系统生成 Linux 桌面可执行文件
|
- **THEN** 系统 SHALL 生成 Linux amd64 和 arm64 desktop 可执行文件
|
||||||
- **AND** 生成 `nex-linux-amd64` 可执行文件
|
- **AND** Linux desktop 构建 SHALL 使用 CGO 和 GTK/AppIndicator 构建依赖
|
||||||
- **AND** 最终发布资产文件名 SHALL 包含 `1.2.3` 和 `linux` 平台标识
|
- **AND** 最终 Linux desktop 发布资产文件名 SHALL 包含 `1.2.3`、`linux` 和对应架构标识
|
||||||
|
|
||||||
|
### Requirement: Linux 桌面发布安装包
|
||||||
|
|
||||||
|
系统 SHALL 为 Linux desktop amd64 和 arm64 生成 tar.gz、AppImage、deb 和 rpm 发布安装包,并 SHALL 在安装包中包含标准桌面集成元数据。
|
||||||
|
|
||||||
|
#### Scenario: Linux desktop tar.gz 裸包
|
||||||
|
|
||||||
|
- **WHEN** 构建 Linux desktop 发布资产
|
||||||
|
- **THEN** 系统 SHALL 为 amd64 生成 `nex-desktop_<version>_linux_amd64.tar.gz`
|
||||||
|
- **AND** 系统 SHALL 为 arm64 生成 `nex-desktop_<version>_linux_arm64.tar.gz`
|
||||||
|
|
||||||
|
#### Scenario: Linux desktop AppImage 包
|
||||||
|
|
||||||
|
- **WHEN** 构建 Linux desktop AppImage 发布资产
|
||||||
|
- **THEN** 系统 SHALL 为 amd64 生成 `nex-desktop_<version>_linux_amd64.AppImage`
|
||||||
|
- **AND** 系统 SHALL 为 arm64 生成 `nex-desktop_<version>_linux_arm64.AppImage`
|
||||||
|
- **AND** AppImage SHALL 包含 desktop entry、应用图标和 desktop 可执行文件
|
||||||
|
- **AND** AppImage SHALL 依赖目标系统提供 GTK3、Ayatana AppIndicator 和运行 AppImage 所需的 runtime/FUSE 能力
|
||||||
|
|
||||||
|
#### Scenario: Linux desktop deb 包
|
||||||
|
|
||||||
|
- **WHEN** 构建 Linux desktop deb 发布资产
|
||||||
|
- **THEN** 系统 SHALL 为 amd64 生成 `nex-desktop_<version>_linux_amd64.deb`
|
||||||
|
- **AND** 系统 SHALL 为 arm64 生成 `nex-desktop_<version>_linux_arm64.deb`
|
||||||
|
- **AND** deb 包 SHALL 将可执行文件安装到 `/usr/bin/nex`
|
||||||
|
- **AND** deb 包 SHALL 将 desktop entry 安装到 `/usr/share/applications/nex.desktop`
|
||||||
|
- **AND** deb 包 SHALL 将 hicolor 图标安装到 `/usr/share/icons/hicolor/.../apps/nex.png`
|
||||||
|
- **AND** deb 包 SHALL 声明 `libgtk-3-0`、`libayatana-appindicator3-1` 和 `xdg-utils` 运行时依赖
|
||||||
|
- **AND** deb 包 metadata 的架构字段 SHALL 使用 `amd64` 或 `arm64`
|
||||||
|
|
||||||
|
#### Scenario: Linux desktop rpm 包
|
||||||
|
|
||||||
|
- **WHEN** 构建 Linux desktop rpm 发布资产
|
||||||
|
- **THEN** 系统 SHALL 为 amd64 生成 `nex-desktop_<version>_linux_amd64.rpm`
|
||||||
|
- **AND** 系统 SHALL 为 arm64 生成 `nex-desktop_<version>_linux_arm64.rpm`
|
||||||
|
- **AND** rpm 包 SHALL 将可执行文件安装到 `/usr/bin/nex`
|
||||||
|
- **AND** rpm 包 SHALL 将 desktop entry 安装到 `/usr/share/applications/nex.desktop`
|
||||||
|
- **AND** rpm 包 SHALL 将 hicolor 图标安装到 `/usr/share/icons/hicolor/.../apps/nex.png`
|
||||||
|
- **AND** rpm 包 SHALL 声明 `gtk3`、`libayatana-appindicator-gtk3` 和 `xdg-utils` 运行时依赖
|
||||||
|
- **AND** rpm 包 metadata 的架构字段 SHALL 使用 `x86_64` 或 `aarch64`
|
||||||
|
|
||||||
|
### Requirement: macOS DMG 打包
|
||||||
|
|
||||||
|
系统 SHALL 为 macOS desktop universal `.app` 生成 unsigned DMG 安装包,并 SHALL 保留 universal zip 发布资产。
|
||||||
|
|
||||||
|
#### Scenario: macOS universal zip 包
|
||||||
|
|
||||||
|
- **WHEN** 构建 macOS desktop 发布资产且当前版本为 `1.2.3`
|
||||||
|
- **THEN** 系统 SHALL 生成 `nex-desktop_1.2.3_macos_universal.zip`
|
||||||
|
- **AND** zip 包 SHALL 包含 `Nex.app`
|
||||||
|
|
||||||
|
#### Scenario: macOS universal DMG 包
|
||||||
|
|
||||||
|
- **WHEN** 构建 macOS desktop DMG 发布资产且当前版本为 `1.2.3`
|
||||||
|
- **THEN** 系统 SHALL 生成 `nex-desktop_1.2.3_macos_universal.dmg`
|
||||||
|
- **AND** DMG SHALL 包含 `Nex.app`
|
||||||
|
- **AND** DMG SHALL 包含指向 `/Applications` 的快捷方式
|
||||||
|
- **AND** DMG SHALL NOT 要求 macOS 签名或 notarization 才能完成构建
|
||||||
|
|
||||||
|
#### Scenario: macOS universal 架构校验
|
||||||
|
|
||||||
|
- **WHEN** macOS desktop universal 可执行文件生成完成
|
||||||
|
- **THEN** 系统 SHALL 验证该可执行文件包含 amd64 和 arm64 架构
|
||||||
|
|
||||||
### Requirement: macOS .app 打包
|
### Requirement: macOS .app 打包
|
||||||
|
|
||||||
|
|||||||
@@ -8,31 +8,31 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
|||||||
|
|
||||||
### Requirement: 样式体系
|
### Requirement: 样式体系
|
||||||
|
|
||||||
前端样式 SHALL 优先使用 TDesign 样式体系,SCSS 作为补充工具。
|
前端样式 SHALL 优先使用 TDesign 组件 props,其次使用 TDesign tokens,最后在前两者无法表达所需效果时使用 SCSS 作为补充工具。
|
||||||
|
|
||||||
#### Scenario: TDesign 组件 Props 优先
|
#### Scenario: TDesign 组件 Props 优先
|
||||||
|
|
||||||
- **WHEN** 实现组件视觉效果
|
- **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 类名覆盖组件内部样式
|
- **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 主题微调
|
#### Scenario: CSS Variables 主题微调
|
||||||
|
|
||||||
- **WHEN** 需要调整全局视觉风格
|
- **WHEN** 需要调整全局视觉风格
|
||||||
- **THEN** 前端 SHALL 通过 \`:root\` 中声明 TDesign CSS Variables(\`--td-*\`)进行覆盖
|
- **THEN** 前端 SHALL 通过 `:root` 中声明 TDesign CSS Variables(`--td-*`)进行覆盖
|
||||||
- **THEN** 前端 SHALL NOT 使用 \`!important\` 或高优先级选择器覆盖组件样式
|
- **THEN** 前端 SHALL NOT 使用 `!important` 或高优先级选择器覆盖组件样式
|
||||||
|
|
||||||
#### Scenario: 布局样式 Token 化
|
#### Scenario: SCSS 兜底使用
|
||||||
|
|
||||||
- **WHEN** 编写布局级 inline style
|
- **WHEN** TDesign 组件 props 和 TDesign tokens 均无法满足布局、响应式或品牌视觉需求
|
||||||
- **THEN** 前端 SHALL 使用 TDesign CSS Token 引用(\`var(--td-*)\`)替代硬编码颜色值
|
|
||||||
- **THEN** 前端 SHALL NOT 在布局样式中硬编码 \`#fff\`、\`#e7e7e7\`、\`#999\` 等颜色值
|
|
||||||
|
|
||||||
#### Scenario: SCSS 补充使用
|
|
||||||
|
|
||||||
- **WHEN** TDesign 样式体系无法满足需求
|
|
||||||
- **THEN** 前端 MAY 使用 SCSS 作为补充
|
- **THEN** 前端 MAY 使用 SCSS 作为补充
|
||||||
- **THEN** SCSS 文件 SHALL 仅用于 \`:root\` 级别的 CSS Variables 声明和全局 reset
|
- **THEN** SCSS SHALL 只承载必要的补充样式
|
||||||
- **THEN** 前端 SHALL NOT 使用纯 CSS 文件(*.css)
|
- **THEN** 前端 SHALL NOT 使用纯 CSS 文件(*.css)
|
||||||
|
|
||||||
|
|
||||||
@@ -231,12 +231,20 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
|||||||
#### Scenario: 桌面布局
|
#### Scenario: 桌面布局
|
||||||
|
|
||||||
- **WHEN** 在桌面屏幕上查看前端
|
- **WHEN** 在桌面屏幕上查看前端
|
||||||
- **THEN** 布局 SHALL 使用 TDesign \`Layout.Aside\` + \`Menu\`
|
- **THEN** 布局 SHALL 使用 TDesign `Layout.Aside` + `Menu`
|
||||||
- **THEN** 侧边栏 SHALL 显示导航菜单,包含图标和文字标签
|
- **THEN** 侧边栏 SHALL 显示导航菜单,包含图标和文字标签
|
||||||
- **THEN** 侧边栏 SHALL 使用固定宽度 232px
|
- **THEN** 侧边栏 SHALL 使用固定宽度 232px
|
||||||
- **THEN** Menu 组件 SHALL 使用 \`logo\` prop 显示品牌标识
|
- **THEN** Menu 组件 SHALL 使用 `logo` prop 显示品牌标识
|
||||||
- **THEN** Menu 组件 SHALL 使用 \`operations\` prop 在底部显示操作区域
|
- **THEN** Menu 组件 SHALL 使用 `operations` prop 在底部显示操作区域
|
||||||
- **THEN** Menu 组件 SHALL 支持 \`collapsed\` 折叠功能
|
- **THEN** Menu 组件 SHALL 支持 `collapsed` 折叠功能
|
||||||
|
|
||||||
|
#### Scenario: 侧边栏折叠布局
|
||||||
|
|
||||||
|
- **WHEN** 用户折叠侧边栏
|
||||||
|
- **THEN** 侧边栏 SHALL 使用折叠宽度 64px
|
||||||
|
- **THEN** Menu logo 区域 SHALL 保留应用图标
|
||||||
|
- **THEN** Menu logo 区域 SHALL 隐藏应用名称文字
|
||||||
|
- **THEN** Menu logo 区域 SHALL NOT 显示为空白
|
||||||
|
|
||||||
#### Scenario: 页面内容区域
|
#### Scenario: 页面内容区域
|
||||||
|
|
||||||
@@ -402,21 +410,29 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
|||||||
|
|
||||||
### Requirement: 提供侧边栏导航
|
### Requirement: 提供侧边栏导航
|
||||||
|
|
||||||
前端 SHALL 使用 TDesign \`Layout.Aside\` 提供侧边栏导航。
|
前端 SHALL 使用 TDesign `Layout.Aside` 提供侧边栏导航。
|
||||||
|
|
||||||
#### Scenario: 侧边栏内容
|
#### Scenario: 侧边栏内容
|
||||||
|
|
||||||
- **WHEN** 渲染侧边栏
|
- **WHEN** 渲染侧边栏
|
||||||
- **THEN** 侧边栏顶部 SHALL 显示应用名称/Logo
|
- **THEN** 侧边栏顶部 SHALL 显示统一应用图标和应用名称 `Nex`
|
||||||
|
- **THEN** 侧边栏 SHALL NOT 显示旧品牌文字 `AI Gateway` 作为应用名称
|
||||||
- **THEN** 侧边栏 SHALL 包含导航菜单
|
- **THEN** 侧边栏 SHALL 包含导航菜单
|
||||||
- **THEN** 导航菜单项 SHALL 包含:供应商管理(ServerIcon 图标)、用量统计(ChartLineIcon 图标)、设置(SettingIcon 图标)、关于(InfoCircleIcon 图标)
|
- **THEN** 导航菜单项 SHALL 包含:供应商管理(ServerIcon 图标)、用量统计(ChartLineIcon 图标)、设置(SettingIcon 图标)、关于(InfoCircleIcon 图标)
|
||||||
|
|
||||||
|
#### Scenario: 侧边栏折叠品牌显示
|
||||||
|
|
||||||
|
- **WHEN** 侧边栏处于折叠状态
|
||||||
|
- **THEN** 侧边栏顶部 SHALL 显示统一应用图标
|
||||||
|
- **THEN** 侧边栏顶部 SHALL 隐藏 `Nex` 文案
|
||||||
|
- **THEN** 侧边栏顶部 SHALL NOT 为空白
|
||||||
|
|
||||||
#### Scenario: 导航菜单交互
|
#### Scenario: 导航菜单交互
|
||||||
|
|
||||||
- **WHEN** 用户点击导航中的"供应商管理"
|
- **WHEN** 用户点击导航中的"供应商管理"
|
||||||
- **THEN** 前端 SHALL 导航到 \`/providers\` 并高亮当前菜单项
|
- **THEN** 前端 SHALL 导航到 `/providers` 并高亮当前菜单项
|
||||||
- **WHEN** 用户点击导航中的"用量统计"
|
- **WHEN** 用户点击导航中的"用量统计"
|
||||||
- **THEN** 前端 SHALL 导航到 \`/stats\` 并高亮当前菜单项
|
- **THEN** 前端 SHALL 导航到 `/stats` 并高亮当前菜单项
|
||||||
- **WHEN** 用户点击导航中的"设置"
|
- **WHEN** 用户点击导航中的"设置"
|
||||||
- **THEN** 前端 SHALL 导航到 `/settings` 并高亮当前菜单项
|
- **THEN** 前端 SHALL 导航到 `/settings` 并高亮当前菜单项
|
||||||
- **WHEN** 用户点击导航中的"关于"
|
- **WHEN** 用户点击导航中的"关于"
|
||||||
@@ -583,3 +599,26 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
|||||||
- **THEN** 前端 SHALL 显示全局错误提示
|
- **THEN** 前端 SHALL 显示全局错误提示
|
||||||
- **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`
|
||||||
|
|
||||||
|
|||||||
@@ -29,13 +29,13 @@
|
|||||||
#### Scenario: 记录请求开始
|
#### Scenario: 记录请求开始
|
||||||
|
|
||||||
- **WHEN** 收到 HTTP 请求
|
- **WHEN** 收到 HTTP 请求
|
||||||
- **THEN** SHALL 记录请求开始日志
|
- **THEN** SHALL 以 debug 级别记录请求开始日志
|
||||||
- **THEN** SHALL 包含请求方法、路径、客户端 IP、请求 ID
|
- **THEN** SHALL 包含请求方法、路径、客户端 IP、请求 ID
|
||||||
|
|
||||||
#### Scenario: 记录请求结束
|
#### Scenario: 记录请求结束
|
||||||
|
|
||||||
- **WHEN** HTTP 请求处理完成
|
- **WHEN** HTTP 请求处理完成
|
||||||
- **THEN** SHALL 记录请求结束日志
|
- **THEN** SHALL 以 debug 级别记录请求结束日志
|
||||||
- **THEN** SHALL 包含响应状态码、响应大小、请求耗时、请求 ID
|
- **THEN** SHALL 包含响应状态码、响应大小、请求耗时、请求 ID
|
||||||
|
|
||||||
#### Scenario: 记录错误
|
#### Scenario: 记录错误
|
||||||
@@ -44,6 +44,12 @@
|
|||||||
- **THEN** SHALL 记录错误日志
|
- **THEN** SHALL 记录错误日志
|
||||||
- **THEN** SHALL 包含错误详情和请求 ID
|
- **THEN** SHALL 包含错误详情和请求 ID
|
||||||
|
|
||||||
|
#### Scenario: Info 级别过滤请求生命周期日志
|
||||||
|
|
||||||
|
- **WHEN** 日志中间件使用 info 级别 logger
|
||||||
|
- **THEN** SHALL NOT 输出“请求开始”日志
|
||||||
|
- **THEN** SHALL NOT 输出“请求结束”日志
|
||||||
|
|
||||||
### Requirement: 实现错误恢复中间件
|
### Requirement: 实现错误恢复中间件
|
||||||
|
|
||||||
系统 SHALL 实现错误恢复中间件。
|
系统 SHALL 实现错误恢复中间件。
|
||||||
|
|||||||
@@ -8,71 +8,234 @@
|
|||||||
|
|
||||||
### Requirement: Tag 驱动发布流水线
|
### Requirement: Tag 驱动发布流水线
|
||||||
|
|
||||||
系统 SHALL 仅在符合 `vX.Y.Z` 格式的 Git tag 上触发发布流水线,普通分支 push SHALL NOT 创建发布。
|
系统 SHALL 仅在符合 `vX.Y.Z` 格式的 Git tag 上触发发布流水线,普通分支 push SHALL NOT 创建发布。发布流水线 SHALL 使用 `./versionctl` 而非 `./backend/cmd/versionctl` 调用版本管理工具。
|
||||||
|
|
||||||
#### Scenario: 有效发布 tag
|
#### Scenario: 有效发布 tag
|
||||||
|
|
||||||
- **WHEN** 仓库收到 `v1.2.3` tag push
|
- **WHEN** 仓库收到 `v1.2.3` tag push
|
||||||
- **THEN** 发布流水线 SHALL 启动版本校验、构建和 Release 组装步骤
|
- **THEN** 发布流水线 SHALL 启动版本校验、构建和 Release 组装步骤
|
||||||
|
- **AND** 版本校验步骤 SHALL 使用 `go run ./versionctl print` 和 `go run ./versionctl verify-tag` 获取并验证版本
|
||||||
|
|
||||||
#### Scenario: 普通分支推送
|
#### Scenario: 普通分支推送
|
||||||
|
|
||||||
- **WHEN** 仓库收到非 tag 的分支 push
|
- **WHEN** 仓库收到非 tag 的分支 push
|
||||||
- **THEN** 系统 SHALL NOT 创建 GitHub Release
|
- **THEN** 系统 SHALL NOT 创建 GitHub Release
|
||||||
|
|
||||||
|
### Requirement: 发布流水线 Go 模块缓存覆盖
|
||||||
|
|
||||||
|
发布流水线 SHALL 在所有 Go module 的 go.sum 文件存在时正确设置 Go 模块缓存路径,确保新增的 `versionctl` module 依赖也被缓存。
|
||||||
|
|
||||||
|
#### Scenario: CI 缓存覆盖所有 module
|
||||||
|
|
||||||
|
- **WHEN** 发布流水线设置 Go 模块缓存
|
||||||
|
- **THEN** `cache-dependency-path` SHALL 覆盖 `backend/go.sum` 和 `versionctl/go.sum`
|
||||||
|
|
||||||
### Requirement: 三平台发布构建
|
### Requirement: 三平台发布构建
|
||||||
|
|
||||||
系统 SHALL 在发布流水线中构建 server 与 desktop 的 Linux、Windows、macOS 三个平台产物。
|
系统 SHALL 在发布流水线中构建 server、web 与 desktop 的发布产物,并覆盖 Linux、Windows、macOS 的目标架构和格式矩阵。
|
||||||
|
|
||||||
#### Scenario: Linux 发布构建
|
#### Scenario: server 发布构建
|
||||||
|
|
||||||
- **WHEN** 发布流水线执行 Linux 构建 job
|
- **WHEN** 发布流水线执行 server 发布构建
|
||||||
- **THEN** 系统 SHALL 生成 Linux server 发布资产
|
- **THEN** 系统 SHALL 生成 `nex-server_<version>_linux_amd64.tar.gz`
|
||||||
- **AND** 系统 SHALL 生成 Linux desktop 发布资产
|
- **AND** 系统 SHALL 生成 `nex-server_<version>_linux_arm64.tar.gz`
|
||||||
|
- **AND** 系统 SHALL 生成 `nex-server_<version>_macos_amd64.tar.gz`
|
||||||
|
- **AND** 系统 SHALL 生成 `nex-server_<version>_macos_arm64.tar.gz`
|
||||||
|
- **AND** 系统 SHALL 生成 `nex-server_<version>_macos_universal.tar.gz`
|
||||||
|
- **AND** 系统 SHALL 生成 `nex-server_<version>_windows_amd64.zip`
|
||||||
|
- **AND** 系统 SHALL 生成 `nex-server_<version>_windows_arm64.zip`
|
||||||
|
|
||||||
#### Scenario: Windows 发布构建
|
#### Scenario: web 发布构建
|
||||||
|
|
||||||
- **WHEN** 发布流水线执行 Windows 构建 job
|
- **WHEN** 发布流水线执行 web 发布构建
|
||||||
- **THEN** 系统 SHALL 生成 Windows server 发布资产
|
- **THEN** 系统 SHALL 使用 Bun 构建 `frontend/dist`
|
||||||
- **AND** 系统 SHALL 生成 Windows desktop 发布资产
|
- **AND** 系统 SHALL 将前端静态资源打包为 `nex-web_<version>.tar.gz`
|
||||||
|
- **AND** server 发布资产 SHALL NOT 内置 Web 管理界面静态资源
|
||||||
|
|
||||||
#### Scenario: macOS 发布构建
|
#### Scenario: Linux desktop 发布构建
|
||||||
|
|
||||||
- **WHEN** 发布流水线执行 macOS 构建 job
|
- **WHEN** 发布流水线执行 Linux desktop 发布构建
|
||||||
- **THEN** 系统 SHALL 生成 darwin-amd64 server 发布资产
|
- **THEN** 系统 SHALL 在可访问 Go、Bun、CGO、GTK3、Ayatana AppIndicator 和 Linux 打包工具链的环境中构建
|
||||||
- **AND** 系统 SHALL 生成 darwin-arm64 server 发布资产
|
- **AND** 系统 SHALL 为 `amd64` 和 `arm64` 分别生成 tar.gz、AppImage、deb 和 rpm desktop 发布资产
|
||||||
- **AND** 系统 SHALL 生成 macOS desktop universal 发布资产
|
- **AND** Linux amd64 desktop 发布构建 SHALL 在 `ubuntu-latest` runner 上执行
|
||||||
|
- **AND** Linux arm64 desktop 发布构建 SHALL 在 `ubuntu-24.04-arm` runner 上执行
|
||||||
|
- **AND** 系统 SHALL NOT 在构建步骤中显式传递 TARGET_ARCH 参数
|
||||||
|
|
||||||
|
#### Scenario: Windows desktop 发布构建
|
||||||
|
|
||||||
|
- **WHEN** 发布流水线执行 Windows desktop 发布构建
|
||||||
|
- **THEN** 系统 SHALL 在包含对应架构 MSYS2/MinGW 或等价 CGO 工具链的环境中构建
|
||||||
|
- **AND** Windows amd64 desktop 发布构建 SHALL 在 `windows-latest` runner 上的 MSYS2 MINGW64 环境中执行
|
||||||
|
- **AND** Windows arm64 desktop 发布构建 SHALL 在 `windows-11-arm` runner 上的 MSYS2 CLANGARM64 环境中执行
|
||||||
|
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_windows_amd64.zip`
|
||||||
|
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_windows_arm64.zip`
|
||||||
|
- **AND** 系统 SHALL NOT 在构建步骤中显式传递 TARGET_ARCH 参数
|
||||||
|
|
||||||
|
#### Scenario: macOS desktop 发布构建
|
||||||
|
|
||||||
|
- **WHEN** 发布流水线执行 macOS desktop 发布构建
|
||||||
|
- **THEN** 系统 SHALL 在可访问 Go、Bun、Xcode 命令行工具、`lipo`、`hdiutil` 和 zip 打包工具的 macOS 环境中构建
|
||||||
|
- **AND** 系统 SHALL 在 ARM64 macOS runner 上编译 amd64 和 arm64 双架构二进制并使用 `lipo` 合并为 universal binary
|
||||||
|
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_macos_universal.zip`
|
||||||
|
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_macos_universal.dmg`
|
||||||
|
|
||||||
|
#### Scenario: 原生架构构建
|
||||||
|
|
||||||
|
- **WHEN** 发布流水线执行 Linux 或 Windows 的 server/desktop 构建步骤
|
||||||
|
- **THEN** 系统 SHALL NOT 显式传递 TARGET_ARCH 参数
|
||||||
|
- **AND** Makefile SHALL 通过 `go env GOARCH` 自动检测目标架构
|
||||||
|
- **AND** 原生 runner 的实际架构 SHALL 与 `go env GOARCH` 返回值一致
|
||||||
|
|
||||||
|
### Requirement: 三平台发布构建预检
|
||||||
|
|
||||||
|
系统 SHALL 在正式执行各平台 release 构建前验证对应 job 的关键工具链可用性,并在环境不完整时快速失败且输出明确诊断。
|
||||||
|
|
||||||
|
#### Scenario: Linux 预检通过后开始构建
|
||||||
|
|
||||||
|
- **WHEN** Linux 发布 job 中的 `go`、`bun`、`gcc`、`pkg-config`、GTK3、Ayatana AppIndicator 和 Linux 打包工具均可用
|
||||||
|
- **THEN** 系统 SHALL 输出关键工具的版本信息或解析路径
|
||||||
|
- **AND** 系统 SHALL 继续执行对应 Linux release 构建
|
||||||
|
|
||||||
|
#### Scenario: Windows 预检通过后开始构建
|
||||||
|
|
||||||
|
- **WHEN** Windows 发布 job 中的 `go`、`bun`、`make`、对应架构 CGO 编译器和 resource 生成工具均可用
|
||||||
|
- **THEN** 系统 SHALL 输出关键工具的版本信息或解析路径
|
||||||
|
- **AND** 系统 SHALL 继续执行对应 Windows release 构建
|
||||||
|
|
||||||
|
#### Scenario: macOS 预检通过后开始构建
|
||||||
|
|
||||||
|
- **WHEN** macOS 发布 job 中的 `go`、`bun`、`ditto`、`lipo`、`vtool` 和 `hdiutil` 均可用
|
||||||
|
- **THEN** 系统 SHALL 输出关键工具的版本信息或解析路径
|
||||||
|
- **AND** 系统 SHALL 继续执行对应 macOS release 构建
|
||||||
|
|
||||||
|
#### Scenario: web 预检通过后开始构建
|
||||||
|
|
||||||
|
- **WHEN** web 发布 job 中的 `bun` 和前端构建依赖均可用
|
||||||
|
- **THEN** 系统 SHALL 输出 Bun 版本信息
|
||||||
|
- **AND** 系统 SHALL 继续执行 web release 构建
|
||||||
|
|
||||||
|
#### Scenario: 任一预检发现工具缺失
|
||||||
|
|
||||||
|
- **WHEN** 任一发布 job 中存在关键工具不可用
|
||||||
|
- **THEN** 发布流水线 SHALL 在正式构建前失败
|
||||||
|
- **AND** 系统 SHALL 在日志中标识缺失的工具链名称
|
||||||
|
|
||||||
|
### Requirement: Windows arm64 CGO 编译器指定
|
||||||
|
|
||||||
|
系统 SHALL 在 Windows arm64 发布构建中显式指定 `CC=clang` 和 `CXX=clang++` 环境变量,确保 Go cgo 在 MSYS2 CLANGARM64 环境下使用正确的 C 编译器进行 `windows/arm64` 交叉编译。
|
||||||
|
|
||||||
|
#### Scenario: Windows arm64 构建使用 clang
|
||||||
|
|
||||||
|
- **WHEN** 发布流水线在 `windows-11-arm` runner 上执行 Windows arm64 构建步骤
|
||||||
|
- **THEN** 构建步骤 SHALL 将 `CC=clang` 和 `CXX=clang++` 注入 go build 环境
|
||||||
|
- **AND** Go cgo SHALL 使用 `clang` 编译 `runtime/cgo` 等 CGO 组件
|
||||||
|
|
||||||
|
#### Scenario: Windows amd64 构建保持 gcc
|
||||||
|
|
||||||
|
- **WHEN** 发布流水线在 `windows-latest` runner 上执行 Windows amd64 构建步骤
|
||||||
|
- **THEN** 构建步骤 MAY 显式指定 `CC=gcc` 和 `CXX=g++` 或依赖 Go 默认编译器探测
|
||||||
|
- **AND** 显式指定的 `gcc` SHALL 等价于 MSYS2 MINGW64 默认 C 编译器(`x86_64-w64-mingw32-gcc`)
|
||||||
|
|
||||||
|
### Requirement: 发布流水线 LFS 资产拉取
|
||||||
|
|
||||||
|
发布流水线 SHALL 在所有会 checkout 仓库并参与版本校验、web 构建、server 构建或 desktop 构建的 job 中拉取 Git LFS 真实文件,确保发布构建读取到真实二进制资产而非 LFS pointer 文本。
|
||||||
|
|
||||||
|
#### Scenario: 发布 job 获取真实 LFS 图标资产
|
||||||
|
|
||||||
|
- **WHEN** 发布流水线执行任一参与版本校验、web 构建、server 构建或 desktop 构建的 job 的 checkout 步骤
|
||||||
|
- **THEN** checkout 步骤 SHALL 拉取 Git LFS 文件
|
||||||
|
- **AND** `assets/icon.ico`、`assets/icon.icns`、`assets/icon.png` 和 `frontend/public/icon.png` SHALL 在后续步骤中表现为真实图标文件而非 LFS pointer 文本
|
||||||
|
|
||||||
|
#### Scenario: 新增矩阵 job 获取真实 LFS 资产
|
||||||
|
|
||||||
|
- **WHEN** 发布流水线新增 server、web、desktop、platform 或 arch 矩阵 job
|
||||||
|
- **THEN** 该 job 的 checkout 步骤 SHALL 使用与现有发布 job 一致的 Git LFS 拉取配置
|
||||||
|
|
||||||
|
### Requirement: 发布资产图标预检
|
||||||
|
|
||||||
|
发布流水线 SHALL 在正式执行任何需要图标资产、前端 public 图标或 desktop 打包资源的发布构建前校验关键图标资产可用,并在检测到 LFS pointer 或错误格式时快速失败且输出明确诊断。
|
||||||
|
|
||||||
|
#### Scenario: 图标资产为 LFS pointer
|
||||||
|
|
||||||
|
- **WHEN** 发布资产预检发现关键图标文件内容为 Git LFS pointer 文本
|
||||||
|
- **THEN** 发布流水线 SHALL 在执行对应 release 构建前失败
|
||||||
|
- **AND** 系统 SHALL 在日志中标识对应图标文件需要拉取 Git LFS 真实内容
|
||||||
|
|
||||||
|
#### Scenario: 图标资产格式无效
|
||||||
|
|
||||||
|
- **WHEN** 发布资产预检发现关键图标文件不是对应格式的有效资源
|
||||||
|
- **THEN** 发布流水线 SHALL 在执行对应 release 构建前失败
|
||||||
|
- **AND** 系统 SHALL 在日志中标识格式无效的图标文件路径
|
||||||
|
|
||||||
|
#### Scenario: 图标资产预检通过
|
||||||
|
|
||||||
|
- **WHEN** `assets/icon.ico`、`assets/icon.icns`、`assets/icon.png` 和 `frontend/public/icon.png` 均为真实且格式可用的图标资产
|
||||||
|
- **THEN** 发布流水线 SHALL 继续执行依赖这些资产的 release 构建
|
||||||
|
|
||||||
|
### Requirement: 发布流水线运行时兼容性
|
||||||
|
|
||||||
|
系统 SHALL 保持与 GitHub-hosted runner 当前受支持的 workflow runtime 约束兼容,避免发布流程依赖已声明弃用的 runtime 或执行约束。
|
||||||
|
|
||||||
|
#### Scenario: runner runtime 升级前完成兼容更新
|
||||||
|
|
||||||
|
- **WHEN** GitHub-hosted runner 宣布 workflow runtime 或关键执行约束将从旧版本迁移到新版本
|
||||||
|
- **THEN** 发布流水线 SHALL 在旧约束移除前完成兼容性更新
|
||||||
|
|
||||||
|
#### Scenario: 发布流水线执行时不依赖已弃用 runtime
|
||||||
|
|
||||||
|
- **WHEN** 发布流水线运行 release workflow
|
||||||
|
- **THEN** 关键发布步骤 SHALL NOT 依赖已被 GitHub-hosted runner 标记为待移除的 runtime 或执行约束
|
||||||
|
|
||||||
### Requirement: 版本化发布资产命名
|
### Requirement: 版本化发布资产命名
|
||||||
|
|
||||||
系统 SHALL 为 server 与 desktop 的发布资产使用包含统一版本号和目标平台信息的文件名,确保 Release 页面可直接区分产物用途与平台。
|
系统 SHALL 为 server、web 与 desktop 发布资产使用包含统一版本号、组件、目标平台和目标架构信息的文件名,确保 Release 页面可直接区分产物用途、平台、架构和格式。
|
||||||
|
|
||||||
#### Scenario: server 资产命名
|
#### Scenario: server 资产命名
|
||||||
|
|
||||||
- **WHEN** 当前发布版本为 `1.2.3`
|
- **WHEN** 当前发布版本为 `1.2.3`
|
||||||
- **THEN** Linux server 发布资产文件名 SHALL 包含 `1.2.3`、`linux` 和 `amd64`
|
- **THEN** Linux server 发布资产文件名 SHALL 为 `nex-server_1.2.3_linux_amd64.tar.gz` 和 `nex-server_1.2.3_linux_arm64.tar.gz`
|
||||||
- **AND** Windows server 发布资产文件名 SHALL 包含 `1.2.3`、`windows` 和 `amd64`
|
- **AND** macOS server 发布资产文件名 SHALL 为 `nex-server_1.2.3_macos_amd64.tar.gz`、`nex-server_1.2.3_macos_arm64.tar.gz` 和 `nex-server_1.2.3_macos_universal.tar.gz`
|
||||||
- **AND** macOS server 发布资产文件名 SHALL 分别包含 `1.2.3`、`darwin`、`amd64` 与 `1.2.3`、`darwin`、`arm64`
|
- **AND** Windows server 发布资产文件名 SHALL 为 `nex-server_1.2.3_windows_amd64.zip` 和 `nex-server_1.2.3_windows_arm64.zip`
|
||||||
|
|
||||||
|
#### Scenario: web 资产命名
|
||||||
|
|
||||||
|
- **WHEN** 当前发布版本为 `1.2.3`
|
||||||
|
- **THEN** web 发布资产文件名 SHALL 为 `nex-web_1.2.3.tar.gz`
|
||||||
|
- **AND** web 发布资产文件名 SHALL NOT 包含平台或架构字段
|
||||||
|
|
||||||
#### Scenario: desktop 资产命名
|
#### Scenario: desktop 资产命名
|
||||||
|
|
||||||
- **WHEN** 当前发布版本为 `1.2.3`
|
- **WHEN** 当前发布版本为 `1.2.3`
|
||||||
- **THEN** Linux desktop 发布资产文件名 SHALL 包含 `1.2.3` 和 `linux`
|
- **THEN** Linux desktop 发布资产文件名 SHALL 使用 `nex-desktop_1.2.3_linux_<arch>.<format>` 格式
|
||||||
- **AND** Windows desktop 发布资产文件名 SHALL 包含 `1.2.3` 和 `windows`
|
- **AND** Windows desktop 发布资产文件名 SHALL 为 `nex-desktop_1.2.3_windows_amd64.zip` 和 `nex-desktop_1.2.3_windows_arm64.zip`
|
||||||
- **AND** macOS desktop universal 发布资产文件名 SHALL 包含 `1.2.3` 和 `macOS`
|
- **AND** macOS desktop 发布资产文件名 SHALL 为 `nex-desktop_1.2.3_macos_universal.zip` 和 `nex-desktop_1.2.3_macos_universal.dmg`
|
||||||
|
- **AND** 发布资产文件名中的 macOS 平台字段 SHALL 使用 `macos` 而非 `darwin`
|
||||||
|
|
||||||
### Requirement: Draft Release 组装
|
### Requirement: Draft Release 组装
|
||||||
|
|
||||||
系统 SHALL 将发布流水线产物上传到 GitHub Draft Release,由人工确认后再公开发布。
|
系统 SHALL 将发布流水线产物上传到 GitHub Draft Release,由人工确认后再公开发布,并 SHALL 生成覆盖全部发布资产的校验和清单。
|
||||||
|
|
||||||
#### Scenario: 发布成功时创建 Draft Release
|
#### Scenario: 发布成功时创建 Draft Release
|
||||||
|
|
||||||
- **WHEN** 版本校验通过且三平台发布资产构建完成
|
- **WHEN** 版本校验通过且 server、web、desktop 的全部目标发布资产构建完成
|
||||||
- **THEN** 系统 SHALL 创建或更新与该 tag 对应的 GitHub Draft Release
|
- **THEN** 系统 SHALL 创建或更新与该 tag 对应的 GitHub Draft Release
|
||||||
- **AND** 系统 SHALL 上传 server 与 desktop 的全部发布资产
|
- **AND** 系统 SHALL 上传 server、web 与 desktop 的全部发布资产
|
||||||
|
- **AND** 系统 SHALL 上传 `SHA256SUMS`
|
||||||
|
|
||||||
|
#### Scenario: 校验和覆盖全部资产
|
||||||
|
|
||||||
|
- **WHEN** Draft Release 组装步骤生成 `SHA256SUMS`
|
||||||
|
- **THEN** `SHA256SUMS` SHALL 包含除自身以外的全部发布资产文件
|
||||||
|
- **AND** `SHA256SUMS` 中的文件名 SHALL 与实际上传的 release asset 文件名一致
|
||||||
|
|
||||||
#### Scenario: 构建失败时阻止完成发布
|
#### Scenario: 构建失败时阻止完成发布
|
||||||
|
|
||||||
- **WHEN** 任一平台发布资产构建失败或版本校验失败
|
- **WHEN** 任一目标发布资产构建失败、打包失败、校验失败、artifact 上传为空或版本校验失败
|
||||||
- **THEN** 发布流水线 SHALL 失败
|
- **THEN** 发布流水线 SHALL 失败
|
||||||
- **AND** 系统 SHALL NOT 产生可直接公开的成功发布结果
|
- **AND** 系统 SHALL NOT 产生可直接公开的成功发布结果
|
||||||
|
|
||||||
|
#### Scenario: artifact 缺失时快速失败
|
||||||
|
|
||||||
|
- **WHEN** 任一构建 job 尝试上传 release artifact 但匹配不到目标文件
|
||||||
|
- **THEN** 该 job SHALL 失败
|
||||||
|
- **AND** Draft Release 组装 SHALL NOT 继续发布不完整资产集合
|
||||||
|
|||||||
@@ -66,3 +66,56 @@
|
|||||||
- **WHEN** 执行前端生产构建
|
- **WHEN** 执行前端生产构建
|
||||||
- **THEN** 构建流程 SHALL 注入 `VITE_APP_VERSION`
|
- **THEN** 构建流程 SHALL 注入 `VITE_APP_VERSION`
|
||||||
- **AND** 该值 SHALL 等于 `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 将该状态判定为版本不一致
|
||||||
|
|||||||
@@ -82,21 +82,29 @@
|
|||||||
|
|
||||||
### Requirement: 记录请求日志
|
### Requirement: 记录请求日志
|
||||||
|
|
||||||
系统 SHALL 记录 HTTP 请求日志。
|
系统 SHALL 记录 HTTP 请求日志,其中请求开始和请求结束生命周期日志 SHALL 使用 debug 级别记录。
|
||||||
|
|
||||||
#### Scenario: 请求开始日志
|
#### Scenario: 请求开始日志
|
||||||
|
|
||||||
- **WHEN** 收到 HTTP 请求
|
- **WHEN** 收到 HTTP 请求
|
||||||
|
- **THEN** SHALL 以 debug 级别记录请求开始日志
|
||||||
- **THEN** SHALL 记录请求方法、路径、客户端 IP
|
- **THEN** SHALL 记录请求方法、路径、客户端 IP
|
||||||
- **THEN** SHALL 包含请求 ID
|
- **THEN** SHALL 包含请求 ID
|
||||||
|
|
||||||
#### Scenario: 请求结束日志
|
#### Scenario: 请求结束日志
|
||||||
|
|
||||||
- **WHEN** HTTP 请求处理完成
|
- **WHEN** HTTP 请求处理完成
|
||||||
|
- **THEN** SHALL 以 debug 级别记录请求结束日志
|
||||||
- **THEN** SHALL 记录响应状态码、响应大小
|
- **THEN** SHALL 记录响应状态码、响应大小
|
||||||
- **THEN** SHALL 记录请求耗时
|
- **THEN** SHALL 记录请求耗时
|
||||||
- **THEN** SHALL 包含请求 ID
|
- **THEN** SHALL 包含请求 ID
|
||||||
|
|
||||||
|
#### Scenario: Info 级别过滤请求生命周期日志
|
||||||
|
|
||||||
|
- **WHEN** 日志级别配置为 info
|
||||||
|
- **THEN** SHALL NOT 输出“请求开始”日志
|
||||||
|
- **THEN** SHALL NOT 输出“请求结束”日志
|
||||||
|
|
||||||
### Requirement: 支持日志级别
|
### Requirement: 支持日志级别
|
||||||
|
|
||||||
系统 SHALL 支持日志级别控制。
|
系统 SHALL 支持日志级别控制。
|
||||||
|
|||||||
123
openspec/specs/version-bump/spec.md
Normal file
123
openspec/specs/version-bump/spec.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# 版本升迁
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
定义 `version bump` 子命令的版本号递增、下游文件同步、倒退防护及 Makefile 编排规则,确保版本升迁流程安全可自动化。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: 版本号递增
|
||||||
|
|
||||||
|
`version bump` 子命令 SHALL 支持三种递增模式:`major`(major+1, minor=0, patch=0)、`minor`(minor+1, patch=0)、`patch`(patch+1),以及直接指定具体版本号。
|
||||||
|
|
||||||
|
#### Scenario: minor 递增
|
||||||
|
|
||||||
|
- **WHEN** 当前 VERSION 为 `0.1.0`,执行 `version bump minor`
|
||||||
|
- **THEN** VERSION 文件 SHALL 被更新为 `0.2.0`
|
||||||
|
|
||||||
|
#### Scenario: major 递增
|
||||||
|
|
||||||
|
- **WHEN** 当前 VERSION 为 `0.1.0`,执行 `version bump major`
|
||||||
|
- **THEN** VERSION 文件 SHALL 被更新为 `1.0.0`
|
||||||
|
|
||||||
|
#### Scenario: patch 递增
|
||||||
|
|
||||||
|
- **WHEN** 当前 VERSION 为 `0.1.0`,执行 `version bump patch`
|
||||||
|
- **THEN** VERSION 文件 SHALL 被更新为 `0.1.1`
|
||||||
|
|
||||||
|
#### Scenario: 指定具体版本号
|
||||||
|
|
||||||
|
- **WHEN** 当前 VERSION 为 `0.1.0`,执行 `version bump 1.0.0`
|
||||||
|
- **THEN** VERSION 文件 SHALL 被更新为 `1.0.0`
|
||||||
|
|
||||||
|
#### Scenario: 指定版本号等于当前 VERSION
|
||||||
|
|
||||||
|
- **WHEN** 当前 VERSION 为 `0.1.0`,执行 `version bump 0.1.0`
|
||||||
|
- **THEN** 命令 SHALL 正常执行,完成 sync 和 check,输出 `0.1.0`
|
||||||
|
|
||||||
|
#### Scenario: 非法 bump 参数
|
||||||
|
|
||||||
|
- **WHEN** 执行 `version bump` 传入既非 `major|minor|patch` 也非合法 semver 的参数
|
||||||
|
- **THEN** 命令 SHALL 以非零退出码失败并输出错误信息
|
||||||
|
|
||||||
|
### Requirement: bump 自动同步下游文件
|
||||||
|
|
||||||
|
`version bump` 子命令 SHALL 在写回 VERSION 文件后自动执行 sync 和 check,确保 `frontend/package.json` 和所有 `frontend/.env.*` 文件与新版本号一致。
|
||||||
|
|
||||||
|
#### Scenario: bump 自动 sync 和 check
|
||||||
|
|
||||||
|
- **WHEN** 执行 `version bump minor` 且当前 VERSION 为 `0.1.0`
|
||||||
|
- **THEN** 命令 SHALL 自动将新版本号 `0.2.0` 同步到 `frontend/package.json` 的 `version` 字段和所有 `frontend/.env.*` 的 `VITE_APP_VERSION` 变量
|
||||||
|
- **AND** 命令 SHALL 自动验证所有下游文件版本号一致性
|
||||||
|
|
||||||
|
#### Scenario: sync 失败时 bump 中止
|
||||||
|
|
||||||
|
- **WHEN** 执行 `version bump minor` 但下游文件同步失败(如文件缺失)
|
||||||
|
- **THEN** 命令 SHALL 以非零退出码失败
|
||||||
|
|
||||||
|
### Requirement: 版本号倒退防护
|
||||||
|
|
||||||
|
`version bump` 子命令 SHALL 检查新版本号严格大于所有已有 git tag 中的最大版本号,防止版本号倒退。
|
||||||
|
|
||||||
|
#### Scenario: 新版本大于已有 tag
|
||||||
|
|
||||||
|
- **WHEN** 已有 tag `v0.1.0`,执行 `version bump minor`
|
||||||
|
- **THEN** 命令 SHALL 成功将版本更新为 `0.2.0`
|
||||||
|
|
||||||
|
#### Scenario: 新版本等于已有 tag
|
||||||
|
|
||||||
|
- **WHEN** 已有 tag `v0.1.0`,执行 `version bump 0.1.0`
|
||||||
|
- **THEN** 命令 SHALL 以非零退出码失败并提示版本号已存在
|
||||||
|
|
||||||
|
#### Scenario: 新版本小于已有 tag
|
||||||
|
|
||||||
|
- **WHEN** 已有 tag `v0.2.0`,执行 `version bump 0.1.5`
|
||||||
|
- **THEN** 命令 SHALL 以非零退出码失败并提示版本号倒退
|
||||||
|
|
||||||
|
#### Scenario: 无已有 tag
|
||||||
|
|
||||||
|
- **WHEN** 不存在任何 `v*.*.*` 格式的 git tag,执行 `version bump 0.1.0`
|
||||||
|
- **THEN** 命令 SHALL 成功
|
||||||
|
|
||||||
|
### Requirement: bump 输出新版本号
|
||||||
|
|
||||||
|
`version bump` 子命令成功时 SHALL 仅将新版本号(不含 `v` 前缀)输出到 stdout,供 Makefile 等外部工具使用。
|
||||||
|
|
||||||
|
#### Scenario: 输出格式
|
||||||
|
|
||||||
|
- **WHEN** 执行 `version bump minor`,当前版本为 `0.1.0`
|
||||||
|
- **THEN** stdout SHALL 输出 `0.2.0`(换行结尾,无额外内容)
|
||||||
|
|
||||||
|
### Requirement: 版本升迁 Makefile 编排
|
||||||
|
|
||||||
|
`make version-bump` SHALL 编排完整的版本升迁流程:工作区干净检查 → `version bump`(含 sync/check/倒退检查)→ git add → git commit → git tag。不传 `BUMP` 参数时 SHALL 默认执行 `BUMP=patch`。
|
||||||
|
|
||||||
|
#### Scenario: 完整升迁流程
|
||||||
|
|
||||||
|
- **WHEN** 执行 `make version-bump BUMP=minor`,工作区干净,当前版本 `0.1.0`
|
||||||
|
- **THEN** Makefile SHALL 依次执行:工作区检查 → `version bump minor` → `git add VERSION frontend/` → `git commit -m "chore: 版本升迁 v0.2.0"` → `git tag v0.2.0`
|
||||||
|
|
||||||
|
#### Scenario: 不传 BUMP 默认 patch
|
||||||
|
|
||||||
|
- **WHEN** 执行 `make version-bump`,工作区干净,当前版本 `0.1.0`
|
||||||
|
- **THEN** Makefile SHALL 等效于执行 `make version-bump BUMP=patch`,将版本更新为 `0.1.1`
|
||||||
|
|
||||||
|
#### Scenario: 工作区不干净
|
||||||
|
|
||||||
|
- **WHEN** 执行 `make version-bump BUMP=minor`,但工作区有未提交的改动
|
||||||
|
- **THEN** Makefile SHALL 以非零退出码失败并提示先提交或暂存改动
|
||||||
|
|
||||||
|
#### Scenario: 支持指定版本号
|
||||||
|
|
||||||
|
- **WHEN** 执行 `make version-bump SET_VERSION=1.0.0`
|
||||||
|
- **THEN** Makefile SHALL 将 `1.0.0` 传递给 `version bump` 子命令
|
||||||
|
|
||||||
|
#### Scenario: SET_VERSION 优先于默认 BUMP
|
||||||
|
|
||||||
|
- **WHEN** 执行 `make version-bump SET_VERSION=2.0.0`
|
||||||
|
- **THEN** Makefile SHALL 使用 `SET_VERSION=2.0.0` 而非默认的 `BUMP=patch`
|
||||||
|
|
||||||
|
#### Scenario: 不自动推送
|
||||||
|
|
||||||
|
- **WHEN** `make version-bump` 成功完成
|
||||||
|
- **THEN** commit 和 tag SHALL 仅存在于本地,SHALL NOT 自动 push 到远程
|
||||||
@@ -8,17 +8,26 @@
|
|||||||
|
|
||||||
### Requirement: 根目录公开命令分层
|
### Requirement: 根目录公开命令分层
|
||||||
|
|
||||||
根目录 `Makefile` SHALL 仅暴露全局命令、版本命令、server 产品命令、desktop 产品命令和 release 命令,不再作为 backend 局部维护命令或内部打包步骤的公开入口。
|
根目录 `Makefile` SHALL 仅暴露全局命令、版本命令、server 产品命令、desktop 产品命令和 release 命令,不再作为 backend 局部维护命令或内部打包步骤的公开入口。release 命令 SHALL 使用 `release-assets` 前缀,并 SHALL 通过清晰的目标名或变量参数表达 component、platform、arch 和 format。
|
||||||
|
|
||||||
#### Scenario: 查看根目录公开命令
|
#### Scenario: 查看根目录公开命令
|
||||||
|
|
||||||
- **WHEN** 开发者查看根目录 `Makefile` 的公开 target
|
- **WHEN** 开发者查看根目录 `Makefile` 的公开 target
|
||||||
- **THEN** SHALL 仅看到 `lint`、`test`、`clean`、`version-sync`、`version-check`、`server-run`、`server-build`、`server-lint`、`server-test`、`server-clean`、`desktop-build-mac`、`desktop-build-win`、`desktop-build-linux`、`desktop-lint`、`desktop-test`、`desktop-clean`、`release-assets-linux`、`release-assets-windows`、`release-assets-macos` 这类公共入口
|
- **THEN** SHALL 仅看到 `lint`、`test`、`clean`、`version-sync`、`version-check`、`server-run`、`server-build`、`server-lint`、`server-test`、`server-clean`、`desktop-build-mac`、`desktop-build-win`、`desktop-build-linux`、`desktop-lint`、`desktop-test`、`desktop-clean` 和 `release-assets` 前缀的 release 公共入口
|
||||||
|
- **AND** release 公共入口 SHALL 能覆盖 server、web、desktop 的目标发布产物
|
||||||
|
|
||||||
#### Scenario: 根目录不暴露局部和内部命令
|
#### Scenario: 根目录不暴露局部和内部命令
|
||||||
|
|
||||||
- **WHEN** 开发者查看根目录 `Makefile` 的公开 target
|
- **WHEN** 开发者查看根目录 `Makefile` 的公开 target
|
||||||
- **THEN** SHALL NOT 暴露 `backend-*`、`frontend-*`、数据库迁移命令、MySQL 专项测试命令或 `desktop-prepare-*` 之类内部步骤
|
- **THEN** SHALL NOT 暴露 `backend-*`、`frontend-*`、数据库迁移命令、MySQL 专项测试命令或 `desktop-prepare-*` 之类内部步骤
|
||||||
- **THEN** SHALL NOT 暴露 `dev`、`build`、`all`、`desktop-dev`、`desktop-build` 这类模糊或聚合式公共命令
|
- **THEN** SHALL NOT 暴露 `dev`、`build`、`all`、`desktop-dev`、`desktop-build` 这类模糊或聚合式公共命令
|
||||||
|
|
||||||
|
#### Scenario: release 内部步骤保持内部化
|
||||||
|
|
||||||
|
- **WHEN** 根目录 `Makefile` 需要复用 release 构建、打包、校验辅助步骤
|
||||||
|
- **THEN** 内部辅助 target SHALL 使用 `_` 前缀或 Make 变量参数化方式表达
|
||||||
|
- **AND** 内部辅助 target SHALL NOT 成为文档化的公共入口
|
||||||
|
|
||||||
### Requirement: 全局质量与清理命令
|
### Requirement: 全局质量与清理命令
|
||||||
|
|
||||||
根目录 `Makefile` SHALL 提供 `lint`、`test`、`clean` 作为全仓默认入口。
|
根目录 `Makefile` SHALL 提供 `lint`、`test`、`clean` 作为全仓默认入口。
|
||||||
@@ -97,12 +106,33 @@
|
|||||||
|
|
||||||
### Requirement: Release 命令沿用根目录入口
|
### Requirement: Release 命令沿用根目录入口
|
||||||
|
|
||||||
根目录 `Makefile` SHALL 继续提供 `release-assets-*` 作为发布资产入口,并与新的版本校验规则保持一致。
|
根目录 `Makefile` SHALL 继续提供 `release-assets` 前缀 target 作为发布资产入口,并与版本校验、发布资产预检和多组件打包规则保持一致。
|
||||||
|
|
||||||
#### Scenario: 执行 release 资产命令
|
#### Scenario: 执行 release 资产命令
|
||||||
- **WHEN** 执行 `make release-assets-linux`、`make release-assets-windows` 或 `make release-assets-macos`
|
|
||||||
|
- **WHEN** 执行任一 `release-assets` 前缀的公共 release target
|
||||||
- **THEN** SHALL 在构建发布资产前执行版本一致性校验
|
- **THEN** SHALL 在构建发布资产前执行版本一致性校验
|
||||||
- **THEN** SHALL NOT 隐式执行 `version-sync` 或修改版本镜像文件
|
- **AND** SHALL 在需要图标或桌面资源的构建前执行发布资产预检
|
||||||
|
- **AND** SHALL NOT 隐式执行 `version-sync` 或修改版本镜像文件
|
||||||
|
|
||||||
|
#### Scenario: release target 职责清晰
|
||||||
|
|
||||||
|
- **WHEN** GitHub Actions 调用根目录 `Makefile` 生成 release 产物
|
||||||
|
- **THEN** 对应 release target SHALL 明确生成的 component、platform、arch 或 format 范围
|
||||||
|
- **AND** GitHub Actions SHALL NOT 以内联脚本替代 Makefile 中已有的核心构建和打包逻辑
|
||||||
|
|
||||||
|
#### Scenario: web release 产物生成
|
||||||
|
|
||||||
|
- **WHEN** 执行 web release 资产命令
|
||||||
|
- **THEN** SHALL 使用 Bun 构建 `frontend/dist`
|
||||||
|
- **AND** SHALL 打包生成 `nex-web_<version>.tar.gz`
|
||||||
|
- **AND** SHALL NOT 修改前端版本镜像文件
|
||||||
|
|
||||||
|
#### Scenario: checksum release 产物生成
|
||||||
|
|
||||||
|
- **WHEN** 执行 release 汇总或 Draft Release 组装相关命令
|
||||||
|
- **THEN** SHALL 能基于当前 release 产物目录生成 `SHA256SUMS`
|
||||||
|
- **AND** `SHA256SUMS` SHALL 覆盖除自身以外的全部 release 资产
|
||||||
|
|
||||||
### Requirement: Backend 局部命令下沉
|
### Requirement: Backend 局部命令下沉
|
||||||
|
|
||||||
|
|||||||
5
packaging/linux/AppRun
Normal file
5
packaging/linux/AppRun
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
APPDIR=$(dirname "$(readlink -f "$0")")
|
||||||
|
exec "$APPDIR/usr/bin/nex" "$@"
|
||||||
9
packaging/linux/nex.desktop
Normal file
9
packaging/linux/nex.desktop
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Nex
|
||||||
|
Comment=AI Gateway
|
||||||
|
Exec=nex
|
||||||
|
Icon=nex
|
||||||
|
Terminal=false
|
||||||
|
Categories=Development;Network;
|
||||||
|
StartupNotify=false
|
||||||
29
packaging/linux/nex.spec
Normal file
29
packaging/linux/nex.spec
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
Name: nex
|
||||||
|
Version: %{nex_version}
|
||||||
|
Release: 1%{?dist}
|
||||||
|
Summary: AI Gateway desktop application
|
||||||
|
License: Apache-2.0
|
||||||
|
URL: https://github.com/nex/gateway
|
||||||
|
Requires: gtk3
|
||||||
|
Requires: libayatana-appindicator-gtk3
|
||||||
|
Requires: xdg-utils
|
||||||
|
|
||||||
|
%description
|
||||||
|
Nex is an AI Gateway desktop application.
|
||||||
|
|
||||||
|
%prep
|
||||||
|
|
||||||
|
%build
|
||||||
|
|
||||||
|
%install
|
||||||
|
mkdir -p %{buildroot}/usr/bin
|
||||||
|
install -m 0755 %{nex_binary} %{buildroot}/usr/bin/nex
|
||||||
|
mkdir -p %{buildroot}/usr/share/applications
|
||||||
|
install -m 0644 %{nex_desktop_file} %{buildroot}/usr/share/applications/nex.desktop
|
||||||
|
mkdir -p %{buildroot}/usr/share/icons/hicolor
|
||||||
|
cp -a %{nex_icons_dir}/. %{buildroot}/usr/share/icons/hicolor/
|
||||||
|
|
||||||
|
%files
|
||||||
|
/usr/bin/nex
|
||||||
|
/usr/share/applications/nex.desktop
|
||||||
|
/usr/share/icons/hicolor/*/apps/nex.png
|
||||||
30
scripts/push-all-remotes.sh
Executable file
30
scripts/push-all-remotes.sh
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR=$(git rev-parse --show-toplevel)
|
||||||
|
BRANCH=$(git branch --show-current)
|
||||||
|
|
||||||
|
if [[ -z "$BRANCH" ]]; then
|
||||||
|
echo "当前仓库处于 detached HEAD 状态,无法推送当前分支" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
REMOTES=()
|
||||||
|
while IFS= read -r REMOTE; do
|
||||||
|
REMOTES+=("$REMOTE")
|
||||||
|
done < <(git remote)
|
||||||
|
|
||||||
|
if [[ ${#REMOTES[@]} -eq 0 ]]; then
|
||||||
|
echo "当前仓库未配置任何远端" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for REMOTE in "${REMOTES[@]}"; do
|
||||||
|
echo "推送分支 $BRANCH 和 tags 到远端 $REMOTE"
|
||||||
|
git push "$REMOTE" "$BRANCH" --tags
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "已推送分支 $BRANCH 和 tags 到所有远端"
|
||||||
52
versionctl/.golangci.yml
Normal file
52
versionctl/.golangci.yml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
run:
|
||||||
|
timeout: 5m
|
||||||
|
tests: true
|
||||||
|
|
||||||
|
linters:
|
||||||
|
disable-all: true
|
||||||
|
enable:
|
||||||
|
- errorlint
|
||||||
|
- errcheck
|
||||||
|
- staticcheck
|
||||||
|
- revive
|
||||||
|
- gocritic
|
||||||
|
- gosec
|
||||||
|
- nilerr
|
||||||
|
- goimports
|
||||||
|
- gocyclo
|
||||||
|
|
||||||
|
linters-settings:
|
||||||
|
errcheck:
|
||||||
|
check-blank: true
|
||||||
|
check-type-assertions: true
|
||||||
|
revive:
|
||||||
|
rules:
|
||||||
|
- name: exported
|
||||||
|
- name: var-naming
|
||||||
|
- name: indent-error-flow
|
||||||
|
- name: error-strings
|
||||||
|
- name: error-return
|
||||||
|
- name: blank-imports
|
||||||
|
goimports:
|
||||||
|
local-prefixes: nex/versionctl
|
||||||
|
gocyclo:
|
||||||
|
min-complexity: 10
|
||||||
|
|
||||||
|
issues:
|
||||||
|
exclude-generated: true
|
||||||
|
exclude-rules:
|
||||||
|
- path: '_test\.go'
|
||||||
|
linters:
|
||||||
|
- errcheck
|
||||||
|
source: '(^\s*_\s*=|,\s*_)'
|
||||||
|
- path: '_test\.go'
|
||||||
|
linters:
|
||||||
|
- revive
|
||||||
|
text: '^exported:'
|
||||||
|
- path: '_test\.go'
|
||||||
|
linters:
|
||||||
|
- gosec
|
||||||
|
text: 'G(101|401|501)'
|
||||||
|
- path: 'main\.go'
|
||||||
|
linters:
|
||||||
|
- gocyclo
|
||||||
16
versionctl/Makefile
Normal file
16
versionctl/Makefile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
.PHONY: \
|
||||||
|
lint test test-coverage clean
|
||||||
|
|
||||||
|
lint:
|
||||||
|
go tool golangci-lint run ./...
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./... -v
|
||||||
|
|
||||||
|
test-coverage:
|
||||||
|
go test ./... -coverprofile=coverage.out
|
||||||
|
go tool cover -html=coverage.out -o coverage.html
|
||||||
|
@printf 'Coverage report generated: versionctl/coverage.html\n'
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf coverage.out coverage.html
|
||||||
14
versionctl/go.mod
Normal file
14
versionctl/go.mod
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
module nex/versionctl
|
||||||
|
|
||||||
|
go 1.26.2
|
||||||
|
|
||||||
|
require github.com/stretchr/testify v1.11.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
8
versionctl/go.sum
Normal file
8
versionctl/go.sum
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"nex/backend/pkg/projectversion"
|
"nex/versionctl/projectversion"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -41,6 +41,11 @@ func run(args []string) error {
|
|||||||
return fmt.Errorf("verify-tag 需要一个 tag 参数")
|
return fmt.Errorf("verify-tag 需要一个 tag 参数")
|
||||||
}
|
}
|
||||||
return projectversion.VerifyTag(root, args[1])
|
return projectversion.VerifyTag(root, args[1])
|
||||||
|
case "bump":
|
||||||
|
if len(args) != 2 {
|
||||||
|
return fmt.Errorf("bump 需要一个参数: major|minor|patch 或具体版本号")
|
||||||
|
}
|
||||||
|
return runBump(root, args[1])
|
||||||
case "macos-plist":
|
case "macos-plist":
|
||||||
if len(args) != 2 {
|
if len(args) != 2 {
|
||||||
return fmt.Errorf("macos-plist 需要一个最低系统版本参数")
|
return fmt.Errorf("macos-plist 需要一个最低系统版本参数")
|
||||||
@@ -48,11 +53,43 @@ func run(args []string) error {
|
|||||||
return printMacOSPlist(root, args[1])
|
return printMacOSPlist(root, args[1])
|
||||||
case "asset-name":
|
case "asset-name":
|
||||||
return printAssetName(root, args[1:])
|
return printAssetName(root, args[1:])
|
||||||
|
case "release-assets-check":
|
||||||
|
if len(args) != 1 {
|
||||||
|
return fmt.Errorf("release-assets-check 不需要额外参数")
|
||||||
|
}
|
||||||
|
return projectversion.CheckReleaseAssets(root)
|
||||||
default:
|
default:
|
||||||
return usageError()
|
return usageError()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runBump(root, arg string) error {
|
||||||
|
newVersion, err := projectversion.Bump(root, arg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := projectversion.ListGitTags(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := projectversion.CheckNoRegression(newVersion, tags); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := projectversion.Sync(root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := projectversion.Check(root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(newVersion.String())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func printMacOSPlist(root, minMacOSVersion string) error {
|
func printMacOSPlist(root, minMacOSVersion string) error {
|
||||||
version, err := projectversion.ReadString(root)
|
version, err := projectversion.ReadString(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -69,8 +106,8 @@ func printMacOSPlist(root, minMacOSVersion string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func printAssetName(root string, args []string) error {
|
func printAssetName(root string, args []string) error {
|
||||||
if len(args) < 2 {
|
if len(args) == 0 {
|
||||||
return fmt.Errorf("asset-name 至少需要 kind 和 platform 参数")
|
return fmt.Errorf("asset-name 需要组件参数: server|web|desktop")
|
||||||
}
|
}
|
||||||
|
|
||||||
version, err := projectversion.ReadString(root)
|
version, err := projectversion.ReadString(root)
|
||||||
@@ -78,30 +115,31 @@ func printAssetName(root string, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var platform, arch, format string
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "server":
|
case "server", "desktop":
|
||||||
if len(args) != 3 {
|
if len(args) != 4 {
|
||||||
return fmt.Errorf("server 资产命名需要 platform 和 arch 参数")
|
return fmt.Errorf("%s 资产命名需要 platform、arch 和 format 参数", args[0])
|
||||||
}
|
}
|
||||||
name, nameErr := projectversion.ServerAssetName(version, args[1], args[2])
|
platform = args[1]
|
||||||
if nameErr != nil {
|
arch = args[2]
|
||||||
return nameErr
|
format = args[3]
|
||||||
}
|
case "web":
|
||||||
fmt.Println(name)
|
|
||||||
return nil
|
|
||||||
case "desktop":
|
|
||||||
if len(args) != 2 {
|
if len(args) != 2 {
|
||||||
return fmt.Errorf("desktop 资产命名只需要 platform 参数")
|
return fmt.Errorf("web 资产命名只需要 format 参数")
|
||||||
}
|
}
|
||||||
name, nameErr := projectversion.DesktopAssetName(version, args[1])
|
format = args[1]
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("不支持的资产组件 %q", args[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
name, nameErr := projectversion.ReleaseAssetName(version, args[0], platform, arch, format)
|
||||||
if nameErr != nil {
|
if nameErr != nil {
|
||||||
return nameErr
|
return nameErr
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println(name)
|
fmt.Println(name)
|
||||||
return nil
|
return nil
|
||||||
default:
|
|
||||||
return fmt.Errorf("不支持的资产类型 %q", args[0])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustGetwd() string {
|
func mustGetwd() string {
|
||||||
@@ -115,5 +153,5 @@ func mustGetwd() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func usageError() error {
|
func usageError() error {
|
||||||
return fmt.Errorf("用法: versionctl <print|sync|check|verify-tag|macos-plist|asset-name>")
|
return fmt.Errorf("用法: version <print|sync|check|verify-tag|bump|macos-plist|asset-name|release-assets-check>")
|
||||||
}
|
}
|
||||||
69
versionctl/projectversion/release_assets.go
Normal file
69
versionctl/projectversion/release_assets.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package projectversion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
var releaseAssetChecks = []releaseAssetCheck{
|
||||||
|
{
|
||||||
|
path: "assets/icon.ico",
|
||||||
|
description: "Windows ICO 图标",
|
||||||
|
magic: []byte{0x00, 0x00, 0x01, 0x00},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "assets/icon.icns",
|
||||||
|
description: "macOS ICNS 图标",
|
||||||
|
magic: []byte("icns"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "assets/icon.png",
|
||||||
|
description: "PNG 图标",
|
||||||
|
magic: []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "frontend/public/icon.png",
|
||||||
|
description: "前端 PNG 图标",
|
||||||
|
magic: []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var gitLFSPointerPrefix = []byte("version https://git-lfs.github.com/spec/v1")
|
||||||
|
|
||||||
|
type releaseAssetCheck struct {
|
||||||
|
path string
|
||||||
|
description string
|
||||||
|
magic []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckReleaseAssets(root string) error {
|
||||||
|
var errs []error
|
||||||
|
|
||||||
|
for _, check := range releaseAssetChecks {
|
||||||
|
if err := checkReleaseAsset(root, check); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkReleaseAsset(root string, check releaseAssetCheck) error {
|
||||||
|
content, err := os.ReadFile(filepath.Join(root, check.path))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s 不可读取: %w", check.path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes.HasPrefix(content, gitLFSPointerPrefix) {
|
||||||
|
return fmt.Errorf("%s 是 Git LFS pointer,请先拉取 Git LFS 真实内容", check.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.HasPrefix(content, check.magic) {
|
||||||
|
return fmt.Errorf("%s 不是有效的%s", check.path, check.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
58
versionctl/projectversion/release_assets_test.go
Normal file
58
versionctl/projectversion/release_assets_test.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package projectversion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckReleaseAssets(t *testing.T) {
|
||||||
|
t.Run("valid assets", func(t *testing.T) {
|
||||||
|
root := setupReleaseAssetRoot(t)
|
||||||
|
|
||||||
|
require.NoError(t, CheckReleaseAssets(root))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("lfs pointer", func(t *testing.T) {
|
||||||
|
root := setupReleaseAssetRoot(t)
|
||||||
|
writeReleaseAsset(t, root, "assets/icon.ico", []byte("version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 123\n"))
|
||||||
|
|
||||||
|
err := CheckReleaseAssets(root)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "assets/icon.ico 是 Git LFS pointer")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid format", func(t *testing.T) {
|
||||||
|
root := setupReleaseAssetRoot(t)
|
||||||
|
writeReleaseAsset(t, root, "frontend/public/icon.png", []byte("not a png"))
|
||||||
|
|
||||||
|
err := CheckReleaseAssets(root)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "frontend/public/icon.png 不是有效的前端 PNG 图标")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupReleaseAssetRoot(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
writeReleaseAsset(t, root, "assets/icon.ico", []byte{0x00, 0x00, 0x01, 0x00, 0x01})
|
||||||
|
writeReleaseAsset(t, root, "assets/icon.icns", []byte("icnsdata"))
|
||||||
|
writeReleaseAsset(t, root, "assets/icon.png", []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00})
|
||||||
|
writeReleaseAsset(t, root, "frontend/public/icon.png", []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00})
|
||||||
|
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeReleaseAsset(t *testing.T, root, relPath string, content []byte) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
fullPath := filepath.Join(root, relPath)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(fullPath, content, 0o600))
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -57,6 +58,18 @@ func (v Version) String() string {
|
|||||||
return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
|
return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v Version) Less(other Version) bool {
|
||||||
|
if v.Major != other.Major {
|
||||||
|
return v.Major < other.Major
|
||||||
|
}
|
||||||
|
|
||||||
|
if v.Minor != other.Minor {
|
||||||
|
return v.Minor < other.Minor
|
||||||
|
}
|
||||||
|
|
||||||
|
return v.Patch < other.Patch
|
||||||
|
}
|
||||||
|
|
||||||
func FindRepoRoot(start string) (string, error) {
|
func FindRepoRoot(start string) (string, error) {
|
||||||
current := start
|
current := start
|
||||||
for {
|
for {
|
||||||
@@ -109,7 +122,7 @@ func Sync(root string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(packageJSONPath, []byte(updatedPackageJSON), 0o644); err != nil {
|
if err := os.WriteFile(packageJSONPath, []byte(updatedPackageJSON), 0o600); err != nil {
|
||||||
return fmt.Errorf("写入 frontend/package.json 失败: %w", err)
|
return fmt.Errorf("写入 frontend/package.json 失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +134,7 @@ func Sync(root string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updated := UpsertEnvVar(string(content), "VITE_APP_VERSION", version)
|
updated := UpsertEnvVar(string(content), "VITE_APP_VERSION", version)
|
||||||
if err := os.WriteFile(fullPath, []byte(updated), 0o644); err != nil {
|
if err := os.WriteFile(fullPath, []byte(updated), 0o600); err != nil {
|
||||||
return fmt.Errorf("写入 %s 失败: %w", relPath, err)
|
return fmt.Errorf("写入 %s 失败: %w", relPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,44 +263,86 @@ func ReadEnvVar(content, key string) (string, bool) {
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
func ServerAssetName(version, goos, arch string) (string, error) {
|
func ReleaseAssetName(version, component, platform, arch, format string) (string, error) {
|
||||||
if _, err := Parse(version); err != nil {
|
if _, err := Parse(version); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
switch goos {
|
switch component {
|
||||||
case "linux", "windows", "darwin":
|
case "server":
|
||||||
|
return serverAssetName(version, platform, arch, format)
|
||||||
|
case "web":
|
||||||
|
return webAssetName(version, platform, arch, format)
|
||||||
|
case "desktop":
|
||||||
|
return desktopAssetName(version, platform, arch, format)
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("不支持的 server 平台 %q", goos)
|
return "", fmt.Errorf("不支持的资产组件 %q", component)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if arch == "" {
|
func serverAssetName(version, platform, arch, format string) (string, error) {
|
||||||
return "", errors.New("server 资产命名缺少架构")
|
if !validCombination(platform, arch, format, []releaseAssetTarget{
|
||||||
|
{platform: "linux", arch: "amd64", format: "tar.gz"},
|
||||||
|
{platform: "linux", arch: "arm64", format: "tar.gz"},
|
||||||
|
{platform: "macos", arch: "amd64", format: "tar.gz"},
|
||||||
|
{platform: "macos", arch: "arm64", format: "tar.gz"},
|
||||||
|
{platform: "macos", arch: "universal", format: "tar.gz"},
|
||||||
|
{platform: "windows", arch: "amd64", format: "zip"},
|
||||||
|
{platform: "windows", arch: "arm64", format: "zip"},
|
||||||
|
}) {
|
||||||
|
return "", fmt.Errorf("不支持的 server 资产目标 %s/%s/%s", platform, arch, format)
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := ".tar.gz"
|
return fmt.Sprintf("nex-server_%s_%s_%s.%s", version, platform, arch, format), nil
|
||||||
if goos == "windows" {
|
|
||||||
ext = ".zip"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("nex-server_%s_%s_%s%s", version, goos, arch, ext), nil
|
func webAssetName(version, platform, arch, format string) (string, error) {
|
||||||
|
if platform != "" || arch != "" {
|
||||||
|
return "", errors.New("web 资产命名不支持平台或架构参数")
|
||||||
}
|
}
|
||||||
|
|
||||||
func DesktopAssetName(version, platform string) (string, error) {
|
if format != "tar.gz" {
|
||||||
if _, err := Parse(version); err != nil {
|
return "", fmt.Errorf("不支持的 web 资产格式 %q", format)
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch platform {
|
return fmt.Sprintf("nex-web_%s.tar.gz", version), nil
|
||||||
case "linux":
|
|
||||||
return fmt.Sprintf("Nex_%s_linux_amd64.tar.gz", version), nil
|
|
||||||
case "windows":
|
|
||||||
return fmt.Sprintf("Nex_%s_windows_amd64.zip", version), nil
|
|
||||||
case "macos":
|
|
||||||
return fmt.Sprintf("Nex_%s_macOS_universal.zip", version), nil
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("不支持的 desktop 平台 %q", platform)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func desktopAssetName(version, platform, arch, format string) (string, error) {
|
||||||
|
if !validCombination(platform, arch, format, []releaseAssetTarget{
|
||||||
|
{platform: "linux", arch: "amd64", format: "tar.gz"},
|
||||||
|
{platform: "linux", arch: "amd64", format: "AppImage"},
|
||||||
|
{platform: "linux", arch: "amd64", format: "deb"},
|
||||||
|
{platform: "linux", arch: "amd64", format: "rpm"},
|
||||||
|
{platform: "linux", arch: "arm64", format: "tar.gz"},
|
||||||
|
{platform: "linux", arch: "arm64", format: "AppImage"},
|
||||||
|
{platform: "linux", arch: "arm64", format: "deb"},
|
||||||
|
{platform: "linux", arch: "arm64", format: "rpm"},
|
||||||
|
{platform: "macos", arch: "universal", format: "zip"},
|
||||||
|
{platform: "macos", arch: "universal", format: "dmg"},
|
||||||
|
{platform: "windows", arch: "amd64", format: "zip"},
|
||||||
|
{platform: "windows", arch: "arm64", format: "zip"},
|
||||||
|
}) {
|
||||||
|
return "", fmt.Errorf("不支持的 desktop 资产目标 %s/%s/%s", platform, arch, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("nex-desktop_%s_%s_%s.%s", version, platform, arch, format), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type releaseAssetTarget struct {
|
||||||
|
platform string
|
||||||
|
arch string
|
||||||
|
format string
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCombination(platform, arch, format string, targets []releaseAssetTarget) bool {
|
||||||
|
for _, target := range targets {
|
||||||
|
if target.platform == platform && target.arch == arch && target.format == format {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func DesktopInfoPlist(version, minMacOSVersion string) (string, error) {
|
func DesktopInfoPlist(version, minMacOSVersion string) (string, error) {
|
||||||
@@ -340,3 +395,91 @@ func DesktopInfoPlist(version, minMacOSVersion string) (string, error) {
|
|||||||
|
|
||||||
return content + "\n", nil
|
return content + "\n", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var tagRegex = regexp.MustCompile(`^v(\d+\.\d+\.\d+)$`)
|
||||||
|
|
||||||
|
func ListGitTags(root string) ([]string, error) {
|
||||||
|
cmd := exec.Command("git", "-C", root, "tag", "--list", "--merge", "HEAD")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("获取 git tag 列表失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tags []string
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line != "" {
|
||||||
|
tags = append(tags, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckNoRegression(newVersion Version, tags []string) error {
|
||||||
|
var maxVersion Version
|
||||||
|
found := false
|
||||||
|
|
||||||
|
for _, tag := range tags {
|
||||||
|
parts := tagRegex.FindStringSubmatch(tag)
|
||||||
|
if parts == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
v, err := Parse(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found || maxVersion.Less(v) {
|
||||||
|
maxVersion = v
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if newVersion == maxVersion {
|
||||||
|
return fmt.Errorf("版本号 %s 已存在(tag v%s)", newVersion, maxVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
if newVersion.Less(maxVersion) {
|
||||||
|
return fmt.Errorf("版本号 %s 小于已有 tag v%s,不允许倒退", newVersion, maxVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Bump(root, arg string) (Version, error) {
|
||||||
|
current, err := Read(root)
|
||||||
|
if err != nil {
|
||||||
|
return Version{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var newVersion Version
|
||||||
|
|
||||||
|
switch arg {
|
||||||
|
case "major":
|
||||||
|
newVersion = Version{Major: current.Major + 1, Minor: 0, Patch: 0}
|
||||||
|
case "minor":
|
||||||
|
newVersion = Version{Major: current.Major, Minor: current.Minor + 1, Patch: 0}
|
||||||
|
case "patch":
|
||||||
|
newVersion = Version{Major: current.Major, Minor: current.Minor, Patch: current.Patch + 1}
|
||||||
|
default:
|
||||||
|
parsed, parseErr := Parse(arg)
|
||||||
|
if parseErr != nil {
|
||||||
|
return Version{}, fmt.Errorf("参数 %q 既非 major|minor|patch 也非合法版本号: %w", arg, parseErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
newVersion = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
versionPath := filepath.Join(root, versionFileName)
|
||||||
|
if err := os.WriteFile(versionPath, []byte(newVersion.String()+"\n"), 0o600); err != nil {
|
||||||
|
return Version{}, fmt.Errorf("写入 VERSION 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newVersion, nil
|
||||||
|
}
|
||||||
267
versionctl/projectversion/version_test.go
Normal file
267
versionctl/projectversion/version_test.go
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
package projectversion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParse(t *testing.T) {
|
||||||
|
t.Run("valid", func(t *testing.T) {
|
||||||
|
version, err := Parse("1.2.3")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, Version{Major: 1, Minor: 2, Patch: 3}, version)
|
||||||
|
assert.Equal(t, "1.2.3", version.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid", func(t *testing.T) {
|
||||||
|
invalidValues := []string{"", "1.2", "1.2.3.4", "v1.2.3", "01.2.3", "1.02.3"}
|
||||||
|
for _, tc := range invalidValues {
|
||||||
|
_, err := Parse(tc)
|
||||||
|
assert.Error(t, err, "%q 应校验失败", tc)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePackageJSONVersion(t *testing.T) {
|
||||||
|
content := "{\n \"name\": \"frontend\",\n \"version\": \"0.0.0\"\n}\n"
|
||||||
|
updated, err := UpdatePackageJSONVersion(content, "1.2.3")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, updated, `"version": "1.2.3"`)
|
||||||
|
|
||||||
|
version, err := ReadPackageJSONVersion(updated)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "1.2.3", version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertEnvVar(t *testing.T) {
|
||||||
|
updated := UpsertEnvVar("VITE_API_BASE=/api\n", "VITE_APP_VERSION", "1.2.3")
|
||||||
|
assert.Contains(t, updated, "VITE_API_BASE=/api\n")
|
||||||
|
assert.Contains(t, updated, "VITE_APP_VERSION=1.2.3\n")
|
||||||
|
|
||||||
|
updated = UpsertEnvVar(updated, "VITE_APP_VERSION", "2.0.0")
|
||||||
|
value, ok := ReadEnvVar(updated, "VITE_APP_VERSION")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "2.0.0", value)
|
||||||
|
assert.Equal(t, 1, strings.Count(updated, "VITE_APP_VERSION="))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncAndCheck(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, "VERSION"), []byte("1.2.3\n"), 0o600))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(root, "frontend"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", "package.json"), []byte("{\n \"name\": \"frontend\",\n \"version\": \"0.0.0\"\n}\n"), 0o600))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", ".env.production"), []byte("VITE_API_BASE=/api\n"), 0o600))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", ".env.development"), []byte("VITE_API_BASE=\n"), 0o600))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", ".env.desktop"), []byte("VITE_API_BASE=\n"), 0o600))
|
||||||
|
|
||||||
|
require.NoError(t, Sync(root))
|
||||||
|
require.NoError(t, Check(root))
|
||||||
|
|
||||||
|
packageJSONContent, err := os.ReadFile(filepath.Join(root, "frontend", "package.json"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(packageJSONContent), `"version": "1.2.3"`)
|
||||||
|
|
||||||
|
for _, relPath := range frontendVersionFiles {
|
||||||
|
content, readErr := os.ReadFile(filepath.Join(root, relPath))
|
||||||
|
require.NoError(t, readErr)
|
||||||
|
assert.Contains(t, string(content), "VITE_APP_VERSION=1.2.3\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyTag(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, "VERSION"), []byte("1.2.3\n"), 0o600))
|
||||||
|
|
||||||
|
require.NoError(t, VerifyTag(root, "v1.2.3"))
|
||||||
|
assert.Error(t, VerifyTag(root, "1.2.3"))
|
||||||
|
assert.Error(t, VerifyTag(root, "v1.2.4"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssetNames(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
component string
|
||||||
|
platform string
|
||||||
|
arch string
|
||||||
|
format string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"server linux amd64", "server", "linux", "amd64", "tar.gz", "nex-server_1.2.3_linux_amd64.tar.gz"},
|
||||||
|
{"server linux arm64", "server", "linux", "arm64", "tar.gz", "nex-server_1.2.3_linux_arm64.tar.gz"},
|
||||||
|
{"server macos amd64", "server", "macos", "amd64", "tar.gz", "nex-server_1.2.3_macos_amd64.tar.gz"},
|
||||||
|
{"server macos arm64", "server", "macos", "arm64", "tar.gz", "nex-server_1.2.3_macos_arm64.tar.gz"},
|
||||||
|
{"server macos universal", "server", "macos", "universal", "tar.gz", "nex-server_1.2.3_macos_universal.tar.gz"},
|
||||||
|
{"server windows amd64", "server", "windows", "amd64", "zip", "nex-server_1.2.3_windows_amd64.zip"},
|
||||||
|
{"server windows arm64", "server", "windows", "arm64", "zip", "nex-server_1.2.3_windows_arm64.zip"},
|
||||||
|
{"web", "web", "", "", "tar.gz", "nex-web_1.2.3.tar.gz"},
|
||||||
|
{"desktop linux amd64 tar", "desktop", "linux", "amd64", "tar.gz", "nex-desktop_1.2.3_linux_amd64.tar.gz"},
|
||||||
|
{"desktop linux amd64 appimage", "desktop", "linux", "amd64", "AppImage", "nex-desktop_1.2.3_linux_amd64.AppImage"},
|
||||||
|
{"desktop linux amd64 deb", "desktop", "linux", "amd64", "deb", "nex-desktop_1.2.3_linux_amd64.deb"},
|
||||||
|
{"desktop linux amd64 rpm", "desktop", "linux", "amd64", "rpm", "nex-desktop_1.2.3_linux_amd64.rpm"},
|
||||||
|
{"desktop linux arm64 tar", "desktop", "linux", "arm64", "tar.gz", "nex-desktop_1.2.3_linux_arm64.tar.gz"},
|
||||||
|
{"desktop linux arm64 appimage", "desktop", "linux", "arm64", "AppImage", "nex-desktop_1.2.3_linux_arm64.AppImage"},
|
||||||
|
{"desktop linux arm64 deb", "desktop", "linux", "arm64", "deb", "nex-desktop_1.2.3_linux_arm64.deb"},
|
||||||
|
{"desktop linux arm64 rpm", "desktop", "linux", "arm64", "rpm", "nex-desktop_1.2.3_linux_arm64.rpm"},
|
||||||
|
{"desktop macos zip", "desktop", "macos", "universal", "zip", "nex-desktop_1.2.3_macos_universal.zip"},
|
||||||
|
{"desktop macos dmg", "desktop", "macos", "universal", "dmg", "nex-desktop_1.2.3_macos_universal.dmg"},
|
||||||
|
{"desktop windows amd64", "desktop", "windows", "amd64", "zip", "nex-desktop_1.2.3_windows_amd64.zip"},
|
||||||
|
{"desktop windows arm64", "desktop", "windows", "arm64", "zip", "nex-desktop_1.2.3_windows_arm64.zip"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := ReleaseAssetName("1.2.3", tc.component, tc.platform, tc.arch, tc.format)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidCases := []struct {
|
||||||
|
name string
|
||||||
|
component string
|
||||||
|
platform string
|
||||||
|
arch string
|
||||||
|
format string
|
||||||
|
}{
|
||||||
|
{"invalid version", "server", "linux", "amd64", "tar.gz"},
|
||||||
|
{"invalid component", "mobile", "linux", "amd64", "tar.gz"},
|
||||||
|
{"darwin platform", "server", "darwin", "arm64", "tar.gz"},
|
||||||
|
{"server unsupported format", "server", "linux", "amd64", "zip"},
|
||||||
|
{"server unsupported arch", "server", "windows", "universal", "zip"},
|
||||||
|
{"web with platform", "web", "linux", "amd64", "tar.gz"},
|
||||||
|
{"web unsupported format", "web", "", "", "zip"},
|
||||||
|
{"desktop unsupported platform", "desktop", "ios", "arm64", "zip"},
|
||||||
|
{"desktop unsupported format", "desktop", "macos", "universal", "tar.gz"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range invalidCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
version := "1.2.3"
|
||||||
|
if tc.name == "invalid version" {
|
||||||
|
version = "1.2"
|
||||||
|
}
|
||||||
|
_, err := ReleaseAssetName(version, tc.component, tc.platform, tc.arch, tc.format)
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDesktopInfoPlist(t *testing.T) {
|
||||||
|
plist, err := DesktopInfoPlist("1.2.3", "13.0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, plist, "<key>CFBundleShortVersionString</key>\n <string>1.2.3</string>")
|
||||||
|
assert.Contains(t, plist, "<key>CFBundleVersion</key>\n <string>1.2.3</string>")
|
||||||
|
assert.Contains(t, plist, "<key>LSMinimumSystemVersion</key>\n <string>13.0</string>")
|
||||||
|
|
||||||
|
_, err = DesktopInfoPlist("1.2", "13.0")
|
||||||
|
assert.Error(t, err)
|
||||||
|
_, err = DesktopInfoPlist("1.2.3", "")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLess(t *testing.T) {
|
||||||
|
assert.True(t, Version{1, 0, 0}.Less(Version{2, 0, 0}))
|
||||||
|
assert.True(t, Version{1, 1, 0}.Less(Version{1, 2, 0}))
|
||||||
|
assert.True(t, Version{1, 0, 1}.Less(Version{1, 0, 2}))
|
||||||
|
assert.False(t, Version{2, 0, 0}.Less(Version{1, 0, 0}))
|
||||||
|
assert.False(t, Version{1, 0, 0}.Less(Version{1, 0, 0}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBump(t *testing.T) {
|
||||||
|
setupRoot := func(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, "VERSION"), []byte("0.1.0\n"), 0o600))
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("major", func(t *testing.T) {
|
||||||
|
root := setupRoot(t)
|
||||||
|
v, err := Bump(root, "major")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, Version{1, 0, 0}, v)
|
||||||
|
|
||||||
|
read, readErr := ReadString(root)
|
||||||
|
require.NoError(t, readErr)
|
||||||
|
assert.Equal(t, "1.0.0", read)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("minor", func(t *testing.T) {
|
||||||
|
root := setupRoot(t)
|
||||||
|
v, err := Bump(root, "minor")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, Version{0, 2, 0}, v)
|
||||||
|
|
||||||
|
read, readErr := ReadString(root)
|
||||||
|
require.NoError(t, readErr)
|
||||||
|
assert.Equal(t, "0.2.0", read)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("patch", func(t *testing.T) {
|
||||||
|
root := setupRoot(t)
|
||||||
|
v, err := Bump(root, "patch")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, Version{0, 1, 1}, v)
|
||||||
|
|
||||||
|
read, readErr := ReadString(root)
|
||||||
|
require.NoError(t, readErr)
|
||||||
|
assert.Equal(t, "0.1.1", read)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("specific version", func(t *testing.T) {
|
||||||
|
root := setupRoot(t)
|
||||||
|
v, err := Bump(root, "1.0.0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, Version{1, 0, 0}, v)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("same version as current", func(t *testing.T) {
|
||||||
|
root := setupRoot(t)
|
||||||
|
v, err := Bump(root, "0.1.0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, Version{0, 1, 0}, v)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid argument", func(t *testing.T) {
|
||||||
|
root := setupRoot(t)
|
||||||
|
_, err := Bump(root, "invalid")
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckNoRegression(t *testing.T) {
|
||||||
|
t.Run("greater than existing tag", func(t *testing.T) {
|
||||||
|
err := CheckNoRegression(Version{0, 2, 0}, []string{"v0.1.0"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("equal to existing tag", func(t *testing.T) {
|
||||||
|
err := CheckNoRegression(Version{0, 1, 0}, []string{"v0.1.0"})
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("less than existing tag", func(t *testing.T) {
|
||||||
|
err := CheckNoRegression(Version{0, 1, 5}, []string{"v0.2.0"})
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no tags", func(t *testing.T) {
|
||||||
|
err := CheckNoRegression(Version{0, 1, 0}, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("skips non-semver tags", func(t *testing.T) {
|
||||||
|
err := CheckNoRegression(Version{0, 2, 0}, []string{"v0.1.0", "some-other-tag"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("picks max tag", func(t *testing.T) {
|
||||||
|
err := CheckNoRegression(Version{0, 1, 5}, []string{"v0.1.0", "v0.2.0", "v0.0.5"})
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user