Compare commits
3 Commits
380586afa6
...
b3258e76df
| Author | SHA1 | Date | |
|---|---|---|---|
| b3258e76df | |||
| 64dc66afa6 | |||
| 15f08ee2ca |
15
Makefile
15
Makefile
@@ -3,7 +3,7 @@
|
||||
backend-lint backend-deps backend-generate \
|
||||
backend-migrate-up backend-migrate-down backend-migrate-status backend-migrate-create \
|
||||
frontend-build frontend-dev frontend-test frontend-test-watch frontend-test-coverage frontend-test-e2e frontend-lint \
|
||||
desktop desktop-darwin desktop-windows desktop-linux package-macos
|
||||
desktop-mac desktop-win desktop-linux package-macos
|
||||
|
||||
# ============================================
|
||||
# 后端
|
||||
@@ -82,9 +82,6 @@ frontend-lint:
|
||||
# 桌面应用
|
||||
# ============================================
|
||||
|
||||
desktop: frontend-build-desktop embedfs-prepare
|
||||
cd backend && CGO_ENABLED=1 go build -o ../build/nex ./cmd/desktop
|
||||
|
||||
frontend-build-desktop:
|
||||
cd frontend && cp .env.desktop .env.production.local && bun install && bun run build && rm -f .env.production.local
|
||||
|
||||
@@ -93,12 +90,12 @@ embedfs-prepare:
|
||||
cp -r assets embedfs/assets
|
||||
cp -r frontend/dist embedfs/frontend-dist
|
||||
|
||||
desktop-darwin: frontend-build-desktop embedfs-prepare
|
||||
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -o ../build/nex-darwin-arm64 ./cmd/desktop
|
||||
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -o ../build/nex-darwin-amd64 ./cmd/desktop
|
||||
desktop-mac: frontend-build-desktop embedfs-prepare
|
||||
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -o ../build/nex-mac-arm64 ./cmd/desktop
|
||||
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -o ../build/nex-mac-amd64 ./cmd/desktop
|
||||
|
||||
desktop-windows: frontend-build-desktop embedfs-prepare
|
||||
cd backend && CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -ldflags "-H=windowsgui" -o ../build/nex-windows-amd64.exe ./cmd/desktop
|
||||
desktop-win: frontend-build-desktop embedfs-prepare
|
||||
cd backend && CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -ldflags "-H=windowsgui" -o ../build/nex-win-amd64.exe ./cmd/desktop
|
||||
|
||||
desktop-linux: frontend-build-desktop embedfs-prepare
|
||||
cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o ../build/nex-linux-amd64 ./cmd/desktop
|
||||
|
||||
@@ -91,22 +91,19 @@ nex/
|
||||
**构建桌面应用**:
|
||||
|
||||
```bash
|
||||
# 当前平台
|
||||
make desktop
|
||||
|
||||
# macOS (arm64 + amd64)
|
||||
make desktop-darwin
|
||||
make desktop-mac
|
||||
make package-macos # 打包为 .app
|
||||
|
||||
# Windows
|
||||
make desktop-windows
|
||||
make desktop-win
|
||||
|
||||
# Linux
|
||||
make desktop-linux
|
||||
```
|
||||
|
||||
**使用桌面应用**:
|
||||
- 双击启动应用(macOS: Nex.app,Windows: nex.exe,Linux: nex)
|
||||
- 双击启动应用(macOS: Nex.app,Windows: nex-win-amd64.exe,Linux: nex-linux-amd64)
|
||||
- 系统托盘图标出现,浏览器自动打开管理界面
|
||||
- 点击托盘图标显示菜单,可打开管理界面或退出
|
||||
- 关闭浏览器后服务继续运行,可通过托盘重新打开
|
||||
|
||||
33
backend/cmd/desktop/icon_test.go
Normal file
33
backend/cmd/desktop/icon_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"nex/embedfs"
|
||||
)
|
||||
|
||||
func TestIconSelection_Windows(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("图标格式选择测试仅在 Windows 上运行")
|
||||
}
|
||||
|
||||
if err := testIconLoad("assets/icon.ico"); err != nil {
|
||||
t.Fatalf("Windows 应加载 .ico 文件: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconSelection_NonWindows(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("图标格式选择测试在非 Windows 平台运行")
|
||||
}
|
||||
|
||||
if err := testIconLoad("assets/icon.png"); err != nil {
|
||||
t.Fatalf("非 Windows 平台应加载 .png 文件: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testIconLoad(path string) error {
|
||||
_, err := embedfs.Assets.ReadFile(path)
|
||||
return err
|
||||
}
|
||||
@@ -14,9 +14,11 @@ import (
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/getlantern/systray"
|
||||
"github.com/gofrs/flock"
|
||||
"github.com/pressly/goose/v3"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/sqlite"
|
||||
@@ -47,11 +49,12 @@ var (
|
||||
func main() {
|
||||
port := 9826
|
||||
|
||||
if err := acquireSingleInstance(); err != nil {
|
||||
singleLock := NewSingletonLock(filepath.Join(os.TempDir(), "nex-gateway.lock"))
|
||||
if err := singleLock.Lock(); err != nil {
|
||||
showError("Nex Gateway", "已有 Nex 实例运行")
|
||||
os.Exit(1)
|
||||
}
|
||||
defer releaseSingleInstance()
|
||||
defer singleLock.Unlock()
|
||||
|
||||
if err := checkPortAvailable(port); err != nil {
|
||||
showError("Nex Gateway", err.Error())
|
||||
@@ -89,10 +92,21 @@ func main() {
|
||||
modelRepo := repository.NewModelRepository(db)
|
||||
statsRepo := repository.NewStatsRepository(db)
|
||||
|
||||
providerService := service.NewProviderService(providerRepo, modelRepo)
|
||||
modelService := service.NewModelService(modelRepo, providerRepo)
|
||||
routingService := service.NewRoutingService(modelRepo, providerRepo)
|
||||
statsService := service.NewStatsService(statsRepo)
|
||||
routingCache := service.NewRoutingCache(modelRepo, providerRepo, zapLogger)
|
||||
if err := routingCache.Preload(); err != nil {
|
||||
zapLogger.Warn("缓存预热失败,将使用懒加载", zap.Error(err))
|
||||
}
|
||||
|
||||
statsBuffer := service.NewStatsBuffer(statsRepo, zapLogger,
|
||||
service.WithFlushInterval(5*time.Second),
|
||||
service.WithFlushThreshold(100))
|
||||
statsBuffer.Start()
|
||||
defer statsBuffer.Stop()
|
||||
|
||||
providerService := service.NewProviderService(providerRepo, modelRepo, routingCache)
|
||||
modelService := service.NewModelService(modelRepo, providerRepo, routingCache)
|
||||
routingService := service.NewRoutingService(routingCache)
|
||||
statsService := service.NewStatsService(statsRepo, statsBuffer)
|
||||
|
||||
registry := conversion.NewMemoryRegistry()
|
||||
if err := registry.Register(openai.NewAdapter()); err != nil {
|
||||
@@ -317,7 +331,13 @@ func setupStaticFiles(r *gin.Engine) {
|
||||
|
||||
func setupSystray(port int) {
|
||||
systray.Run(func() {
|
||||
icon, err := embedfs.Assets.ReadFile("assets/icon.png")
|
||||
var icon []byte
|
||||
var err error
|
||||
if runtime.GOOS == "windows" {
|
||||
icon, err = embedfs.Assets.ReadFile("assets/icon.ico")
|
||||
} else {
|
||||
icon, err = embedfs.Assets.ReadFile("assets/icon.png")
|
||||
}
|
||||
if err != nil {
|
||||
zapLogger.Error("无法加载托盘图标", zap.String("error", err.Error()))
|
||||
}
|
||||
@@ -378,31 +398,29 @@ func checkPortAvailable(port int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var lockFile *os.File
|
||||
type SingletonLock struct {
|
||||
flock *flock.Flock
|
||||
}
|
||||
|
||||
func acquireSingleInstance() error {
|
||||
lockPath := filepath.Join(os.TempDir(), "nex-gateway.lock")
|
||||
func NewSingletonLock(lockPath string) *SingletonLock {
|
||||
return &SingletonLock{
|
||||
flock: flock.New(lockPath),
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0666)
|
||||
func (s *SingletonLock) Lock() error {
|
||||
locked, err := s.flock.TryLock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
if !locked {
|
||||
return fmt.Errorf("已有实例运行")
|
||||
}
|
||||
|
||||
lockFile = f
|
||||
return nil
|
||||
}
|
||||
|
||||
func releaseSingleInstance() {
|
||||
if lockFile != nil {
|
||||
syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN)
|
||||
lockFile.Close()
|
||||
}
|
||||
func (s *SingletonLock) Unlock() {
|
||||
s.flock.Unlock()
|
||||
}
|
||||
|
||||
func openBrowser(url string) error {
|
||||
@@ -436,7 +454,7 @@ func showError(title, message string) {
|
||||
script := fmt.Sprintf(`display dialog "%s" buttons {"OK"} default button "OK" with title "%s"`, message, title)
|
||||
exec.Command("osascript", "-e", script).Run()
|
||||
case "windows":
|
||||
exec.Command("msg", "*", message).Run()
|
||||
messageBox(title, message, MB_ICONERROR)
|
||||
case "linux":
|
||||
exec.Command("zenity", "--error", fmt.Sprintf("--title=%s", title), fmt.Sprintf("--text=%s", message)).Run()
|
||||
}
|
||||
@@ -449,8 +467,29 @@ func showAbout() {
|
||||
script := fmt.Sprintf(`display dialog "%s" buttons {"OK"} default button "OK" with title "关于 Nex Gateway"`, message)
|
||||
exec.Command("osascript", "-e", script).Run()
|
||||
case "windows":
|
||||
exec.Command("msg", "*", message).Run()
|
||||
messageBox("关于 Nex Gateway", message, MB_ICONINFORMATION)
|
||||
case "linux":
|
||||
exec.Command("zenity", "--info", "--title=关于 Nex Gateway", fmt.Sprintf("--text=%s", message)).Run()
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
MB_ICONERROR = 0x10
|
||||
MB_ICONINFORMATION = 0x40
|
||||
)
|
||||
|
||||
var (
|
||||
user32 = syscall.NewLazyDLL("user32.dll")
|
||||
procMessageBoxW = user32.NewProc("MessageBoxW")
|
||||
)
|
||||
|
||||
func messageBox(title, message string, flags uint) {
|
||||
titlePtr, _ := syscall.UTF16PtrFromString(title)
|
||||
messagePtr, _ := syscall.UTF16PtrFromString(message)
|
||||
procMessageBoxW.Call(
|
||||
0,
|
||||
uintptr(unsafe.Pointer(messagePtr)),
|
||||
uintptr(unsafe.Pointer(titlePtr)),
|
||||
uintptr(flags),
|
||||
)
|
||||
}
|
||||
|
||||
30
backend/cmd/desktop/messagebox_test.go
Normal file
30
backend/cmd/desktop/messagebox_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMessageBoxW_WindowsOnly(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("MessageBoxW 仅在 Windows 上测试")
|
||||
}
|
||||
|
||||
messageBox("测试标题", "测试消息", MB_ICONINFORMATION)
|
||||
}
|
||||
|
||||
func TestShowError_WindowsBranch(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("Windows 原生对话框测试仅在 Windows 上运行")
|
||||
}
|
||||
|
||||
showError("测试错误", "这是一条测试错误消息")
|
||||
}
|
||||
|
||||
func TestShowAbout_WindowsBranch(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("Windows 原生对话框测试仅在 Windows 上运行")
|
||||
}
|
||||
|
||||
showAbout()
|
||||
}
|
||||
@@ -3,37 +3,56 @@ package main
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAcquireSingleInstance(t *testing.T) {
|
||||
lockPath := filepath.Join(os.TempDir(), "nex-gateway-test.lock")
|
||||
|
||||
origLockFile := lockFile
|
||||
lockFile = nil
|
||||
defer func() { lockFile = origLockFile }()
|
||||
|
||||
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0666)
|
||||
if err != nil {
|
||||
t.Fatalf("无法创建锁文件: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
func TestSingletonLock_FirstLockSuccess(t *testing.T) {
|
||||
lockPath := filepath.Join(os.TempDir(), "nex-gateway-test-first.lock")
|
||||
defer os.Remove(lockPath)
|
||||
|
||||
err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
||||
if err != nil {
|
||||
t.Fatalf("无法获取文件锁: %v", err)
|
||||
lock := NewSingletonLock(lockPath)
|
||||
if err := lock.Lock(); err != nil {
|
||||
t.Fatalf("首次加锁应成功,但返回错误: %v", err)
|
||||
}
|
||||
defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||
|
||||
t.Log("单实例锁测试通过")
|
||||
defer lock.Unlock()
|
||||
}
|
||||
|
||||
func TestReleaseSingleInstance(t *testing.T) {
|
||||
lockFile = nil
|
||||
|
||||
releaseSingleInstance()
|
||||
|
||||
t.Log("释放空锁测试通过")
|
||||
func TestSingletonLock_DuplicateLockFails(t *testing.T) {
|
||||
lockPath := filepath.Join(os.TempDir(), "nex-gateway-test-dup.lock")
|
||||
defer os.Remove(lockPath)
|
||||
|
||||
lock1 := NewSingletonLock(lockPath)
|
||||
if err := lock1.Lock(); err != nil {
|
||||
t.Fatalf("首次加锁应成功: %v", err)
|
||||
}
|
||||
defer lock1.Unlock()
|
||||
|
||||
lock2 := NewSingletonLock(lockPath)
|
||||
err := lock2.Lock()
|
||||
if err == nil {
|
||||
lock2.Unlock()
|
||||
t.Fatal("重复加锁应失败,但返回 nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingletonLock_UnlockThenRelock(t *testing.T) {
|
||||
lockPath := filepath.Join(os.TempDir(), "nex-gateway-test-relock.lock")
|
||||
defer os.Remove(lockPath)
|
||||
|
||||
lock1 := NewSingletonLock(lockPath)
|
||||
if err := lock1.Lock(); err != nil {
|
||||
t.Fatalf("首次加锁应成功: %v", err)
|
||||
}
|
||||
lock1.Unlock()
|
||||
|
||||
lock2 := NewSingletonLock(lockPath)
|
||||
if err := lock2.Lock(); err != nil {
|
||||
t.Fatalf("释放后重新加锁应成功: %v", err)
|
||||
}
|
||||
lock2.Unlock()
|
||||
}
|
||||
|
||||
func TestSingletonLock_UnlockWithoutLock(t *testing.T) {
|
||||
lock := NewSingletonLock(filepath.Join(os.TempDir(), "nex-gateway-test-nil.lock"))
|
||||
lock.Unlock()
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ require (
|
||||
github.com/getlantern/systray v1.2.2
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-playground/validator/v10 v10.30.2
|
||||
github.com/gofrs/flock v0.13.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/pressly/goose/v3 v3.27.0
|
||||
@@ -102,7 +103,6 @@ require (
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/gofrs/flock v0.12.1 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
|
||||
github.com/golangci/go-printf-func-name v0.1.0 // indirect
|
||||
|
||||
@@ -239,8 +239,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
||||
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
||||
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
||||
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button } from 'tdesign-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
export function NotFound() {
|
||||
export default function NotFound() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ProviderTable } from './ProviderTable';
|
||||
import { ProviderForm } from './ProviderForm';
|
||||
import { ModelForm } from './ModelForm';
|
||||
|
||||
export function ProvidersPage() {
|
||||
export default function ProvidersPage() {
|
||||
const { data: providers = [], isLoading } = useProviders();
|
||||
const createProvider = useCreateProvider();
|
||||
const updateProvider = useUpdateProvider();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Card } from 'tdesign-react';
|
||||
|
||||
export function SettingsPage() {
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<Card title="设置">
|
||||
<div style={{ textAlign: 'center', padding: '40px 0', color: 'var(--td-text-color-placeholder)' }}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { StatCards } from './StatCards';
|
||||
import { UsageChart } from './UsageChart';
|
||||
import { StatsTable } from './StatsTable';
|
||||
|
||||
export function StatsPage() {
|
||||
export default function StatsPage() {
|
||||
const { data: providers = [] } = useProviders();
|
||||
|
||||
const [providerId, setProviderId] = useState<string | undefined>();
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router';
|
||||
import { Loading } from 'tdesign-react';
|
||||
import { AppLayout } from '@/components/AppLayout';
|
||||
import { ProvidersPage } from '@/pages/Providers';
|
||||
import { StatsPage } from '@/pages/Stats';
|
||||
import { SettingsPage } from '@/pages/Settings';
|
||||
import { NotFound } from '@/pages/NotFound';
|
||||
|
||||
const ProvidersPage = lazy(() => import('@/pages/Providers'));
|
||||
const StatsPage = lazy(() => import('@/pages/Stats'));
|
||||
const SettingsPage = lazy(() => import('@/pages/Settings'));
|
||||
const NotFound = lazy(() => import('@/pages/NotFound'));
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<Navigate to="/providers" replace />} />
|
||||
<Route path="providers" element={<ProvidersPage />} />
|
||||
<Route path="stats" element={<StatsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<Navigate to="/providers" replace />} />
|
||||
<Route path="providers" element={<ProvidersPage />} />
|
||||
<Route path="stats" element={<StatsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@ import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'node:path'
|
||||
|
||||
const vendorChunks: Record<string, string[]> = {
|
||||
'vendor-react': ['react', 'react-dom', 'react-router'],
|
||||
'vendor-tdesign': ['tdesign-react', 'tdesign-icons-react'],
|
||||
'vendor-recharts': ['recharts'],
|
||||
}
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -18,4 +24,20 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
chunkSizeWarningLimit: 700,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
for (const [chunkName, modules] of Object.entries(vendorChunks)) {
|
||||
for (const mod of modules) {
|
||||
if (id.includes(`/node_modules/${mod}/`)) {
|
||||
return chunkName
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-22
|
||||
81
openspec/changes/fix-windows-desktop-packaging/design.md
Normal file
81
openspec/changes/fix-windows-desktop-packaging/design.md
Normal file
@@ -0,0 +1,81 @@
|
||||
## Context
|
||||
|
||||
Nex 桌面应用是一个将后端服务(Go/Gin)与前端静态资源(embed.FS)打包为单一可执行文件的跨平台应用。当前 Windows 构建存在三类问题:
|
||||
|
||||
1. **通用 `desktop` target 无平台感知**:输出 `build/nex` 无 `.exe` 后缀,且缺少 `-H=windowsgui` linker flag 导致控制台窗口闪现
|
||||
2. **系统托盘图标加载失败**:`getlantern/systray` 在 Windows 上期望 ICO 格式,代码传入了 64x64 的 PNG
|
||||
3. **`showError`/`showAbout` 使用 `msg *`**:Windows Home 版本可能不可用,配合 `-H=windowsgui` 后行为不可预测,且不支持自定义标题栏
|
||||
|
||||
项目已有 `assets/icon.ico`(256x256)但代码未使用。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Windows 构建产物可直接双击运行(.exe 后缀、无控制台窗口)
|
||||
- 系统托盘图标在所有平台上正确加载
|
||||
- Windows 上使用原生 `MessageBoxW` 对话框替代 `msg *`
|
||||
- Makefile target 命名简洁统一
|
||||
|
||||
**Non-Goals:**
|
||||
- 不引入新的第三方依赖
|
||||
- 不改变 macOS/Linux 上的现有行为
|
||||
- 不涉及应用签名或代码公证(属于发布流程)
|
||||
- 不重构整体打包架构
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. 删除通用 `desktop` target,重命名平台 target
|
||||
|
||||
**决策**:删除 `desktop` target,将 `desktop-darwin`/`desktop-windows`/`desktop-linux` 重命名为 `desktop-mac`/`desktop-win`/`desktop-linux`。
|
||||
|
||||
**理由**:通用 target 在跨平台构建时必然需要条件判断,增加复杂度。按平台分离更明确,且项目已有先例。短命名 `win`/`mac`/`linux` 更简洁。
|
||||
|
||||
**产物命名统一**:`nex-{os}-{arch}[.exe]`
|
||||
- `nex-mac-arm64`、`nex-mac-amd64`
|
||||
- `nex-win-amd64.exe`
|
||||
- `nex-linux-amd64`
|
||||
|
||||
### 2. 托盘图标运行时按平台选择格式
|
||||
|
||||
**决策**:在 `setupSystray` 中根据 `runtime.GOOS` 选择图标文件:
|
||||
- Windows:加载 `assets/icon.ico`(256x256 ICO)
|
||||
- 其他:加载 `assets/icon.png`(PNG)
|
||||
|
||||
**备选方案**:
|
||||
- ~~Build tags + 编译时选择~~:增加文件数,维护成本高
|
||||
- ~~所有平台统一用 ICO~~:Linux/macOS 的 systray 实现对 ICO 支持不一致
|
||||
|
||||
**理由**:运行时判断最简单,两个文件都已通过 `embedfs.Assets`(`assets/*`)嵌入,零额外成本。
|
||||
|
||||
### 3. Windows 原生对话框使用 `user32.MessageBoxW`
|
||||
|
||||
**决策**:通过 `syscall` 调用 `user32.dll` 的 `MessageBoxW`,替换 `msg *`。
|
||||
|
||||
**实现方式**:
|
||||
```go
|
||||
var (
|
||||
user32 = syscall.NewLazyDLL("user32.dll")
|
||||
procMessageBoxW = user32.NewProc("MessageBoxW")
|
||||
)
|
||||
func messageBox(title, message string) {
|
||||
procMessageBoxW.Call(0, uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(message))),
|
||||
uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(title))), 0x10)
|
||||
}
|
||||
```
|
||||
|
||||
**备选方案**:
|
||||
- ~~继续用 `msg *`~~:不解决 Home 版不可用、标题栏不支持的问题
|
||||
- ~~`rundll32` 调用~~:同样不可靠
|
||||
- ~~引入 `lxn/walk` 等 GUI 库~~:引入重依赖,过度
|
||||
|
||||
**理由**:`MessageBoxW` 是 Windows 原生 API,所有版本都有,与 `-H=windowsgui` 完美兼容,支持标题栏和图标类型,零依赖。使用 `syscall`(非 `unsafe` 外部依赖)即可。
|
||||
|
||||
### 4. `showError`/`showAbout` 统一用平台 switch
|
||||
|
||||
**决策**:保持现有的 `switch runtime.GOOS` 结构,仅替换 Windows 分支实现。macOS(osascript)和 Linux(zenity)不变。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[syscall 跨架构]** `MessageBoxW` 的 `syscall.NewLazyDLL` 仅在 Windows 上有效 → 使用 `runtime.GOOS` 守卫,非 Windows 不会执行该路径,编译时通过 build 文件或运行时判断确保不触发
|
||||
- **[ICO 嵌入体积]** `icon.ico` 270KB,已在 `embedfs` 中,不增加新体积 → 无风险
|
||||
- **[Makefile 兼容性]** 删除 `desktop` target 后,CI/本地脚本如果引用它需更新 → 需检查是否有外部引用
|
||||
28
openspec/changes/fix-windows-desktop-packaging/proposal.md
Normal file
28
openspec/changes/fix-windows-desktop-packaging/proposal.md
Normal file
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
Windows 桌面应用存在三个影响用户体验的问题:构建产物无 `.exe` 后缀无法双击运行、运行时弹出控制台窗口、系统托盘图标加载失败。此外 `showError`/`showAbout` 在 Windows 上使用 `msg *` 命令不可靠。这些问题导致应用在 Windows 上不够专业,需要统一修复。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 删除通用 `desktop` Makefile target,仅保留按平台分离的 target
|
||||
- Makefile target 重命名为简短形式:`desktop-win`、`desktop-mac`、`desktop-linux`
|
||||
- 构建产物文件名统一为 `nex-{os}-{arch}[.exe]` 格式
|
||||
- 系统托盘图标在 Windows 上使用 `.ico` 格式(运行时 `runtime.GOOS` 判断)
|
||||
- Windows `showError`/`showAbout` 改用 `user32.dll` 的 `MessageBoxW` 原生对话框
|
||||
- 同步更新已有 `desktop-app` spec 中的构建产物命名和图标格式要求
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
无
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `desktop-app`: 构建产物命名规范变更(`nex-{os}-{arch}`);Windows 托盘图标需使用 `.ico` 格式;Windows 原生对话框替代 `msg *` 命令
|
||||
|
||||
## Impact
|
||||
|
||||
- `Makefile`:删除 `desktop` target,重命名其余三个 target 和产物文件名
|
||||
- `backend/cmd/desktop/main.go`:修改 `setupSystray` 图标加载逻辑、`showError`/`showAbout` Windows 实现
|
||||
- `openspec/specs/desktop-app/spec.md`:更新构建产物命名和 Windows 图标格式要求
|
||||
@@ -0,0 +1,87 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 系统托盘
|
||||
|
||||
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择。
|
||||
|
||||
#### Scenario: 托盘图标显示
|
||||
- **WHEN** 桌面应用启动成功
|
||||
- **THEN** 系统根据平台加载正确的图标格式
|
||||
- **AND** 在 Windows 上加载 ICO 格式图标(`assets/icon.ico`)
|
||||
- **AND** 在 macOS 和 Linux 上加载 PNG 格式图标(`assets/icon.png`)
|
||||
- **AND** 托盘图标 tooltip 显示"AI Gateway"
|
||||
|
||||
#### Scenario: 托盘菜单显示
|
||||
- **WHEN** 用户点击托盘图标(左键或右键)
|
||||
- **THEN** 显示托盘菜单
|
||||
- **AND** 菜单包含"打开管理界面"选项
|
||||
- **AND** 菜单包含"状态: 运行中"选项(禁用状态)
|
||||
- **AND** 菜单包含"端口: 9826"选项(禁用状态)
|
||||
- **AND** 菜单包含"关于"选项
|
||||
- **AND** 菜单包含"退出"选项
|
||||
|
||||
#### Scenario: 打开管理界面
|
||||
- **WHEN** 用户点击托盘菜单"打开管理界面"
|
||||
- **THEN** 系统在浏览器中打开 `http://localhost:9826`
|
||||
|
||||
#### Scenario: 浏览器打开失败
|
||||
- **WHEN** 系统无法打开浏览器(浏览器未安装等)
|
||||
- **THEN** 托盘菜单仍可正常使用
|
||||
- **AND** 用户可手动访问 `http://localhost:9826`
|
||||
|
||||
#### Scenario: 退出应用
|
||||
- **WHEN** 用户点击托盘菜单"退出"
|
||||
- **THEN** 系统优雅关闭后端服务
|
||||
- **AND** 托盘图标消失
|
||||
- **AND** 应用进程退出
|
||||
|
||||
### Requirement: 跨平台构建
|
||||
|
||||
系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台分离,产物文件名 SHALL 使用 `nex-{os}-{arch}[.exe]` 格式。
|
||||
|
||||
#### Scenario: macOS 构建
|
||||
- **WHEN** 执行 `desktop-mac` 构建命令
|
||||
- **THEN** 生成 `nex-mac-arm64` 和 `nex-mac-amd64` 可执行文件
|
||||
- **AND** 可打包为 `.app` bundle
|
||||
|
||||
#### Scenario: Windows 构建
|
||||
- **WHEN** 执行 `desktop-win` 构建命令
|
||||
- **THEN** 生成 `nex-win-amd64.exe` 可执行文件
|
||||
- **AND** 使用 `-H=windowsgui` linker flag 隐藏控制台窗口
|
||||
|
||||
#### Scenario: Linux 构建
|
||||
- **WHEN** 执行 `desktop-linux` 构建命令
|
||||
- **THEN** 生成 `nex-linux-amd64` 可执行文件
|
||||
|
||||
### Requirement: 关于对话框
|
||||
|
||||
系统 SHALL 提供关于对话框显示应用信息。在 Windows 上 SHALL 使用 `user32.dll` 的 `MessageBoxW` API 实现。
|
||||
|
||||
#### Scenario: 显示关于
|
||||
- **WHEN** 用户点击托盘菜单"关于"
|
||||
- **THEN** 显示对话框包含应用名称、项目链接
|
||||
- **AND** 在 Windows 上使用 `MessageBoxW` 原生对话框实现
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Windows 原生对话框
|
||||
|
||||
系统 SHALL 在 Windows 上使用 `user32.dll` 的 `MessageBoxW` API 显示错误和关于对话框,替代 `msg *` 命令。
|
||||
|
||||
#### Scenario: 错误提示对话框
|
||||
- **WHEN** 应用在 Windows 上遇到启动错误(端口占用、配置加载失败等)
|
||||
- **THEN** 使用 `MessageBoxW` 显示模态对话框
|
||||
- **AND** 对话框标题栏显示应用名称
|
||||
- **AND** 对话框包含错误描述文本
|
||||
- **AND** 对话框显示错误图标(MB_ICONERROR)
|
||||
|
||||
#### Scenario: 关于对话框
|
||||
- **WHEN** 用户在 Windows 上点击托盘菜单"关于"
|
||||
- **THEN** 使用 `MessageBoxW` 显示模态对话框
|
||||
- **AND** 对话框标题栏显示"关于 Nex Gateway"
|
||||
- **AND** 对话框包含应用信息文本
|
||||
- **AND** 对话框显示信息图标(MB_ICONINFORMATION)
|
||||
|
||||
#### Scenario: 非 Windows 平台不受影响
|
||||
- **WHEN** 应用运行在 macOS 或 Linux 上
|
||||
- **THEN** 错误和关于对话框仍使用平台原有实现(osascript / zenity)
|
||||
28
openspec/changes/fix-windows-desktop-packaging/tasks.md
Normal file
28
openspec/changes/fix-windows-desktop-packaging/tasks.md
Normal file
@@ -0,0 +1,28 @@
|
||||
## 1. Makefile 重构
|
||||
|
||||
- [x] 1.1 删除通用 `desktop` target 及其相关 `.PHONY` 声明
|
||||
- [x] 1.2 将 `desktop-darwin` 重命名为 `desktop-mac`,产物文件名改为 `nex-mac-arm64` 和 `nex-mac-amd64`
|
||||
- [x] 1.3 将 `desktop-windows` 重命名为 `desktop-win`,产物文件名改为 `nex-win-amd64.exe`
|
||||
- [x] 1.4 将 `desktop-linux` 产物文件名改为 `nex-linux-amd64`
|
||||
- [x] 1.5 更新 `.PHONY` 声明和 `all` target(如引用了旧名称)
|
||||
|
||||
## 2. Windows 原生对话框
|
||||
|
||||
- [x] 2.1 在 `backend/cmd/desktop/main.go` 中添加 Windows 平台的 `user32.MessageBoxW` 调用封装(`syscall.NewLazyDLL` + `syscall.StringToUTF16Ptr`),在 `showError`/`showAbout` 的 Windows `runtime.GOOS` 分支内直接调用
|
||||
- [x] 2.2 替换 `showError` 函数的 Windows 分支,使用 `MessageBoxW` 替代 `msg *`
|
||||
- [x] 2.3 替换 `showAbout` 函数的 Windows 分支,使用 `MessageBoxW` 替代 `msg *`
|
||||
|
||||
## 3. 系统托盘图标修复
|
||||
|
||||
- [x] 3.1 修改 `setupSystray` 函数中的图标加载逻辑,根据 `runtime.GOOS` 在 Windows 上加载 `assets/icon.ico`,其他平台加载 `assets/icon.png`
|
||||
|
||||
## 4. 文档和脚本更新
|
||||
|
||||
- [x] 4.1 更新 `README.md` 中的构建命令引用(`desktop-darwin` → `desktop-mac`,`desktop-windows` → `desktop-win`,`desktop-linux` 保持不变或改为 `desktop-linux`)
|
||||
- [x] 4.2 更新 `scripts/build/package-macos.sh` 中对 `desktop-darwin` 的引用
|
||||
|
||||
## 5. 测试验证
|
||||
|
||||
- [x] 5.1 为 `showError`/`showAbout` 的 Windows `MessageBoxW` 封装编写单元测试(验证参数传递和调用正确性)
|
||||
- [x] 5.2 为图标加载的平台选择逻辑编写单元测试(验证 Windows 选 `.ico`,其他选 `.png`)
|
||||
- [x] 5.3 运行 `make desktop-win` 在 Windows 上验证:产物有 `.exe` 后缀、双击无控制台窗口、托盘图标正常显示、错误对话框使用原生样式
|
||||
52
openspec/specs/bundle-optimization/spec.md
Normal file
52
openspec/specs/bundle-optimization/spec.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# 前端构建包优化
|
||||
|
||||
## Purpose
|
||||
|
||||
TBD - 配置 Vite 构建优化策略,包括路由级代码分割和 Vendor 分包
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 路由级代码分割
|
||||
|
||||
前端 SHALL 使用 `React.lazy()` 和动态 `import()` 实现路由级代码分割,每个页面组件按需加载。
|
||||
|
||||
#### Scenario: 页面组件懒加载
|
||||
|
||||
- **WHEN** 应用启动
|
||||
- **THEN** 路由配置 SHALL 使用 `lazy(() => import(...))` 导入所有页面组件(ProvidersPage、StatsPage、SettingsPage、NotFound)
|
||||
- **THEN** 页面组件 SHALL NOT 使用静态 `import` 语句直接导入
|
||||
|
||||
#### Scenario: Suspense 加载边界
|
||||
|
||||
- **WHEN** 页面 chunk 尚未加载完成
|
||||
- **THEN** 路由出口 SHALL 显示 TDesign `Loading` 组件作为 fallback
|
||||
- **THEN** `Suspense` 边界 SHALL 包裹在 `<Routes>` 外层
|
||||
|
||||
### Requirement: Vendor 分包
|
||||
|
||||
前端 SHALL 配置 Vite `build.rollupOptions.output.manualChunks` 将第三方库拆分为独立 chunk。
|
||||
|
||||
#### Scenario: React 核心 chunk
|
||||
|
||||
- **WHEN** 执行生产构建
|
||||
- **THEN** react、react-dom、react-router SHALL 被打包为独立 chunk(vendor-react)
|
||||
|
||||
#### Scenario: TDesign chunk
|
||||
|
||||
- **WHEN** 执行生产构建
|
||||
- **THEN** tdesign-react、tdesign-icons-react SHALL 被打包为独立 chunk(vendor-tdesign)
|
||||
|
||||
#### Scenario: Recharts chunk
|
||||
|
||||
- **WHEN** 执行生产构建
|
||||
- **THEN** recharts SHALL 被打包为独立 chunk(vendor-recharts)
|
||||
|
||||
#### Scenario: chunk 告警阈值
|
||||
|
||||
- **WHEN** 执行生产构建
|
||||
- **THEN** `build.chunkSizeWarningLimit` SHALL 设置为 700,避免 vendor chunk 误触发告警
|
||||
|
||||
#### Scenario: chunk 命名
|
||||
|
||||
- **WHEN** 执行生产构建
|
||||
- **THEN** vendor chunk 文件名 SHALL 包含 chunk 名称前缀(如 `vendor-react-[hash].js`)
|
||||
53
openspec/specs/cross-platform-singleton/spec.md
Normal file
53
openspec/specs/cross-platform-singleton/spec.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 跨平台单实例锁
|
||||
|
||||
## Purpose
|
||||
|
||||
TBD - 提供跨平台单实例文件锁机制,使用 `github.com/gofrs/flock` 封装,支持 Windows、macOS、Linux 三平台。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 跨平台单实例文件锁
|
||||
|
||||
系统 SHALL 提供跨平台的单实例文件锁机制,使用 `github.com/gofrs/flock` 实现,在 Windows、macOS、Linux 上均可正常工作。
|
||||
|
||||
#### Scenario: 首次加锁成功
|
||||
- **WHEN** 调用 `NewSingletonLock(lockPath)` 创建锁实例并调用 `Lock()`
|
||||
- **AND** 锁文件未被其他进程持有
|
||||
- **THEN** 加锁成功,返回 `nil`
|
||||
|
||||
#### Scenario: 重复加锁失败
|
||||
- **WHEN** 另一个进程已持有同一锁文件的排他锁
|
||||
- **AND** 调用 `Lock()`
|
||||
- **THEN** 加锁失败,返回错误
|
||||
|
||||
#### Scenario: 释放锁
|
||||
- **WHEN** 调用 `Unlock()`
|
||||
- **THEN** 排他锁被释放
|
||||
- **AND** 其他进程可以成功加锁
|
||||
|
||||
#### Scenario: 进程崩溃后锁自动释放
|
||||
- **WHEN** 持有锁的进程异常退出(崩溃、kill)
|
||||
- **THEN** 操作系统自动释放文件锁
|
||||
- **AND** 新进程可以成功加锁
|
||||
|
||||
> 注:此场景由操作系统和 `gofrs/flock` 库保证,无需显式单元测试。
|
||||
|
||||
### Requirement: 锁路径参数化
|
||||
|
||||
`SingletonLock` SHALL 通过构造函数参数接收锁文件路径,支持不同运行上下文使用不同锁文件。
|
||||
|
||||
#### Scenario: 正式运行锁路径
|
||||
- **WHEN** 桌面应用正常启动
|
||||
- **THEN** 使用 `%TEMP%/nex-gateway.lock` 作为锁文件路径
|
||||
|
||||
#### Scenario: 测试运行锁路径
|
||||
- **WHEN** 运行 `go test`
|
||||
- **THEN** 测试代码使用独立的锁文件路径(如 `%TEMP%/nex-gateway-test.lock`)
|
||||
- **AND** 与正式版锁文件互不影响
|
||||
|
||||
#### Scenario: 锁路径隔离
|
||||
- **WHEN** 正式版桌面应用正在运行
|
||||
- **AND** 同时执行 `go test`
|
||||
- **THEN** 正式版持有 `nex-gateway.lock`
|
||||
- **AND** 测试持有 `nex-gateway-test.lock`
|
||||
- **AND** 两者互不阻塞
|
||||
@@ -12,23 +12,32 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
||||
|
||||
#### Scenario: 双击启动
|
||||
- **WHEN** 用户双击桌面应用可执行文件
|
||||
- **THEN** 系统启动后端服务
|
||||
- **THEN** 系统使用 `gofrs/flock` 尝试获取排他文件锁
|
||||
- **AND** 锁文件路径为系统临时目录下的 `nex-gateway.lock`
|
||||
- **AND** 系统启动后端服务
|
||||
- **AND** 系统托盘图标出现
|
||||
- **AND** 浏览器自动打开 `http://localhost:9826` 显示管理界面
|
||||
|
||||
#### Scenario: 单实例检查
|
||||
- **WHEN** 用户尝试启动第二个实例
|
||||
- **THEN** 系统检测到已有实例运行
|
||||
- **THEN** 系统检测到已有实例持有文件锁
|
||||
- **AND** 显示错误提示"已有 Nex 实例运行"
|
||||
- **AND** 新实例退出
|
||||
|
||||
#### Scenario: 退出释放锁
|
||||
- **WHEN** 用户点击托盘菜单"退出"
|
||||
- **THEN** 系统释放文件锁
|
||||
- **AND** 应用进程退出
|
||||
|
||||
### Requirement: 系统托盘
|
||||
|
||||
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。
|
||||
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择。
|
||||
|
||||
#### Scenario: 托盘图标显示
|
||||
- **WHEN** 桌面应用启动成功
|
||||
- **THEN** 系统托盘区域显示应用图标
|
||||
- **THEN** 系统根据平台加载正确的图标格式
|
||||
- **AND** 在 Windows 上加载 ICO 格式图标(`assets/icon.ico`)
|
||||
- **AND** 在 macOS 和 Linux 上加载 PNG 格式图标(`assets/icon.png`)
|
||||
- **AND** 托盘图标 tooltip 显示"AI Gateway"
|
||||
|
||||
#### Scenario: 托盘菜单显示
|
||||
@@ -86,20 +95,20 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
||||
|
||||
### Requirement: 跨平台构建
|
||||
|
||||
系统 SHALL 支持跨平台构建和打包。
|
||||
系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台分离,产物文件名 SHALL 使用 `nex-{os}-{arch}[.exe]` 格式。
|
||||
|
||||
#### Scenario: macOS 构建
|
||||
- **WHEN** 执行 macOS 构建命令
|
||||
- **THEN** 生成 `nex-darwin-arm64` 和 `nex-darwin-amd64` 可执行文件
|
||||
- **WHEN** 执行 `desktop-mac` 构建命令
|
||||
- **THEN** 生成 `nex-mac-arm64` 和 `nex-mac-amd64` 可执行文件
|
||||
- **AND** 可打包为 `.app` bundle
|
||||
|
||||
#### Scenario: Windows 构建
|
||||
- **WHEN** 执行 Windows 构建命令
|
||||
- **THEN** 生成 `nex-windows-amd64.exe` 可执行文件
|
||||
- **AND** 运行时不显示控制台窗口
|
||||
- **WHEN** 执行 `desktop-win` 构建命令
|
||||
- **THEN** 生成 `nex-win-amd64.exe` 可执行文件
|
||||
- **AND** 使用 `-H=windowsgui` linker flag 隐藏控制台窗口
|
||||
|
||||
#### Scenario: Linux 构建
|
||||
- **WHEN** 执行 Linux 构建命令
|
||||
- **WHEN** 执行 `desktop-linux` 构建命令
|
||||
- **THEN** 生成 `nex-linux-amd64` 可执行文件
|
||||
|
||||
### Requirement: macOS .app 打包
|
||||
@@ -116,8 +125,31 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
||||
|
||||
### Requirement: 关于对话框
|
||||
|
||||
系统 SHALL 提供关于对话框显示应用信息。
|
||||
系统 SHALL 提供关于对话框显示应用信息。在 Windows 上 SHALL 使用 `user32.dll` 的 `MessageBoxW` API 实现。
|
||||
|
||||
#### Scenario: 显示关于
|
||||
- **WHEN** 用户点击托盘菜单"关于"
|
||||
- **THEN** 显示对话框包含应用名称、项目链接
|
||||
- **AND** 在 Windows 上使用 `MessageBoxW` 原生对话框实现
|
||||
|
||||
### Requirement: Windows 原生对话框
|
||||
|
||||
系统 SHALL 在 Windows 上使用 `user32.dll` 的 `MessageBoxW` API 显示错误和关于对话框,替代 `msg *` 命令。
|
||||
|
||||
#### Scenario: 错误提示对话框
|
||||
- **WHEN** 应用在 Windows 上遇到启动错误(端口占用、配置加载失败等)
|
||||
- **THEN** 使用 `MessageBoxW` 显示模态对话框
|
||||
- **AND** 对话框标题栏显示应用名称
|
||||
- **AND** 对话框包含错误描述文本
|
||||
- **AND** 对话框显示错误图标(MB_ICONERROR)
|
||||
|
||||
#### Scenario: 关于对话框
|
||||
- **WHEN** 用户在 Windows 上点击托盘菜单"关于"
|
||||
- **THEN** 使用 `MessageBoxW` 显示模态对话框
|
||||
- **AND** 对话框标题栏显示"关于 Nex Gateway"
|
||||
- **AND** 对话框包含应用信息文本
|
||||
- **AND** 对话框显示信息图标(MB_ICONINFORMATION)
|
||||
|
||||
#### Scenario: 非 Windows 平台不受影响
|
||||
- **WHEN** 应用运行在 macOS 或 Linux 上
|
||||
- **THEN** 错误和关于对话框仍使用平台原有实现(osascript / zenity)
|
||||
|
||||
@@ -422,23 +422,30 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
||||
|
||||
### Requirement: 提供导航
|
||||
|
||||
前端 SHALL 使用 React Router v7 提供导航。
|
||||
前端 SHALL 使用 React Router v7 提供导航,并支持路由级懒加载。
|
||||
|
||||
#### Scenario: 路由配置
|
||||
|
||||
- **WHEN** 应用启动
|
||||
- **THEN** 前端 SHALL 使用 React Router v7 Library 模式(BrowserRouter)
|
||||
- **THEN** \`/providers\` 路径 SHALL 显示供应商管理页面
|
||||
- **THEN** \`/stats\` 路径 SHALL 显示用量统计页面
|
||||
- **THEN** \`/\` 路径 SHALL 重定向到 \`/providers\`
|
||||
- **THEN** `/providers` 路径 SHALL 显示供应商管理页面
|
||||
- **THEN** `/stats` 路径 SHALL 显示用量统计页面
|
||||
- **THEN** `/` 路径 SHALL 重定向到 `/providers`
|
||||
- **THEN** 不存在的路径 SHALL 显示 404 页面
|
||||
|
||||
#### Scenario: 路由级懒加载
|
||||
|
||||
- **WHEN** 用户访问某个路由
|
||||
- **THEN** 前端 SHALL 使用 `React.lazy()` 按需加载对应页面组件
|
||||
- **THEN** 页面组件加载期间 SHALL 显示 TDesign `Loading` 组件作为 fallback
|
||||
- **THEN** 所有页面组件 SHALL 通过动态 `import()` 导入
|
||||
|
||||
#### Scenario: 导航菜单
|
||||
|
||||
- **WHEN** 用户点击导航中的"供应商管理"
|
||||
- **THEN** 前端 SHALL 导航到 \`/providers\` 并高亮当前菜单项
|
||||
- **THEN** 前端 SHALL 导航到 `/providers` 并高亮当前菜单项
|
||||
- **WHEN** 用户点击导航中的"用量统计"
|
||||
- **THEN** 前端 SHALL 导航到 \`/stats\` 并高亮当前菜单项
|
||||
- **THEN** 前端 SHALL 导航到 `/stats` 并高亮当前菜单项
|
||||
|
||||
#### Scenario: URL 同步
|
||||
|
||||
|
||||
@@ -13,12 +13,12 @@ echo "打包 macOS .app..."
|
||||
mkdir -p "${BUILD_DIR}/${APP_NAME}.app/Contents/MacOS"
|
||||
mkdir -p "${BUILD_DIR}/${APP_NAME}.app/Contents/Resources"
|
||||
|
||||
if [ -f "${BUILD_DIR}/nex-darwin-arm64" ]; then
|
||||
cp "${BUILD_DIR}/nex-darwin-arm64" "${BUILD_DIR}/${APP_NAME}.app/Contents/MacOS/nex"
|
||||
elif [ -f "${BUILD_DIR}/nex-darwin-amd64" ]; then
|
||||
cp "${BUILD_DIR}/nex-darwin-amd64" "${BUILD_DIR}/${APP_NAME}.app/Contents/MacOS/nex"
|
||||
if [ -f "${BUILD_DIR}/nex-mac-arm64" ]; then
|
||||
cp "${BUILD_DIR}/nex-mac-arm64" "${BUILD_DIR}/${APP_NAME}.app/Contents/MacOS/nex"
|
||||
elif [ -f "${BUILD_DIR}/nex-mac-amd64" ]; then
|
||||
cp "${BUILD_DIR}/nex-mac-amd64" "${BUILD_DIR}/${APP_NAME}.app/Contents/MacOS/nex"
|
||||
else
|
||||
echo "错误: 未找到 macOS 二进制文件,请先运行 make desktop-darwin"
|
||||
echo "错误: 未找到 macOS 二进制文件,请先运行 make desktop-mac"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user