From cf085770e71827386de2987757479aad466259fe Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Wed, 20 May 2026 17:53:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=96=B0=E5=A2=9E=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E8=AE=BE=E7=BD=AE=E9=A1=B5=E9=9D=A2=E5=92=8C=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 settings 表用于存储 public/private 两行 JSON 配置 - 添加 AdminSettings 相关类型定义和 API 接口 - 实现管理后台系统设置页面,支持可视化和 JSON 编辑模式 - 集成 CodeMirror 编辑器用于 JSON 配置编辑 - 添加系统设置相关的路由和接口 - 更新依赖包并修改相关样式和布局文件 --- CHANGELOG.md | 2 + docs/backend-database.md | 32 +++ docs/pending-test.md | 2 + docs/todo.md | 4 - handler/settings.go | 38 +++ model/setting.go | 50 ++++ repository/db.go | 1 + repository/setting.go | 48 ++++ router/router.go | 3 + service/settings.go | 45 ++++ web/bun.lock | 42 ++++ web/package.json | 2 + web/src/app/(admin)/admin/layout.tsx | 7 +- web/src/app/(admin)/admin/prompts/page.tsx | 2 +- web/src/app/(admin)/admin/settings/page.tsx | 242 ++++++++++++++++++++ web/src/app/globals.css | 30 +++ web/src/services/api/admin.ts | 36 +++ 17 files changed, 578 insertions(+), 8 deletions(-) create mode 100644 handler/settings.go create mode 100644 model/setting.go create mode 100644 repository/setting.go create mode 100644 service/settings.go create mode 100644 web/src/app/(admin)/admin/settings/page.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 388839c..26c3600 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased ++ [新增] 管理后台新增系统设置页面,支持可视化编辑和手动编辑 JSON。 ++ [新增] 后端新增 `settings` 表和系统设置接口,支持 public/private 两行 JSON 配置。 + [修复] Docker 构建和运行阶段补充拷贝 `CHANGELOG.md`,避免版本信息读取失败。 ## v0.0.5 - 2026-05-20 diff --git a/docs/backend-database.md b/docs/backend-database.md index 2e1c033..a307e70 100644 --- a/docs/backend-database.md +++ b/docs/backend-database.md @@ -17,6 +17,7 @@ - `users` - `prompts` - `assets` +- `settings` 后续新增表时,优先保持表数量少,能用字段或 JSON 表达的配置、状态、统计和扩展信息先不拆表。 @@ -104,6 +105,37 @@ `public.value` 常放前端展示和可公开读取的配置,例如模型列表、订阅套餐、功能开关等。 `private.value` 常放渠道密钥、支付配置、奖励规则、后台内部开关等。 +当前系统设置接口会按后端结构体序列化和反序列化已知字段;数据库 JSON 中额外存在的旧字段会被忽略。 + +`public.value` 当前字段: + +| 字段 | 类型 | 说明 | +|-------------------|----------|----------------| +| `availableModels` | string[] | 系统可用模型列表 | +| `defaultModel` | string | 默认模型 | +| `defaultImageModel` | string | 默认图片模型 | +| `defaultTextModel` | string | 默认文本模型 | +| `systemPrompt` | string | 系统提示词 | +| `allowCustomModel` | bool | 是否允许用户自定义模型 | + +`private.value` 当前字段: + +| 字段 | 类型 | 说明 | +|------------|----------|----------| +| `channels` | object[] | 模型渠道配置列表 | + +`channels` 每项字段: + +| 字段 | 类型 | 说明 | +|----------|----------|----------| +| `key` | string | 渠道标识 | +| `name` | string | 渠道名称 | +| `baseUrl` | string | 渠道接口地址 | +| `apiKey` | string | 渠道密钥 | +| `models` | string[] | 渠道可用模型列表 | +| `enabled` | bool | 是否启用 | +| `remark` | string | 备注 | + ### dicts 字典表。一个字典一行,具体字典项数据放在 `items`。 diff --git a/docs/pending-test.md b/docs/pending-test.md index a66f83b..3b6948d 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -3,3 +3,5 @@ - 点击右上角版本号时,应弹出版本更新弹窗,并展示当前版本、最新版本、检查更新入口和可滚动的版本时间线。 - 设置弹窗中填写系统提示词后,生图、编辑图和画布助手文本请求应携带该提示词。 - Docker 镜像构建和运行时,前端应能正常读取 `CHANGELOG.md` 并完成版本信息注入。 +- 后端应自动维护 `settings` 表,并支持公开读取 public 配置、管理员读取和保存 public/private 两行 JSON 配置。 +- 管理后台系统设置页面应支持可视化编辑和手动编辑 JSON,并能保存 public/private 配置。 diff --git a/docs/todo.md b/docs/todo.md index a9345e8..7e444ef 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -10,7 +10,6 @@ - 登录注册和用户模块:新增 `users` 表,用户角色、算力点余额、邀请码、邀请人、邀请人数和第三方平台用户 ID 等直接放在用户表字段里;补齐登录、注册、第三方登录、用户信息、管理员用户管理等基础能力。 -- 系统设置和模型配置:新增 `settings` 表,只保存 `public` 和 `private` 两行 JSON 配置,用于系统可用模型、模型渠道、系统提示词、用户是否允许自定义模型等后台开关。 - 字典管理:新增 `dicts` 表,一个字典一行,具体字典项用 JSON 保存;用于维护分类、标签、业务枚举、模型分类、日志类型等可配置项。 - 日志管理:新增 `credit_logs` 记录用户算力点变更流水,新增 `api_logs` 记录第三方大模型 API 调用情况。 - 对话工具调用:在助手对话中增加类似工具调用的形式,展示模型调用、生成图片、插入画布等步骤。 @@ -43,7 +42,4 @@ ## P2 体验和工程 -- 移动端适配:先不整体展开,只围绕在线生图等移动端必要页面做基础适配。 - 接口调用队列:图片/文本生成接口增加队列、并发限制、后台状态和基础失败处理,避免大量请求直接打到模型渠道。 -- README 优化:保持 README 简洁,补项目介绍、核心功能、快速开始和文档入口。 -- 版本发布流程:规范版本号、changelog、release 脚本、镜像 tag 规则和升级说明。 diff --git a/handler/settings.go b/handler/settings.go new file mode 100644 index 0000000..1422a6c --- /dev/null +++ b/handler/settings.go @@ -0,0 +1,38 @@ +package handler + +import ( + "encoding/json" + "net/http" + + "github.com/basketikun/infinite-canvas/model" + "github.com/basketikun/infinite-canvas/service" +) + +func Settings(w http.ResponseWriter, r *http.Request) { + settings, err := service.PublicSettings() + if err != nil { + Fail(w, err.Error()) + return + } + OK(w, settings) +} + +func AdminSettings(w http.ResponseWriter, r *http.Request) { + settings, err := service.AdminSettings() + if err != nil { + Fail(w, err.Error()) + return + } + OK(w, settings) +} + +func AdminSaveSettings(w http.ResponseWriter, r *http.Request) { + var settings model.Settings + _ = json.NewDecoder(r.Body).Decode(&settings) + result, err := service.SaveSettings(settings) + if err != nil { + Fail(w, err.Error()) + return + } + OK(w, result) +} diff --git a/model/setting.go b/model/setting.go new file mode 100644 index 0000000..b799651 --- /dev/null +++ b/model/setting.go @@ -0,0 +1,50 @@ +package model + +import "encoding/json" + +type SettingKey string + +const ( + SettingKeyPublic SettingKey = "public" + SettingKeyPrivate SettingKey = "private" +) + +// ModelChannel 模型渠道配置。 +type ModelChannel struct { + Key string `json:"key"` + Name string `json:"name"` + BaseURL string `json:"baseUrl"` + APIKey string `json:"apiKey"` + Models []string `json:"models"` + Enabled bool `json:"enabled"` + Remark string `json:"remark"` +} + +// PublicSetting 公开配置。 +type PublicSetting struct { + AvailableModels []string `json:"availableModels"` + DefaultModel string `json:"defaultModel"` + DefaultImageModel string `json:"defaultImageModel"` + DefaultTextModel string `json:"defaultTextModel"` + SystemPrompt string `json:"systemPrompt"` + AllowCustomModel bool `json:"allowCustomModel"` +} + +// PrivateSetting 私有配置。 +type PrivateSetting struct { + Channels []ModelChannel `json:"channels"` +} + +// Setting 系统配置。 +type Setting struct { + Key SettingKey `json:"key" gorm:"primaryKey"` + Value json.RawMessage `json:"value" gorm:"serializer:json"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// Settings 系统公开和私有配置。 +type Settings struct { + Public PublicSetting `json:"public"` + Private PrivateSetting `json:"private"` +} diff --git a/repository/db.go b/repository/db.go index 03b43b1..4a1ae7f 100644 --- a/repository/db.go +++ b/repository/db.go @@ -48,6 +48,7 @@ func DB() (*gorm.DB, error) { &model.User{}, &model.Prompt{}, &model.Asset{}, + &model.Setting{}, ) }) return db, dbErr diff --git a/repository/setting.go b/repository/setting.go new file mode 100644 index 0000000..9e07395 --- /dev/null +++ b/repository/setting.go @@ -0,0 +1,48 @@ +package repository + +import ( + "encoding/json" + + "github.com/basketikun/infinite-canvas/model" + "gorm.io/gorm/clause" +) + +// GetSettings 返回 public 和 private 两行配置。 +func GetSettings() (model.Settings, error) { + db, err := DB() + if err != nil { + return model.Settings{}, err + } + var items []model.Setting + if err := db.Find(&items).Error; err != nil { + return model.Settings{}, err + } + result := model.Settings{} + for _, item := range items { + if item.Key == model.SettingKeyPrivate { + _ = json.Unmarshal(item.Value, &result.Private) + } else if item.Key == model.SettingKeyPublic { + _ = json.Unmarshal(item.Value, &result.Public) + } + } + return result, nil +} + +// SaveSettings 保存 public 和 private 两行配置。 +func SaveSettings(settings model.Settings, now string) (model.Settings, error) { + db, err := DB() + if err != nil { + return settings, err + } + publicValue, _ := json.Marshal(settings.Public) + privateValue, _ := json.Marshal(settings.Private) + items := []model.Setting{ + {Key: model.SettingKeyPublic, Value: publicValue, CreatedAt: now, UpdatedAt: now}, + {Key: model.SettingKeyPrivate, Value: privateValue, CreatedAt: now, UpdatedAt: now}, + } + err = db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "key"}}, + DoUpdates: clause.AssignmentColumns([]string{"value", "updated_at"}), + }).Create(&items).Error + return settings, err +} diff --git a/router/router.go b/router/router.go index c311a44..3dc2ed2 100644 --- a/router/router.go +++ b/router/router.go @@ -19,6 +19,7 @@ func New() *gin.Engine { api.POST("/auth/register", gin.WrapF(handler.Register)) api.POST("/auth/login", gin.WrapF(handler.Login)) api.GET("/auth/me", middleware.OptionalAuth, gin.WrapF(handler.CurrentUser)) + api.GET("/settings", gin.WrapF(handler.Settings)) api.GET("/prompts", middleware.OptionalAuth, gin.WrapF(handler.Prompts)) api.GET("/assets", middleware.OptionalAuth, gin.WrapF(handler.Assets)) api.POST("/admin/login", gin.WrapF(handler.AdminLogin)) @@ -29,6 +30,8 @@ func New() *gin.Engine { admin.DELETE("/users/:id", func(c *gin.Context) { handler.AdminDeleteUser(c.Writer, c.Request, c.Param("id")) }) + admin.GET("/settings", gin.WrapF(handler.AdminSettings)) + admin.POST("/settings", gin.WrapF(handler.AdminSaveSettings)) admin.GET("/prompt-categories", gin.WrapF(handler.AdminPromptCategories)) admin.POST("/prompt-categories/sync", gin.WrapF(handler.AdminSyncPromptCategories)) admin.GET("/prompts", gin.WrapF(handler.AdminPrompts)) diff --git a/service/settings.go b/service/settings.go new file mode 100644 index 0000000..fb32ecc --- /dev/null +++ b/service/settings.go @@ -0,0 +1,45 @@ +package service + +import ( + "github.com/basketikun/infinite-canvas/model" + "github.com/basketikun/infinite-canvas/repository" +) + +func PublicSettings() (model.PublicSetting, error) { + settings, err := repository.GetSettings() + return normalizePublicSetting(settings.Public), err +} + +func AdminSettings() (model.Settings, error) { + settings, err := repository.GetSettings() + return normalizeSettings(settings), err +} + +func SaveSettings(settings model.Settings) (model.Settings, error) { + return repository.SaveSettings(normalizeSettings(settings), now()) +} + +func normalizeSettings(settings model.Settings) model.Settings { + settings.Public = normalizePublicSetting(settings.Public) + settings.Private = normalizePrivateSetting(settings.Private) + return settings +} + +func normalizePublicSetting(setting model.PublicSetting) model.PublicSetting { + if setting.AvailableModels == nil { + setting.AvailableModels = []string{} + } + return setting +} + +func normalizePrivateSetting(setting model.PrivateSetting) model.PrivateSetting { + if setting.Channels == nil { + setting.Channels = []model.ModelChannel{} + } + for i := range setting.Channels { + if setting.Channels[i].Models == nil { + setting.Channels[i].Models = []string{} + } + } + return setting +} diff --git a/web/bun.lock b/web/bun.lock index ee6b716..32220d7 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -7,7 +7,9 @@ "dependencies": { "@ant-design/icons": "^6.1.1", "@ant-design/pro-components": "3.0.0-beta.3", + "@codemirror/lang-json": "^6.0.2", "@tanstack/react-query": "^5.100.9", + "@uiw/react-codemirror": "^4.25.9", "antd": "^6.4.2", "axios": "^1.16.0", "class-variance-authority": "^0.7.1", @@ -113,6 +115,24 @@ "@babel/types": ["@babel/types@7.29.0", "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ=="], + + "@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="], + + "@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="], + + "@codemirror/language": ["@codemirror/language@6.12.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA=="], + + "@codemirror/lint": ["@codemirror/lint@6.9.6", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A=="], + + "@codemirror/search": ["@codemirror/search@6.7.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg=="], + + "@codemirror/state": ["@codemirror/state@6.6.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="], + + "@codemirror/theme-one-dark": ["@codemirror/theme-one-dark@6.1.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA=="], + + "@codemirror/view": ["@codemirror/view@6.43.0", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA=="], + "@ctrl/tinycolor": ["@ctrl/tinycolor@4.2.0", "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", {}, "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A=="], "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], @@ -231,6 +251,16 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="], + + "@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="], + + "@lezer/json": ["@lezer/json@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="], + + "@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="], + + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "https://registry.npmmirror.com/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "@mswjs/interceptors": ["@mswjs/interceptors@0.41.9", "https://registry.npmmirror.com/@mswjs/interceptors/-/interceptors-0.41.9.tgz", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w=="], @@ -543,6 +573,10 @@ "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "https://registry.npmmirror.com/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], + "@uiw/codemirror-extensions-basic-setup": ["@uiw/codemirror-extensions-basic-setup@4.25.9", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-QFAqr+pu6lDmNpAlecODcF49TlsrZ0bj15zPzfhiqSDl+Um3EsDLFLppixC7kFLn+rdDM2LTvVjn5CPvefpRgw=="], + + "@uiw/react-codemirror": ["@uiw/react-codemirror@4.25.9", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@codemirror/commands": "^6.1.0", "@codemirror/state": "^6.1.1", "@codemirror/theme-one-dark": "^6.0.0", "@uiw/codemirror-extensions-basic-setup": "4.25.9", "codemirror": "^6.0.0" }, "peerDependencies": { "@codemirror/view": ">=6.0.0", "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-HftqCBUYShAOH0pGi1CHP8vfm5L8fQ3+0j0VI6lQD6QpK+UBu3J7nxfEN5O/BXMilMNf9ZyFJRvRcuMMOLHMng=="], + "@umijs/route-utils": ["@umijs/route-utils@4.0.3", "https://registry.npmmirror.com/@umijs/route-utils/-/route-utils-4.0.3.tgz", {}, "sha512-zPEcYhl1cSfkSRDzzGgoD1mDvGjxoOTJFvkn55srfgdQ3NZe2ZMCScCU6DEnOxuKP1XDVf8pqyqCDVd2+RCQIw=="], "@umijs/use-params": ["@umijs/use-params@1.0.9", "https://registry.npmmirror.com/@umijs/use-params/-/use-params-1.0.9.tgz", { "peerDependencies": { "react": "*" } }, "sha512-QlN0RJSBVQBwLRNxbxjQ5qzqYIGn+K7USppMoIOVlf7fxXHsnQZ2bEsa6Pm74bt6DVQxpUE8HqvdStn6Y9FV1w=="], @@ -625,6 +659,8 @@ "code-block-writer": ["code-block-writer@13.0.3", "https://registry.npmmirror.com/code-block-writer/-/code-block-writer-13.0.3.tgz", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], + "codemirror": ["codemirror@6.0.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="], + "color-convert": ["color-convert@2.0.1", "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -653,6 +689,8 @@ "cosmiconfig": ["cosmiconfig@9.0.1", "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-9.0.1.tgz", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="], + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], + "cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cssesc": ["cssesc@3.0.0", "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], @@ -1279,6 +1317,8 @@ "strip-final-newline": ["strip-final-newline@4.0.0", "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-4.0.0.tgz", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + "style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], + "style-to-object": ["style-to-object@0.4.4", "https://registry.npmmirror.com/style-to-object/-/style-to-object-0.4.4.tgz", { "dependencies": { "inline-style-parser": "0.1.1" } }, "sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], @@ -1373,6 +1413,8 @@ "vfile-message": ["vfile-message@3.1.4", "https://registry.npmmirror.com/vfile-message/-/vfile-message-3.1.4.tgz", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-stringify-position": "^3.0.0" } }, "sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "which": ["which@4.0.0", "https://registry.npmmirror.com/which/-/which-4.0.0.tgz", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], diff --git a/web/package.json b/web/package.json index 72f53b8..70e5758 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,9 @@ "dependencies": { "@ant-design/icons": "^6.1.1", "@ant-design/pro-components": "3.0.0-beta.3", + "@codemirror/lang-json": "^6.0.2", "@tanstack/react-query": "^5.100.9", + "@uiw/react-codemirror": "^4.25.9", "antd": "^6.4.2", "axios": "^1.16.0", "class-variance-authority": "^0.7.1", diff --git a/web/src/app/(admin)/admin/layout.tsx b/web/src/app/(admin)/admin/layout.tsx index a40320b..23f751f 100644 --- a/web/src/app/(admin)/admin/layout.tsx +++ b/web/src/app/(admin)/admin/layout.tsx @@ -1,6 +1,6 @@ "use client"; -import { FileTextOutlined, HomeOutlined, LogoutOutlined, PictureOutlined } from "@ant-design/icons"; +import { FileTextOutlined, HomeOutlined, LogoutOutlined, PictureOutlined, SettingOutlined } from "@ant-design/icons"; import { Button, Flex, Layout, Menu, Typography } from "antd"; import { LogOut } from "lucide-react"; import Link from "next/link"; @@ -21,8 +21,8 @@ export default function AdminLayout({ children }: { children: ReactNode }) { const logout = useUserStore((state) => state.clearSession); const colorTheme = useThemeStore((state) => state.theme); const setTheme = useThemeStore((state) => state.setTheme); - const activeKey = pathname.startsWith("/admin/assets") ? "/admin/assets" : pathname.startsWith("/admin/prompts") ? "/admin/prompts" : ""; - const pageTitle = pathname.startsWith("/admin/assets") ? "素材库管理" : "提示词管理"; + const activeKey = pathname.startsWith("/admin/settings") ? "/admin/settings" : pathname.startsWith("/admin/assets") ? "/admin/assets" : pathname.startsWith("/admin/prompts") ? "/admin/prompts" : ""; + const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : "提示词管理"; const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || "dev"; useEffect(() => { @@ -58,6 +58,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) { items={[ { key: "/admin/prompts", icon: , label: 提示词管理 }, { key: "/admin/assets", icon: , label: 素材库 }, + { key: "/admin/settings", icon: , label: 系统设置 }, ]} /> diff --git a/web/src/app/(admin)/admin/prompts/page.tsx b/web/src/app/(admin)/admin/prompts/page.tsx index bf0f7d9..5db1e65 100644 --- a/web/src/app/(admin)/admin/prompts/page.tsx +++ b/web/src/app/(admin)/admin/prompts/page.tsx @@ -136,7 +136,7 @@ export default function AdminPromptsPage() { ) : null} - !isSyncing && setIsSyncOpen(false)} maskClosable={!isSyncing} footer={}> + !isSyncing && setIsSyncOpen(false)} mask={{ closable: !isSyncing }} footer={}> item.remote)} diff --git a/web/src/app/(admin)/admin/settings/page.tsx b/web/src/app/(admin)/admin/settings/page.tsx new file mode 100644 index 0000000..38035d7 --- /dev/null +++ b/web/src/app/(admin)/admin/settings/page.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { CheckCircleOutlined, CodeOutlined, DeleteOutlined, FormatPainterOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from "@ant-design/icons"; +import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; +import { json } from "@codemirror/lang-json"; +import { tags } from "@lezer/highlight"; +import { App, Button, Card, Col, Flex, Form, Input, Row, Segmented, Select, Space, Switch, Tag, Typography } from "antd"; +import dynamic from "next/dynamic"; +import { useEffect, useState } from "react"; +import { EditorView } from "@uiw/react-codemirror"; + +import { fetchAdminSettings, saveAdminSettings, type AdminSettings } from "@/services/api/admin"; +import { useUserStore } from "@/stores/use-user-store"; + +const CodeMirror = dynamic(() => import("@uiw/react-codemirror"), { ssr: false }); +const jsonEditorTheme = EditorView.theme({ + "&": { backgroundColor: "var(--card)", color: "var(--foreground)" }, + ".cm-content": { caretColor: "var(--foreground)", padding: "12px 0" }, + ".cm-line": { padding: "0 18px" }, + ".cm-gutters": { backgroundColor: "color-mix(in srgb, var(--card) 94%, var(--foreground))", borderRight: "1px solid var(--border)", color: "var(--muted-foreground)" }, + ".cm-activeLine": { backgroundColor: "color-mix(in srgb, var(--primary) 9%, transparent)" }, + ".cm-activeLineGutter": { backgroundColor: "color-mix(in srgb, var(--primary) 10%, transparent)", color: "var(--foreground)" }, + ".cm-cursor": { borderLeftColor: "var(--foreground)" }, + ".cm-selectionBackground, &.cm-focused .cm-selectionBackground": { backgroundColor: "color-mix(in srgb, var(--primary) 25%, transparent)" }, + ".cm-foldPlaceholder": { backgroundColor: "var(--muted)", border: "1px solid var(--border)", color: "var(--muted-foreground)" }, + "&.cm-focused": { outline: "none" }, +}, { dark: true }); +const jsonHighlightStyle = HighlightStyle.define([ + { tag: tags.propertyName, color: "#f2a7a7" }, + { tag: tags.string, color: "#d6c6a8" }, + { tag: tags.number, color: "#ddb27d" }, + { tag: tags.bool, color: "#c5b3ff" }, + { tag: tags.null, color: "#c5b3ff" }, + { tag: tags.punctuation, color: "rgba(250, 250, 249, 0.62)" }, + { tag: tags.squareBracket, color: "rgba(250, 250, 249, 0.62)" }, + { tag: tags.brace, color: "rgba(250, 250, 249, 0.62)" }, +]); + +const emptySettings: AdminSettings = { + public: { + availableModels: [], + defaultModel: "", + defaultImageModel: "", + defaultTextModel: "", + systemPrompt: "", + allowCustomModel: false, + }, + private: { channels: [] }, +}; + +export default function AdminSettingsPage() { + const token = useUserStore((state) => state.token); + const { message } = App.useApp(); + const [form] = Form.useForm(); + const [mode, setMode] = useState<"visual" | "json">("visual"); + const [jsonText, setJsonText] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const models = Form.useWatch(["public", "availableModels"], form) || []; + const jsonError = mode === "json" ? getJsonError(jsonText) : ""; + + const loadSettings = async () => { + if (!token) return; + setIsLoading(true); + try { + const data = normalizeSettings(await fetchAdminSettings(token)); + form.setFieldsValue(data); + setJsonText(JSON.stringify(data, null, 2)); + } catch (error) { + message.error(error instanceof Error ? error.message : "读取设置失败"); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + void loadSettings(); + }, [token]); + + const changeMode = (nextMode: "visual" | "json") => { + if (nextMode === "json") { + const values = normalizeSettings(form.getFieldsValue(true) as AdminSettings); + setJsonText(JSON.stringify(values, null, 2)); + } else { + const parsed = parseJsonSettings(jsonText); + if (!parsed) { + message.error("JSON 格式不正确"); + return; + } + form.setFieldsValue(parsed); + } + setMode(nextMode); + }; + + const saveSettings = async () => { + if (!token) return; + const values = mode === "json" ? parseJsonSettings(jsonText) : normalizeSettings(await form.validateFields()); + if (!values) { + message.error("JSON 格式不正确"); + return; + } + setIsSaving(true); + try { + const saved = normalizeSettings(await saveAdminSettings(token, values)); + form.setFieldsValue(saved); + setJsonText(JSON.stringify(saved, null, 2)); + message.success("已保存"); + } catch (error) { + message.error(error instanceof Error ? error.message : "保存失败"); + } finally { + setIsSaving(false); + } + }; + + const formatJson = () => { + const parsed = parseJsonSettings(jsonText); + if (!parsed) { + message.error("JSON 格式不正确"); + return; + } + setJsonText(JSON.stringify(parsed, null, 2)); + }; + + return ( +
+ + + + + changeMode(value as "visual" | "json")} options={[{ label: "可视化编辑", value: "visual" }, { label: "手动编辑 JSON", value: "json" }]} /> + + + + + + + {mode === "visual" ? ( +
+ + +
({ label: item, value: item }))} /> + ({ label: item, value: item }))} /> + + + + + + + + {(fields, { add, remove }) => ( + + {fields.map((field) => ( +
+ + {`渠道 ${field.name + 1}`} +
+ + + + +