From 4eeb14e84445653a0ce9573ffa2c49e328bc1c9a Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Thu, 7 May 2026 14:10:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E8=AE=BE=E7=BD=AE=E9=A1=B5=E9=9D=A2=EF=BC=8C?= =?UTF-8?q?=E5=8C=BA=E5=88=86=20desktop=20=E5=8F=AF=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E4=B8=8E=20server=20=E5=8F=AA=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + README.md | 13 +- backend/cmd/desktop/main.go | 13 +- backend/cmd/desktop/routes_test.go | 2 +- backend/cmd/server/main.go | 13 +- backend/cmd/server/routes_test.go | 2 +- backend/internal/config/config.go | 45 +- .../internal/config/config_metadata_test.go | 94 ++++ backend/internal/handler/settings_handler.go | 223 ++++++++++ .../internal/handler/settings_handler_test.go | 418 ++++++++++++++++++ .../src/__tests__/hooks/useSettings.test.tsx | 106 +++++ .../src/__tests__/pages/Settings.test.tsx | 169 +++++++ frontend/src/api/settings.ts | 10 + frontend/src/hooks/useSettings.ts | 33 ++ .../pages/Settings/StartupSettingsCard.tsx | 275 ++++++++++++ frontend/src/pages/Settings/index.tsx | 10 +- frontend/src/types/index.ts | 46 ++ openspec/specs/config-management/spec.md | 102 +++++ openspec/specs/frontend/spec.md | 45 +- 19 files changed, 1589 insertions(+), 32 deletions(-) create mode 100644 backend/internal/config/config_metadata_test.go create mode 100644 backend/internal/handler/settings_handler.go create mode 100644 backend/internal/handler/settings_handler_test.go create mode 100644 frontend/src/__tests__/hooks/useSettings.test.tsx create mode 100644 frontend/src/__tests__/pages/Settings.test.tsx create mode 100644 frontend/src/api/settings.ts create mode 100644 frontend/src/hooks/useSettings.ts create mode 100644 frontend/src/pages/Settings/StartupSettingsCard.tsx diff --git a/.gitignore b/.gitignore index d5bfc7c..1393d5d 100644 --- a/.gitignore +++ b/.gitignore @@ -409,6 +409,8 @@ skills-lock.json .worktrees !scripts/build/ backend/bin +backend/server +backend/desktop # Embedfs generated embedfs/assets/ diff --git a/README.md b/README.md index 6ca7f46..7f5685f 100644 --- a/README.md +++ b/README.md @@ -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 页面诊断前后端版本一致性 diff --git a/backend/cmd/desktop/main.go b/backend/cmd/desktop/main.go index 30ca48b..c73a166 100644 --- a/backend/cmd/desktop/main.go +++ b/backend/cmd/desktop/main.go @@ -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"}) }) diff --git a/backend/cmd/desktop/routes_test.go b/backend/cmd/desktop/routes_test.go index 10b7e98..3b71a0b 100644 --- a/backend/cmd/desktop/routes_test.go +++ b/backend/cmd/desktop/routes_test.go @@ -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("fallback")}, }) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index ffce8bc..4fbc414 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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"}) }) diff --git a/backend/cmd/server/routes_test.go b/backend/cmd/server/routes_test.go index 53eb03e..abff128 100644 --- a/backend/cmd/server/routes_test.go +++ b/backend/cmd/server/routes_test.go @@ -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() diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index ef2a01a..c11d099 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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) diff --git a/backend/internal/config/config_metadata_test.go b/backend/internal/config/config_metadata_test.go new file mode 100644 index 0000000..39e510a --- /dev/null +++ b/backend/internal/config/config_metadata_test.go @@ -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 +} diff --git a/backend/internal/handler/settings_handler.go b/backend/internal/handler/settings_handler.go new file mode 100644 index 0000000..74960a0 --- /dev/null +++ b/backend/internal/handler/settings_handler.go @@ -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), + }) +} diff --git a/backend/internal/handler/settings_handler_test.go b/backend/internal/handler/settings_handler_test.go new file mode 100644 index 0000000..27b9483 --- /dev/null +++ b/backend/internal/handler/settings_handler_test.go @@ -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) +} diff --git a/frontend/src/__tests__/hooks/useSettings.test.tsx b/frontend/src/__tests__/hooks/useSettings.test.tsx new file mode 100644 index 0000000..a37b25b --- /dev/null +++ b/frontend/src/__tests__/hooks/useSettings.test.tsx @@ -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 + return HttpResponse.json({ + ...mockDesktopSettings, + config: (body as Record).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 }) => ( + {children} + ) +} + +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() + }) +}) diff --git a/frontend/src/__tests__/pages/Settings.test.tsx b/frontend/src/__tests__/pages/Settings.test.tsx new file mode 100644 index 0000000..ab7d7db --- /dev/null +++ b/frontend/src/__tests__/pages/Settings.test.tsx @@ -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() + 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 + return HttpResponse.json({ ...mockDesktopSettings, config: (body as Record).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 }) => ( + + {children} + + ) +} + +describe('SettingsPage', () => { + it('renders startup settings card', async () => { + const mswServer = setupServer(...desktopHandlers) + mswServer.listen({ onUnhandledRequest: 'bypass' }) + + render(, { 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(, { 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(, { wrapper: createWrapper() }) + + expect(await screen.findByText('服务配置')).toBeInTheDocument() + expect(screen.getByText('数据库配置')).toBeInTheDocument() + expect(screen.getByText('日志配置')).toBeInTheDocument() + }) + + it('shows success message on save', async () => { + render(, { 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(, { 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((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) + }) +} diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts new file mode 100644 index 0000000..70ef11f --- /dev/null +++ b/frontend/src/api/settings.ts @@ -0,0 +1,10 @@ +import type { StartupSettings, SaveStartupSettingsInput } from '@/types' +import { request } from './client' + +export async function getStartupSettings(): Promise { + return request('GET', '/api/settings/startup') +} + +export async function saveStartupSettings(input: SaveStartupSettingsInput): Promise { + return request('PUT', '/api/settings/startup', input) +} diff --git a/frontend/src/hooks/useSettings.ts b/frontend/src/hooks/useSettings.ts new file mode 100644 index 0000000..093cb7f --- /dev/null +++ b/frontend/src/hooks/useSettings.ts @@ -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) + }, + }) +} diff --git a/frontend/src/pages/Settings/StartupSettingsCard.tsx b/frontend/src/pages/Settings/StartupSettingsCard.tsx new file mode 100644 index 0000000..ac5ee24 --- /dev/null +++ b/frontend/src/pages/Settings/StartupSettingsCard.tsx @@ -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 { + 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(settings?.config.database.driver ?? 'sqlite') + + useEffect(() => { + if (settings?.config && form) { + form.setFieldsValue(flattenConfig(settings.config)) + } + }, [form, settings?.config]) + + const handleDriverChange = (changedValues: Record) => { + 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 + 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 ( + +
+ +
+
+ ) + } + + if (isError || !settings) { + return ( + + + + ) + } + + return ( + + {isDesktop && ( + + )} + {!editable && ( + + )} + +
+
服务配置
+ + + + + + + + + + + + +
数据库配置
+ + + + + {isSqlite && ( + + + + )} + + {isMysql && ( + <> + + + + + + + + + + + + + + + + + )} + + + + + + + + + + + + + +
日志配置
+ + + + + + + + + + + + + + + + + + + + {editable && ( + + + + )} + +
+ ) +} diff --git a/frontend/src/pages/Settings/index.tsx b/frontend/src/pages/Settings/index.tsx index 529d8c9..40baf18 100644 --- a/frontend/src/pages/Settings/index.tsx +++ b/frontend/src/pages/Settings/index.tsx @@ -1,11 +1,5 @@ -import { Card } from 'tdesign-react' +import { StartupSettingsCard } from './StartupSettingsCard' export default function SettingsPage() { - return ( - -
- 设置功能开发中... -
-
- ) + return } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 3056b83..c7a5646 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 +} diff --git a/openspec/specs/config-management/spec.md b/openspec/specs/config-management/spec.md index 5f5912b..aca4b08 100644 --- a/openspec/specs/config-management/spec.md +++ b/openspec/specs/config-management/spec.md @@ -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 修改当前运行配置 diff --git a/openspec/specs/frontend/spec.md b/openspec/specs/frontend/spec.md index f03cd28..159f195 100644 --- a/openspec/specs/frontend/spec.md +++ b/openspec/specs/frontend/spec.md @@ -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