ソースを参照

✨ refactor(console_setting): migrate console settings to model_setting auto-injection

Backend
- Introduce `setting/console_setting` package that defines `ConsoleSetting` struct with JSON tags and validation rules.
- Register the new module with `config.GlobalConfig` to enable automatic injection/export of configuration values.
- Remove legacy `setting/console.go` and the manual `OptionMap` hooks; clean up `model/option.go`.
- Add `controller/console_migrate.go` providing `/api/option/migrate_console_setting` endpoint for one-off data migration.
- Update controllers (`misc`, `option`, `uptime_kuma`) and router to consume namespaced keys `console_setting.*`.

Frontend
- Refactor dashboard pages (`SettingsAPIInfo`, `SettingsAnnouncements`, `SettingsFAQ`, `SettingsUptimeKuma`) and detail page to read/write the new keys.
- Simplify `DashboardSetting.js` state to only include namespaced options.

BREAKING CHANGE: All console-related option keys are now stored under `console_setting.*`. Run the migration endpoint once after deployment to preserve existing data.
Apple\Apple 8 ヶ月 前
コミット
c554015526

+ 4 - 3
controller/misc.go

@@ -11,6 +11,7 @@ import (
 	"one-api/setting"
 	"one-api/setting/operation_setting"
 	"one-api/setting/system_setting"
+	"one-api/setting/console_setting"
 	"strings"
 
 	"github.com/gin-gonic/gin"
@@ -79,9 +80,9 @@ func GetStatus(c *gin.Context) {
 			"oidc_client_id":              system_setting.GetOIDCSettings().ClientId,
 			"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
 			"setup":                       constant.Setup,
-			"api_info":                    setting.GetApiInfo(),
-			"announcements":               setting.GetAnnouncements(),
-			"faq":                         setting.GetFAQ(),
+			"api_info":                    console_setting.GetApiInfo(),
+			"announcements":               console_setting.GetAnnouncements(),
+			"faq":                         console_setting.GetFAQ(),
 		},
 	})
 	return

+ 7 - 6
controller/option.go

@@ -6,6 +6,7 @@ import (
 	"one-api/common"
 	"one-api/model"
 	"one-api/setting"
+	"one-api/setting/console_setting"
 	"one-api/setting/system_setting"
 	"strings"
 
@@ -119,8 +120,8 @@ func UpdateOption(c *gin.Context) {
 			})
 			return
 		}
