1
0

feat: 新增启动参数设置页面,区分 desktop 可编辑与 server 只读

This commit is contained in:
2026-05-07 14:10:56 +08:00
parent c04a13bf8a
commit 4eeb14e844
19 changed files with 1589 additions and 32 deletions

2
.gitignore vendored
View File

@@ -409,6 +409,8 @@ skills-lock.json
.worktrees
!scripts/build/
backend/bin
backend/server
backend/desktop
# Embedfs generated
embedfs/assets/

View File

@@ -27,7 +27,7 @@ nex/
│ │ ├── api/ # API 层(统一请求封装 + 字段转换)
│ │ ├── hooks/ # TanStack Query hooks
│ │ ├── components/ # 通用组件AppLayout
│ │ ├── pages/ # 页面Providers, Stats
│ │ ├── pages/ # 页面Providers, Stats, Settings
│ │ ├── routes/ # React Router 路由配置
│ │ ├── types/ # TypeScript 类型定义
│ │ └── __tests__/ # 单元测试 + 组件测试
@@ -57,6 +57,7 @@ nex/
- **多供应商管理**:配置和管理多个供应商(供应商 ID 仅限字母、数字、下划线)
- **用量统计**:按供应商、模型、日期统计请求数量
- **Web 配置界面**:提供供应商和模型配置管理
- **启动参数设置**:通过 Web 界面查看和编辑启动参数Desktop 可编辑、Server 只读)
## 技术栈
@@ -239,6 +240,16 @@ server 和 desktop 发布产物自包含运行时数据库迁移资源(通过
查询参数支持:`provider_id``model_name``start_date``end_date``group_by`
#### 启动参数设置
- `GET /api/settings/startup` - 查询启动参数设置
- `PUT /api/settings/startup` - 保存启动参数设置(仅 Desktop 模式)
**行为差异**
- **Desktop 模式**:查询返回配置文件编辑视图(`~/.nex/config.yaml` + 默认值),允许保存到配置文件,保存后当前运行服务不受影响,需重启 Desktop 生效
- **Server 模式**:查询返回当前运行有效配置,保存请求始终返回 403
响应包含 `mode``editable``config_path``restart_required` 元数据和完整启动参数配置。Duration 字段使用字符串格式(如 `30s``1h`)。
#### 版本信息
- `GET /api/version` - 获取后端构建版本信息(`version``commit``build_time`),用于前端 About 页面诊断前后端版本一致性

View File

@@ -45,7 +45,7 @@ var (
func main() {
minimalLogger := pkgLogger.NewMinimal()
cfg, err := config.LoadDesktopConfig()
cfg, cfgMeta, err := config.LoadDesktopConfigWithMetadata()
if err != nil {
minimalLogger.Error("加载配置失败", zap.Error(err))
showError(appName, desktopConfigErrorMessage(getDesktopConfigPath(), err))
@@ -133,6 +133,7 @@ func main() {
modelHandler := handler.NewModelHandler(modelService)
statsHandler := handler.NewStatsHandler(statsService)
versionHandler := handler.NewVersionHandler()
settingsHandler := handler.NewSettingsHandler(cfg, "desktop", true, cfgMeta.ConfigPath)
gin.SetMode(gin.ReleaseMode)
r := gin.New()
@@ -142,7 +143,7 @@ func main() {
r.Use(middleware.Logging(zapLogger))
r.Use(middleware.CORS())
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler)
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler, settingsHandler)
setupStaticFiles(r)
server = &http.Server{
@@ -175,7 +176,7 @@ func main() {
setupSystray(port)
}
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler, versionHandler *handler.VersionHandler) {
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler, versionHandler *handler.VersionHandler, settingsHandler *handler.SettingsHandler) {
r.Any("/openai/*path", withProtocol("openai", proxyHandler.HandleProxy))
r.Any("/anthropic/*path", withProtocol("anthropic", proxyHandler.HandleProxy))
r.GET("/api/version", versionHandler.GetVersion)
@@ -204,6 +205,12 @@ func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHand
stats.GET("/aggregate", statsHandler.AggregateStats)
}
settings := r.Group("/api/settings")
{
settings.GET("/startup", settingsHandler.GetStartupSettings)
settings.PUT("/startup", settingsHandler.SaveStartupSettings)
}
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})

View File

@@ -16,7 +16,7 @@ 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())
setupRoutes(r, &handler.ProxyHandler{}, &handler.ProviderHandler{}, &handler.ModelHandler{}, &handler.StatsHandler{}, handler.NewVersionHandler(), handler.NewSettingsHandler(nil, "desktop", true, ""))
setupStaticFilesWithFS(r, fstest.MapFS{
"index.html": {Data: []byte("<html>fallback</html>")},
})

View File

@@ -29,7 +29,7 @@ import (
func main() {
minimalLogger := pkgLogger.NewMinimal()
cfg, err := config.LoadServerConfig()
cfg, cfgMeta, err := config.LoadServerConfigWithMetadata()
if err != nil {
minimalLogger.Fatal("加载配置失败", zap.Error(err))
}
@@ -94,6 +94,7 @@ func main() {
modelHandler := handler.NewModelHandler(modelService)
statsHandler := handler.NewStatsHandler(statsService)
versionHandler := handler.NewVersionHandler()
settingsHandler := handler.NewSettingsHandler(cfg, "server", false, cfgMeta.ConfigPath)
gin.SetMode(gin.ReleaseMode)
r := gin.New()
@@ -103,7 +104,7 @@ func main() {
r.Use(middleware.Logging(zapLogger))
r.Use(middleware.CORS())
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler)
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler, settingsHandler)
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
@@ -141,7 +142,7 @@ func main() {
zapLogger.Info("服务器已关闭")
}
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler, versionHandler *handler.VersionHandler) {
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler, versionHandler *handler.VersionHandler, settingsHandler *handler.SettingsHandler) {
r.Any("/:protocol/*path", proxyHandler.HandleProxy)
r.GET("/api/version", versionHandler.GetVersion)
@@ -169,6 +170,12 @@ func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHand
stats.GET("/aggregate", statsHandler.AggregateStats)
}
settings := r.Group("/api/settings")
{
settings.GET("/startup", settingsHandler.GetStartupSettings)
settings.PUT("/startup", settingsHandler.SaveStartupSettings)
}
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})

View File

@@ -15,7 +15,7 @@ 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())
setupRoutes(r, &handler.ProxyHandler{}, &handler.ProviderHandler{}, &handler.ModelHandler{}, &handler.StatsHandler{}, handler.NewVersionHandler(), handler.NewSettingsHandler(nil, "server", false, ""))
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
w := httptest.NewRecorder()

View File

