1
0

3 Commits

Author SHA1 Message Date
b3258e76df perf: 前端打包产物优化——路由级懒加载和 vendor 分包
- 使用 React.lazy() + Suspense 实现路由级代码分割
- 配置 manualChunks 将 react/tdesign/recharts 拆分为独立 vendor chunk
- 页面组件改为 export default 以支持动态导入
- 新增 bundle-optimization 规范,更新 frontend 导航规范
2026-04-23 00:26:54 +08:00
64dc66afa6 fix: Windows 桌面应用打包问题修复
- 删除通用 desktop target,重命名 platform targets 为简短形式 (desktop-mac/win/linux)
- 构建产物文件名统一为 nex-{os}-{arch}[.exe] 格式
- Windows 托盘图标使用 .ico 格式(运行时按平台选择)
- Windows 原生对话框使用 user32.MessageBoxW 替代 msg * 命令
- 更新 README.md 和 package-macos.sh 中的引用
- 添加单元测试覆盖 MessageBoxW 封装和图标选择逻辑
- 同步更新 desktop-app spec 规范文档
2026-04-22 23:20:39 +08:00
15f08ee2ca fix: 桌面应用跨平台编译和单实例锁
- 使用 gofrs/flock 替代 syscall.Flock 以支持 Windows
- 引入 SingletonLock 结构体,支持锁路径参数化(测试与生产隔离)
- 对齐服务初始化流程与 cmd/server(RoutingCache、StatsBuffer)
- 添加 gofrs/flock 依赖
- 重写单例测试,覆盖加锁/解锁/重复加锁场景
- 更新 desktop-app 规范,补充跨平台锁细节
- 新增 cross-platform-singleton 规范
2026-04-22 22:32:55 +08:00
24 changed files with 619 additions and 107 deletions

View File

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

View File

@@ -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.appWindows: nex.exeLinux: nex
- 双击启动应用macOS: Nex.appWindows: nex-win-amd64.exeLinux: nex-linux-amd64
- 系统托盘图标出现,浏览器自动打开管理界面
- 点击托盘图标显示菜单,可打开管理界面或退出
- 关闭浏览器后服务继续运行,可通过托盘重新打开

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 分支实现。macOSosascript和 Linuxzenity不变。
## Risks / Trade-offs
- **[syscall 跨架构]** `MessageBoxW``syscall.NewLazyDLL` 仅在 Windows 上有效 → 使用 `runtime.GOOS` 守卫,非 Windows 不会执行该路径,编译时通过 build 文件或运行时判断确保不触发
- **[ICO 嵌入体积]** `icon.ico` 270KB已在 `embedfs` 中,不增加新体积 → 无风险
- **[Makefile 兼容性]** 删除 `desktop` target 后CI/本地脚本如果引用它需更新 → 需检查是否有外部引用

View 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 图标格式要求

View File

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

View 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` 后缀、双击无控制台窗口、托盘图标正常显示、错误对话框使用原生样式

View 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 被打包为独立 chunkvendor-react
#### Scenario: TDesign chunk
- **WHEN** 执行生产构建
- **THEN** tdesign-react、tdesign-icons-react SHALL 被打包为独立 chunkvendor-tdesign
#### Scenario: Recharts chunk
- **WHEN** 执行生产构建
- **THEN** recharts SHALL 被打包为独立 chunkvendor-recharts
#### Scenario: chunk 告警阈值
- **WHEN** 执行生产构建
- **THEN** `build.chunkSizeWarningLimit` SHALL 设置为 700避免 vendor chunk 误触发告警
#### Scenario: chunk 命名
- **WHEN** 执行生产构建
- **THEN** vendor chunk 文件名 SHALL 包含 chunk 名称前缀(如 `vendor-react-[hash].js`

View 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** 两者互不阻塞

View File

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

View File

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

View File

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