🚦 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.
This commit is contained in:
Apple\Apple
2025-06-15 02:54:54 +08:00
parent 5f05803643
commit abfb3f4006
8 changed files with 851 additions and 409 deletions

View File

@@ -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': '',

View File

@@ -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",
"请输入状态页面的Slugmy-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",

View File

@@ -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>

View File

@@ -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']]);
useEffect(() => {
if (formApiRef.current) {
formApiRef.current.setValues(initValues, { isOverride: true });
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>
)
}
}, [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 handleSave = async () => {
const api = formApiRef.current;
if (!api) {
showError(t('表单未初始化'));
return;
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);
}
};
const submitUptimeGroups = async () => {
try {
setLoading(true);
const { uptimeKumaUrl, uptimeKumaSlug } = await api.validate();
const trimmedUrl = (uptimeKumaUrl || '').trim();
const trimmedSlug = (uptimeKumaSlug || '').trim();
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?.['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('保存失败,请重试'));
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 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 {
new URL(uptimeForm.url);
} catch (error) {
showError('请输入有效的URL地址');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(uptimeForm.slug)) {
showError('Slug只能包含字母、数字、下划线和连字符');
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];
}
setUptimeGroupsList(newList);
setHasChanges(true);
setShowUptimeModal(false);
showSuccess(editingGroup ? '分类已更新,请及时点击“保存设置”进行保存' : '分类已添加,请及时点击“保存设置”进行保存');
} catch (error) {
showError('操作失败: ' + error.message);
} finally {
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>
<div className="flex gap-2 items-center">
<Divider margin="12px" />
<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
theme='light'
type='primary'
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} />}
theme='solid'
type='primary'
onClick={handleSave}
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('请输入状态页面的Slugmy-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>
</>
);
};