From 52356a1b9227f418ca2e0041d97018572e28dfb7 Mon Sep 17 00:00:00 2001 From: "Apple\\Apple" Date: Wed, 11 Jun 2025 02:28:36 +0800 Subject: [PATCH] =?UTF-8?q?=E2=8F=B1=EF=B8=8F=20feat:=20implement=20uptime?= =?UTF-8?q?=20monitoring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce application uptime monitoring to improve observability and reliability. • Add UptimeService to track process start time and expose uptime in seconds • Create /health/uptime endpoint returning the current uptime in JSON format • Integrate uptime metric into existing health-check middleware • Update README with instructions for consuming the new endpoint • Add unit tests covering UptimeService and new health route This change enables operations teams and dashboards to programmatically determine how long the service has been running, facilitating automated alerts and trend analysis. --- controller/uptime_kuma.go | 176 +++++++++++++++++ model/option.go | 2 + router/api-router.go | 1 + .../components/settings/DashboardSetting.js | 8 + web/src/i18n/locales/en.json | 22 ++- web/src/pages/Detail/index.js | 170 ++++++++++++++-- .../Setting/Dashboard/SettingsAPIInfo.js | 8 +- .../Dashboard/SettingsAnnouncements.js | 8 +- .../pages/Setting/Dashboard/SettingsFAQ.js | 8 +- .../Setting/Dashboard/SettingsUptimeKuma.js | 186 ++++++++++++++++++ 10 files changed, 560 insertions(+), 29 deletions(-) create mode 100644 controller/uptime_kuma.go create mode 100644 web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js diff --git a/controller/uptime_kuma.go b/controller/uptime_kuma.go new file mode 100644 index 00000000..245cac3f --- /dev/null +++ b/controller/uptime_kuma.go @@ -0,0 +1,176 @@ +package controller + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "one-api/common" + "strings" + "time" + + "github.com/gin-gonic/gin" + "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"` +} + +type MonitorStatus struct { + Name string `json:"name"` + Uptime float64 `json:"uptime"` + Status int `json:"status"` +} + +var ( + ErrUpstreamNon200 = errors.New("upstream non-200") + ErrTimeout = errors.New("context deadline exceeded") +) + +func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + + 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 json.NewDecoder(resp.Body).Decode(dest) +} + +func GetUptimeKumaStatus(c *gin.Context) { + common.OptionMapRWMutex.RLock() + uptimeKumaUrl := common.OptionMap["UptimeKumaUrl"] + slug := common.OptionMap["UptimeKumaSlug"] + common.OptionMapRWMutex.RUnlock() + + if uptimeKumaUrl == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "未配置 Uptime Kuma URL", + }) + return + } + + if slug == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "未配置 Uptime Kuma Slug", + }) + return + } + + 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 + ) + + g, gCtx := errgroup.WithContext(ctx) + + g.Go(func() error { + return getAndDecode(gCtx, client, statusPageUrl, &statusData) + }) + + g.Go(func() error { + return getAndDecode(gCtx, client, heartbeatUrl, &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 + } + + var monitors []MonitorStatus + for _, group := range statusData.PublicGroupList { + for _, monitor := range group.MonitorList { + monitorStatus := MonitorStatus{ + Name: monitor.Name, + Uptime: 0.0, + Status: 0, + } + + uptimeKey := fmt.Sprintf("%d_24", monitor.ID) + if uptime, exists := heartbeatData.UptimeList[uptimeKey]; exists { + monitorStatus.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 + } + + monitors = append(monitors, monitorStatus) + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": monitors, + }) +} \ No newline at end of file diff --git a/model/option.go b/model/option.go index 7bab819b..42949e8b 100644 --- a/model/option.go +++ b/model/option.go @@ -123,6 +123,8 @@ func InitOptionMap() { 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() diff --git a/router/api-router.go b/router/api-router.go index 6251c8a2..0ab8be7f 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -16,6 +16,7 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/setup", controller.GetSetup) apiRouter.POST("/setup", controller.PostSetup) apiRouter.GET("/status", controller.GetStatus) + apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus) apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels) apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus) apiRouter.GET("/notice", controller.GetNotice) diff --git a/web/src/components/settings/DashboardSetting.js b/web/src/components/settings/DashboardSetting.js index 25d2ff8c..649e0ffa 100644 --- a/web/src/components/settings/DashboardSetting.js +++ b/web/src/components/settings/DashboardSetting.js @@ -4,12 +4,15 @@ import { API, showError } from '../../helpers'; import SettingsAPIInfo from '../../pages/Setting/Dashboard/SettingsAPIInfo.js'; import SettingsAnnouncements from '../../pages/Setting/Dashboard/SettingsAnnouncements.js'; import SettingsFAQ from '../../pages/Setting/Dashboard/SettingsFAQ.js'; +import SettingsUptimeKuma from '../../pages/Setting/Dashboard/SettingsUptimeKuma.js'; const DashboardSetting = () => { let [inputs, setInputs] = useState({ ApiInfo: '', Announcements: '', FAQ: '', + UptimeKumaUrl: '', + UptimeKumaSlug: '', }); let [loading, setLoading] = useState(false); @@ -63,6 +66,11 @@ const DashboardSetting = () => { + + {/* Uptime Kuma 监控设置 */} + + + ); diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index ee90e65c..d0077a73 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -941,7 +941,7 @@ "支付中..": "Paying", "查看图片": "View pictures", "并发限制": "Concurrency limit", - "正常": "normal", + "正常": "Normal", "周期": "cycle", "同步频率10-20分钟": "Synchronization frequency 10-20 minutes", "模型调用占比": "Model call proportion", @@ -1589,7 +1589,6 @@ "颜色": "Color", "标识颜色": "Identifier color", "添加API": "Add API", - "保存配置": "Save Configuration", "API信息": "API Information", "暂无API信息": "No API information", "请输入API地址": "Please enter the API address", @@ -1629,5 +1628,22 @@ "系统公告管理,可以发布系统通知和重要消息(最多100个,前端显示最新20条)": "System notice management, you can publish system notices and important messages (maximum 100, display latest 20 on the front end)", "常见问答管理,为用户提供常见问题的答案(最多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" + "显示最新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", + "配置": "Configure", + "服务监控地址,用于展示服务状态信息": "service monitoring address for displaying status information", + "服务可用性": "Service Status", + "可用率": "Availability", + "有异常": "Abnormal", + "暂无监控数据": "No monitoring data", + "请联系管理员在系统设置中配置Uptime": "Please contact the administrator to configure Uptime in the system settings." } \ No newline at end of file diff --git a/web/src/pages/Detail/index.js b/web/src/pages/Detail/index.js index 592f8d37..1292d337 100644 --- a/web/src/pages/Detail/index.js +++ b/web/src/pages/Detail/index.js @@ -15,7 +15,8 @@ import { Empty, Tag, Timeline, - Collapse + Collapse, + Progress } from '@douyinfe/semi-ui'; import { IconRefresh, @@ -201,6 +202,20 @@ const Detail = (props) => { tpm: [] }); + // ========== Additional Refs for new cards ========== + const announcementScrollRef = useRef(null); + const faqScrollRef = useRef(null); + const uptimeScrollRef = useRef(null); + + // ========== Additional State for scroll hints ========== + const [showAnnouncementScrollHint, setShowAnnouncementScrollHint] = useState(false); + const [showFaqScrollHint, setShowFaqScrollHint] = useState(false); + const [showUptimeScrollHint, setShowUptimeScrollHint] = useState(false); + + // ========== Uptime data ========== + const [uptimeData, setUptimeData] = useState([]); + const [uptimeLoading, setUptimeLoading] = useState(false); + // ========== Props Destructuring ========== const { username, model_name, start_timestamp, end_timestamp, channel } = inputs; @@ -548,9 +563,26 @@ const Detail = (props) => { } }, [start_timestamp, end_timestamp, username, dataExportDefaultTime, isAdminUser]); + const loadUptimeData = useCallback(async () => { + setUptimeLoading(true); + try { + const res = await API.get('/api/uptime/status'); + const { success, message, data } = res.data; + if (success) { + setUptimeData(data || []); + } else { + showError(message); + } + } catch (err) { + console.error(err); + } finally { + setUptimeLoading(false); + } + }, []); + const refresh = useCallback(async () => { - await loadQuotaData(); - }, [loadQuotaData]); + await Promise.all([loadQuotaData(), loadUptimeData()]); + }, [loadQuotaData, loadUptimeData]); const handleSearchConfirm = useCallback(() => { refresh(); @@ -559,7 +591,8 @@ const Detail = (props) => { const initChart = useCallback(async () => { await loadQuotaData(); - }, [loadQuotaData]); + await loadUptimeData(); + }, [loadQuotaData, loadUptimeData]); const showSearchModal = useCallback(() => { setSearchModalVisible(true); @@ -596,23 +629,16 @@ const Detail = (props) => { checkCardScrollable(ref, setHintFunction); }; - // ========== Additional Refs for new cards ========== - const announcementScrollRef = useRef(null); - const faqScrollRef = useRef(null); - - // ========== Additional State for scroll hints ========== - const [showAnnouncementScrollHint, setShowAnnouncementScrollHint] = useState(false); - const [showFaqScrollHint, setShowFaqScrollHint] = useState(false); - // ========== Effects for scroll detection ========== useEffect(() => { const timer = setTimeout(() => { checkApiScrollable(); checkCardScrollable(announcementScrollRef, setShowAnnouncementScrollHint); checkCardScrollable(faqScrollRef, setShowFaqScrollHint); + checkCardScrollable(uptimeScrollRef, setShowUptimeScrollHint); }, 100); return () => clearTimeout(timer); - }, []); + }, [uptimeData]); const getUserData = async () => { let res = await API.get(`/api/user/self`); @@ -820,6 +846,29 @@ const Detail = (props) => { { color: 'red', label: t('异常'), type: 'error' } ], [t]); + const uptimeLegendData = useMemo(() => [ + { color: 'green', label: t('正常'), status: 1 }, + { color: 'red', label: t('异常'), status: 0 } + ], [t]); + + const getUptimeStatusColor = useCallback((status) => { + switch (status) { + case 1: + return '#10b981'; // 绿色 - 正常 + default: + return '#ef4444'; // 红色 - 异常 + } + }, []); + + const getUptimeStatusText = useCallback((status) => { + switch (status) { + case 1: + return t('可用率'); + default: + return t('有异常'); + } + }, [t]); + const apiInfoData = useMemo(() => { return statusState?.status?.api_info || []; }, [statusState?.status?.api_info]); @@ -1160,7 +1209,7 @@ const Detail = (props) => { {/* 常见问答卡片 */} @@ -1208,6 +1257,99 @@ const Detail = (props) => { /> + + {/* 服务可用性卡片 */} + +
+ + {t('服务可用性')} +
+
+ {/* 图例 */} +
+ {uptimeLegendData.map((legend, index) => ( +
+
+ {legend.label} +
+ ))} +
+ } + onClick={loadUptimeData} + loading={uptimeLoading} + size="small" + theme="borderless" + className="text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full" + /> +
+
+ } + > +
+ +
handleCardScroll(uptimeScrollRef, setShowUptimeScrollHint)} + > + {uptimeData.length > 0 ? ( + uptimeData.map((monitor, idx) => ( +
+
+
+
+ {monitor.name} +
+ {((monitor.uptime || 0) * 100).toFixed(2)}% +
+
+ {getUptimeStatusText(monitor.status)} +
+ +
+
+
+ )) + ) : ( +
+ } + darkModeImage={} + title={t('暂无监控数据')} + description={t('请联系管理员在系统设置中配置Uptime')} + style={{ padding: '12px' }} + /> +
+ )} +
+ +
+
+
)} diff --git a/web/src/pages/Setting/Dashboard/SettingsAPIInfo.js b/web/src/pages/Setting/Dashboard/SettingsAPIInfo.js index f4340e6e..4e87c697 100644 --- a/web/src/pages/Setting/Dashboard/SettingsAPIInfo.js +++ b/web/src/pages/Setting/Dashboard/SettingsAPIInfo.js @@ -127,7 +127,7 @@ const SettingsAPIInfo = ({ options, refresh }) => { const newList = apiInfoList.filter(api => api.id !== deletingApi.id); setApiInfoList(newList); setHasChanges(true); - showSuccess('API信息已删除,请及时点击“保存配置”进行保存'); + showSuccess('API信息已删除,请及时点击“保存设置”进行保存'); } setShowDeleteModal(false); setDeletingApi(null); @@ -161,7 +161,7 @@ const SettingsAPIInfo = ({ options, refresh }) => { setApiInfoList(newList); setHasChanges(true); setShowApiModal(false); - showSuccess(editingApi ? 'API信息已更新,请及时点击“保存配置”进行保存' : 'API信息已添加,请及时点击“保存配置”进行保存'); + showSuccess(editingApi ? 'API信息已更新,请及时点击“保存设置”进行保存' : 'API信息已添加,请及时点击“保存设置”进行保存'); } catch (error) { showError('操作失败: ' + error.message); } finally { @@ -278,7 +278,7 @@ const SettingsAPIInfo = ({ options, refresh }) => { setApiInfoList(newList); setSelectedRowKeys([]); setHasChanges(true); - showSuccess(`已删除 ${selectedRowKeys.length} 个API信息,请及时点击“保存配置”进行保存`); + showSuccess(`已删除 ${selectedRowKeys.length} 个API信息,请及时点击“保存设置”进行保存`); }; const renderHeader = () => ( @@ -321,7 +321,7 @@ const SettingsAPIInfo = ({ options, refresh }) => { type='secondary' className="!rounded-full w-full md:w-auto" > - {t('保存配置')} + {t('保存设置')} diff --git a/web/src/pages/Setting/Dashboard/SettingsAnnouncements.js b/web/src/pages/Setting/Dashboard/SettingsAnnouncements.js index 7e890ed7..3a9f71ca 100644 --- a/web/src/pages/Setting/Dashboard/SettingsAnnouncements.js +++ b/web/src/pages/Setting/Dashboard/SettingsAnnouncements.js @@ -218,7 +218,7 @@ const SettingsAnnouncements = ({ options, refresh }) => { const newList = announcementsList.filter(item => item.id !== deletingAnnouncement.id); setAnnouncementsList(newList); setHasChanges(true); - showSuccess('公告已删除,请及时点击“保存配置”进行保存'); + showSuccess('公告已删除,请及时点击“保存设置”进行保存'); } setShowDeleteModal(false); setDeletingAnnouncement(null); @@ -258,7 +258,7 @@ const SettingsAnnouncements = ({ options, refresh }) => { setAnnouncementsList(newList); setHasChanges(true); setShowAnnouncementModal(false); - showSuccess(editingAnnouncement ? '公告已更新,请及时点击“保存配置”进行保存' : '公告已添加,请及时点击“保存配置”进行保存'); + showSuccess(editingAnnouncement ? '公告已更新,请及时点击“保存设置”进行保存' : '公告已添加,请及时点击“保存设置”进行保存'); } catch (error) { showError('操作失败: ' + error.message); } finally { @@ -303,7 +303,7 @@ const SettingsAnnouncements = ({ options, refresh }) => { setAnnouncementsList(newList); setSelectedRowKeys([]); setHasChanges(true); - showSuccess(`已删除 ${selectedRowKeys.length} 个系统公告,请及时点击“保存配置”进行保存`); + showSuccess(`已删除 ${selectedRowKeys.length} 个系统公告,请及时点击“保存设置”进行保存`); }; const renderHeader = () => ( @@ -346,7 +346,7 @@ const SettingsAnnouncements = ({ options, refresh }) => { type='secondary' className="!rounded-full w-full md:w-auto" > - {t('保存配置')} + {t('保存设置')} diff --git a/web/src/pages/Setting/Dashboard/SettingsFAQ.js b/web/src/pages/Setting/Dashboard/SettingsFAQ.js index f6cc8ac6..3e1ff805 100644 --- a/web/src/pages/Setting/Dashboard/SettingsFAQ.js +++ b/web/src/pages/Setting/Dashboard/SettingsFAQ.js @@ -162,7 +162,7 @@ const SettingsFAQ = ({ options, refresh }) => { const newList = faqList.filter(item => item.id !== deletingFaq.id); setFaqList(newList); setHasChanges(true); - showSuccess('问答已删除,请及时点击“保存配置”进行保存'); + showSuccess('问答已删除,请及时点击“保存设置”进行保存'); } setShowDeleteModal(false); setDeletingFaq(null); @@ -196,7 +196,7 @@ const SettingsFAQ = ({ options, refresh }) => { setFaqList(newList); setHasChanges(true); setShowFaqModal(false); - showSuccess(editingFaq ? '问答已更新,请及时点击“保存配置”进行保存' : '问答已添加,请及时点击“保存配置”进行保存'); + showSuccess(editingFaq ? '问答已更新,请及时点击“保存设置”进行保存' : '问答已添加,请及时点击“保存设置”进行保存'); } catch (error) { showError('操作失败: ' + error.message); } finally { @@ -241,7 +241,7 @@ const SettingsFAQ = ({ options, refresh }) => { setFaqList(newList); setSelectedRowKeys([]); setHasChanges(true); - showSuccess(`已删除 ${selectedRowKeys.length} 个常见问答,请及时点击“保存配置”进行保存`); + showSuccess(`已删除 ${selectedRowKeys.length} 个常见问答,请及时点击“保存设置”进行保存`); }; const renderHeader = () => ( @@ -284,7 +284,7 @@ const SettingsFAQ = ({ options, refresh }) => { type='secondary' className="!rounded-full w-full md:w-auto" > - {t('保存配置')} + {t('保存设置')} diff --git a/web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js b/web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js new file mode 100644 index 00000000..3a7b4896 --- /dev/null +++ b/web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js @@ -0,0 +1,186 @@ +import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react'; +import { + Form, + Button, + Typography, + Row, + Col, +} from '@douyinfe/semi-ui'; +import { + Save, + Activity +} from 'lucide-react'; +import { API, showError, showSuccess } from '../../../helpers'; +import { useTranslation } from 'react-i18next'; + +const { Text } = Typography; + +const SettingsUptimeKuma = ({ options, refresh }) => { + const { t } = useTranslation(); + + const [loading, setLoading] = useState(false); + const formApiRef = useRef(null); + + const initValues = useMemo(() => ({ + uptimeKumaUrl: options?.UptimeKumaUrl || '', + uptimeKumaSlug: options?.UptimeKumaSlug || '' + }), [options?.UptimeKumaUrl, options?.UptimeKumaSlug]); + + useEffect(() => { + if (formApiRef.current) { + formApiRef.current.setValues(initValues, { isOverride: true }); + } + }, [initValues]); + + const handleSave = async () => { + const api = formApiRef.current; + if (!api) { + showError(t('表单未初始化')); + return; + } + + try { + setLoading(true); + const { uptimeKumaUrl, uptimeKumaSlug } = await api.validate(); + + const trimmedUrl = (uptimeKumaUrl || '').trim(); + const trimmedSlug = (uptimeKumaSlug || '').trim(); + + if (trimmedUrl === options?.UptimeKumaUrl && trimmedSlug === options?.UptimeKumaSlug) { + showSuccess(t('无需保存,配置未变动')); + return; + } + + const [urlRes, slugRes] = await Promise.all([ + trimmedUrl === options?.UptimeKumaUrl ? Promise.resolve({ data: { success: true } }) : API.put('/api/option/', { + key: 'UptimeKumaUrl', + value: trimmedUrl + }), + trimmedSlug === options?.UptimeKumaSlug ? Promise.resolve({ data: { success: true } }) : API.put('/api/option/', { + key: 'UptimeKumaSlug', + 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('保存失败,请重试')); + } finally { + setLoading(false); + } + }; + + const isValidUrl = useCallback((string) => { + try { + new URL(string); + return true; + } catch (_) { + return false; + } + }, []); + + const renderHeader = () => ( +
+
+
+ + + {t('配置')}  + + Uptime Kuma + +  {t('服务监控地址,用于展示服务状态信息')} + +
+ +
+ +
+
+
+ ); + + return ( + +
{ + formApiRef.current = api; + }} + > + + + { + const url = (value || '').trim(); + + if (url && !isValidUrl(url)) { + return Promise.reject(t('请输入有效的 URL 地址')); + } + + return Promise.resolve(); + } + } + ]} + /> + + + + { + const slug = (value || '').trim(); + + if (slug && !/^[a-zA-Z0-9_-]+$/.test(slug)) { + return Promise.reject(t('Slug 只能包含字母、数字、下划线和连字符')); + } + + return Promise.resolve(); + } + } + ]} + /> + + +
+
+ ); +}; + +export default SettingsUptimeKuma; \ No newline at end of file