feat: 新增启动参数设置页面,区分 desktop 可编辑与 server 只读
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -409,6 +409,8 @@ skills-lock.json
|
|||||||
.worktrees
|
.worktrees
|
||||||
!scripts/build/
|
!scripts/build/
|
||||||
backend/bin
|
backend/bin
|
||||||
|
backend/server
|
||||||
|
backend/desktop
|
||||||
|
|
||||||
# Embedfs generated
|
# Embedfs generated
|
||||||
embedfs/assets/
|
embedfs/assets/
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -27,7 +27,7 @@ nex/
|
|||||||
│ │ ├── api/ # API 层(统一请求封装 + 字段转换)
|
│ │ ├── api/ # API 层(统一请求封装 + 字段转换)
|
||||||
│ │ ├── hooks/ # TanStack Query hooks
|
│ │ ├── hooks/ # TanStack Query hooks
|
||||||
│ │ ├── components/ # 通用组件(AppLayout)
|
│ │ ├── components/ # 通用组件(AppLayout)
|
||||||
│ │ ├── pages/ # 页面(Providers, Stats)
|
│ │ ├── pages/ # 页面(Providers, Stats, Settings)
|
||||||
│ │ ├── routes/ # React Router 路由配置
|
│ │ ├── routes/ # React Router 路由配置
|
||||||
│ │ ├── types/ # TypeScript 类型定义
|
│ │ ├── types/ # TypeScript 类型定义
|
||||||
│ │ └── __tests__/ # 单元测试 + 组件测试
|
│ │ └── __tests__/ # 单元测试 + 组件测试
|
||||||
@@ -57,6 +57,7 @@ nex/
|
|||||||
- **多供应商管理**:配置和管理多个供应商(供应商 ID 仅限字母、数字、下划线)
|
- **多供应商管理**:配置和管理多个供应商(供应商 ID 仅限字母、数字、下划线)
|
||||||
- **用量统计**:按供应商、模型、日期统计请求数量
|
- **用量统计**:按供应商、模型、日期统计请求数量
|
||||||
- **Web 配置界面**:提供供应商和模型配置管理
|
- **Web 配置界面**:提供供应商和模型配置管理
|
||||||
|
- **启动参数设置**:通过 Web 界面查看和编辑启动参数(Desktop 可编辑、Server 只读)
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
@@ -239,6 +240,16 @@ server 和 desktop 发布产物自包含运行时数据库迁移资源(通过
|
|||||||
|
|
||||||
查询参数支持:`provider_id`、`model_name`、`start_date`、`end_date`、`group_by`
|
查询参数支持:`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 页面诊断前后端版本一致性
|
- `GET /api/version` - 获取后端构建版本信息(`version`、`commit`、`build_time`),用于前端 About 页面诊断前后端版本一致性
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ var (
|
|||||||
func main() {
|
func main() {
|
||||||
minimalLogger := pkgLogger.NewMinimal()
|
minimalLogger := pkgLogger.NewMinimal()
|
||||||
|
|
||||||
cfg, err := config.LoadDesktopConfig()
|
cfg, cfgMeta, err := config.LoadDesktopConfigWithMetadata()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
minimalLogger.Error("加载配置失败", zap.Error(err))
|
minimalLogger.Error("加载配置失败", zap.Error(err))
|
||||||
showError(appName, desktopConfigErrorMessage(getDesktopConfigPath(), err))
|
showError(appName, desktopConfigErrorMessage(getDesktopConfigPath(), err))
|
||||||
@@ -133,6 +133,7 @@ func main() {
|
|||||||
modelHandler := handler.NewModelHandler(modelService)
|
modelHandler := handler.NewModelHandler(modelService)
|
||||||
statsHandler := handler.NewStatsHandler(statsService)
|
statsHandler := handler.NewStatsHandler(statsService)
|
||||||
versionHandler := handler.NewVersionHandler()
|
versionHandler := handler.NewVersionHandler()
|
||||||
|
settingsHandler := handler.NewSettingsHandler(cfg, "desktop", true, cfgMeta.ConfigPath)
|
||||||
|
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
@@ -142,7 +143,7 @@ func main() {
|
|||||||
r.Use(middleware.Logging(zapLogger))
|
r.Use(middleware.Logging(zapLogger))
|
||||||
r.Use(middleware.CORS())
|
r.Use(middleware.CORS())
|
||||||
|
|
||||||
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler)
|
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler, settingsHandler)
|
||||||
setupStaticFiles(r)
|
setupStaticFiles(r)
|
||||||
|
|
||||||
server = &http.Server{
|
server = &http.Server{
|
||||||
@@ -175,7 +176,7 @@ func main() {
|
|||||||
setupSystray(port)
|
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("/openai/*path", withProtocol("openai", proxyHandler.HandleProxy))
|
||||||
r.Any("/anthropic/*path", withProtocol("anthropic", proxyHandler.HandleProxy))
|
r.Any("/anthropic/*path", withProtocol("anthropic", proxyHandler.HandleProxy))
|
||||||
r.GET("/api/version", versionHandler.GetVersion)
|
r.GET("/api/version", versionHandler.GetVersion)
|
||||||
@@ -204,6 +205,12 @@ func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHand
|
|||||||
stats.GET("/aggregate", statsHandler.AggregateStats)
|
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) {
|
r.GET("/health", func(c *gin.Context) {
|
||||||
c.JSON(200, gin.H{"status": "ok"})
|
c.JSON(200, gin.H{"status": "ok"})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func TestSetupRoutes_VersionDoesNotFallback(t *testing.T) {
|
|||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
r := gin.New()
|
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{
|
setupStaticFilesWithFS(r, fstest.MapFS{
|
||||||
"index.html": {Data: []byte("<html>fallback</html>")},
|
"index.html": {Data: []byte("<html>fallback</html>")},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
minimalLogger := pkgLogger.NewMinimal()
|
minimalLogger := pkgLogger.NewMinimal()
|
||||||
|
|
||||||
cfg, err := config.LoadServerConfig()
|
cfg, cfgMeta, err := config.LoadServerConfigWithMetadata()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
minimalLogger.Fatal("加载配置失败", zap.Error(err))
|
minimalLogger.Fatal("加载配置失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
@@ -94,6 +94,7 @@ func main() {
|
|||||||
modelHandler := handler.NewModelHandler(modelService)
|
modelHandler := handler.NewModelHandler(modelService)
|
||||||
statsHandler := handler.NewStatsHandler(statsService)
|
statsHandler := handler.NewStatsHandler(statsService)
|
||||||
versionHandler := handler.NewVersionHandler()
|
versionHandler := handler.NewVersionHandler()
|
||||||
|
settingsHandler := handler.NewSettingsHandler(cfg, "server", false, cfgMeta.ConfigPath)
|
||||||
|
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
@@ -103,7 +104,7 @@ func main() {
|
|||||||
r.Use(middleware.Logging(zapLogger))
|
r.Use(middleware.Logging(zapLogger))
|
||||||
r.Use(middleware.CORS())
|
r.Use(middleware.CORS())
|
||||||
|
|
||||||
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler)
|
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler, settingsHandler)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
|
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
|
||||||
@@ -141,7 +142,7 @@ func main() {
|
|||||||
zapLogger.Info("服务器已关闭")
|
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.Any("/:protocol/*path", proxyHandler.HandleProxy)
|
||||||
r.GET("/api/version", versionHandler.GetVersion)
|
r.GET("/api/version", versionHandler.GetVersion)
|
||||||
|
|
||||||
@@ -169,6 +170,12 @@ func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHand
|
|||||||
stats.GET("/aggregate", statsHandler.AggregateStats)
|
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) {
|
r.GET("/health", func(c *gin.Context) {
|
||||||
c.JSON(200, gin.H{"status": "ok"})
|
c.JSON(200, gin.H{"status": "ok"})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func TestSetupRoutes_Version(t *testing.T) {
|
|||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
r := gin.New()
|
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)
|
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ type DatabaseConfig struct {
|
|||||||
Driver string `yaml:"driver" mapstructure:"driver" validate:"required,oneof=sqlite mysql"`
|
Driver string `yaml:"driver" mapstructure:"driver" validate:"required,oneof=sqlite mysql"`
|
||||||
Path string `yaml:"path" mapstructure:"path" validate:"required_if=Driver sqlite"`
|
Path string `yaml:"path" mapstructure:"path" validate:"required_if=Driver sqlite"`
|
||||||
Host string `yaml:"host" mapstructure:"host" validate:"required_if=Driver mysql"`
|
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"`
|
User string `yaml:"user" mapstructure:"user" validate:"required_if=Driver mysql"`
|
||||||
Password string `yaml:"password" mapstructure:"password"`
|
Password string `yaml:"password" mapstructure:"password"`
|
||||||
DBName string `yaml:"dbname" mapstructure:"dbname" validate:"required_if=Driver mysql"`
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadOptions 控制配置加载器行为
|
type ConfigMetadata struct {
|
||||||
|
ConfigPath string
|
||||||
|
}
|
||||||
|
|
||||||
type loadOptions struct {
|
type loadOptions struct {
|
||||||
configPathOverride string
|
configPathOverride string
|
||||||
useCLI bool
|
useCLI bool
|
||||||
@@ -270,15 +273,19 @@ func resolveConfigPath(v *viper.Viper, opts loadOptions) (string, error) {
|
|||||||
return configPath, nil
|
return configPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadConfig 共享配置加载逻辑,通过 loadOptions 控制是否启用 CLI、环境变量和 --config 覆盖
|
|
||||||
func loadConfig(opts loadOptions) (*Config, error) {
|
func loadConfig(opts loadOptions) (*Config, error) {
|
||||||
|
cfg, _, err := loadConfigWithMetadata(opts)
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfigWithMetadata(opts loadOptions) (*Config, ConfigMetadata, error) {
|
||||||
v := viper.New()
|
v := viper.New()
|
||||||
|
|
||||||
setupDefaults(v)
|
setupDefaults(v)
|
||||||
|
|
||||||
configPath, err := resolveConfigPath(v, opts)
|
configPath, err := resolveConfigPath(v, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, ConfigMetadata{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.useEnv {
|
if opts.useEnv {
|
||||||
@@ -286,7 +293,7 @@ func loadConfig(opts loadOptions) (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := setupConfigFile(v, configPath); err != nil {
|
if err := setupConfigFile(v, configPath); err != nil {
|
||||||
return nil, err
|
return nil, ConfigMetadata{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &Config{}
|
cfg := &Config{}
|
||||||
@@ -294,23 +301,28 @@ func loadConfig(opts loadOptions) (*Config, error) {
|
|||||||
mapstructure.StringToTimeDurationHookFunc(),
|
mapstructure.StringToTimeDurationHookFunc(),
|
||||||
mapstructure.StringToSliceHookFunc(","),
|
mapstructure.StringToSliceHookFunc(","),
|
||||||
))); err != nil {
|
))); err != nil {
|
||||||
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
|
return nil, ConfigMetadata{}, appErrors.Wrap(appErrors.ErrInternal, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cfg.Validate(); err != nil {
|
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
|
// LoadServerConfig 为 server 入口加载配置,支持 CLI 参数、环境变量和 --config
|
||||||
func LoadServerConfig() (*Config, error) {
|
func LoadServerConfig() (*Config, error) {
|
||||||
|
cfg, _, err := LoadServerConfigWithMetadata()
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadServerConfigWithMetadata() (*Config, ConfigMetadata, error) {
|
||||||
configPath, err := GetConfigPath()
|
configPath, err := GetConfigPath()
|
||||||
if err != nil {
|
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,
|
configPathOverride: configPath,
|
||||||
useCLI: true,
|
useCLI: true,
|
||||||
useEnv: true,
|
useEnv: true,
|
||||||
@@ -320,11 +332,16 @@ func LoadServerConfig() (*Config, error) {
|
|||||||
|
|
||||||
// LoadDesktopConfig 为 desktop 入口加载配置,固定使用默认配置文件,不支持 CLI、环境变量和 --config
|
// LoadDesktopConfig 为 desktop 入口加载配置,固定使用默认配置文件,不支持 CLI、环境变量和 --config
|
||||||
func LoadDesktopConfig() (*Config, error) {
|
func LoadDesktopConfig() (*Config, error) {
|
||||||
|
cfg, _, err := LoadDesktopConfigWithMetadata()
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadDesktopConfigWithMetadata() (*Config, ConfigMetadata, error) {
|
||||||
configPath, err := GetConfigPath()
|
configPath, err := GetConfigPath()
|
||||||
if err != nil {
|
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,
|
configPathOverride: configPath,
|
||||||
useCLI: false,
|
useCLI: false,
|
||||||
useEnv: false,
|
useEnv: false,
|
||||||
@@ -365,13 +382,15 @@ func SaveConfig(cfg *Config) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return appErrors.Wrap(appErrors.ErrInternal, err)
|
return appErrors.Wrap(appErrors.ErrInternal, err)
|
||||||
}
|
}
|
||||||
|
return SaveConfigToPath(cfg, configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveConfigToPath(cfg *Config, configPath string) error {
|
||||||
data, err := yaml.Marshal(cfg)
|
data, err := yaml.Marshal(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return appErrors.Wrap(appErrors.ErrInternal, err)
|
return appErrors.Wrap(appErrors.ErrInternal, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure directory exists
|
|
||||||
dir := filepath.Dir(configPath)
|
dir := filepath.Dir(configPath)
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return appErrors.Wrap(appErrors.ErrInternal, err)
|
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() {
|
export default function SettingsPage() {
|
||||||
return (
|
return <StartupSettingsCard />
|
||||||
<Card title='设置'>
|
|
||||||
<div style={{ textAlign: 'center', padding: '40px 0', color: 'var(--td-text-color-placeholder)' }}>
|
|
||||||
设置功能开发中...
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,3 +92,49 @@ export interface ApiErrorResponse {
|
|||||||
error: string
|
error: string
|
||||||
code?: 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_*` 环境变量
|
- **WHEN** desktop 启动时存在 `NEX_SERVER_PORT`、`NEX_DATABASE_PATH`、`NEX_LOG_LEVEL` 或其他 `NEX_*` 环境变量
|
||||||
- **THEN** SHALL NOT 读取这些环境变量作为配置源
|
- **THEN** SHALL NOT 读取这些环境变量作为配置源
|
||||||
- **THEN** SHALL 使用 `~/.nex/config.yaml` 和默认值加载配置
|
- **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: 提供设置页面
|
### Requirement: 提供设置页面
|
||||||
|
|
||||||
前端 SHALL 提供设置页面。
|
前端 SHALL 提供设置页面,并在设置页面中以独立 Card 展示启动参数设置。
|
||||||
|
|
||||||
#### Scenario: 显示设置页面
|
#### Scenario: 显示设置页面
|
||||||
|
|
||||||
- **WHEN** 用户访问设置页面
|
- **WHEN** 用户访问设置页面
|
||||||
- **THEN** 前端 SHALL 显示设置页面
|
- **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
|
### Requirement: 显示统一模型 ID
|
||||||
|
|||||||
Reference in New Issue
Block a user