@@ -36,7 +36,7 @@ type DatabaseConfig struct {
Driver string `yaml:"driver" mapstructure:"driver" validate:"required,oneof=sqlite mysql"`
Path string `yaml:"path" mapstructure:"path" validate:"required_if=Driver sqlite"`
Host string `yaml:"host" mapstructure:"host" validate:"required_if=Driver mysql"`
Port int `yaml:"port" mapstructure:"port" validate:"required_if=Driver mysql,min=1,max=65535"`
Port int `yaml:"port" mapstructure:"port" validate:"required_if=Driver mysql,omitempty,min=1,max=65535"`
User string `yaml:"user" mapstructure:"user" validate:"required_if=Driver mysql"`
Password string `yaml:"password" mapstructure:"password"`
DBName string `yaml:"dbname" mapstructure:"dbname" validate:"required_if=Driver mysql"`
@@ -233,7 +233,10 @@ func setupConfigFile(v *viper.Viper, configPath string) error {
return nil
}
// loadOptions 控制配置加载器行为
type ConfigMetadata struct {
ConfigPath string
}
type loadOptions struct {
configPathOverride string
useCLI bool
@@ -270,15 +273,19 @@ func resolveConfigPath(v *viper.Viper, opts loadOptions) (string, error) {
return configPath, nil
}
// loadConfig 共享配置加载逻辑,通过 loadOptions 控制是否启用 CLI、环境变量和 --config 覆盖
func loadConfig(opts loadOptions) (*Config, error) {
cfg, _, err := loadConfigWithMetadata(opts)
return cfg, err
}
func loadConfigWithMetadata(opts loadOptions) (*Config, ConfigMetadata, error) {
v := viper.New()
setupDefaults(v)
configPath, err := resolveConfigPath(v, opts)
if err != nil {
return nil, err
return nil, ConfigMetadata{}, err
}
if opts.useEnv {
@@ -286,7 +293,7 @@ func loadConfig(opts loadOptions) (*Config, error) {
}
if err := setupConfigFile(v, configPath); err != nil {
return nil, err
return nil, ConfigMetadata{}, err
}
cfg := &Config{}
@@ -294,23 +301,28 @@ func loadConfig(opts loadOptions) (*Config, error) {
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
))); err != nil {
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
return nil, ConfigMetadata{}, appErrors.Wrap(appErrors.ErrInternal, err)
}
if err := cfg.Validate(); err != nil {
return nil, err
return nil, ConfigMetadata{}, err
}
return cfg, nil
return cfg, ConfigMetadata{ConfigPath: configPath}, nil
}
// LoadServerConfig 为 server 入口加载配置,支持 CLI 参数、环境变量和 --config
func LoadServerConfig() (*Config, error) {
cfg, _, err := LoadServerConfigWithMetadata()
return cfg, err
}
func LoadServerConfigWithMetadata() (*Config, ConfigMetadata, error) {
configPath, err := GetConfigPath()
if err != nil {
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
return nil, ConfigMetadata{}, appErrors.Wrap(appErrors.ErrInternal, err)
}
return loadConfig(loadOptions{
return loadConfigWithMetadata(loadOptions{
configPathOverride: configPath,
useCLI: true,
useEnv: true,
@@ -320,11 +332,16 @@ func LoadServerConfig() (*Config, error) {
// LoadDesktopConfig 为 desktop 入口加载配置,固定使用默认配置文件,不支持 CLI、环境变量和 --config
func LoadDesktopConfig() (*Config, error) {
cfg, _, err := LoadDesktopConfigWithMetadata()
return cfg, err
}
func LoadDesktopConfigWithMetadata() (*Config, ConfigMetadata, error) {
configPath, err := GetConfigPath()
if err != nil {
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
return nil, ConfigMetadata{}, appErrors.Wrap(appErrors.ErrInternal, err)
}
return loadConfig(loadOptions{
return loadConfigWithMetadata(loadOptions{
configPathOverride: configPath,
useCLI: false,
useEnv: false,
@@ -365,13 +382,15 @@ func SaveConfig(cfg *Config) error {
if err != nil {
return appErrors.Wrap(appErrors.ErrInternal, err)
}
return SaveConfigToPath(cfg, configPath)
}
func SaveConfigToPath(cfg *Config, configPath string) error {
data, err := yaml.Marshal(cfg)
if err != nil {
return appErrors.Wrap(appErrors.ErrInternal, err)
}
// Ensure directory exists
dir := filepath.Dir(configPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return appErrors.Wrap(appErrors.ErrInternal, err)

View File

@@ -0,0 +1,94 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func TestLoadDesktopConfigAtPath_WithMetadata(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
cfg := DefaultConfig()
cfg.Server.Port = 8888
data, err := yaml.Marshal(cfg)
require.NoError(t, err)
require.NoError(t, os.WriteFile(configPath, data, 0o600))
loaded, meta, err := loadConfigWithMetadata(loadOptions{
configPathOverride: configPath,
useCLI: false,
useEnv: false,
useConfigFlag: false,
})
require.NoError(t, err)
assert.Equal(t, 8888, loaded.Server.Port)
assert.Equal(t, configPath, meta.ConfigPath)
}
func TestSaveConfigToPath(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "sub", "config.yaml")
cfg := DefaultConfig()
cfg.Server.Port = 7777
err := SaveConfigToPath(cfg, configPath)
require.NoError(t, err)
data, err := os.ReadFile(configPath)
require.NoError(t, err)
assert.Contains(t, string(data), "7777")
}
func TestSaveConfigToPath_InvalidDir(t *testing.T) {
cfg := DefaultConfig()
err := SaveConfigToPath(cfg, "/dev/null/impossible/config.yaml")
assert.Error(t, err)
}
func TestDurationConversion(t *testing.T) {
cfg := DefaultConfig()
dto := configToDTO(cfg)
parsed, err := time.ParseDuration(dto.Server.ReadTimeout)
require.NoError(t, err)
assert.Equal(t, cfg.Server.ReadTimeout, parsed)
parsed, err = time.ParseDuration(dto.Database.ConnMaxLifetime)
require.NoError(t, err)
assert.Equal(t, cfg.Database.ConnMaxLifetime, parsed)
}
func configToDTO(c *Config) struct {
Server struct {
Port int `json:"port"`
ReadTimeout string `json:"read_timeout"`
WriteTimeout string `json:"write_timeout"`
}
Database struct {
ConnMaxLifetime string `json:"conn_max_lifetime"`
}
} {
var result struct {
Server struct {
Port int `json:"port"`
ReadTimeout string `json:"read_timeout"`
WriteTimeout string `json:"write_timeout"`
}
Database struct {
ConnMaxLifetime string `json:"conn_max_lifetime"`
}
}
result.Server.Port = c.Server.Port
result.Server.ReadTimeout = c.Server.ReadTimeout.String()
result.Server.WriteTimeout = c.Server.WriteTimeout.String()
result.Database.ConnMaxLifetime = c.Database.ConnMaxLifetime.String()
return result
}

View File

@@ -0,0 +1,223 @@
package handler
import (
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"nex/backend/internal/config"
appErrors "nex/backend/pkg/errors"
)
type SettingsHandler struct {
runtimeCfg *config.Config
mode string
editable bool
configPath string
}
func NewSettingsHandler(runtimeCfg *config.Config, mode string, editable bool, configPath string) *SettingsHandler {
return &SettingsHandler{
runtimeCfg: runtimeCfg,
mode: mode,
editable: editable,
configPath: configPath,
}
}
type serverConfigDTO struct {
Port int `json:"port"`
ReadTimeout string `json:"read_timeout"`
WriteTimeout string `json:"write_timeout"`
}
type databaseConfigDTO struct {
Driver string `json:"driver"`
Path string `json:"path"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
DBName string `json:"dbname"`
MaxIdleConns int `json:"max_idle_conns"`
MaxOpenConns int `json:"max_open_conns"`
ConnMaxLifetime string `json:"conn_max_lifetime"`
}
type logConfigDTO struct {
Level string `json:"level"`
Path string `json:"path"`
MaxSize int `json:"max_size"`
MaxBackups int `json:"max_backups"`
MaxAge int `json:"max_age"`
Compress bool `json:"compress"`
}
type startupSettingsDTO struct {
Server serverConfigDTO `json:"server"`
Database databaseConfigDTO `json:"database"`
Log logConfigDTO `json:"log"`
}
type startupSettingsResponse struct {
Mode string `json:"mode"`
Editable bool `json:"editable"`
ConfigPath string `json:"config_path"`
RestartRequired bool `json:"restart_required"`
Config startupSettingsDTO `json:"config"`
}
func configToDTO(cfg *config.Config) startupSettingsDTO {
return startupSettingsDTO{
Server: serverConfigDTO{
Port: cfg.Server.Port,
ReadTimeout: cfg.Server.ReadTimeout.String(),
WriteTimeout: cfg.Server.WriteTimeout.String(),
},
Database: databaseConfigDTO{
Driver: cfg.Database.Driver,
Path: cfg.Database.Path,
Host: cfg.Database.Host,
Port: cfg.Database.Port,
User: cfg.Database.User,
Password: cfg.Database.Password,
DBName: cfg.Database.DBName,
MaxIdleConns: cfg.Database.MaxIdleConns,
MaxOpenConns: cfg.Database.MaxOpenConns,
ConnMaxLifetime: cfg.Database.ConnMaxLifetime.String(),
},
Log: logConfigDTO{
Level: cfg.Log.Level,
Path: cfg.Log.Path,
MaxSize: cfg.Log.MaxSize,
MaxBackups: cfg.Log.MaxBackups,
MaxAge: cfg.Log.MaxAge,
Compress: cfg.Log.Compress,
},
}
}
func dtoToConfig(dto startupSettingsDTO) (*config.Config, error) {
readTimeout, err := time.ParseDuration(dto.Server.ReadTimeout)
if err != nil {
return nil, appErrors.WithMessage(appErrors.ErrInvalidRequest, "read_timeout 格式错误,例如 30s")
}
writeTimeout, err := time.ParseDuration(dto.Server.WriteTimeout)
if err != nil {
return nil, appErrors.WithMessage(appErrors.ErrInvalidRequest, "write_timeout 格式错误,例如 30s")
}
connMaxLifetime, err := time.ParseDuration(dto.Database.ConnMaxLifetime)
if err != nil {
return nil, appErrors.WithMessage(appErrors.ErrInvalidRequest, "conn_max_lifetime 格式错误,例如 1h")
}
return &config.Config{
Server: config.ServerConfig{
Port: dto.Server.Port,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
},
Database: config.DatabaseConfig{
Driver: dto.Database.Driver,
Path: dto.Database.Path,
Host: dto.Database.Host,
Port: dto.Database.Port,
User: dto.Database.User,
Password: dto.Database.Password,
DBName: dto.Database.DBName,
MaxIdleConns: dto.Database.MaxIdleConns,
MaxOpenConns: dto.Database.MaxOpenConns,
ConnMaxLifetime: connMaxLifetime,
},
Log: config.LogConfig{
Level: dto.Log.Level,
Path: dto.Log.Path,
MaxSize: dto.Log.MaxSize,
MaxBackups: dto.Log.MaxBackups,
MaxAge: dto.Log.MaxAge,
Compress: dto.Log.Compress,
},
}, nil
}
func (h *SettingsHandler) GetStartupSettings(c *gin.Context) {
var cfg *config.Config
var configPath string
if h.mode == "desktop" {
desktopCfg, err := config.LoadDesktopConfigAtPath(h.configPath)
if err != nil {
writeError(c, err)
return
}
cfg = desktopCfg
configPath = h.configPath
} else {
cfg = h.runtimeCfg
configPath = h.configPath
}
c.JSON(http.StatusOK, startupSettingsResponse{
Mode: h.mode,
Editable: h.editable,
ConfigPath: configPath,
RestartRequired: h.editable,
Config: configToDTO(cfg),
})
}
func (h *SettingsHandler) SaveStartupSettings(c *gin.Context) {
if !h.editable {
c.JSON(http.StatusForbidden, gin.H{
"error": "server 模式下不允许保存启动参数",
"code": "forbidden",
})
return
}
var req struct {
Config startupSettingsDTO `json:"config"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "无效的请求格式",
})
return
}
cfg, err := dtoToConfig(req.Config)
if err != nil {
writeError(c, err)
return
}
if err := cfg.Validate(); err != nil {
writeError(c, err)
return
}
if err := config.SaveConfigToPath(cfg, h.configPath); err != nil {
if errors.Is(err, appErrors.ErrInvalidRequest) {
writeError(c, err)
return
}
writeError(c, appErrors.Wrap(appErrors.ErrInternal, err))
return
}
savedCfg, err := config.LoadDesktopConfigAtPath(h.configPath)
if err != nil {
writeError(c, appErrors.Wrap(appErrors.ErrInternal, err))
return
}
c.JSON(http.StatusOK, startupSettingsResponse{
Mode: h.mode,
Editable: h.editable,
ConfigPath: h.configPath,
RestartRequired: true,
Config: configToDTO(savedCfg),
})
}

View File

@@ -0,0 +1,418 @@
package handler
import (
"bytes"
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
"nex/backend/internal/config"
)
func init() {
gin.SetMode(gin.TestMode)
}
func createTestConfig(t *testing.T) (*config.Config, string) {
t.Helper()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
cfg := config.DefaultConfig()
cfg.Database.Path = filepath.Join(dir, "test.db")
cfg.Log.Path = filepath.Join(dir, "log")
data, err := yaml.Marshal(cfg)
require.NoError(t, err)
require.NoError(t, os.WriteFile(configPath, data, 0o600))
return cfg, configPath
}
func TestSettingsHandler_GetStartupSettings_Desktop(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "desktop", true, configPath)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/settings/startup", nil)
h.GetStartupSettings(c)
assert.Equal(t, 200, w.Code)
var resp startupSettingsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "desktop", resp.Mode)
assert.True(t, resp.Editable)
assert.True(t, resp.RestartRequired)
assert.Equal(t, configPath, resp.ConfigPath)
assert.Equal(t, cfg.Server.Port, resp.Config.Server.Port)
assert.Equal(t, "30s", resp.Config.Server.ReadTimeout)
assert.Equal(t, cfg.Database.Driver, resp.Config.Database.Driver)
}
func TestSettingsHandler_GetStartupSettings_Server(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "server", false, configPath)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/settings/startup", nil)
h.GetStartupSettings(c)
assert.Equal(t, 200, w.Code)
var resp startupSettingsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "server", resp.Mode)
assert.False(t, resp.Editable)
assert.False(t, resp.RestartRequired)
assert.Equal(t, configPath, resp.ConfigPath)
}
func TestSettingsHandler_SaveStartupSettings_Desktop(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "desktop", true, configPath)
newPort := 9999
body, _ := json.Marshal(map[string]interface{}{
"config": map[string]interface{}{
"server": map[string]interface{}{
"port": newPort,
"read_timeout": "30s",
"write_timeout": "30s",
},
"database": map[string]interface{}{
"driver": "sqlite",
"path": filepath.Join(t.TempDir(), "new.db"),
"port": 3306,
"dbname": "nex",
"max_idle_conns": 10,
"max_open_conns": 100,
"conn_max_lifetime": "1h",
},
"log": map[string]interface{}{
"level": "info",
"path": filepath.Join(t.TempDir(), "log"),
"max_size": 100,
"max_backups": 10,
"max_age": 30,
"compress": true,
},
},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.SaveStartupSettings(c)
assert.Equal(t, 200, w.Code)
var resp startupSettingsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, newPort, resp.Config.Server.Port)
assert.True(t, resp.Editable)
assert.True(t, resp.RestartRequired)
savedCfg, err := config.LoadDesktopConfigAtPath(configPath)
require.NoError(t, err)
assert.Equal(t, newPort, savedCfg.Server.Port)
}
func TestSettingsHandler_SaveStartupSettings_Server_Forbidden(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "server", false, configPath)
body, _ := json.Marshal(map[string]interface{}{
"config": map[string]interface{}{
"server": map[string]interface{}{
"port": 9999,
"read_timeout": "30s",
"write_timeout": "30s",
},
"database": map[string]interface{}{
"driver": "sqlite",
"path": "/tmp/test.db",
"port": 3306,
"dbname": "nex",
"max_idle_conns": 10,
"max_open_conns": 100,
"conn_max_lifetime": "1h",
},
"log": map[string]interface{}{
"level": "info",
"path": "/tmp/log",
"max_size": 100,
"max_backups": 10,
"max_age": 30,
"compress": true,
},
},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.SaveStartupSettings(c)
assert.Equal(t, 403, w.Code)
assert.Contains(t, w.Body.String(), "不允许保存")
}
func TestSettingsHandler_SaveStartupSettings_Desktop_InvalidConfig(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "desktop", true, configPath)
originalData, err := os.ReadFile(configPath)
require.NoError(t, err)
body, _ := json.Marshal(map[string]interface{}{
"config": map[string]interface{}{
"server": map[string]interface{}{
"port": 0,
"read_timeout": "30s",
"write_timeout": "30s",
},
"database": map[string]interface{}{
"driver": "sqlite",
"path": "/tmp/test.db",
"port": 3306,
"dbname": "nex",
"max_idle_conns": 10,
"max_open_conns": 100,
"conn_max_lifetime": "1h",
},
"log": map[string]interface{}{
"level": "info",
"path": "/tmp/log",
"max_size": 100,
"max_backups": 10,
"max_age": 30,
"compress": true,
},
},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.SaveStartupSettings(c)
assert.Equal(t, 400, w.Code)
currentData, err := os.ReadFile(configPath)
require.NoError(t, err)
assert.Equal(t, originalData, currentData)
}
func TestSettingsHandler_SaveStartupSettings_Desktop_InvalidDuration(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "desktop", true, configPath)
body, _ := json.Marshal(map[string]interface{}{
"config": map[string]interface{}{
"server": map[string]interface{}{
"port": 9826,
"read_timeout": "not-a-duration",
"write_timeout": "30s",
},
"database": map[string]interface{}{
"driver": "sqlite",
"path": "/tmp/test.db",
"port": 3306,
"dbname": "nex",
"max_idle_conns": 10,
"max_open_conns": 100,
"conn_max_lifetime": "1h",
},
"log": map[string]interface{}{
"level": "info",
"path": "/tmp/log",
"max_size": 100,
"max_backups": 10,
"max_age": 30,
"compress": true,
},
},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.SaveStartupSettings(c)
assert.Equal(t, 400, w.Code)
assert.Contains(t, w.Body.String(), "read_timeout")
}
func TestSettingsHandler_SaveStartupSettings_Desktop_CreatesConfigFile(t *testing.T) {
cfg := config.DefaultConfig()
dir := t.TempDir()
configPath := filepath.Join(dir, "nex", "config.yaml")
_, err := os.Stat(configPath)
assert.True(t, os.IsNotExist(err))
h := NewSettingsHandler(cfg, "desktop", true, configPath)
body, _ := json.Marshal(map[string]interface{}{
"config": map[string]interface{}{
"server": map[string]interface{}{
"port": 9826,
"read_timeout": "30s",
"write_timeout": "30s",
},
"database": map[string]interface{}{
"driver": "sqlite",
"path": filepath.Join(dir, "test.db"),
"port": 3306,
"dbname": "nex",
"max_idle_conns": 10,
"max_open_conns": 100,
"conn_max_lifetime": "1h",
},
"log": map[string]interface{}{
"level": "info",
"path": filepath.Join(dir, "log"),
"max_size": 100,
"max_backups": 10,
"max_age": 30,
"compress": true,
},
},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.SaveStartupSettings(c)
assert.Equal(t, 200, w.Code)
_, err = os.Stat(configPath)
assert.NoError(t, err)
}
func TestSettingsHandler_SaveStartupSettings_Desktop_PasswordIncluded(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "desktop", true, configPath)
body, _ := json.Marshal(map[string]interface{}{
"config": map[string]interface{}{
"server": map[string]interface{}{
"port": 9826,
"read_timeout": "30s",
"write_timeout": "30s",
},
"database": map[string]interface{}{
"driver": "mysql",
"host": "localhost",
"port": 3306,
"user": "root",
"password": "secret123",
"dbname": "nex",
"path": "",
"max_idle_conns": 10,
"max_open_conns": 100,
"conn_max_lifetime": "1h",
},
"log": map[string]interface{}{
"level": "info",
"path": filepath.Join(t.TempDir(), "log"),
"max_size": 100,
"max_backups": 10,
"max_age": 30,
"compress": true,
},
},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.SaveStartupSettings(c)
assert.Equal(t, 200, w.Code)
var resp startupSettingsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "secret123", resp.Config.Database.Password)
}
func TestSettingsHandler_GetStartupSettings_DesktopReadsConfigFile(t *testing.T) {
cfg, configPath := createTestConfig(t)
savedCfg := config.DefaultConfig()
savedCfg.Server.Port = 5555
data, err := yaml.Marshal(savedCfg)
require.NoError(t, err)
require.NoError(t, os.WriteFile(configPath, data, 0o600))
h := NewSettingsHandler(cfg, "desktop", true, configPath)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/settings/startup", nil)
h.GetStartupSettings(c)
assert.Equal(t, 200, w.Code)
var resp startupSettingsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, 5555, resp.Config.Server.Port)
}
func TestSettingsHandler_GetStartupSettings_ServerReturnsRuntime(t *testing.T) {
cfg, configPath := createTestConfig(t)
cfg.Server.Port = 7777
h := NewSettingsHandler(cfg, "server", false, configPath)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/settings/startup", nil)
h.GetStartupSettings(c)
assert.Equal(t, 200, w.Code)
var resp startupSettingsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, 7777, resp.Config.Server.Port)
}
func TestSettingsHandler_SaveStartupSettings_InvalidJSON(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "desktop", true, configPath)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader([]byte("{invalid")))
c.Request.Header.Set("Content-Type", "application/json")
h.SaveStartupSettings(c)
assert.Equal(t, 400, w.Code)
}

View File

@@ -0,0 +1,106 @@
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 { MessagePlugin } from 'tdesign-react'
import { useStartupSettings, useSaveStartupSettings } from '@/hooks/useSettings'
import type { StartupSettings } from '@/types'
vi.mock('tdesign-react', () => ({
MessagePlugin: {
success: vi.fn(),
error: vi.fn(),
},
}))
const mockDesktopSettings: StartupSettings = {
mode: 'desktop',
editable: true,
configPath: '/home/user/.nex/config.yaml',
restartRequired: true,
config: {
server: { port: 9826, readTimeout: '30s', writeTimeout: '30s' },
database: {
driver: 'sqlite',
path: '/home/user/.nex/config.db',
host: '',
port: 3306,
user: '',
password: '',
dbname: 'nex',
maxIdleConns: 10,
maxOpenConns: 100,
connMaxLifetime: '1h',
},
log: { level: 'info', path: '/home/user/.nex/log', maxSize: 100, maxBackups: 10, maxAge: 30, compress: true },
},
}
const handlers = [
http.get('/api/settings/startup', () => {
return HttpResponse.json(mockDesktopSettings)
}),
http.put('/api/settings/startup', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>
return HttpResponse.json({
...mockDesktopSettings,
config: (body as Record<string, unknown>).config,
})
}),
]
const server = setupServer(...handlers)
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
describe('useStartupSettings', () => {
it('fetches startup settings', async () => {
const { result } = renderHook(() => useStartupSettings(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data?.mode).toBe('desktop')
expect(result.current.data?.editable).toBe(true)
expect(result.current.data?.configPath).toBe('/home/user/.nex/config.yaml')
expect(result.current.data?.restartRequired).toBe(true)
expect(result.current.data?.config.server.port).toBe(9826)
expect(result.current.data?.config.database.driver).toBe('sqlite')
expect(result.current.data?.config.log.level).toBe('info')
})
})
describe('useSaveStartupSettings', () => {
it('saves settings and shows success message for desktop', async () => {
const { result } = renderHook(() => useSaveStartupSettings(), { wrapper: createWrapper() })
result.current.mutate({ config: mockDesktopSettings.config })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(MessagePlugin.success).toHaveBeenCalledWith(
'配置已保存到配置文件。当前运行中的服务仍使用启动时配置,重启 Desktop 后生效'
)
})
it('shows error message on failure', async () => {
server.use(
http.put('/api/settings/startup', () => {
return HttpResponse.json({ error: '保存失败' }, { status: 500 })
})
)
const { result } = renderHook(() => useSaveStartupSettings(), { wrapper: createWrapper() })
result.current.mutate({ config: mockDesktopSettings.config })
await waitFor(() => expect(result.current.isError).toBe(true))
expect(MessagePlugin.error).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,169 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import React from 'react'
import { MemoryRouter } from 'react-router'
import { MessagePlugin } from 'tdesign-react'
import SettingsPage from '@/pages/Settings'
import type { StartupSettings } from '@/types'
vi.mock('tdesign-react', async (importOriginal) => {
const actual = await importOriginal<typeof import('tdesign-react')>()
return {
...actual,
MessagePlugin: {
success: vi.fn(),
error: vi.fn(),
},
}
})
const mockDesktopSettings: StartupSettings = {
mode: 'desktop',
editable: true,
configPath: '/home/user/.nex/config.yaml',
restartRequired: true,
config: {
server: {
port: 9826,
readTimeout: '30s',
writeTimeout: '30s',
},
database: {
driver: 'sqlite',
path: '/home/user/.nex/config.db',
host: '',
port: 3306,
user: '',
password: '',
dbname: 'nex',
maxIdleConns: 10,
maxOpenConns: 100,
connMaxLifetime: '1h',
},
log: {
level: 'info',
path: '/home/user/.nex/log',
maxSize: 100,
maxBackups: 10,
maxAge: 30,
compress: true,
},
},
}
const mockServerSettings: StartupSettings = {
...mockDesktopSettings,
mode: 'server',
editable: false,
restartRequired: false,
configPath: '/etc/nex/config.yaml',
}
const desktopHandlers = [
http.get('/api/settings/startup', () => HttpResponse.json(mockDesktopSettings)),
http.put('/api/settings/startup', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>
return HttpResponse.json({ ...mockDesktopSettings, config: (body as Record<string, unknown>).config })
}),
]
const serverHandlers = [http.get('/api/settings/startup', () => HttpResponse.json(mockServerSettings))]
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return ({ children }: { children: React.ReactNode }) => (
<MemoryRouter>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</MemoryRouter>
)
}
describe('SettingsPage', () => {
it('renders startup settings card', async () => {
const mswServer = setupServer(...desktopHandlers)
mswServer.listen({ onUnhandledRequest: 'bypass' })
render(<SettingsPage />, { wrapper: createWrapper() })
expect(await screen.findByText('服务配置')).toBeInTheDocument()
expect(screen.getByText('启动参数设置')).toBeInTheDocument()
mswServer.close()
})
})
describe('StartupSettingsCard - Desktop mode', () => {
const mswServer = setupServer(...desktopHandlers)
beforeAll(() => mswServer.listen({ onUnhandledRequest: 'bypass' }))
afterEach(() => mswServer.resetHandlers())
afterAll(() => mswServer.close())
it('shows editable form with save button in desktop mode', async () => {
render(<SettingsPage />, { wrapper: createWrapper() })
expect(
await screen.findByText('Desktop 模式下此页面编辑的是启动配置文件,保存后重启 Desktop 生效')
).toBeInTheDocument()
expect(screen.getByRole('button', { name: '保存' })).toBeInTheDocument()
})
it('shows form fields for server, database, and log', async () => {
render(<SettingsPage />, { wrapper: createWrapper() })
expect(await screen.findByText('服务配置')).toBeInTheDocument()
expect(screen.getByText('数据库配置')).toBeInTheDocument()
expect(screen.getByText('日志配置')).toBeInTheDocument()
})
it('shows success message on save', async () => {
render(<SettingsPage />, { wrapper: createWrapper() })
expect(await screen.findByText('服务配置')).toBeInTheDocument()
const saveButton = screen.getByRole('button', { name: '保存' })
await userEvent.click(saveButton)
await waitFor(() => {
expect(MessagePlugin.success).toHaveBeenCalledWith(
'配置已保存到配置文件。当前运行中的服务仍使用启动时配置,重启 Desktop 后生效'
)
})
})
})
describe('StartupSettingsCard - Server mode', () => {
const mswServer = setupServer(...serverHandlers)
beforeAll(() => mswServer.listen({ onUnhandledRequest: 'bypass' }))
afterEach(() => mswServer.resetHandlers())
afterAll(() => mswServer.close())
it('shows read-only form with server-only warning', async () => {
render(<SettingsPage />, { wrapper: createWrapper() })
expect(await screen.findByText('Server 模式下启动参数仅支持查看,不支持从前端编辑')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '保存' })).not.toBeInTheDocument()
})
})
function waitFor(fn: () => void, opts?: { timeout?: number }) {
return new Promise<void>((resolve, reject) => {
const start = Date.now()
const interval = setInterval(() => {
try {
fn()
clearInterval(interval)
resolve()
} catch {
if (Date.now() - start > (opts?.timeout ?? 3000)) {
clearInterval(interval)
reject(new Error('waitFor timeout'))
}
}
}, 50)
})
}

View File

@@ -0,0 +1,10 @@
import type { StartupSettings, SaveStartupSettingsInput } from '@/types'
import { request } from './client'
export async function getStartupSettings(): Promise<StartupSettings> {
return request<StartupSettings>('GET', '/api/settings/startup')
}
export async function saveStartupSettings(input: SaveStartupSettingsInput): Promise<StartupSettings> {
return request<StartupSettings>('PUT', '/api/settings/startup', input)
}

View File

@@ -0,0 +1,33 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { MessagePlugin } from 'tdesign-react'
import * as api from '@/api/settings'
import type { SaveStartupSettingsInput, ApiError } from '@/types'
export const settingsKeys = {
startup: ['settings', 'startup'] as const,
}
export function useStartupSettings() {
return useQuery({
queryKey: settingsKeys.startup,
queryFn: api.getStartupSettings,
staleTime: 0,
})
}
export function useSaveStartupSettings() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (input: SaveStartupSettingsInput) => api.saveStartupSettings(input),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: settingsKeys.startup })
if (data.mode === 'desktop') {
MessagePlugin.success('配置已保存到配置文件。当前运行中的服务仍使用启动时配置,重启 Desktop 后生效')
}
},
onError: (error: ApiError) => {
MessagePlugin.error(error.message)
},
})
}

View File

@@ -0,0 +1,275 @@
import { useEffect, useState } from 'react'
import { Card, Form, Input, InputNumber, Select, Switch, Button, Alert, Divider, Space, Loading } from 'tdesign-react'
import { useStartupSettings, useSaveStartupSettings } from '@/hooks/useSettings'
import type { StartupConfig } from '@/types'
import type { SubmitContext } from 'tdesign-react/es/form/type'
const DURATION_PLACEHOLDERS = {
readTimeout: '例如 30s',
writeTimeout: '例如 30s',
connMaxLifetime: '例如 1h',
}
function flattenConfig(c: StartupConfig): Record<string, unknown> {
return {
'server.port': c.server.port,
'server.readTimeout': c.server.readTimeout,
'server.writeTimeout': c.server.writeTimeout,
'database.driver': c.database.driver,
'database.path': c.database.path,
'database.host': c.database.host,
'database.port': c.database.port,
'database.user': c.database.user,
'database.password': c.database.password,
'database.dbname': c.database.dbname,
'database.maxIdleConns': c.database.maxIdleConns,
'database.maxOpenConns': c.database.maxOpenConns,
'database.connMaxLifetime': c.database.connMaxLifetime,
'log.level': c.log.level,
'log.path': c.log.path,
'log.maxSize': c.log.maxSize,
'log.maxBackups': c.log.maxBackups,
'log.maxAge': c.log.maxAge,
'log.compress': c.log.compress,
}
}
export function StartupSettingsCard() {
const { data: settings, isLoading, isError } = useStartupSettings()
const saveMutation = useSaveStartupSettings()
const [form] = Form.useForm()
const isDesktop = settings?.mode === 'desktop'
const editable = settings?.editable ?? false
const [driver, setDriver] = useState<string>(settings?.config.database.driver ?? 'sqlite')
useEffect(() => {
if (settings?.config && form) {
form.setFieldsValue(flattenConfig(settings.config))
}
}, [form, settings?.config])
const handleDriverChange = (changedValues: Record<string, unknown>) => {
if ('database.driver' in changedValues) {
setDriver(changedValues['database.driver'] as string)
}
}
const isSqlite = driver === 'sqlite'
const isMysql = driver === 'mysql'
const handleSubmit = (context: SubmitContext) => {
if (context.validateResult !== true || !form) return
const values = form.getFieldsValue(true) as Record<string, unknown>
const config: StartupConfig = {
server: {
port: values['server.port'] as number,
readTimeout: values['server.readTimeout'] as string,
writeTimeout: values['server.writeTimeout'] as string,
},
database: {
driver: values['database.driver'] as string,
path: values['database.path'] as string,
host: values['database.host'] as string,
port: values['database.port'] as number,
user: values['database.user'] as string,
password: values['database.password'] as string,
dbname: values['database.dbname'] as string,
maxIdleConns: values['database.maxIdleConns'] as number,
maxOpenConns: values['database.maxOpenConns'] as number,
connMaxLifetime: values['database.connMaxLifetime'] as string,
},
log: {
level: values['log.level'] as string,
path: values['log.path'] as string,
maxSize: values['log.maxSize'] as number,
maxBackups: values['log.maxBackups'] as number,
maxAge: values['log.maxAge'] as number,
compress: values['log.compress'] as boolean,
},
}
saveMutation.mutate({ config })
}
if (isLoading) {
return (
<Card title='启动参数设置'>
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Loading text='加载中...' />
</div>
</Card>
)
}
if (isError || !settings) {
return (
<Card title='启动参数设置'>
<Alert theme='error' message='加载启动参数失败,请刷新页面重试' />
</Card>
)
}
return (
<Card title='启动参数设置'>
{isDesktop && (
<Alert
theme='info'
message='Desktop 模式下此页面编辑的是启动配置文件,保存后重启 Desktop 生效'
style={{ marginBottom: 16 }}
/>
)}
{!editable && (
<Alert
theme='warning'
message='Server 模式下启动参数仅支持查看,不支持从前端编辑'
style={{ marginBottom: 16 }}
/>
)}
<Form
form={form}
layout='vertical'
labelWidth={140}
initialData={flattenConfig(settings.config)}
onSubmit={handleSubmit}
onValuesChange={handleDriverChange}
disabled={!editable}
>
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}></div>
<Form.FormItem label='端口' name='server.port' rules={[{ required: true, message: '请输入端口' }]}>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</Form.FormItem>
<Form.FormItem label='读超时' name='server.readTimeout' rules={[{ required: true, message: '请输入读超时' }]}>
<Input placeholder={DURATION_PLACEHOLDERS.readTimeout} />
</Form.FormItem>
<Form.FormItem label='写超时' name='server.writeTimeout' rules={[{ required: true, message: '请输入写超时' }]}>
<Input placeholder={DURATION_PLACEHOLDERS.writeTimeout} />
</Form.FormItem>
<Divider />
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}></div>
<Form.FormItem label='驱动' name='database.driver' rules={[{ required: true, message: '请选择数据库驱动' }]}>
<Select>
<Select.Option value='sqlite'>SQLite</Select.Option>
<Select.Option value='mysql'>MySQL</Select.Option>
</Select>
</Form.FormItem>
{isSqlite && (
<Form.FormItem
label='数据库路径'
name='database.path'
rules={[{ required: true, message: '请输入数据库路径' }]}
>
<Input placeholder='例如 ~/.nex/config.db' />
</Form.FormItem>
)}
{isMysql && (
<>
<Form.FormItem
label='主机地址'
name='database.host'
rules={[{ required: true, message: '请输入主机地址' }]}
>
<Input placeholder='例如 localhost' />
</Form.FormItem>
<Form.FormItem label='端口' name='database.port' rules={[{ required: true, message: '请输入端口' }]}>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</Form.FormItem>
<Form.FormItem label='用户名' name='database.user' rules={[{ required: true, message: '请输入用户名' }]}>
<Input placeholder='例如 root' />
</Form.FormItem>
<Form.FormItem label='密码' name='database.password'>
<Input placeholder='MySQL 密码' />
</Form.FormItem>
<Form.FormItem
label='数据库名'
name='database.dbname'
rules={[{ required: true, message: '请输入数据库名' }]}
>
<Input placeholder='例如 nex' />
</Form.FormItem>
</>
)}
<Form.FormItem
label='最大空闲连接数'
name='database.maxIdleConns'
rules={[{ required: true, message: '请输入最大空闲连接数' }]}
>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.FormItem>
<Form.FormItem
label='最大打开连接数'
name='database.maxOpenConns'
rules={[{ required: true, message: '请输入最大打开连接数' }]}
>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.FormItem>
<Form.FormItem
label='连接最大生命周期'
name='database.connMaxLifetime'
rules={[{ required: true, message: '请输入连接最大生命周期' }]}
>
<Input placeholder={DURATION_PLACEHOLDERS.connMaxLifetime} />
</Form.FormItem>
<Divider />
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}></div>
<Form.FormItem label='日志级别' name='log.level' rules={[{ required: true, message: '请选择日志级别' }]}>
<Select>
<Select.Option value='debug'>debug</Select.Option>
<Select.Option value='info'>info</Select.Option>
<Select.Option value='warn'>warn</Select.Option>
<Select.Option value='error'>error</Select.Option>
</Select>
</Form.FormItem>
<Form.FormItem label='日志路径' name='log.path' rules={[{ required: true, message: '请输入日志路径' }]}>
<Input placeholder='例如 ~/.nex/log' />
</Form.FormItem>
<Form.FormItem
label='单文件最大大小'
name='log.maxSize'
rules={[{ required: true, message: '请输入最大大小' }]}
>
<InputNumber min={1} suffix=' MB' style={{ width: '100%' }} />
</Form.FormItem>
<Form.FormItem
label='最大备份数'
name='log.maxBackups'
rules={[{ required: true, message: '请输入最大备份数' }]}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.FormItem>
<Form.FormItem
label='最大保留天数'
name='log.maxAge'
rules={[{ required: true, message: '请输入最大保留天数' }]}
>
<InputNumber min={0} suffix=' 天' style={{ width: '100%' }} />
</Form.FormItem>
<Form.FormItem label='压缩旧日志' name='log.compress'>
<Switch />
</Form.FormItem>
{editable && (
<Space style={{ marginTop: 16 }}>
<Button
theme='primary'
loading={saveMutation.isPending}
onClick={() => {
form?.submit()
}}
>
</Button>
</Space>
)}
</Form>
</Card>
)
}

