feat: 新增启动参数设置页面,区分 desktop 可编辑与 server 只读
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -409,6 +409,8 @@ skills-lock.json
|
||||
.worktrees
|
||||
!scripts/build/
|
||||
backend/bin
|
||||
backend/server
|
||||
backend/desktop
|
||||
|
||||
# Embedfs generated
|
||||
embedfs/assets/
|
||||
|
||||
13
README.md
13
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 页面诊断前后端版本一致性
|
||||
|
||||
|
||||
@@ -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"})
|
||||
})
|
||||
|
||||
@@ -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>")},
|
||||
})
|
||||
|
||||
@@ -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"})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
94
backend/internal/config/config_metadata_test.go
Normal file
94
backend/internal/config/config_metadata_test.go
Normal 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
|
||||
}
|
||||
223
backend/internal/handler/settings_handler.go
Normal file
223
backend/internal/handler/settings_handler.go
Normal 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),
|
||||
})
|
||||
}
|
||||
418
backend/internal/handler/settings_handler_test.go
Normal file
418
backend/internal/handler/settings_handler_test.go
Normal 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)
|
||||
}
|
||||
106
frontend/src/__tests__/hooks/useSettings.test.tsx
Normal file
106
frontend/src/__tests__/hooks/useSettings.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
169
frontend/src/__tests__/pages/Settings.test.tsx
Normal file
169
frontend/src/__tests__/pages/Settings.test.tsx
Normal 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)
|
||||
})
|
||||
}
|
||||
10
frontend/src/api/settings.ts
Normal file
10
frontend/src/api/settings.ts
Normal 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)
|
||||
}
|
||||
33
frontend/src/hooks/useSettings.ts
Normal file
33
frontend/src/hooks/useSettings.ts
Normal 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)
|
||||
},
|
||||
})
|
||||
}
|
||||
275
frontend/src/pages/Settings/StartupSettingsCard.tsx
Normal file
275
frontend/src/pages/Settings/StartupSettingsCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 修改当前运行配置
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user