Explorar o código

🚦 feat(uptime-kuma): support custom category names & monitor subgroup rendering

Backend
• controller/uptime_kuma.go
  - Added Group field to Monitor struct to carry publicGroupList.name.
  - Extended status page parsing to capture group Name and inject it into each monitor.
  - Re-worked fetchGroupData loop: aggregate all sub-groups, drop unnecessary pre-allocation/breaks.

Frontend
• web/src/pages/Detail/index.js
  - renderMonitorList now buckets monitors by the new group field and renders a lightweight header per subgroup.
  - Fallback gracefully when group is empty to preserve previous single-list behaviour.

Other
• Expanded anonymous struct definition for statusData.PublicGroupList to include ID/Name, enabling JSON unmarshalling of group names.

Result
Custom CategoryName continues to work while each uptime group’s internal sub-groups are now clearly displayed in the UI, providing finer-grained visibility without impacting performance or existing validation logic.
Apple\Apple hai 6 meses
pai
achega
abfb3f4006

+ 9 - 0
controller/option.go

@@ -147,6 +147,15 @@ func UpdateOption(c *gin.Context) {
 			})
 			return
 		}
+	case "console_setting.uptime_kuma_groups":
+		err = console_setting.ValidateConsoleSettings(option.Value, "UptimeKumaGroups")
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": err.Error(),
+			})
+			return
+		}
 	}
 	err = model.UpdateOption(option.Key, option.Value)
 	if err != nil {

+ 95 - 109
controller/uptime_kuma.go

@@ -4,9 +4,9 @@ import (
 	"context"
 	"encoding/json"
 	"errors"
-	"fmt"
 	"net/http"
 	"one-api/setting/console_setting"
+	"strconv"
 	"strings"
 	"time"
 
@@ -14,45 +14,25 @@ import (
 	"golang.org/x/sync/errgroup"
 )
 
-type UptimeKumaMonitor struct {
-	ID   int    `json:"id"`
-	Name string `json:"name"`
-	Type string `json:"type"`
-}
-
-type UptimeKumaGroup struct {
-	ID          int                  `json:"id"`
-	Name        string               `json:"name"`
-	Weight      int                  `json:"weight"`
-	MonitorList []UptimeKumaMonitor  `json:"monitorList"`
-}
-
-type UptimeKumaHeartbeat struct {
-	Status int      `json:"status"`
-	Time   string   `json:"time"`
-	Msg    string   `json:"msg"`
-	Ping   *float64 `json:"ping"`
-}
-
-type UptimeKumaStatusResponse struct {
-	PublicGroupList []UptimeKumaGroup `json:"publicGroupList"`
-}
-
-type UptimeKumaHeartbeatResponse struct {
-	HeartbeatList map[string][]UptimeKumaHeartbeat `json:"heartbeatList"`
-	UptimeList    map[string]float64               `json:"uptimeList"`
-}
+const (
+	requestTimeout   = 30 * time.Second
+	httpTimeout      = 10 * time.Second
+	uptimeKeySuffix  = "_24"
+	apiStatusPath    = "/api/status-page/"
+	apiHeartbeatPath = "/api/status-page/heartbeat/"
+)
 
-type MonitorStatus struct {
+type Monitor struct {
 	Name   string  `json:"name"`
 	Uptime float64 `json:"uptime"`
 	Status int     `json:"status"`
+	Group  string  `json:"group,omitempty"`
 }
 
-var (
-	ErrUpstreamNon200 = errors.New("upstream non-200")
-	ErrTimeout        = errors.New("context deadline exceeded")
-)
+type UptimeGroupResult struct {
+	CategoryName string    `json:"categoryName"`
+	Monitors  []Monitor `json:"monitors"`
+}
 
 func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error {
 	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
@@ -62,107 +42,113 @@ func getAndDecode(ctx context.Context, client *http.Client, url string, dest int
 
 	resp, err := client.Do(req)
 	if err != nil {
-		if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
-			return ErrTimeout
-		}
 		return err
 	}
 	defer resp.Body.Close()
 
 	if resp.StatusCode != http.StatusOK {
-		return ErrUpstreamNon200
+		return errors.New("non-200 status")
 	}
 
 	return json.NewDecoder(resp.Body).Decode(dest)
 }
 
-func GetUptimeKumaStatus(c *gin.Context) {
-	cs := console_setting.GetConsoleSetting()
-	uptimeKumaUrl := cs.UptimeKumaUrl
-	slug := cs.UptimeKumaSlug
-
-	if uptimeKumaUrl == "" || slug == "" {
-		c.JSON(http.StatusOK, gin.H{
-			"success": true,
-			"message": "",
-			"data":    []MonitorStatus{},
-		})
-		return
+func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[string]interface{}) UptimeGroupResult {
+	url, _ := groupConfig["url"].(string)
+	slug, _ := groupConfig["slug"].(string)
+	categoryName, _ := groupConfig["categoryName"].(string)
+	
+	result := UptimeGroupResult{
+		CategoryName: categoryName,
+		Monitors:  []Monitor{},
+	}
+	
+	if url == "" || slug == "" {
+		return result
 	}
 
-	uptimeKumaUrl = strings.TrimSuffix(uptimeKumaUrl, "/")
-
-	ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
-	defer cancel()
-
-	client := &http.Client{}
-
-	statusPageUrl := fmt.Sprintf("%s/api/status-page/%s", uptimeKumaUrl, slug)
-	heartbeatUrl := fmt.Sprintf("%s/api/status-page/heartbeat/%s", uptimeKumaUrl, slug)
-
-	var (
-		statusData    UptimeKumaStatusResponse
-		heartbeatData UptimeKumaHeartbeatResponse
-	)
+	baseURL := strings.TrimSuffix(url, "/")
+	
+	var statusData struct {
+		PublicGroupList []struct {
+			ID   int    `json:"id"`
+			Name string `json:"name"`
+			MonitorList []struct {
+				ID   int    `json:"id"`
+				Name string `json:"name"`
+			} `json:"monitorList"`
+		} `json:"publicGroupList"`
+	}
+	
+	var heartbeatData struct {
+		HeartbeatList map[string][]struct {
+			Status int `json:"status"`
+		} `json:"heartbeatList"`
+		UptimeList map[string]float64 `json:"uptimeList"`
+	}
 
 	g, gCtx := errgroup.WithContext(ctx)
-
-	g.Go(func() error {
-		return getAndDecode(gCtx, client, statusPageUrl, &statusData)
+	g.Go(func() error { 
+		return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData) 
 	})
-
-	g.Go(func() error {
-		return getAndDecode(gCtx, client, heartbeatUrl, &heartbeatData)
+	g.Go(func() error { 
+		return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData) 
 	})
 
-	if err := g.Wait(); err != nil {
-		switch err {
-		case ErrUpstreamNon200:
-			c.JSON(http.StatusBadRequest, gin.H{
-				"success": false,
-				"message": "上游接口出现问题",
-			})
-		case ErrTimeout:
-			c.JSON(http.StatusRequestTimeout, gin.H{
-				"success": false,
-				"message": "请求上游接口超时",
-			})
-		default:
-			c.JSON(http.StatusBadRequest, gin.H{
-				"success": false,
-				"message": err.Error(),
-			})
-		}
-		return
+	if g.Wait() != nil {
+		return result
 	}
 
-	var monitors []MonitorStatus
-	for _, group := range statusData.PublicGroupList {
-		for _, monitor := range group.MonitorList {
-			monitorStatus := MonitorStatus{
-				Name:   monitor.Name,
-				Uptime: 0.0,
-				Status: 0,
+	for _, pg := range statusData.PublicGroupList {
+		if len(pg.MonitorList) == 0 {
+			continue
+		}
+
+		for _, m := range pg.MonitorList {
+			monitor := Monitor{
+				Name:  m.Name,
+				Group: pg.Name,
 			}
 
-			uptimeKey := fmt.Sprintf("%d_24", monitor.ID)
-			if uptime, exists := heartbeatData.UptimeList[uptimeKey]; exists {
-				monitorStatus.Uptime = uptime
+			monitorID := strconv.Itoa(m.ID)
+
+			if uptime, exists := heartbeatData.UptimeList[monitorID+uptimeKeySuffix]; exists {
+				monitor.Uptime = uptime
 			}
 
-			heartbeatKey := fmt.Sprintf("%d", monitor.ID)
-			if heartbeats, exists := heartbeatData.HeartbeatList[heartbeatKey]; exists && len(heartbeats) > 0 {
-				latestHeartbeat := heartbeats[0]
-				monitorStatus.Status = latestHeartbeat.Status
+			if heartbeats, exists := heartbeatData.HeartbeatList[monitorID]; exists && len(heartbeats) > 0 {
+				monitor.Status = heartbeats[0].Status
 			}
 
-			monitors = append(monitors, monitorStatus)
+			result.Monitors = append(result.Monitors, monitor)
 		}
 	}
 
-	c.JSON(http.StatusOK, gin.H{
-		"success": true,
-		"message": "",
-		"data":    monitors,
-	})
+	return result
+}
+
+func GetUptimeKumaStatus(c *gin.Context) {
+	groups := console_setting.GetUptimeKumaGroups()
+	if len(groups) == 0 {
+		c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": []UptimeGroupResult{}})
+		return
+	}
+
+	ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
+	defer cancel()
+
+	client := &http.Client{Timeout: httpTimeout}
+	results := make([]UptimeGroupResult, len(groups))
+	
+	g, gCtx := errgroup.WithContext(ctx)
+	for i, group := range groups {
+		i, group := i, group
+		g.Go(func() error {
+			results[i] = fetchGroupData(gCtx, client, group)
+			return nil
+		})
+	}
+	
+	g.Wait()
+	c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": results})
 } 

+ 8 - 10
setting/console_setting/config.go

@@ -3,11 +3,10 @@ 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 数组字符串)
+    ApiInfo           string `json:"api_info"`           // 控制台 API 信息 (JSON 数组字符串)
+    UptimeKumaGroups  string `json:"uptime_kuma_groups"` // Uptime Kuma 分组配置 (JSON 数组字符串)
+    Announcements     string `json:"announcements"`      // 系统公告 (JSON 数组字符串)
+    FAQ               string `json:"faq"`                // 常见问题 (JSON 数组字符串)
     ApiInfoEnabled        bool `json:"api_info_enabled"`        // 是否启用 API 信息面板
     UptimeKumaEnabled     bool `json:"uptime_kuma_enabled"`     // 是否启用 Uptime Kuma 面板
     AnnouncementsEnabled  bool `json:"announcements_enabled"`   // 是否启用系统公告面板
@@ -16,11 +15,10 @@ type ConsoleSetting struct {
 
 // 默认配置
 var defaultConsoleSetting = ConsoleSetting{
-    ApiInfo:        "",
-    UptimeKumaUrl:  "",
-    UptimeKumaSlug: "",
-    Announcements:  "",
-    FAQ:            "",
+    ApiInfo:          "",
+    UptimeKumaGroups: "",
+    Announcements:    "",
+    FAQ:              "",
     ApiInfoEnabled:       true,
     UptimeKumaEnabled:    true,
     AnnouncementsEnabled: true,

+ 143 - 62
setting/console_setting/validation.go

@@ -9,10 +9,58 @@ import (
     "time"
 )
 
-// ValidateConsoleSettings 验证控制台设置信息格式
+var (
+    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})?(?:/.*)?$`)
+    dangerousChars = []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
+    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,
+    }
+    slugRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
+)
+
+func parseJSONArray(jsonStr string, typeName string) ([]map[string]interface{}, error) {
+    var list []map[string]interface{}
+    if err := json.Unmarshal([]byte(jsonStr), &list); err != nil {
+        return nil, fmt.Errorf("%s格式错误:%s", typeName, err.Error())
+    }
+    return list, nil
+}
+
+func validateURL(urlStr string, index int, itemType string) error {
+    if !urlRegex.MatchString(urlStr) {
+        return fmt.Errorf("第%d个%s的URL格式不正确", index, itemType)
+    }
+    if _, err := url.Parse(urlStr); err != nil {
+        return fmt.Errorf("第%d个%s的URL无法解析:%s", index, itemType, err.Error())
+    }
+    return nil
+}
+
+func checkDangerousContent(content string, index int, itemType string) error {
+    lower := strings.ToLower(content)
+    for _, d := range dangerousChars {
+        if strings.Contains(lower, d) {
+            return fmt.Errorf("第%d个%s包含不允许的内容", index, itemType)
+        }
+    }
+    return nil
+}
+
+func getJSONList(jsonStr string) []map[string]interface{} {
+    if jsonStr == "" {
+        return []map[string]interface{}{}
+    }
+    var list []map[string]interface{}
+    json.Unmarshal([]byte(jsonStr), &list)
+    return list
+}
+
 func ValidateConsoleSettings(settingsStr string, settingType string) error {
     if settingsStr == "" {
-        return nil // 空字符串是合法的
+        return nil
     }
 
     switch settingType {
@@ -22,34 +70,23 @@ func ValidateConsoleSettings(settingsStr string, settingType string) error {
         return validateAnnouncements(settingsStr)
     case "FAQ":
         return validateFAQ(settingsStr)
+    case "UptimeKumaGroups":
+        return validateUptimeKumaGroups(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())
+    apiInfoList, err := parseJSONArray(apiInfoStr, "API信息")
+    if err != nil {
+        return err
     }
 
-    // 验证数组长度
     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 == "" {
@@ -67,12 +104,11 @@ func validateApiInfo(apiInfoStr string) error {
         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 err := validateURL(urlStr, i+1, "API信息"); err != nil {
+            return err
         }
+
         if len(urlStr) > 500 {
             return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1)
         }
@@ -82,39 +118,29 @@ func validateApiInfo(apiInfoStr string) error {
         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)
-            }
+
+        if err := checkDangerousContent(description, i+1, "API信息"); err != nil {
+            return err
+        }
+        if err := checkDangerousContent(route, i+1, "API信息"); err != nil {
+            return err
         }
     }
     return nil
 }
 
-// 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
+    return getJSONList(GetConsoleSetting().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())
+    list, err := parseJSONArray(announcementsStr, "系统公告")
+    if err != nil {
+        return err
     }
     if len(list) > 100 {
         return fmt.Errorf("系统公告数量不能超过100个")
@@ -158,9 +184,9 @@ func validateAnnouncements(announcementsStr string) error {
 }
 
 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())
+    list, err := parseJSONArray(faqStr, "FAQ信息")
+    if err != nil {
+        return err
     }
     if len(list) > 100 {
         return fmt.Errorf("FAQ数量不能超过100个")
@@ -184,24 +210,79 @@ func validateFAQ(faqStr string) error {
     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
+    return getJSONList(GetConsoleSetting().Announcements)
 }
 
-// GetFAQ 获取常见问题
 func GetFAQ() []map[string]interface{} {
-    faqStr := GetConsoleSetting().FAQ
-    if faqStr == "" {
-        return []map[string]interface{}{}
+    return getJSONList(GetConsoleSetting().FAQ)
+}
+
+func validateUptimeKumaGroups(groupsStr string) error {
+    groups, err := parseJSONArray(groupsStr, "Uptime Kuma分组配置")
+    if err != nil {
+        return err
+    }
+
+    if len(groups) > 20 {
+        return fmt.Errorf("Uptime Kuma分组数量不能超过20个")
+    }
+
+    nameSet := make(map[string]bool)
+
+    for i, group := range groups {
+        categoryName, ok := group["categoryName"].(string)
+        if !ok || categoryName == "" {
+            return fmt.Errorf("第%d个分组缺少分类名称字段", i+1)
+        }
+        if nameSet[categoryName] {
+            return fmt.Errorf("第%d个分组的分类名称与其他分组重复", i+1)
+        }
+        nameSet[categoryName] = true
+        urlStr, ok := group["url"].(string)
+        if !ok || urlStr == "" {
+            return fmt.Errorf("第%d个分组缺少URL字段", i+1)
+        }
+        slug, ok := group["slug"].(string)
+        if !ok || slug == "" {
+            return fmt.Errorf("第%d个分组缺少Slug字段", i+1)
+        }
+        description, ok := group["description"].(string)
+        if !ok {
+            description = ""
+        }
+
+        if err := validateURL(urlStr, i+1, "分组"); err != nil {
+            return err
+        }
+
+        if len(categoryName) > 50 {
+            return fmt.Errorf("第%d个分组的分类名称长度不能超过50字符", i+1)
+        }
+        if len(urlStr) > 500 {
+            return fmt.Errorf("第%d个分组的URL长度不能超过500字符", i+1)
+        }
+        if len(slug) > 100 {
+            return fmt.Errorf("第%d个分组的Slug长度不能超过100字符", i+1)
+        }
+        if len(description) > 200 {
+            return fmt.Errorf("第%d个分组的描述长度不能超过200字符", i+1)
+        }
+
+        if !slugRegex.MatchString(slug) {
+            return fmt.Errorf("第%d个分组的Slug只能包含字母、数字、下划线和连字符", i+1)
+        }
+
+        if err := checkDangerousContent(description, i+1, "分组"); err != nil {
+            return err
+        }
+        if err := checkDangerousContent(categoryName, i+1, "分组"); err != nil {
+            return err
+        }
     }
-    var faq []map[string]interface{}
-    _ = json.Unmarshal([]byte(faqStr), &faq)
-    return faq
+    return nil
+}
+
+func GetUptimeKumaGroups() []map[string]interface{} {
+    return getJSONList(GetConsoleSetting().UptimeKumaGroups)
 } 

+ 1 - 2
web/src/components/settings/DashboardSetting.js

@@ -11,8 +11,7 @@ const DashboardSetting = () => {
     'console_setting.api_info': '',
     'console_setting.announcements': '',
     'console_setting.faq': '',
-    'console_setting.uptime_kuma_url': '',
-    'console_setting.uptime_kuma_slug': '',
+    'console_setting.uptime_kuma_groups': '',
     'console_setting.api_info_enabled': '',
     'console_setting.announcements_enabled': '',
     'console_setting.faq_enabled': '',

+ 9 - 10
web/src/i18n/locales/en.json

@@ -1628,16 +1628,15 @@
   "常见问答管理,为用户提供常见问题的答案(最多50个,前端显示最新20条)": "FAQ management, providing answers to common questions for users (maximum 50, display latest 20 on the front end)",
   "暂无常见问答": "No FAQ",
   "显示最新20条": "Display latest 20",
-  "Uptime Kuma 服务地址": "Uptime Kuma service address",
-  "状态页面 Slug": "Status page slug",
-  "请输入 Uptime Kuma 服务的完整地址,例如:https://uptime.example.com": "Please enter the complete address of Uptime Kuma, for example: https://uptime.example.com",
-  "请输入状态页面的 slug 标识符,例如:my-status": "Please enter the slug identifier for the status page, for example: my-status",
-  "Uptime Kuma 服务地址不能为空": "Uptime Kuma service address cannot be empty",
-  "请输入有效的 URL 地址": "Please enter a valid URL address",
-  "状态页面 Slug 不能为空": "Status page slug cannot be empty",
-  "Slug 只能包含字母、数字、下划线和连字符": "Slug can only contain letters, numbers, underscores, and hyphens",
-  "请输入 Uptime Kuma 服务地址": "Please enter the Uptime Kuma service address",
-  "请输入状态页面 Slug": "Please enter the status page slug",
+  "Uptime Kuma监控分类管理,可以配置多个监控分类用于服务状态展示(最多20个)": "Uptime Kuma monitoring category management, you can configure multiple monitoring categories for service status display (maximum 20)",
+  "添加分类": "Add Category",
+  "分类名称": "Category Name",
+  "Uptime Kuma地址": "Uptime Kuma Address",
+  "状态页面Slug": "Status Page Slug",
+  "请输入分类名称,如:OpenAI、Claude等": "Please enter the category name, such as: OpenAI, Claude, etc.",
+  "请输入Uptime Kuma服务地址,如:https://status.example.com": "Please enter the Uptime Kuma service address, such as: https://status.example.com",
+  "请输入状态页面的Slug,如:my-status": "Please enter the slug for the status page, such as: my-status",
+  "确定要删除此分类吗?": "Are you sure you want to delete this category?",
   "配置": "Configure",
   "服务监控地址,用于展示服务状态信息": "service monitoring address for displaying status information",
   "服务可用性": "Service Status",

+ 177 - 74
web/src/pages/Detail/index.js

@@ -16,7 +16,8 @@ import {
   Tag,
   Timeline,
   Collapse,
-  Progress
+  Progress,
+  Divider
 } from '@douyinfe/semi-ui';
 import {
   IconRefresh,
@@ -215,6 +216,7 @@ const Detail = (props) => {
   const announcementScrollRef = useRef(null);
   const faqScrollRef = useRef(null);
   const uptimeScrollRef = useRef(null);
+  const uptimeTabScrollRefs = useRef({});
 
   // ========== Additional State for scroll hints ==========
   const [showAnnouncementScrollHint, setShowAnnouncementScrollHint] = useState(false);
@@ -224,6 +226,7 @@ const Detail = (props) => {
   // ========== Uptime data ==========
   const [uptimeData, setUptimeData] = useState([]);
   const [uptimeLoading, setUptimeLoading] = useState(false);
+  const [activeUptimeTab, setActiveUptimeTab] = useState('');
 
   // ========== Props Destructuring ==========
   const { username, model_name, start_timestamp, end_timestamp, channel } = inputs;
@@ -579,6 +582,9 @@ const Detail = (props) => {
       const { success, message, data } = res.data;
       if (success) {
         setUptimeData(data || []);
+        if (data && data.length > 0 && !activeUptimeTab) {
+          setActiveUptimeTab(data[0].categoryName);
+        }
       } else {
         showError(message);
       }
@@ -587,7 +593,7 @@ const Detail = (props) => {
     } finally {
       setUptimeLoading(false);
     }
-  }, []);
+  }, [activeUptimeTab]);
 
   const refresh = useCallback(async () => {
     await Promise.all([loadQuotaData(), loadUptimeData()]);
@@ -644,10 +650,18 @@ const Detail = (props) => {
       checkApiScrollable();
       checkCardScrollable(announcementScrollRef, setShowAnnouncementScrollHint);
       checkCardScrollable(faqScrollRef, setShowFaqScrollHint);
-      checkCardScrollable(uptimeScrollRef, setShowUptimeScrollHint);
+
+      if (uptimeData.length === 1) {
+        checkCardScrollable(uptimeScrollRef, setShowUptimeScrollHint);
+      } else if (uptimeData.length > 1 && activeUptimeTab) {
+        const activeTabRef = uptimeTabScrollRefs.current[activeUptimeTab];
+        if (activeTabRef) {
+          checkCardScrollable(activeTabRef, setShowUptimeScrollHint);
+        }
+      }
     }, 100);
     return () => clearTimeout(timer);
-  }, [uptimeData]);
+  }, [uptimeData, activeUptimeTab]);
 
   const getUserData = async () => {
     let res = await API.get(`/api/user/self`);
@@ -883,7 +897,6 @@ const Detail = (props) => {
 
   const announcementData = useMemo(() => {
     const announcements = statusState?.status?.announcements || [];
-    // 处理后台配置的公告数据,自动生成相对时间
     return announcements.map(item => ({
       ...item,
       time: getRelativeTime(item.publishDate)
@@ -894,6 +907,68 @@ const Detail = (props) => {
     return statusState?.status?.faq || [];
   }, [statusState?.status?.faq]);
 
+  const renderMonitorList = useCallback((monitors) => {
+    if (!monitors || monitors.length === 0) {
+      return (
+        <div className="flex justify-center items-center py-4">
+          <Empty
+            image={<IllustrationConstruction />}
+            darkModeImage={<IllustrationConstructionDark />}
+            title={t('暂无监控数据')}
+            style={{ padding: '8px' }}
+          />
+        </div>
+      );
+    }
+
+    const grouped = {};
+    monitors.forEach((m) => {
+      const g = m.group || '';
+      if (!grouped[g]) grouped[g] = [];
+      grouped[g].push(m);
+    });
+
+    const renderItem = (monitor, idx) => (
+      <div key={idx} className="p-2 hover:bg-white rounded-lg transition-colors">
+        <div className="flex items-center justify-between mb-1">
+          <div className="flex items-center gap-2">
+            <div
+              className="w-2 h-2 rounded-full flex-shrink-0"
+              style={{ backgroundColor: getUptimeStatusColor(monitor.status) }}
+            />
+            <span className="text-sm font-medium text-gray-900">{monitor.name}</span>
+          </div>
+          <span className="text-xs text-gray-500">{((monitor.uptime || 0) * 100).toFixed(2)}%</span>
+        </div>
+        <div className="flex items-center gap-2">
+          <span className="text-xs text-gray-500">{getUptimeStatusText(monitor.status)}</span>
+          <div className="flex-1">
+            <Progress
+              percent={(monitor.uptime || 0) * 100}
+              showInfo={false}
+              aria-label={`${monitor.name} uptime`}
+              stroke={getUptimeStatusColor(monitor.status)}
+            />
+          </div>
+        </div>
+      </div>
+    );
+
+    return Object.entries(grouped).map(([gname, list]) => (
+      <div key={gname || 'default'} className="mb-2">
+        {gname && (
+          <>
+            <div className="text-md font-semibold text-gray-500 px-2 py-1">
+              {gname}
+            </div>
+            <Divider />
+          </>
+        )}
+        {list.map(renderItem)}
+      </div>
+    ));
+  }, [t, getUptimeStatusColor, getUptimeStatusText]);
+
   // ========== Hooks - Effects ==========
   useEffect(() => {
     getUserData();
@@ -1127,8 +1202,8 @@ const Detail = (props) => {
                     ) : (
                       <div className="flex justify-center items-center py-8">
                         <Empty
-                          image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
-                          darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
+                          image={<IllustrationConstruction />}
+                          darkModeImage={<IllustrationConstructionDark />}
                           title={t('暂无API信息')}
                           description={t('请联系管理员在系统设置中配置API信息')}
                           style={{ padding: '12px' }}
@@ -1199,8 +1274,8 @@ const Detail = (props) => {
                       ) : (
                         <div className="flex justify-center items-center py-8">
                           <Empty
-                            image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
-                            darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
+                            image={<IllustrationConstruction />}
+                            darkModeImage={<IllustrationConstructionDark />}
                             title={t('暂无系统公告')}
                             description={t('请联系管理员在系统设置中配置公告信息')}
                             style={{ padding: '12px' }}
@@ -1227,6 +1302,7 @@ const Detail = (props) => {
                       {t('常见问答')}
                     </div>
                   }
+                  bodyStyle={{ padding: 0 }}
                 >
                   <div className="card-content-container">
                     <div
@@ -1253,8 +1329,8 @@ const Detail = (props) => {
                       ) : (
                         <div className="flex justify-center items-center py-8">
                           <Empty
-                            image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
-                            darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
+                            image={<IllustrationConstruction />}
+                            darkModeImage={<IllustrationConstructionDark />}
                             title={t('暂无常见问答')}
                             description={t('请联系管理员在系统设置中配置常见问答')}
                             style={{ padding: '12px' }}
@@ -1274,7 +1350,7 @@ const Detail = (props) => {
               {uptimeEnabled && (
                 <Card
                   {...CARD_PROPS}
-                  className="shadow-sm !rounded-2xl lg:col-span-1"
+                  className="shadow-sm !rounded-2xl lg:col-span-1 flex flex-col"
                   title={
                     <div className="flex items-center justify-between w-full gap-2">
                       <div className="flex items-center gap-2">
@@ -1291,11 +1367,93 @@ const Detail = (props) => {
                       />
                     </div>
                   }
-                  footer={uptimeData.length > 0 ? (
-                    <Card
-                      bordered={false}
-                      className="!rounded-2xl backdrop-blur !shadow-none"
-                    >
+                  bodyStyle={{ padding: 0 }}
+                >
+                  {/* 内容区域 */}
+                  <div className="flex-1 relative">
+                    <Spin spinning={uptimeLoading}>
+                      {uptimeData.length > 0 ? (
+                        uptimeData.length === 1 ? (
+                          <div className="card-content-container">
+                            <div
+                              ref={uptimeScrollRef}
+                              className="p-2 max-h-[24rem] overflow-y-auto card-content-scroll"
+                              onScroll={() => handleCardScroll(uptimeScrollRef, setShowUptimeScrollHint)}
+                            >
+                              {renderMonitorList(uptimeData[0].monitors)}
+                            </div>
+                            <div
+                              className="card-content-fade-indicator"
+                              style={{ opacity: showUptimeScrollHint ? 1 : 0 }}
+                            />
+                          </div>
+                        ) : (
+                          <Tabs
+                            type="card"
+                            collapsible
+                            activeKey={activeUptimeTab}
+                            onChange={setActiveUptimeTab}
+                            size="small"
+                          >
+                            {uptimeData.map((group, groupIdx) => {
+                              if (!uptimeTabScrollRefs.current[group.categoryName]) {
+                                uptimeTabScrollRefs.current[group.categoryName] = React.createRef();
+                              }
+                              const tabScrollRef = uptimeTabScrollRefs.current[group.categoryName];
+
+                              return (
+                                <TabPane
+                                  tab={
+                                    <span className="flex items-center gap-2">
+                                      <Gauge size={14} />
+                                      {group.categoryName}
+                                      <Tag
+                                        color={activeUptimeTab === group.categoryName ? 'red' : 'grey'}
+                                        size='small'
+                                        shape='circle'
+                                      >
+                                        {group.monitors ? group.monitors.length : 0}
+                                      </Tag>
+                                    </span>
+                                  }
+                                  itemKey={group.categoryName}
+                                  key={groupIdx}
+                                >
+                                  <div className="card-content-container">
+                                    <div
+                                      ref={tabScrollRef}
+                                      className="p-2 max-h-[21.5rem] overflow-y-auto card-content-scroll"
+                                      onScroll={() => handleCardScroll(tabScrollRef, setShowUptimeScrollHint)}
+                                    >
+                                      {renderMonitorList(group.monitors)}
+                                    </div>
+                                    <div
+                                      className="card-content-fade-indicator"
+                                      style={{ opacity: activeUptimeTab === group.categoryName ? showUptimeScrollHint ? 1 : 0 : 0 }}
+                                    />
+                                  </div>
+                                </TabPane>
+                              );
+                            })}
+                          </Tabs>
+                        )
+                      ) : (
+                        <div className="flex justify-center items-center py-8">
+                          <Empty
+                            image={<IllustrationConstruction />}
+                            darkModeImage={<IllustrationConstructionDark />}
+                            title={t('暂无监控数据')}
+                            description={t('请联系管理员在系统设置中配置Uptime分组')}
+                            style={{ padding: '12px' }}
+                          />
+                        </div>
+                      )}
+                    </Spin>
+                  </div>
+
+                  {/* 固定在底部的图例 */}
+                  {uptimeData.length > 0 && (
+                    <div className="p-3 mt-auto bg-gray-50 rounded-b-2xl">
                       <div className="flex flex-wrap gap-3 text-xs justify-center">
                         {uptimeLegendData.map((legend, index) => (
                           <div key={index} className="flex items-center gap-1">
@@ -1307,63 +1465,8 @@ const Detail = (props) => {
                           </div>
                         ))}
                       </div>
-                    </Card>
-                  ) : null}
-                  footerStyle={uptimeData.length > 0 ? { padding: '0px' } : undefined}
-                >
-                  <div className="card-content-container">
-                    <Spin spinning={uptimeLoading}>
-                      <div
-                        ref={uptimeScrollRef}
-                        className="p-2 max-h-80 overflow-y-auto card-content-scroll"
-                        onScroll={() => handleCardScroll(uptimeScrollRef, setShowUptimeScrollHint)}
-                      >
-                        {uptimeData.length > 0 ? (
-                          uptimeData.map((monitor, idx) => (
-                            <div key={idx} className="p-2 hover:bg-white rounded-lg transition-colors">
-                              <div className="flex items-center justify-between mb-1">
-                                <div className="flex items-center gap-2">
-                                  <div
-                                    className="w-2 h-2 rounded-full flex-shrink-0"
-                                    style={{
-                                      backgroundColor: getUptimeStatusColor(monitor.status)
-                                    }}
-                                  />
-                                  <span className="text-sm font-medium text-gray-900">{monitor.name}</span>
-                                </div>
-                                <span className="text-xs text-gray-500">{((monitor.uptime || 0) * 100).toFixed(2)}%</span>
-                              </div>
-                              <div className="flex items-center gap-2">
-                                <span className="text-xs text-gray-500">{getUptimeStatusText(monitor.status)}</span>
-                                <div className="flex-1">
-                                  <Progress
-                                    percent={(monitor.uptime || 0) * 100}
-                                    showInfo={false}
-                                    aria-label={`${monitor.name} uptime`}
-                                    stroke={getUptimeStatusColor(monitor.status)}
-                                  />
-                                </div>
-                              </div>
-                            </div>
-                          ))
-                        ) : (
-                          <div className="flex justify-center items-center py-8">
-                            <Empty
-                              image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
-                              darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
-                              title={t('暂无监控数据')}
-                              description={t('请联系管理员在系统设置中配置Uptime')}
-                              style={{ padding: '12px' }}
-                            />
-                          </div>
-                        )}
-                      </div>
-                    </Spin>
-                    <div
-                      className="card-content-fade-indicator"
-                      style={{ opacity: showUptimeScrollHint ? 1 : 0 }}
-                    />
-                  </div>
+                    </div>
+                  )}
                 </Card>
               )}
             </div>

+ 399 - 132
web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js

@@ -1,13 +1,23 @@
-import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react';
+import React, { useEffect, useState } from 'react';
 import {
-  Form,
   Button,
+  Space,
+  Table,
+  Form,
   Typography,
-  Row,
-  Col,
-  Switch,
+  Empty,
+  Divider,
+  Modal,
+  Switch
 } from '@douyinfe/semi-ui';
 import {
+  IllustrationNoResult,
+  IllustrationNoResultDark
+} from '@douyinfe/semi-illustrations';
+import {
+  Plus,
+  Edit,
+  Trash2,
   Save,
   Activity
 } from 'lucide-react';
@@ -19,69 +29,242 @@ const { Text } = Typography;
 const SettingsUptimeKuma = ({ options, refresh }) => {
   const { t } = useTranslation();
 
+  const [uptimeGroupsList, setUptimeGroupsList] = useState([]);
+  const [showUptimeModal, setShowUptimeModal] = useState(false);
+  const [showDeleteModal, setShowDeleteModal] = useState(false);
+  const [deletingGroup, setDeletingGroup] = useState(null);
+  const [editingGroup, setEditingGroup] = useState(null);
+  const [modalLoading, setModalLoading] = useState(false);
   const [loading, setLoading] = useState(false);
+  const [hasChanges, setHasChanges] = useState(false);
+  const [uptimeForm, setUptimeForm] = useState({
+    categoryName: '',
+    url: '',
+    slug: '',
+  });
+  const [currentPage, setCurrentPage] = useState(1);
+  const [pageSize, setPageSize] = useState(10);
+  const [selectedRowKeys, setSelectedRowKeys] = useState([]);
   const [panelEnabled, setPanelEnabled] = useState(true);
-  const formApiRef = useRef(null);
 
-  const initValues = useMemo(() => ({
-    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']]);
+  const columns = [
+    {
+      title: t('分类名称'),
+      dataIndex: 'categoryName',
+      key: 'categoryName',
+      render: (text) => (
+        <div style={{
+          fontWeight: 'bold',
+          color: 'var(--semi-color-text-0)'
+        }}>
+          {text}
+        </div>
+      )
+    },
+    {
+      title: t('Uptime Kuma地址'),
+      dataIndex: 'url',
+      key: 'url',
+      render: (text) => (
+        <div style={{
+          maxWidth: '300px',
+          wordBreak: 'break-all',
+          fontFamily: 'monospace',
+          color: 'var(--semi-color-primary)'
+        }}>
+          {text}
+        </div>
+      )
+    },
+    {
+      title: t('状态页面Slug'),
+      dataIndex: 'slug',
+      key: 'slug',
+      render: (text) => (
+        <div style={{
+          fontFamily: 'monospace',
+          color: 'var(--semi-color-text-1)'
+        }}>
+          {text}
+        </div>
+      )
+    },
+    {
+      title: t('操作'),
+      key: 'action',
+      fixed: 'right',
+      width: 150,
+      render: (text, record) => (
+        <Space>
+          <Button
+            icon={<Edit size={14} />}
+            theme='light'
+            type='tertiary'
+            size='small'
+            className="!rounded-full"
+            onClick={() => handleEditGroup(record)}
+          >
+            {t('编辑')}
+          </Button>
+          <Button
+            icon={<Trash2 size={14} />}
+            type='danger'
+            theme='light'
+            size='small'
+            className="!rounded-full"
+            onClick={() => handleDeleteGroup(record)}
+          >
+            {t('删除')}
+          </Button>
+        </Space>
+      )
+    }
+  ];
 
-  useEffect(() => {
-    if (formApiRef.current) {
-      formApiRef.current.setValues(initValues, { isOverride: true });
+  const updateOption = async (key, value) => {
+    const res = await API.put('/api/option/', {
+      key,
+      value,
+    });
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess('Uptime Kuma配置已更新');
+      if (refresh) refresh();
+    } else {
+      showError(message);
     }
-  }, [initValues]);
+  };
 
-  useEffect(() => {
-    const enabledStr = options?.['console_setting.uptime_kuma_enabled'];
-    setPanelEnabled(enabledStr === undefined ? true : enabledStr === 'true' || enabledStr === true);
-  }, [options?.['console_setting.uptime_kuma_enabled']]);
+  const submitUptimeGroups = async () => {
+    try {
+      setLoading(true);
+      const groupsJson = JSON.stringify(uptimeGroupsList);
+      await updateOption('console_setting.uptime_kuma_groups', groupsJson);
+      setHasChanges(false);
+    } catch (error) {
+      console.error('Uptime Kuma配置更新失败', error);
+      showError('Uptime Kuma配置更新失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleAddGroup = () => {
+    setEditingGroup(null);
+    setUptimeForm({
+      categoryName: '',
+      url: '',
+      slug: '',
+    });
+    setShowUptimeModal(true);
+  };
 
-  const handleSave = async () => {
-    const api = formApiRef.current;
-    if (!api) {
-      showError(t('表单未初始化'));
+  const handleEditGroup = (group) => {
+    setEditingGroup(group);
+    setUptimeForm({
+      categoryName: group.categoryName,
+      url: group.url,
+      slug: group.slug,
+    });
+    setShowUptimeModal(true);
+  };
+
+  const handleDeleteGroup = (group) => {
+    setDeletingGroup(group);
+    setShowDeleteModal(true);
+  };
+
+  const confirmDeleteGroup = () => {
+    if (deletingGroup) {
+      const newList = uptimeGroupsList.filter(item => item.id !== deletingGroup.id);
+      setUptimeGroupsList(newList);
+      setHasChanges(true);
+      showSuccess('分类已删除,请及时点击“保存设置”进行保存');
+    }
+    setShowDeleteModal(false);
+    setDeletingGroup(null);
+  };
+
+  const handleSaveGroup = async () => {
+    if (!uptimeForm.categoryName || !uptimeForm.url || !uptimeForm.slug) {
+      showError('请填写完整的分类信息');
       return;
     }
 
     try {
-      setLoading(true);
-      const { uptimeKumaUrl, uptimeKumaSlug } = await api.validate();
+      new URL(uptimeForm.url);
+    } catch (error) {
+      showError('请输入有效的URL地址');
+      return;
+    }
 
-      const trimmedUrl = (uptimeKumaUrl || '').trim();
-      const trimmedSlug = (uptimeKumaSlug || '').trim();
+    if (!/^[a-zA-Z0-9_-]+$/.test(uptimeForm.slug)) {
+      showError('Slug只能包含字母、数字、下划线和连字符');
+      return;
+    }
 
-      if (trimmedUrl === options?.['console_setting.uptime_kuma_url'] && trimmedSlug === options?.['console_setting.uptime_kuma_slug']) {
-        showSuccess(t('无需保存,配置未变动'));
-        return;
+    try {
+      setModalLoading(true);
+
+      let newList;
+      if (editingGroup) {
+        newList = uptimeGroupsList.map(item =>
+          item.id === editingGroup.id
+            ? { ...item, ...uptimeForm }
+            : item
+        );
+      } else {
+        const newId = Math.max(...uptimeGroupsList.map(item => item.id), 0) + 1;
+        const newGroup = {
+          id: newId,
+          ...uptimeForm
+        };
+        newList = [...uptimeGroupsList, newGroup];
       }
 
-      const [urlRes, slugRes] = await Promise.all([
-        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?.['console_setting.uptime_kuma_slug'] ? Promise.resolve({ data: { success: true } }) : API.put('/api/option/', {
-          key: 'console_setting.uptime_kuma_slug',
-          value: trimmedSlug
-        })
-      ]);
-
-      if (!urlRes.data.success) throw new Error(urlRes.data.message || t('URL 保存失败'));
-      if (!slugRes.data.success) throw new Error(slugRes.data.message || t('Slug 保存失败'));
-
-      showSuccess(t('Uptime Kuma 设置保存成功'));
-      refresh?.();
-    } catch (err) {
-      console.error(err);
-      showError(err.message || t('保存失败,请重试'));
+      setUptimeGroupsList(newList);
+      setHasChanges(true);
+      setShowUptimeModal(false);
+      showSuccess(editingGroup ? '分类已更新,请及时点击“保存设置”进行保存' : '分类已添加,请及时点击“保存设置”进行保存');
+    } catch (error) {
+      showError('操作失败: ' + error.message);
     } finally {
-      setLoading(false);
+      setModalLoading(false);
     }
   };
 
+  const parseUptimeGroups = (groupsStr) => {
+    if (!groupsStr) {
+      setUptimeGroupsList([]);
+      return;
+    }
+
+    try {
+      const parsed = JSON.parse(groupsStr);
+      const list = Array.isArray(parsed) ? parsed : [];
+      const listWithIds = list.map((item, index) => ({
+        ...item,
+        id: item.id || index + 1
+      }));
+      setUptimeGroupsList(listWithIds);
+    } catch (error) {
+      console.error('解析Uptime Kuma配置失败:', error);
+      setUptimeGroupsList([]);
+    }
+  };
+
+  useEffect(() => {
+    const groupsStr = options['console_setting.uptime_kuma_groups'];
+    if (groupsStr !== undefined) {
+      parseUptimeGroups(groupsStr);
+    }
+  }, [options['console_setting.uptime_kuma_groups']]);
+
+  useEffect(() => {
+    const enabledStr = options['console_setting.uptime_kuma_enabled'];
+    setPanelEnabled(enabledStr === undefined ? true : enabledStr === 'true' || enabledStr === true);
+  }, [options['console_setting.uptime_kuma_enabled']]);
+
   const handleToggleEnabled = async (checked) => {
     const newValue = checked ? 'true' : 'false';
     try {
@@ -101,46 +284,65 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
     }
   };
 
-  const isValidUrl = useCallback((string) => {
-    try {
-      new URL(string);
-      return true;
-    } catch (_) {
-      return false;
+  const handleBatchDelete = () => {
+    if (selectedRowKeys.length === 0) {
+      showError('请先选择要删除的分类');
+      return;
     }
-  }, []);
+
+    const newList = uptimeGroupsList.filter(item => !selectedRowKeys.includes(item.id));
+    setUptimeGroupsList(newList);
+    setSelectedRowKeys([]);
+    setHasChanges(true);
+    showSuccess(`已删除 ${selectedRowKeys.length} 个分类,请及时点击“保存设置”进行保存`);
+  };
 
   const renderHeader = () => (
     <div className="flex flex-col w-full">
-      <div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4 mb-2">
+      <div className="mb-2">
         <div className="flex items-center text-blue-500">
           <Activity size={16} className="mr-2" />
-          <Text>
-            {t('配置')}&nbsp;
-            <a
-              href="https://github.com/louislam/uptime-kuma"
-              target="_blank"
-              rel="noopener noreferrer"
-              className="text-blue-600 hover:underline"
-            >
-              Uptime&nbsp;Kuma
-            </a>
-            &nbsp;{t('服务监控地址,用于展示服务状态信息')}
-          </Text>
+          <Text>{t('Uptime Kuma监控分类管理,可以配置多个监控分类用于服务状态展示(最多20个)')}</Text>
         </div>
+      </div>
+
+      <Divider margin="12px" />
 
-        <div className="flex gap-2 items-center">
+      <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
+        <div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
           <Button
-            icon={<Save size={14} />}
-            theme='solid'
+            theme='light'
             type='primary'
-            onClick={handleSave}
+            icon={<Plus size={14} />}
+            className="!rounded-full w-full md:w-auto"
+            onClick={handleAddGroup}
+          >
+            {t('添加分类')}
+          </Button>
+          <Button
+            icon={<Trash2 size={14} />}
+            type='danger'
+            theme='light'
+            onClick={handleBatchDelete}
+            disabled={selectedRowKeys.length === 0}
+            className="!rounded-full w-full md:w-auto"
+          >
+            {t('批量删除')} {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
+          </Button>
+          <Button
+            icon={<Save size={14} />}
+            onClick={submitUptimeGroups}
             loading={loading}
-            className="!rounded-full"
+            disabled={!hasChanges}
+            type='secondary'
+            className="!rounded-full w-full md:w-auto"
           >
             {t('保存设置')}
           </Button>
+        </div>
 
+        {/* 启用开关 */}
+        <div className="order-1 md:order-2 flex items-center gap-2">
           <Switch checked={panelEnabled} onChange={handleToggleEnabled} />
           <Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
         </div>
@@ -148,67 +350,132 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
     </div>
   );
 
+  const getCurrentPageData = () => {
+    const startIndex = (currentPage - 1) * pageSize;
+    const endIndex = startIndex + pageSize;
+    return uptimeGroupsList.slice(startIndex, endIndex);
+  };
+
+  const rowSelection = {
+    selectedRowKeys,
+    onChange: (selectedRowKeys, selectedRows) => {
+      setSelectedRowKeys(selectedRowKeys);
+    },
+    onSelect: (record, selected, selectedRows) => {
+      console.log(`选择行: ${selected}`, record);
+    },
+    onSelectAll: (selected, selectedRows) => {
+      console.log(`全选: ${selected}`, selectedRows);
+    },
+    getCheckboxProps: (record) => ({
+      disabled: false,
+      name: record.id,
+    }),
+  };
+
   return (
-    <Form.Section text={renderHeader()}>
-      <Form
-        layout="vertical"
-        autoScrollToError
-        initValues={initValues}
-        getFormApi={(api) => {
-          formApiRef.current = api;
+    <>
+      <Form.Section text={renderHeader()}>
+        <Table
+          columns={columns}
+          dataSource={getCurrentPageData()}
+          rowSelection={rowSelection}
+          rowKey="id"
+          scroll={{ x: 'max-content' }}
+          pagination={{
+            currentPage: currentPage,
+            pageSize: pageSize,
+            total: uptimeGroupsList.length,
+            showSizeChanger: true,
+            showQuickJumper: true,
+            formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
+              start: page.currentStart,
+              end: page.currentEnd,
+              total: uptimeGroupsList.length,
+            }),
+            pageSizeOptions: ['5', '10', '20', '50'],
+            onChange: (page, size) => {
+              setCurrentPage(page);
+              setPageSize(size);
+            },
+            onShowSizeChange: (current, size) => {
+              setCurrentPage(1);
+              setPageSize(size);
+            }
+          }}
+          size='middle'
+          loading={loading}
+          empty={
+            <Empty
+              image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
+              darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
+              description={t('暂无监控数据')}
+              style={{ padding: 30 }}
+            />
+          }
+          className="rounded-xl overflow-hidden"
+        />
+      </Form.Section>
+
+      <Modal
+        title={editingGroup ? t('编辑分类') : t('添加分类')}
+        visible={showUptimeModal}
+        onOk={handleSaveGroup}
+        onCancel={() => setShowUptimeModal(false)}
+        okText={t('保存')}
+        cancelText={t('取消')}
+        className="rounded-xl"
+        confirmLoading={modalLoading}
+        width={600}
+      >
+        <Form layout='vertical' initValues={uptimeForm} key={editingGroup ? editingGroup.id : 'new'}>
+          <Form.Input
+            field='categoryName'
+            label={t('分类名称')}
+            placeholder={t('请输入分类名称,如:OpenAI、Claude等')}
+            maxLength={50}
+            rules={[{ required: true, message: t('请输入分类名称') }]}
+            onChange={(value) => setUptimeForm({ ...uptimeForm, categoryName: value })}
+          />
+          <Form.Input
+            field='url'
+            label={t('Uptime Kuma地址')}
+            placeholder={t('请输入Uptime Kuma服务地址,如:https://status.example.com')}
+            maxLength={500}
+            rules={[{ required: true, message: t('请输入Uptime Kuma地址') }]}
+            onChange={(value) => setUptimeForm({ ...uptimeForm, url: value })}
+          />
+          <Form.Input
+            field='slug'
+            label={t('状态页面Slug')}
+            placeholder={t('请输入状态页面的Slug,如:my-status')}
+            maxLength={100}
+            rules={[{ required: true, message: t('请输入状态页面Slug') }]}
+            onChange={(value) => setUptimeForm({ ...uptimeForm, slug: value })}
+          />
+        </Form>
+      </Modal>
+
+      <Modal
+        title={t('确认删除')}
+        visible={showDeleteModal}
+        onOk={confirmDeleteGroup}
+        onCancel={() => {
+          setShowDeleteModal(false);
+          setDeletingGroup(null);
+        }}
+        okText={t('确认删除')}
+        cancelText={t('取消')}
+        type="warning"
+        className="rounded-xl"
+        okButtonProps={{
+          type: 'danger',
+          theme: 'solid'
         }}
       >
-        <Row gutter={[24, 24]}>
-          <Col xs={24} md={12}>
-            <Form.Input
-              showClear
-              field="uptimeKumaUrl"
-              label={{ text: t("Uptime Kuma 服务地址") }}
-              placeholder={t("请输入 Uptime Kuma 服务地址")}
-              style={{ fontFamily: 'monospace' }}
-              helpText={t("请输入 Uptime Kuma 服务的完整地址,例如:https://uptime.example.com")}
-              rules={[
-                {
-                  validator: (_, value) => {
-                    const url = (value || '').trim();
-
-                    if (url && !isValidUrl(url)) {
-                      return Promise.reject(t('请输入有效的 URL 地址'));
-                    }
-
-                    return Promise.resolve();
-                  }
-                }
-              ]}
-            />
-          </Col>
-
-          <Col xs={24} md={12}>
-            <Form.Input
-              showClear
-              field="uptimeKumaSlug"
-              label={{ text: t("状态页面 Slug") }}
-              placeholder={t("请输入状态页面 Slug")}
-              style={{ fontFamily: 'monospace' }}
-              helpText={t("请输入状态页面的 slug 标识符,例如:my-status")}
-              rules={[
-                {
-                  validator: (_, value) => {
-                    const slug = (value || '').trim();
-
-                    if (slug && !/^[a-zA-Z0-9_-]+$/.test(slug)) {
-                      return Promise.reject(t('Slug 只能包含字母、数字、下划线和连字符'));
-                    }
-
-                    return Promise.resolve();
-                  }
-                }
-              ]}
-            />
-          </Col>
-        </Row>
-      </Form>
-    </Form.Section>
+        <Text>{t('确定要删除此分类吗?')}</Text>
+      </Modal>
+    </>
   );
 };