View File

@@ -1,11 +1,5 @@
import { Card } from 'tdesign-react'
import { StartupSettingsCard } from './StartupSettingsCard'
export default function SettingsPage() {
return (
<Card title='设置'>
<div style={{ textAlign: 'center', padding: '40px 0', color: 'var(--td-text-color-placeholder)' }}>
...
</div>
</Card>
)
return <StartupSettingsCard />
}

View File

@@ -92,3 +92,49 @@ export interface ApiErrorResponse {
error: string
code?: string
}
export interface StartupServerConfig {
port: number
readTimeout: string
writeTimeout: string
}
export interface StartupDatabaseConfig {
driver: string
path: string
host: string
port: number
user: string
password: string
dbname: string
maxIdleConns: number
maxOpenConns: number
connMaxLifetime: string
}
export interface StartupLogConfig {
level: string
path: string
maxSize: number
maxBackups: number
maxAge: number
compress: boolean
}
export interface StartupConfig {
server: StartupServerConfig
database: StartupDatabaseConfig
log: StartupLogConfig
}
export interface StartupSettings {
mode: 'server' | 'desktop'
editable: boolean
configPath: string
restartRequired: boolean
config: StartupConfig
}
export type SaveStartupSettingsInput = {
config: StartupConfig
}

View File