-	case "ApiInfo":
-		err = setting.ValidateApiInfo(option.Value)
+	case "console_setting.api_info":
+		err = console_setting.ValidateApiInfo(option.Value)
 		if err != nil {
 			c.JSON(http.StatusOK, gin.H{
 				"success": false,
@@ -128,8 +129,8 @@ func UpdateOption(c *gin.Context) {
 			})
 			return
 		}
-	case "Announcements":
-		err = setting.ValidateConsoleSettings(option.Value, "Announcements")
+	case "console_setting.announcements":
+		err = console_setting.ValidateConsoleSettings(option.Value, "Announcements")
 		if err != nil {
 			c.JSON(http.StatusOK, gin.H{
 				"success": false,
@@ -137,8 +138,8 @@ func UpdateOption(c *gin.Context) {
 			})
 			return
 		}
-	case "FAQ":
-		err = setting.ValidateConsoleSettings(option.Value, "FAQ")
+	case "console_setting.faq":
+		err = console_setting.ValidateConsoleSettings(option.Value, "FAQ")
 		if err != nil {
 			c.JSON(http.StatusOK, gin.H{
 				"success": false,

+ 4 - 5
controller/uptime_kuma.go

@@ -6,7 +6,7 @@ import (
 	"errors"
 	"fmt"
 	"net/http"
-	"one-api/common"
+	"one-api/setting/console_setting"
 	"strings"
 	"time"
 
@@ -77,10 +77,9 @@ func getAndDecode(ctx context.Context, client *http.Client, url string, dest int
 }
 
 func GetUptimeKumaStatus(c *gin.Context) {
-	common.OptionMapRWMutex.RLock()
-	uptimeKumaUrl := common.OptionMap["UptimeKumaUrl"]
-	slug := common.OptionMap["UptimeKumaSlug"]
-	common.OptionMapRWMutex.RUnlock()
+	cs := console_setting.GetConsoleSetting()
+	uptimeKumaUrl := cs.UptimeKumaUrl
+	slug := cs.UptimeKumaSlug
 
 	if uptimeKumaUrl == "" || slug == "" {
 		c.JSON(http.StatusOK, gin.H{

+ 0 - 3
model/option.go

@@ -123,9 +123,6 @@ func InitOptionMap() {
 	common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString()
 	common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength)
 	common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString()
-	common.OptionMap["ApiInfo"] = ""
-	common.OptionMap["UptimeKumaUrl"] = ""
-	common.OptionMap["UptimeKumaSlug"] = ""
 
 	// 自动添加所有注册的模型配置
 	modelConfigs := config.GlobalConfig.ExportAllConfigs()

+ 0 - 327
setting/console.go

@@ -1,327 +0,0 @@
-package setting
-
-import (
-	"encoding/json"
-	"fmt"
-	"net/url"
-	"one-api/common"
-	"regexp"
-	"sort"
-	"strings"
-	"time"
-)
-
-// ValidateConsoleSettings 验证控制台设置信息格式
-func ValidateConsoleSettings(settingsStr string, settingType string) error {
-	if settingsStr == "" {
-		return nil // 空字符串是合法的
-	}
-	
-	switch settingType {
-	case "ApiInfo":
-		return validateApiInfo(settingsStr)
-	case "Announcements":
-		return validateAnnouncements(settingsStr)
-	case "FAQ":
-		return validateFAQ(settingsStr)
-	default:
-		return fmt.Errorf("未知的设置类型:%s", settingType)
-	}
-}
-
-// validateApiInfo 验证API信息格式
-func validateApiInfo(apiInfoStr string) error {
-	var apiInfoList []map[string]interface{}
-	if err := json.Unmarshal([]byte(apiInfoStr), &apiInfoList); err != nil {
-		return fmt.Errorf("API信息格式错误:%s", err.Error())
-	}
-	
-	// 验证数组长度
-	if len(apiInfoList) > 50 {
-		return fmt.Errorf("API信息数量不能超过50个")
-	}
-	
-	// 允许的颜色值
-	validColors := map[string]bool{
-		"blue": true, "green": true, "cyan": true, "purple": true, "pink": true,
-		"red": true, "orange": true, "amber": true, "yellow": true, "lime": true,
-		"light-green": true, "teal": true, "light-blue": true, "indigo": true,
-		"violet": true, "grey": true,
-	}
-	
-	// URL正则表达式,支持域名和IP地址格式
-	// 域名格式:https://example.com 或 https://sub.example.com:8080
-	// IP地址格式:https://192.168.1.1 或 https://192.168.1.1:8080
-	urlRegex := regexp.MustCompile(`^https?://(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?|(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))(?::[0-9]{1,5})?(?:/.*)?$`)
-	
-	for i, apiInfo := range apiInfoList {
-		// 检查必填字段
-		urlStr, ok := apiInfo["url"].(string)
-		if !ok || urlStr == "" {
-			return fmt.Errorf("第%d个API信息缺少URL字段", i+1)
-		}
-		
-		route, ok := apiInfo["route"].(string)
-		if !ok || route == "" {
-			return fmt.Errorf("第%d个API信息缺少线路描述字段", i+1)
-		}
-		
-		description, ok := apiInfo["description"].(string)
-		if !ok || description == "" {
-			return fmt.Errorf("第%d个API信息缺少说明字段", i+1)
-		}
-		
-		color, ok := apiInfo["color"].(string)
-		if !ok || color == "" {
-			return fmt.Errorf("第%d个API信息缺少颜色字段", i+1)
-		}
-		
-		// 验证URL格式
-		if !urlRegex.MatchString(urlStr) {
-			return fmt.Errorf("第%d个API信息的URL格式不正确", i+1)
-		}
-		
-		// 验证URL可解析性
-		if _, err := url.Parse(urlStr); err != nil {
-			return fmt.Errorf("第%d个API信息的URL无法解析:%s", i+1, err.Error())
-		}
-		
-		// 验证字段长度
-		if len(urlStr) > 500 {
-			return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1)
-		}
-		
-		if len(route) > 100 {
-			return fmt.Errorf("第%d个API信息的线路描述长度不能超过100字符", i+1)
-		}
-		
-		if len(description) > 200 {
-			return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1)
-		}
-		
-		// 验证颜色值
-		if !validColors[color] {
-			return fmt.Errorf("第%d个API信息的颜色值不合法", i+1)
-		}
-		
-		// 检查并过滤危险字符(防止XSS)
-		dangerousChars := []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
-		for _, dangerous := range dangerousChars {
-			if strings.Contains(strings.ToLower(description), dangerous) {
-				return fmt.Errorf("第%d个API信息的说明包含不允许的内容", i+1)
-			}
-			if strings.Contains(strings.ToLower(route), dangerous) {
-				return fmt.Errorf("第%d个API信息的线路描述包含不允许的内容", i+1)
-			}
-		}
-	}
-	
-	return nil
-}
-
-// ValidateApiInfo 保持向后兼容的函数
-func ValidateApiInfo(apiInfoStr string) error {
-	return validateApiInfo(apiInfoStr)
-}
-
-// GetApiInfo 获取API信息列表
-func GetApiInfo() []map[string]interface{} {
-	// 从OptionMap中获取API信息,如果不存在则返回空数组
-	common.OptionMapRWMutex.RLock()
-	apiInfoStr, exists := common.OptionMap["ApiInfo"]
-	common.OptionMapRWMutex.RUnlock()
-	
-	if !exists || apiInfoStr == "" {
-		// 如果没有配置,返回空数组
-		return []map[string]interface{}{}
-	}
-	
-	// 解析存储的API信息
-	var apiInfo []map[string]interface{}
-	if err := json.Unmarshal([]byte(apiInfoStr), &apiInfo); err != nil {
-		// 如果解析失败,返回空数组
-		return []map[string]interface{}{}
-	}
-	
-	return apiInfo
-}
-
-// validateAnnouncements 验证系统公告格式
-func validateAnnouncements(announcementsStr string) error {
-	var announcementsList []map[string]interface{}
-	if err := json.Unmarshal([]byte(announcementsStr), &announcementsList); err != nil {
-		return fmt.Errorf("系统公告格式错误:%s", err.Error())
-	}
-	
-	// 验证数组长度
-	if len(announcementsList) > 100 {
-		return fmt.Errorf("系统公告数量不能超过100个")
-	}
-	
-	// 允许的类型值
-	validTypes := map[string]bool{
-		"default": true, "ongoing": true, "success": true, "warning": true, "error": true,
-	}
-	
-	for i, announcement := range announcementsList {
-		// 检查必填字段
-		content, ok := announcement["content"].(string)
-		if !ok || content == "" {
-			return fmt.Errorf("第%d个公告缺少内容字段", i+1)
-		}
-		
-		// 检查发布日期字段
-		publishDate, exists := announcement["publishDate"]
-		if !exists {
-			return fmt.Errorf("第%d个公告缺少发布日期字段", i+1)
-		}
-		
-		publishDateStr, ok := publishDate.(string)
-		if !ok || publishDateStr == "" {
-			return fmt.Errorf("第%d个公告的发布日期不能为空", i+1)
-		}
-		
-		// 验证ISO日期格式
-		if _, err := time.Parse(time.RFC3339, publishDateStr); err != nil {
-			return fmt.Errorf("第%d个公告的发布日期格式错误", i+1)
-		}
-		
-		// 验证可选字段
-		if announcementType, exists := announcement["type"]; exists {
-			if typeStr, ok := announcementType.(string); ok {
-				if !validTypes[typeStr] {
-					return fmt.Errorf("第%d个公告的类型值不合法", i+1)
-				}
-			}
-		}
-		
-		// 验证字段长度
-		if len(content) > 500 {
-			return fmt.Errorf("第%d个公告的内容长度不能超过500字符", i+1)
-		}
-		
-		if extra, exists := announcement["extra"]; exists {
-			if extraStr, ok := extra.(string); ok && len(extraStr) > 200 {
-				return fmt.Errorf("第%d个公告的说明长度不能超过200字符", i+1)
-			}
-		}
-		
-		// 检查并过滤危险字符(防止XSS)
-		dangerousChars := []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
-		for _, dangerous := range dangerousChars {
-			if strings.Contains(strings.ToLower(content), dangerous) {
-				return fmt.Errorf("第%d个公告的内容包含不允许的内容", i+1)
-			}
-		}
-	}
-	
-	return nil
-}
-
-// validateFAQ 验证常见问答格式
-func validateFAQ(faqStr string) error {
-	var faqList []map[string]interface{}
-	if err := json.Unmarshal([]byte(faqStr), &faqList); err != nil {
-		return fmt.Errorf("常见问答格式错误:%s", err.Error())
-	}
-	
-	// 验证数组长度
-	if len(faqList) > 100 {
-		return fmt.Errorf("常见问答数量不能超过100个")
-	}
-	
-	for i, faq := range faqList {
-		// 检查必填字段
-		title, ok := faq["title"].(string)
-		if !ok || title == "" {
-			return fmt.Errorf("第%d个问答缺少标题字段", i+1)
-		}
-		
-		content, ok := faq["content"].(string)
-		if !ok || content == "" {
-			return fmt.Errorf("第%d个问答缺少内容字段", i+1)
-		}
-		
-		// 验证字段长度
-		if len(title) > 200 {
-			return fmt.Errorf("第%d个问答的标题长度不能超过200字符", i+1)
-		}
-		
-		if len(content) > 1000 {
-			return fmt.Errorf("第%d个问答的内容长度不能超过1000字符", i+1)
-		}
-		
-		// 检查并过滤危险字符(防止XSS)
-		dangerousChars := []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
-		for _, dangerous := range dangerousChars {
-			if strings.Contains(strings.ToLower(title), dangerous) {
-				return fmt.Errorf("第%d个问答的标题包含不允许的内容", i+1)
-			}
-			if strings.Contains(strings.ToLower(content), dangerous) {
-				return fmt.Errorf("第%d个问答的内容包含不允许的内容", i+1)
-			}
-		}
-	}
-	
-	return nil
-}
-
-// GetAnnouncements 获取系统公告列表(返回最新的前20条)
-func GetAnnouncements() []map[string]interface{} {
-	common.OptionMapRWMutex.RLock()
-	announcementsStr, exists := common.OptionMap["Announcements"]
-	common.OptionMapRWMutex.RUnlock()
-	
-	if !exists || announcementsStr == "" {
-		return []map[string]interface{}{}
-	}
-	
-	var announcements []map[string]interface{}
-	if err := json.Unmarshal([]byte(announcementsStr), &announcements); err != nil {
-		return []map[string]interface{}{}
-	}
-	
-	// 按发布日期降序排序(最新的在前)
-	sort.Slice(announcements, func(i, j int) bool {
-		dateI, okI := announcements[i]["publishDate"].(string)
-		dateJ, okJ := announcements[j]["publishDate"].(string)
-		
-		if !okI || !okJ {
-			return false
-		}
-		
-		timeI, errI := time.Parse(time.RFC3339, dateI)
-		timeJ, errJ := time.Parse(time.RFC3339, dateJ)
-		
-		if errI != nil || errJ != nil {
-			return false
-		}
-		
-		return timeI.After(timeJ)
-	})
-	
-	// 限制返回前20条
-	if len(announcements) > 20 {
-		announcements = announcements[:20]
-	}
-	
-	return announcements
-}
-
-// GetFAQ 获取常见问答列表
-func GetFAQ() []map[string]interface{} {
-	common.OptionMapRWMutex.RLock()
-	faqStr, exists := common.OptionMap["FAQ"]
-	common.OptionMapRWMutex.RUnlock()
-	
-	if !exists || faqStr == "" {
-		return []map[string]interface{}{}
-	}
-	
-	var faq []map[string]interface{}
-	if err := json.Unmarshal([]byte(faqStr), &faq); err != nil {
-		return []map[string]interface{}{}
-	}
-	
-	return faq
-} 

+ 33 - 0
setting/console_setting/config.go

@@ -0,0 +1,33 @@
+package console_setting
+
+import "one-api/setting/config"
+
+type ConsoleSetting struct {
+    ApiInfo        string `json:"api_info"`         // 控制台 API 信息 (JSON 数组字符串)
+    UptimeKumaUrl  string `json:"uptime_kuma_url"`  // Uptime Kuma 服务地址(如 https://status.example.com )
+    UptimeKumaSlug string `json:"uptime_kuma_slug"` // Uptime Kuma Status Page Slug
+    Announcements  string `json:"announcements"`    // 系统公告 (JSON 数组字符串)
+    FAQ            string `json:"faq"`              // 常见问题 (JSON 数组字符串)
+}
+
+// 默认配置
+var defaultConsoleSetting = ConsoleSetting{
+    ApiInfo:        "",
+    UptimeKumaUrl:  "",
+    UptimeKumaSlug: "",
+    Announcements:  "",
+    FAQ:            "",
+}
+
+// 全局实例
+var consoleSetting = defaultConsoleSetting
+
+func init() {
+    // 注册到全局配置管理器,键名为 console_setting
+    config.GlobalConfig.Register("console_setting", &consoleSetting)
+}
+
+// GetConsoleSetting 获取 ConsoleSetting 配置实例
+func GetConsoleSetting() *ConsoleSetting {
+    return &consoleSetting
+} 

+ 212 - 0
setting/console_setting/validation.go

@@ -0,0 +1,212 @@
+package console_setting
+
+import (
+    "encoding/json"
+    "fmt"
+    "net/url"
+    "regexp"
+    "strings"
+    "time"
+)
+
+// ValidateConsoleSettings 验证控制台设置信息格式
+func ValidateConsoleSettings(settingsStr string, settingType string) error {
+    if settingsStr == "" {
+        return nil // 空字符串是合法的
+    }
+
+    switch settingType {
+    case "ApiInfo":
+        return validateApiInfo(settingsStr)
+    case "Announcements":
+        return validateAnnouncements(settingsStr)
+    case "FAQ":
+        return validateFAQ(settingsStr)
+    default:
+        return fmt.Errorf("未知的设置类型:%s", settingType)
+    }
+}
+
+// validateApiInfo 验证API信息格式
+func validateApiInfo(apiInfoStr string) error {
+    var apiInfoList []map[string]interface{}
+    if err := json.Unmarshal([]byte(apiInfoStr), &apiInfoList); err != nil {
+        return fmt.Errorf("API信息格式错误:%s", err.Error())
+    }
+
+    // 验证数组长度
+    if len(apiInfoList) > 50 {
+        return fmt.Errorf("API信息数量不能超过50个")
+    }
+
+    // 允许的颜色值
+    validColors := map[string]bool{
+        "blue": true, "green": true, "cyan": true, "purple": true, "pink": true,
+        "red": true, "orange": true, "amber": true, "yellow": true, "lime": true,
+        "light-green": true, "teal": true, "light-blue": true, "indigo": true,
+        "violet": true, "grey": true,
+    }
+
+    // URL 正则,支持域名 / IP
+    urlRegex := regexp.MustCompile(`^https?://(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?|(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))(?:\:[0-9]{1,5})?(?:/.*)?$`)
+
+    for i, apiInfo := range apiInfoList {
+        urlStr, ok := apiInfo["url"].(string)
+        if !ok || urlStr == "" {
+            return fmt.Errorf("第%d个API信息缺少URL字段", i+1)
+        }
+        route, ok := apiInfo["route"].(string)
+        if !ok || route == "" {
+            return fmt.Errorf("第%d个API信息缺少线路描述字段", i+1)
+        }
+        description, ok := apiInfo["description"].(string)
+        if !ok || description == "" {
+            return fmt.Errorf("第%d个API信息缺少说明字段", i+1)
+        }
+        color, ok := apiInfo["color"].(string)
+        if !ok || color == "" {
+            return fmt.Errorf("第%d个API信息缺少颜色字段", i+1)
+        }
+        if !urlRegex.MatchString(urlStr) {
+            return fmt.Errorf("第%d个API信息的URL格式不正确", i+1)
+        }
+        if _, err := url.Parse(urlStr); err != nil {
+            return fmt.Errorf("第%d个API信息的URL无法解析:%s", i+1, err.Error())
+        }
+        if len(urlStr) > 500 {
+            return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1)
+        }
+        if len(route) > 100 {
+            return fmt.Errorf("第%d个API信息的线路描述长度不能超过100字符", i+1)
+        }
+        if len(description) > 200 {
+            return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1)
+        }
+        if !validColors[color] {
+            return fmt.Errorf("第%d个API信息的颜色值不合法", i+1)
+        }
+        dangerousChars := []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
+        for _, d := range dangerousChars {
+            lower := strings.ToLower(description)
+            if strings.Contains(lower, d) || strings.Contains(strings.ToLower(route), d) {
+                return fmt.Errorf("第%d个API信息包含不允许的内容", i+1)
+            }
+        }
+    }
+    return nil
+}
+
+// ValidateApiInfo 保持向后兼容
+func ValidateApiInfo(apiInfoStr string) error {
+    return validateApiInfo(apiInfoStr)
+}
+
+// GetApiInfo 获取 API 信息列表
+func GetApiInfo() []map[string]interface{} {
+    apiInfoStr := GetConsoleSetting().ApiInfo
+    if apiInfoStr == "" {
+        return []map[string]interface{}{}
+    }
+    var apiInfo []map[string]interface{}
+    if err := json.Unmarshal([]byte(apiInfoStr), &apiInfo); err != nil {
+        return []map[string]interface{}{}
+    }
+    return apiInfo
+}
+
+// ----------------- 公告 / FAQ -----------------
+
+func validateAnnouncements(announcementsStr string) error {
+    var list []map[string]interface{}
+    if err := json.Unmarshal([]byte(announcementsStr), &list); err != nil {
+        return fmt.Errorf("系统公告格式错误:%s", err.Error())
+    }
+    if len(list) > 100 {
+        return fmt.Errorf("系统公告数量不能超过100个")
+    }
+    validTypes := map[string]bool{
+        "default": true, "ongoing": true, "success": true, "warning": true, "error": true,
+    }
+    for i, ann := range list {
+        content, ok := ann["content"].(string)
+        if !ok || content == "" {
+            return fmt.Errorf("第%d个公告缺少内容字段", i+1)
+        }
+        publishDateAny, exists := ann["publishDate"]
+        if !exists {
+            return fmt.Errorf("第%d个公告缺少发布日期字段", i+1)
+        }
+        publishDateStr, ok := publishDateAny.(string)
+        if !ok || publishDateStr == "" {
+            return fmt.Errorf("第%d个公告的发布日期不能为空", i+1)
+        }
+        if _, err := time.Parse(time.RFC3339, publishDateStr); err != nil {
+            return fmt.Errorf("第%d个公告的发布日期格式错误", i+1)
+        }
+        if t, exists := ann["type"]; exists {
+            if typeStr, ok := t.(string); ok {
+                if !validTypes[typeStr] {
+                    return fmt.Errorf("第%d个公告的类型值不合法", i+1)
+                }
+            }
+        }
+        if len(content) > 500 {
+            return fmt.Errorf("第%d个公告的内容长度不能超过500字符", i+1)
+        }
+        if extra, exists := ann["extra"]; exists {
+            if extraStr, ok := extra.(string); ok && len(extraStr) > 200 {
+                return fmt.Errorf("第%d个公告的说明长度不能超过200字符", i+1)
+            }
+        }
+    }
+    return nil
+}
+
+func validateFAQ(faqStr string) error {
+    var list []map[string]interface{}
+    if err := json.Unmarshal([]byte(faqStr), &list); err != nil {
+        return fmt.Errorf("FAQ信息格式错误:%s", err.Error())
+    }
+    if len(list) > 100 {
+        return fmt.Errorf("FAQ数量不能超过100个")
+    }
+    for i, faq := range list {
+        question, ok := faq["question"].(string)
+        if !ok || question == "" {
+            return fmt.Errorf("第%d个FAQ缺少问题字段", i+1)
+        }
+        answer, ok := faq["answer"].(string)
+        if !ok || answer == "" {
+            return fmt.Errorf("第%d个FAQ缺少答案字段", i+1)
+        }
+        if len(question) > 200 {
+            return fmt.Errorf("第%d个FAQ的问题长度不能超过200字符", i+1)
+        }
+        if len(answer) > 1000 {
+            return fmt.Errorf("第%d个FAQ的答案长度不能超过1000字符", i+1)
+        }
+    }
+    return nil
+}
+
+// GetAnnouncements 获取系统公告
+func GetAnnouncements() []map[string]interface{} {
+    annStr := GetConsoleSetting().Announcements
+    if annStr == "" {
+        return []map[string]interface{}{}
+    }
+    var ann []map[string]interface{}
+    _ = json.Unmarshal([]byte(annStr), &ann)
+    return ann
+}
+
+// GetFAQ 获取常见问题
+func GetFAQ() []map[string]interface{} {
+    faqStr := GetConsoleSetting().FAQ
+    if faqStr == "" {
+        return []map[string]interface{}{}
+    }
+    var faq []map[string]interface{}
+    _ = json.Unmarshal([]byte(faqStr), &faq)
+    return faq
+} 

+ 5 - 5
web/src/components/settings/DashboardSetting.js

@@ -8,11 +8,11 @@ import SettingsUptimeKuma from '../../pages/Setting/Dashboard/SettingsUptimeKuma
 
 const DashboardSetting = () => {
   let [inputs, setInputs] = useState({
-    ApiInfo: '',
-    Announcements: '',
-    FAQ: '',
-    UptimeKumaUrl: '',
-    UptimeKumaSlug: '',
+    'console_setting.api_info': '',
+    'console_setting.announcements': '',
+    'console_setting.faq': '',
+    'console_setting.uptime_kuma_url': '',
+    'console_setting.uptime_kuma_slug': '',
   });
 
   let [loading, setLoading] = useState(false);

+ 2 - 2
web/src/pages/Detail/index.js

@@ -1231,10 +1231,10 @@ const Detail = (props) => {
                         {faqData.map((item, index) => (
                           <Collapse.Panel
                             key={index}
-                            header={item.title}
+                            header={item.question}
                             itemKey={index.toString()}
                           >
-                            <p>{item.content}</p>
+                            <p>{item.answer}</p>
                           </Collapse.Panel>
                         ))}
                       </Collapse>

+ 5 - 4
web/src/pages/Setting/Dashboard/SettingsAPIInfo.js

@@ -85,7 +85,7 @@ const SettingsAPIInfo = ({ options, refresh }) => {
     try {
       setLoading(true);
       const apiInfoJson = JSON.stringify(apiInfoList);
-      await updateOption('ApiInfo', apiInfoJson);
+      await updateOption('console_setting.api_info', apiInfoJson);
       setHasChanges(false);
     } catch (error) {
       console.error('API信息更新失败', error);
@@ -185,10 +185,11 @@ const SettingsAPIInfo = ({ options, refresh }) => {
   };
 
   useEffect(() => {
-    if (options.ApiInfo !== undefined) {
-      parseApiInfo(options.ApiInfo);
+    const apiInfoStr = options['console_setting.api_info'] ?? options.ApiInfo;
+    if (apiInfoStr !== undefined) {
+      parseApiInfo(apiInfoStr);
     }
-  }, [options.ApiInfo]);
+  }, [options['console_setting.api_info'], options.ApiInfo]);
 
   const columns = [
     {

+ 5 - 4
web/src/pages/Setting/Dashboard/SettingsAnnouncements.js

@@ -176,7 +176,7 @@ const SettingsAnnouncements = ({ options, refresh }) => {
     try {
       setLoading(true);
       const announcementsJson = JSON.stringify(announcementsList);
-      await updateOption('Announcements', announcementsJson);
+      await updateOption('console_setting.announcements', announcementsJson);
       setHasChanges(false);
     } catch (error) {
       console.error('系统公告更新失败', error);
@@ -288,10 +288,11 @@ const SettingsAnnouncements = ({ options, refresh }) => {
   };
 
   useEffect(() => {
-    if (options.Announcements !== undefined) {
-      parseAnnouncements(options.Announcements);
+    const annStr = options['console_setting.announcements'] ?? options.Announcements;
+    if (annStr !== undefined) {
+      parseAnnouncements(annStr);
     }
-  }, [options.Announcements]);
+  }, [options['console_setting.announcements'], options.Announcements]);
 
   const handleBatchDelete = () => {
     if (selectedRowKeys.length === 0) {

+ 19 - 19
web/src/pages/Setting/Dashboard/SettingsFAQ.js

@@ -37,8 +37,8 @@ const SettingsFAQ = ({ options, refresh }) => {
   const [loading, setLoading] = useState(false);
   const [hasChanges, setHasChanges] = useState(false);
   const [faqForm, setFaqForm] = useState({
-    title: '',
-    content: ''
+    question: '',
+    answer: ''
   });
   const [currentPage, setCurrentPage] = useState(1);
   const [pageSize, setPageSize] = useState(10);
@@ -47,8 +47,8 @@ const SettingsFAQ = ({ options, refresh }) => {
   const columns = [
     {
       title: t('问题标题'),
-      dataIndex: 'title',
-      key: 'title',
+      dataIndex: 'question',
+      key: 'question',
       render: (text) => (
         <div style={{
           maxWidth: '300px',
@@ -61,8 +61,8 @@ const SettingsFAQ = ({ options, refresh }) => {
     },
     {
       title: t('回答内容'),
-      dataIndex: 'content',
-      key: 'content',
+      dataIndex: 'answer',
+      key: 'answer',
       render: (text) => (
         <div style={{
           maxWidth: '400px',
@@ -124,7 +124,7 @@ const SettingsFAQ = ({ options, refresh }) => {
     try {
       setLoading(true);
       const faqJson = JSON.stringify(faqList);
-      await updateOption('FAQ', faqJson);
+      await updateOption('console_setting.faq', faqJson);
       setHasChanges(false);
     } catch (error) {
       console.error('常见问答更新失败', error);
@@ -137,8 +137,8 @@ const SettingsFAQ = ({ options, refresh }) => {
   const handleAddFaq = () => {
     setEditingFaq(null);
     setFaqForm({
-      title: '',
-      content: ''
+      question: '',
+      answer: ''
     });
     setShowFaqModal(true);
   };
@@ -146,8 +146,8 @@ const SettingsFAQ = ({ options, refresh }) => {
   const handleEditFaq = (faq) => {
     setEditingFaq(faq);
     setFaqForm({
-      title: faq.title,
-      content: faq.content
+      question: faq.question,
+      answer: faq.answer
     });
     setShowFaqModal(true);
   };
@@ -169,7 +169,7 @@ const SettingsFAQ = ({ options, refresh }) => {
   };
 
   const handleSaveFaq = async () => {
-    if (!faqForm.title || !faqForm.content) {
+    if (!faqForm.question || !faqForm.answer) {
       showError('请填写完整的问答信息');
       return;
     }
@@ -226,10 +226,10 @@ const SettingsFAQ = ({ options, refresh }) => {
   };
 
   useEffect(() => {
-    if (options.FAQ !== undefined) {
-      parseFAQ(options.FAQ);
+    if (options['console_setting.faq'] !== undefined) {
+      parseFAQ(options['console_setting.faq']);
     }
-  }, [options.FAQ]);
+  }, [options['console_setting.faq']]);
 
   const handleBatchDelete = () => {
     if (selectedRowKeys.length === 0) {
@@ -372,21 +372,21 @@ const SettingsFAQ = ({ options, refresh }) => {
       >
         <Form layout='vertical' initValues={faqForm} key={editingFaq ? editingFaq.id : 'new'}>
           <Form.Input
-            field='title'
+            field='question'
             label={t('问题标题')}
             placeholder={t('请输入问题标题')}
             maxLength={200}
             rules={[{ required: true, message: t('请输入问题标题') }]}
-            onChange={(value) => setFaqForm({ ...faqForm, title: value })}
+            onChange={(value) => setFaqForm({ ...faqForm, question: value })}
           />
           <Form.TextArea
-            field='content'
+            field='answer'
             label={t('回答内容')}
             placeholder={t('请输入回答内容')}
             maxCount={1000}
             rows={6}
             rules={[{ required: true, message: t('请输入回答内容') }]}
-            onChange={(value) => setFaqForm({ ...faqForm, content: value })}
+            onChange={(value) => setFaqForm({ ...faqForm, answer: value })}
           />
         </Form>
       </Modal>

+ 8 - 8
web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js

@@ -22,9 +22,9 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
   const formApiRef = useRef(null);
 
   const initValues = useMemo(() => ({
-    uptimeKumaUrl: options?.UptimeKumaUrl || '',
-    uptimeKumaSlug: options?.UptimeKumaSlug || ''
-  }), [options?.UptimeKumaUrl, options?.UptimeKumaSlug]);
+    uptimeKumaUrl: options?.['console_setting.uptime_kuma_url'] || '',
+    uptimeKumaSlug: options?.['console_setting.uptime_kuma_slug'] || ''
+  }), [options?.['console_setting.uptime_kuma_url'], options?.['console_setting.uptime_kuma_slug']]);
 
   useEffect(() => {
     if (formApiRef.current) {
@@ -46,18 +46,18 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
       const trimmedUrl = (uptimeKumaUrl || '').trim();
       const trimmedSlug = (uptimeKumaSlug || '').trim();
 
-      if (trimmedUrl === options?.UptimeKumaUrl && trimmedSlug === options?.UptimeKumaSlug) {
+      if (trimmedUrl === options?.['console_setting.uptime_kuma_url'] && trimmedSlug === options?.['console_setting.uptime_kuma_slug']) {
         showSuccess(t('无需保存,配置未变动'));
         return;
       }
 
       const [urlRes, slugRes] = await Promise.all([
-        trimmedUrl === options?.UptimeKumaUrl ? Promise.resolve({ data: { success: true } }) : API.put('/api/option/', {
-          key: 'UptimeKumaUrl',
+        trimmedUrl === options?.['console_setting.uptime_kuma_url'] ? Promise.resolve({ data: { success: true } }) : API.put('/api/option/', {
+          key: 'console_setting.uptime_kuma_url',
           value: trimmedUrl
         }),
-        trimmedSlug === options?.UptimeKumaSlug ? Promise.resolve({ data: { success: true } }) : API.put('/api/option/', {
-          key: 'UptimeKumaSlug',
+        trimmedSlug === options?.['console_setting.uptime_kuma_slug'] ? Promise.resolve({ data: { success: true } }) : API.put('/api/option/', {
+          key: 'console_setting.uptime_kuma_slug',
           value: trimmedSlug
         })
       ]);