feat(admin): 新增系统设置页面和配置管理功能

- 新增 settings 表用于存储 public/private 两行 JSON 配置
- 添加 AdminSettings 相关类型定义和 API 接口
- 实现管理后台系统设置页面,支持可视化和 JSON 编辑模式
- 集成 CodeMirror 编辑器用于 JSON 配置编辑
- 添加系统设置相关的路由和接口
- 更新依赖包并修改相关样式和布局文件
This commit is contained in:
HouYunFei
2026-05-20 17:53:07 +08:00
parent a78eef73a8
commit cf085770e7
17 changed files with 578 additions and 8 deletions
+2
View File
@@ -2,6 +2,8 @@
## Unreleased ## Unreleased
+ [新增] 管理后台新增系统设置页面,支持可视化编辑和手动编辑 JSON。
+ [新增] 后端新增 `settings` 表和系统设置接口,支持 public/private 两行 JSON 配置。
+ [修复] Docker 构建和运行阶段补充拷贝 `CHANGELOG.md`,避免版本信息读取失败。 + [修复] Docker 构建和运行阶段补充拷贝 `CHANGELOG.md`,避免版本信息读取失败。
## v0.0.5 - 2026-05-20 ## v0.0.5 - 2026-05-20
+32
View File
@@ -17,6 +17,7 @@
- `users` - `users`
- `prompts` - `prompts`
- `assets` - `assets`
- `settings`
后续新增表时,优先保持表数量少,能用字段或 JSON 表达的配置、状态、统计和扩展信息先不拆表。 后续新增表时,优先保持表数量少,能用字段或 JSON 表达的配置、状态、统计和扩展信息先不拆表。
@@ -104,6 +105,37 @@
`public.value` 常放前端展示和可公开读取的配置,例如模型列表、订阅套餐、功能开关等。 `public.value` 常放前端展示和可公开读取的配置,例如模型列表、订阅套餐、功能开关等。
`private.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 ### dicts
字典表。一个字典一行,具体字典项数据放在 `items` 字典表。一个字典一行,具体字典项数据放在 `items`
+2
View File
@@ -3,3 +3,5 @@
- 点击右上角版本号时,应弹出版本更新弹窗,并展示当前版本、最新版本、检查更新入口和可滚动的版本时间线。 - 点击右上角版本号时,应弹出版本更新弹窗,并展示当前版本、最新版本、检查更新入口和可滚动的版本时间线。
- 设置弹窗中填写系统提示词后,生图、编辑图和画布助手文本请求应携带该提示词。 - 设置弹窗中填写系统提示词后,生图、编辑图和画布助手文本请求应携带该提示词。
- Docker 镜像构建和运行时,前端应能正常读取 `CHANGELOG.md` 并完成版本信息注入。 - Docker 镜像构建和运行时,前端应能正常读取 `CHANGELOG.md` 并完成版本信息注入。
- 后端应自动维护 `settings` 表,并支持公开读取 public 配置、管理员读取和保存 public/private 两行 JSON 配置。
- 管理后台系统设置页面应支持可视化编辑和手动编辑 JSON,并能保存 public/private 配置。
-4
View File
@@ -10,7 +10,6 @@
- 登录注册和用户模块:新增 `users` 表,用户角色、算力点余额、邀请码、邀请人、邀请人数和第三方平台用户 ID - 登录注册和用户模块:新增 `users` 表,用户角色、算力点余额、邀请码、邀请人、邀请人数和第三方平台用户 ID
等直接放在用户表字段里;补齐登录、注册、第三方登录、用户信息、管理员用户管理等基础能力。 等直接放在用户表字段里;补齐登录、注册、第三方登录、用户信息、管理员用户管理等基础能力。
- 系统设置和模型配置:新增 `settings` 表,只保存 `public``private` 两行 JSON 配置,用于系统可用模型、模型渠道、系统提示词、用户是否允许自定义模型等后台开关。
- 字典管理:新增 `dicts` 表,一个字典一行,具体字典项用 JSON 保存;用于维护分类、标签、业务枚举、模型分类、日志类型等可配置项。 - 字典管理:新增 `dicts` 表,一个字典一行,具体字典项用 JSON 保存;用于维护分类、标签、业务枚举、模型分类、日志类型等可配置项。
- 日志管理:新增 `credit_logs` 记录用户算力点变更流水,新增 `api_logs` 记录第三方大模型 API 调用情况。 - 日志管理:新增 `credit_logs` 记录用户算力点变更流水,新增 `api_logs` 记录第三方大模型 API 调用情况。
- 对话工具调用:在助手对话中增加类似工具调用的形式,展示模型调用、生成图片、插入画布等步骤。 - 对话工具调用:在助手对话中增加类似工具调用的形式,展示模型调用、生成图片、插入画布等步骤。
@@ -43,7 +42,4 @@
## P2 体验和工程 ## P2 体验和工程
- 移动端适配:先不整体展开,只围绕在线生图等移动端必要页面做基础适配。
- 接口调用队列:图片/文本生成接口增加队列、并发限制、后台状态和基础失败处理,避免大量请求直接打到模型渠道。 - 接口调用队列:图片/文本生成接口增加队列、并发限制、后台状态和基础失败处理,避免大量请求直接打到模型渠道。
- README 优化:保持 README 简洁,补项目介绍、核心功能、快速开始和文档入口。
- 版本发布流程:规范版本号、changelog、release 脚本、镜像 tag 规则和升级说明。
+38
View File
@@ -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)
}
+50
View File
@@ -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"`
}
+1
View File
@@ -48,6 +48,7 @@ func DB() (*gorm.DB, error) {
&model.User{}, &model.User{},
&model.Prompt{}, &model.Prompt{},
&model.Asset{}, &model.Asset{},
&model.Setting{},
) )
}) })
return db, dbErr return db, dbErr
+48
View File
@@ -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
}
+3
View File
@@ -19,6 +19,7 @@ func New() *gin.Engine {
api.POST("/auth/register", gin.WrapF(handler.Register)) api.POST("/auth/register", gin.WrapF(handler.Register))
api.POST("/auth/login", gin.WrapF(handler.Login)) api.POST("/auth/login", gin.WrapF(handler.Login))
api.GET("/auth/me", middleware.OptionalAuth, gin.WrapF(handler.CurrentUser)) 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("/prompts", middleware.OptionalAuth, gin.WrapF(handler.Prompts))
api.GET("/assets", middleware.OptionalAuth, gin.WrapF(handler.Assets)) api.GET("/assets", middleware.OptionalAuth, gin.WrapF(handler.Assets))
api.POST("/admin/login", gin.WrapF(handler.AdminLogin)) api.POST("/admin/login", gin.WrapF(handler.AdminLogin))
@@ -29,6 +30,8 @@ func New() *gin.Engine {
admin.DELETE("/users/:id", func(c *gin.Context) { admin.DELETE("/users/:id", func(c *gin.Context) {
handler.AdminDeleteUser(c.Writer, c.Request, c.Param("id")) 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.GET("/prompt-categories", gin.WrapF(handler.AdminPromptCategories))
admin.POST("/prompt-categories/sync", gin.WrapF(handler.AdminSyncPromptCategories)) admin.POST("/prompt-categories/sync", gin.WrapF(handler.AdminSyncPromptCategories))
admin.GET("/prompts", gin.WrapF(handler.AdminPrompts)) admin.GET("/prompts", gin.WrapF(handler.AdminPrompts))
+45
View File
@@ -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
}
+42
View File
@@ -7,7 +7,9 @@
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.1.1", "@ant-design/icons": "^6.1.1",
"@ant-design/pro-components": "3.0.0-beta.3", "@ant-design/pro-components": "3.0.0-beta.3",
"@codemirror/lang-json": "^6.0.2",
"@tanstack/react-query": "^5.100.9", "@tanstack/react-query": "^5.100.9",
"@uiw/react-codemirror": "^4.25.9",
"antd": "^6.4.2", "antd": "^6.4.2",
"axios": "^1.16.0", "axios": "^1.16.0",
"class-variance-authority": "^0.7.1", "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=="], "@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=="], "@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=="], "@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=="], "@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=="], "@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=="], "@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=="], "@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/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=="], "@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=="], "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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="],
+2
View File
@@ -11,7 +11,9 @@
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.1.1", "@ant-design/icons": "^6.1.1",
"@ant-design/pro-components": "3.0.0-beta.3", "@ant-design/pro-components": "3.0.0-beta.3",
"@codemirror/lang-json": "^6.0.2",
"@tanstack/react-query": "^5.100.9", "@tanstack/react-query": "^5.100.9",
"@uiw/react-codemirror": "^4.25.9",
"antd": "^6.4.2", "antd": "^6.4.2",
"axios": "^1.16.0", "axios": "^1.16.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
+4 -3
View File
@@ -1,6 +1,6 @@
"use client"; "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 { Button, Flex, Layout, Menu, Typography } from "antd";
import { LogOut } from "lucide-react"; import { LogOut } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@@ -21,8 +21,8 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
const logout = useUserStore((state) => state.clearSession); const logout = useUserStore((state) => state.clearSession);
const colorTheme = useThemeStore((state) => state.theme); const colorTheme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme); const setTheme = useThemeStore((state) => state.setTheme);
const activeKey = pathname.startsWith("/admin/assets") ? "/admin/assets" : pathname.startsWith("/admin/prompts") ? "/admin/prompts" : ""; 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/assets") ? "素材库管理" : "提示词管理"; const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : "提示词管理";
const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || "dev"; const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || "dev";
useEffect(() => { useEffect(() => {
@@ -58,6 +58,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
items={[ items={[
{ key: "/admin/prompts", icon: <FileTextOutlined />, label: <Link href="/admin/prompts" style={{ color: "inherit" }}></Link> }, { key: "/admin/prompts", icon: <FileTextOutlined />, label: <Link href="/admin/prompts" style={{ color: "inherit" }}></Link> },
{ key: "/admin/assets", icon: <PictureOutlined />, label: <Link href="/admin/assets" style={{ color: "inherit" }}></Link> }, { key: "/admin/assets", icon: <PictureOutlined />, label: <Link href="/admin/assets" style={{ color: "inherit" }}></Link> },
{ key: "/admin/settings", icon: <SettingOutlined />, label: <Link href="/admin/settings" style={{ color: "inherit" }}></Link> },
]} ]}
/> />
<Flex vertical gap={8} style={{ position: "absolute", bottom: 0, insetInline: 0, padding: 12 }}> <Flex vertical gap={8} style={{ position: "absolute", bottom: 0, insetInline: 0, padding: 12 }}>
+1 -1
View File
@@ -136,7 +136,7 @@ export default function AdminPromptsPage() {
) : null} ) : null}
</Modal> </Modal>
<Modal title="同步远程提示词源" open={isSyncOpen} width={640} onCancel={() => !isSyncing && setIsSyncOpen(false)} maskClosable={!isSyncing} footer={<Button disabled={isSyncing} onClick={() => setIsSyncOpen(false)}></Button>}> <Modal title="同步远程提示词源" open={isSyncOpen} width={640} onCancel={() => !isSyncing && setIsSyncOpen(false)} mask={{ closable: !isSyncing }} footer={<Button disabled={isSyncing} onClick={() => setIsSyncOpen(false)}></Button>}>
<Table <Table
rowKey="category" rowKey="category"
dataSource={categories.filter((item) => item.remote)} dataSource={categories.filter((item) => item.remote)}
+242
View File
@@ -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<AdminSettings>();
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 (
<main style={{ padding: 24 }}>
<Flex vertical gap={16}>
<Card variant="borderless">
<Flex justify="space-between" align="center" gap={16} wrap>
<Space>
<Segmented value={mode} onChange={(value) => changeMode(value as "visual" | "json")} options={[{ label: "可视化编辑", value: "visual" }, { label: "手动编辑 JSON", value: "json" }]} />
<Button icon={<ReloadOutlined />} loading={isLoading} onClick={() => void loadSettings()}></Button>
</Space>
<Button type="primary" icon={<SaveOutlined />} loading={isSaving} onClick={() => void saveSettings()}></Button>
</Flex>
</Card>
{mode === "visual" ? (
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
<Card title="公开配置" variant="borderless" style={{ marginBottom: 16 }}>
<Row gutter={16}>
<Col span={24}><Form.Item name={["public", "availableModels"]} label="系统可用模型"><Select mode="tags" tokenSeparators={[",", "\n"]} options={models.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name={["public", "defaultModel"]} label="默认模型"><Select showSearch allowClear options={models.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name={["public", "defaultImageModel"]} label="默认图片模型"><Select showSearch allowClear options={models.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name={["public", "defaultTextModel"]} label="默认文本模型"><Select showSearch allowClear options={models.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
<Col span={24}><Form.Item name={["public", "systemPrompt"]} label="系统提示词"><Input.TextArea rows={4} /></Form.Item></Col>
<Col span={24}><Form.Item name={["public", "allowCustomModel"]} label="允许用户自定义模型" valuePropName="checked"><Switch /></Form.Item></Col>
</Row>
</Card>
<Card title="模型渠道" variant="borderless">
<Form.List name={["private", "channels"]}>
{(fields, { add, remove }) => (
<Flex vertical gap={12}>
{fields.map((field) => (
<div key={field.key} style={{ border: "1px solid var(--ant-color-border)", borderRadius: 6, padding: 16 }}>
<Flex justify="space-between" align="center" style={{ marginBottom: 12 }}>
<Typography.Text strong>{`渠道 ${field.name + 1}`}</Typography.Text>
<Button danger type="text" icon={<DeleteOutlined />} onClick={() => remove(field.name)} />
</Flex>
<Row gutter={16}>
<Col xs={24} md={8}><Form.Item name={[field.name, "key"]} label="渠道标识"><Input /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name={[field.name, "name"]} label="渠道名称"><Input /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name={[field.name, "enabled"]} label="启用" valuePropName="checked"><Switch /></Form.Item></Col>
<Col xs={24} md={12}><Form.Item name={[field.name, "baseUrl"]} label="接口地址"><Input /></Form.Item></Col>
<Col xs={24} md={12}><Form.Item name={[field.name, "apiKey"]} label="API Key"><Input.Password /></Form.Item></Col>
<Col span={24}><Form.Item name={[field.name, "models"]} label="渠道可用模型"><Select mode="tags" tokenSeparators={[",", "\n"]} /></Form.Item></Col>
<Col span={24}><Form.Item name={[field.name, "remark"]} label="备注"><Input.TextArea rows={2} /></Form.Item></Col>
</Row>
</div>
))}
<Button icon={<PlusOutlined />} onClick={() => add({ key: "", name: "", baseUrl: "", apiKey: "", models: [], enabled: true, remark: "" })}></Button>
</Flex>
)}
</Form.List>
</Card>
</Form>
) : (
<Card variant="borderless">
<Flex justify="space-between" align="center" gap={12} wrap style={{ marginBottom: 12 }}>
<Space>
<CodeOutlined />
<Typography.Text strong> JSON</Typography.Text>
{jsonError ? <Tag color="error">{jsonError}</Tag> : <Tag color="success" icon={<CheckCircleOutlined />}>JSON </Tag>}
</Space>
<Button icon={<FormatPainterOutlined />} onClick={formatJson}></Button>
</Flex>
<div style={{ overflow: "hidden", border: "1px solid var(--ant-color-border)", borderRadius: 6 }}>
<CodeMirror
value={jsonText}
height="560px"
extensions={[json(), jsonEditorTheme, syntaxHighlighting(jsonHighlightStyle)]}
basicSetup={{ foldGutter: true, lineNumbers: true, highlightActiveLine: true, highlightActiveLineGutter: true }}
theme="none"
onChange={setJsonText}
className="admin-json-editor"
style={{ fontSize: 13 }}
/>
</div>
</Card>
)}
</Flex>
</main>
);
}
function normalizeSettings(settings: Partial<AdminSettings> = {}): AdminSettings {
return {
public: {
...emptySettings.public,
...(settings.public || {}),
availableModels: settings.public?.availableModels || [],
},
private: {
channels: (settings.private?.channels || []).map((item) => ({
key: item.key || "",
name: item.name || "",
baseUrl: item.baseUrl || "",
apiKey: item.apiKey || "",
models: item.models || [],
enabled: Boolean(item.enabled),
remark: item.remark || "",
})),
},
};
}
function parseJsonSettings(value: string) {
try {
return normalizeSettings(JSON.parse(value) as AdminSettings);
} catch {
return null;
}
}
function getJsonError(value: string) {
try {
JSON.parse(value);
return "";
} catch (error) {
return error instanceof Error ? error.message : "JSON 格式不正确";
}
}
+30
View File
@@ -406,6 +406,36 @@
width: 26px; width: 26px;
} }
.admin-json-editor .cm-editor {
background: var(--card);
color: var(--foreground);
font-family: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.admin-json-editor .cm-scroller {
font-family: inherit;
}
.admin-json-editor .cm-gutters {
background: color-mix(in srgb, var(--card) 92%, var(--foreground));
border-right: 1px solid var(--border);
color: var(--muted-foreground);
}
.admin-json-editor .cm-activeLine,
.admin-json-editor .cm-activeLineGutter {
background: color-mix(in srgb, var(--primary) 12%, transparent);
}
.admin-json-editor .cm-cursor {
border-left-color: var(--foreground);
}
.admin-json-editor .cm-selectionBackground,
.admin-json-editor .cm-focused .cm-selectionBackground {
background: color-mix(in srgb, var(--primary) 28%, transparent);
}
@keyframes canvas-batch-child-in { @keyframes canvas-batch-child-in {
from { from {
opacity: 0; opacity: 0;
+36
View File
@@ -81,3 +81,39 @@ export async function saveAdminAsset(token: string, asset: Partial<AdminAsset>)
export async function deleteAdminAsset(token: string, id: string) { export async function deleteAdminAsset(token: string, id: string) {
return apiDelete<boolean>(`/api/admin/assets/${encodeURIComponent(id)}`, token); return apiDelete<boolean>(`/api/admin/assets/${encodeURIComponent(id)}`, token);
} }
export type AdminModelChannel = {
key: string;
name: string;
baseUrl: string;
apiKey: string;
models: string[];
enabled: boolean;
remark: string;
};
export type AdminPublicSettings = {
availableModels: string[];
defaultModel: string;
defaultImageModel: string;
defaultTextModel: string;
systemPrompt: string;
allowCustomModel: boolean;
};
export type AdminPrivateSettings = {
channels: AdminModelChannel[];
};
export type AdminSettings = {
public: AdminPublicSettings;
private: AdminPrivateSettings;
};
export async function fetchAdminSettings(token: string) {
return apiGet<AdminSettings>("/api/admin/settings", undefined, token);
}
export async function saveAdminSettings(token: string, settings: AdminSettings) {
return apiPost<AdminSettings>("/api/admin/settings", settings, token);
}