@@ -467,3 +467,105 @@ server 入口 SHALL 支持通过环境变量设置所有配置项,符合 serve
- **WHEN** desktop 启动时存在 `NEX_SERVER_PORT``NEX_DATABASE_PATH``NEX_LOG_LEVEL` 或其他 `NEX_*` 环境变量
- **THEN** SHALL NOT 读取这些环境变量作为配置源
- **THEN** SHALL 使用 `~/.nex/config.yaml` 和默认值加载配置
### Requirement: 启动参数设置查询
系统 SHALL 提供面向前端设置页的启动参数查询能力,按入口返回用于展示的启动参数设置视图和当前入口的可编辑状态。
#### Scenario: Desktop 查询配置文件编辑视图
- **WHEN** desktop 入口收到启动参数查询请求
- **THEN** 后端 SHALL 使用 desktop 配置语义读取 `~/.nex/config.yaml` 和默认值
- **THEN** 后端 SHALL 返回用于编辑配置文件的启动参数设置视图
- **THEN** 后端 SHALL NOT 将查询结果应用到当前运行配置快照
- **THEN** 返回配置 SHALL 包含 `server``database``log` 配置分组
- **THEN** 返回配置 SHALL 覆盖现有配置结构中的全部启动参数字段
- **THEN** 返回配置 SHALL 直接包含 `database.password` 字段值
#### Scenario: Server 查询当前有效启动参数
- **WHEN** server 入口收到启动参数查询请求
- **THEN** 后端 SHALL 返回当前运行进程启动后使用的有效配置
- **THEN** 返回配置 SHALL 包含 `server``database``log` 配置分组
- **THEN** 返回配置 SHALL 覆盖现有配置结构中的全部启动参数字段
- **THEN** 返回配置 SHALL 直接包含 `database.password` 字段值
#### Scenario: 查询返回入口模式元数据
- **WHEN** 前端请求启动参数设置
- **THEN** 后端 SHALL 返回当前入口模式,取值为 `server``desktop`
- **THEN** 后端 SHALL 返回 `editable` 表示当前入口是否允许前端保存启动参数
- **THEN** 后端 SHALL 返回配置文件路径
- **THEN** 后端 SHALL 返回 `restart_required` 表示保存后是否需要重启生效
#### Scenario: Desktop 查询返回可编辑元数据
- **WHEN** desktop 入口收到启动参数查询请求
- **THEN** 后端 SHALL 返回 `mode``desktop`
- **THEN** 后端 SHALL 返回 `editable` 为 true
- **THEN** 后端 SHALL 返回配置文件路径为默认配置文件 `~/.nex/config.yaml`
- **THEN** 后端 SHALL 返回 `restart_required` 为 true
#### Scenario: 查询不返回来源追踪信息
- **WHEN** 前端请求启动参数设置
- **THEN** 后端 SHALL NOT 要求返回每个字段的配置来源标签
- **THEN** 后端 SHALL NOT 要求区分当前运行值和配置文件值
### Requirement: Desktop 启动参数保存
desktop 入口 SHALL 允许前端通过设置页保存启动参数到默认配置文件,并保持当前运行时配置快照不变。
#### Scenario: Desktop 保存有效启动参数
- **WHEN** desktop 入口收到有效的启动参数保存请求
- **THEN** 后端 SHALL 验证请求配置符合现有配置验证规则
- **THEN** 后端 SHALL 将配置保存到 `~/.nex/config.yaml`
- **THEN** 保存的配置文件权限 SHALL 符合现有配置文件安全要求
- **THEN** 后端 SHALL 返回保存后的启动参数设置
#### Scenario: Desktop 保存时创建配置文件
- **WHEN** desktop 入口收到有效的启动参数保存请求
- **AND** `~/.nex/config.yaml` 不存在
- **THEN** 后端 SHALL 创建配置文件并写入提交的配置
- **THEN** 后端 SHALL NOT 在查询启动参数时自动创建配置文件
#### Scenario: Desktop 保存不动态应用配置
- **WHEN** desktop 入口成功保存启动参数
- **THEN** 当前运行中的配置快照 SHALL 保持不变
- **THEN** 当前运行中的 HTTP server、数据库连接、日志器和请求处理 SHALL NOT 因保存操作而重建或中断
- **THEN** 保存后的配置 SHALL 在下一次 desktop 启动时生效
- **THEN** 后端 SHALL NOT 自动重启 desktop
#### Scenario: Desktop 拒绝无效启动参数
- **WHEN** desktop 入口收到无效的启动参数保存请求
- **THEN** 后端 SHALL 返回验证错误
- **THEN** 后端 SHALL NOT 写入无效配置到 `~/.nex/config.yaml`
### Requirement: Server 启动参数只读
server 入口 SHALL 允许前端查看当前有效启动参数,但 SHALL NOT 允许前端保存或修改启动参数。
#### Scenario: Server 查询只读元数据
- **WHEN** server 入口收到启动参数查询请求
- **THEN** 后端 SHALL 返回 `mode``server`
- **THEN** 后端 SHALL 返回 `editable` 为 false
- **THEN** 后端 SHALL 返回 `restart_required` 为 false
- **THEN** 后端 SHALL 返回 server 启动时实际解析到的配置文件路径
#### Scenario: Server 查询返回自定义配置文件路径
- **WHEN** server 入口使用 `--config /path/to/custom.yaml` 启动
- **AND** server 入口收到启动参数查询请求
- **THEN** 后端 SHALL 返回配置文件路径 `/path/to/custom.yaml`
#### Scenario: Server 拒绝保存启动参数
- **WHEN** server 入口收到启动参数保存请求
- **THEN** 后端 SHALL 返回禁止修改错误
- **THEN** 后端 SHALL NOT 写入配置文件
- **THEN** 后端 SHALL NOT 修改当前运行配置

