✨ 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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
setting/console_setting/config.go
Normal file
33
setting/console_setting/config.go
Normal file
@@ -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
setting/console_setting/validation.go
Normal file
212
setting/console_setting/validation.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user