View File

@@ -374,13 +374,54 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
### Requirement: 提供设置页面
前端 SHALL 提供设置页面。
前端 SHALL 提供设置页面,并在设置页面中以独立 Card 展示启动参数设置
#### Scenario: 显示设置页面
- **WHEN** 用户访问设置页面
- **THEN** 前端 SHALL 显示设置页面
- **THEN** 开发中提示文字颜色 SHALL 使用 \`var(--td-text-color-placeholder)\` Token
- **THEN** 前端 SHALL 显示标题为“启动参数设置”的 Card
- **THEN** 启动参数设置 Card SHALL 与未来其他设置 Card 在视觉结构上保持独立
#### Scenario: Desktop 模式显示可编辑启动参数
- **WHEN** 后端返回启动参数设置 `editable` 为 true
- **THEN** 前端 SHALL 在“启动参数设置” Card 中显示可编辑表单
- **THEN** 表单 SHALL 覆盖 `server``database``log` 配置分组
- **THEN** 前端 SHALL 提示“Desktop 模式下此页面编辑的是启动配置文件,保存后重启 Desktop 生效”
- **THEN** 前端 SHALL 显示保存按钮
- **THEN** 前端 SHALL 在保存成功后提示“配置已保存到配置文件。当前运行中的服务仍使用启动时配置,重启 Desktop 后生效”
#### Scenario: Server 模式显示只读启动参数
- **WHEN** 后端返回启动参数设置 `editable` 为 false
- **THEN** 前端 SHALL 在“启动参数设置” Card 中显示只读表单
- **THEN** 所有启动参数字段 SHALL 不可编辑
- **THEN** 前端 SHALL 隐藏或禁用保存按钮
- **THEN** 前端 SHALL 提示“Server 模式下启动参数仅支持查看,不支持从前端编辑”
#### Scenario: 启动参数展示内容
- **WHEN** 前端渲染启动参数设置表单
- **THEN** 前端 SHALL 直接展示后端返回的启动参数设置值
- **THEN** 前端 SHALL NOT 区分当前运行值和配置文件值
- **THEN** 前端 SHALL NOT 展示配置来源标签
- **THEN** 前端 SHALL 直接展示 `database.password` 字段值
#### Scenario: 数据库驱动表单切换
- **WHEN** 启动参数设置中的 `database.driver``sqlite`
- **THEN** 前端 SHALL 允许配置 SQLite 数据库路径
- **THEN** 前端 SHALL 弱化或禁用 MySQL 专属字段
- **WHEN** 启动参数设置中的 `database.driver``mysql`
- **THEN** 前端 SHALL 允许配置 MySQL host、port、user、password、dbname 字段
- **THEN** 前端 SHALL 弱化或禁用 SQLite 专属路径字段
#### Scenario: 启动参数保存失败
- **WHEN** 用户保存启动参数且后端返回验证错误或保存错误
- **THEN** 前端 SHALL 显示用户可理解的错误提示
- **THEN** 前端 SHALL 保持用户当前填写内容,便于修正后重新保存
### Requirement: 显示统一模型 ID