refactor: 优化设置页面布局与样式
- 设置页全宽对齐其他页面,左侧分类导航去卡片化并支持桌面端吸顶 - 通知渠道改为「渠道列表 + 配置区」布局,支持未保存提醒与校验失败自动跳转 - 系统配置/公网IP采集/告警规则页去嵌套卡片,改为平铺分区(SettingsSection) - 主题管理页上传按钮与警告提示合并为一行 - 调整全部页面的小布局问题
This commit is contained in:
1 parent
45284d31c6
commit
c730873be3
15 files changed
+537
-595
No files matched your search
@@ -20,6 +20,7 @@ const AdminLayout = () => {
|
||||
'/admin/alert-records',
|
||||
'/admin/agents-install/one-click',
|
||||
'/admin/agents-install/manual',
|
||||
'/admin/settings',
|
||||
].includes(location.pathname);
|
||||
const [userInfo, setUserInfo] = useState<User | null>(null);
|
||||
const themeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -134,7 +134,7 @@ const AgentDetail = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 lg:space-y-6">
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title={agent.name || agent.hostname}
|
||||
actions={[{
|
||||
|
||||
@@ -459,7 +459,7 @@ const AgentList = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="探针管理"
|
||||
extra={(
|
||||
|
||||
@@ -258,7 +258,7 @@ const AlertRecordList = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={'space-y-6'}>
|
||||
<div className={'space-y-4'}>
|
||||
<PageHeader
|
||||
title="告警记录"
|
||||
extra={(
|
||||
|
||||
@@ -240,7 +240,7 @@ const ApiKeyList = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="通信密钥管理"
|
||||
extra={(
|
||||
|
||||
@@ -268,7 +268,7 @@ const DDNSPage = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="DDNS 配置管理"
|
||||
extra={(
|
||||
|
||||
@@ -212,7 +212,7 @@ const ManageApiKeyList = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="API密钥管理"
|
||||
extra={(
|
||||
|
||||
@@ -230,7 +230,7 @@ const MonitorList = () => {
|
||||
const total = monitorPaging?.total || 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="服务监控"
|
||||
extra={(
|
||||
|
||||
@@ -1,9 +1,38 @@
|
||||
import { useEffect } from 'react';
|
||||
import { App, Button, Card, Form, InputNumber, Switch } from 'antd';
|
||||
import { App, Button, Form, InputNumber, Switch } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { AlertConfig } from '@/api/property';
|
||||
import { getAlertConfig, saveAlertConfig } from '@/api/property';
|
||||
import { getErrorMessage } from '@/lib/utils';
|
||||
import {SettingsActions, SettingsSection, SettingsSwitchRow} from './SettingsSection';
|
||||
|
||||
interface RuleDefinition {
|
||||
key: string;
|
||||
name: string;
|
||||
threshold?: {
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
tooltip?: string;
|
||||
};
|
||||
duration?: {
|
||||
tooltip?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const RULES: RuleDefinition[] = [
|
||||
{ key: 'cpu', name: 'CPU 使用率', threshold: { label: '阈值 (%)', min: 0, max: 100 }, duration: {} },
|
||||
{ key: 'memory', name: '内存使用率', threshold: { label: '阈值 (%)', min: 0, max: 100 }, duration: {} },
|
||||
{ key: 'disk', name: '磁盘使用率', threshold: { label: '阈值 (%)', min: 0, max: 100 }, duration: {} },
|
||||
{ key: 'network', name: '网速', threshold: { label: '阈值 (MB/s)', min: 0, max: 10000 }, duration: {} },
|
||||
{
|
||||
key: 'cert',
|
||||
name: 'HTTPS 证书',
|
||||
threshold: { label: '剩余天数阈值(天)', min: 1, max: 365, tooltip: '当证书剩余天数低于此阈值时触发告警' },
|
||||
},
|
||||
{ key: 'service', name: '服务下线', duration: { tooltip: '服务持续离线多久后触发告警' } },
|
||||
{ key: 'agentOffline', name: '探针离线', duration: { tooltip: '探针持续离线多久后触发告警' } },
|
||||
];
|
||||
|
||||
const AlertSettings = () => {
|
||||
const [form] = Form.useForm();
|
||||
@@ -42,202 +71,103 @@ const AlertSettings = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form form={form}>
|
||||
<div className="flex w-full min-w-0 flex-col gap-4">
|
||||
<Card title="基本信息" type="inner">
|
||||
<Form.Item label="启用告警" name="enabled" valuePropName="checked">
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="IP 打码"
|
||||
name="maskIP"
|
||||
valuePropName="checked"
|
||||
tooltip="开启后,通知消息中的 IP 地址将显示为 192.168.*.* 格式"
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<SettingsSection title="基本信息" divided={false}>
|
||||
<SettingsSwitchRow
|
||||
title="启用告警"
|
||||
description="总开关,关闭后不再产生和发送任何告警通知。"
|
||||
>
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
<Form.Item noStyle name="enabled" valuePropName="checked">
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭"/>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
</SettingsSwitchRow>
|
||||
<SettingsSwitchRow
|
||||
title="IP 打码"
|
||||
description="开启后,通知消息中的 IP 地址将显示为 192.168.*.* 格式。"
|
||||
>
|
||||
<Form.Item noStyle name="maskIP" valuePropName="checked">
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭"/>
|
||||
</Form.Item>
|
||||
</SettingsSwitchRow>
|
||||
</SettingsSection>
|
||||
|
||||
<Card title="通知开关" type="inner">
|
||||
<Form.Item
|
||||
label="流量告警通知"
|
||||
name={['notifications', 'trafficEnabled']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
<SettingsSection title="通知开关" description="控制哪些事件会通过通知渠道推送。">
|
||||
<SettingsSwitchRow title="流量告警通知">
|
||||
<Form.Item noStyle name={['notifications', 'trafficEnabled']} valuePropName="checked">
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭"/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="SSH 登录成功通知"
|
||||
name={['notifications', 'sshLoginSuccessEnabled']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</SettingsSwitchRow>
|
||||
<SettingsSwitchRow title="SSH 登录成功通知">
|
||||
<Form.Item noStyle name={['notifications', 'sshLoginSuccessEnabled']} valuePropName="checked">
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭"/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="防篡改事件通知"
|
||||
name={['notifications', 'tamperEventEnabled']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</SettingsSwitchRow>
|
||||
<SettingsSwitchRow title="防篡改事件通知">
|
||||
<Form.Item noStyle name={['notifications', 'tamperEventEnabled']} valuePropName="checked">
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭"/>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
</SettingsSwitchRow>
|
||||
</SettingsSection>
|
||||
|
||||
{/*<Divider orientation="left">告警规则</Divider>*/}
|
||||
|
||||
{[
|
||||
{ key: 'cpu', title: 'CPU 告警规则', thresholdLabel: 'CPU 使用率阈值 (%)', max: 100 },
|
||||
{ key: 'memory', title: '内存告警规则', thresholdLabel: '内存使用率阈值 (%)', max: 100 },
|
||||
{ key: 'disk', title: '磁盘告警规则', thresholdLabel: '磁盘使用率阈值 (%)', max: 100 },
|
||||
{ key: 'network', title: '网速告警规则', thresholdLabel: '网速阈值 (MB/s)', max: 10000 },
|
||||
].map((rule) => (
|
||||
<Card key={rule.key} title={rule.title} type="inner">
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
<SettingsSection title="告警规则" description="开启规则后,满足阈值条件并持续指定时间才会触发告警。">
|
||||
<div>
|
||||
{RULES.map((rule) => (
|
||||
<Form.Item key={rule.key} noStyle shouldUpdate>
|
||||
{({ getFieldValue }) => {
|
||||
const enabled = getFieldValue(['rules', `${rule.key}Enabled`]);
|
||||
return (
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-8 gap-y-4 border-t border-[#e8ebf0] py-4 first:border-t-0 first:pt-0 dark:border-[#272b33]">
|
||||
<div className="flex items-center gap-3">
|
||||
<Form.Item
|
||||
label="开关"
|
||||
noStyle
|
||||
name={['rules', `${rule.key}Enabled`]}
|
||||
valuePropName="checked"
|
||||
className="mb-0"
|
||||
>
|
||||
<Switch />
|
||||
<Switch size="small"/>
|
||||
</Form.Item>
|
||||
<span className="text-[13px] font-medium text-[#1f2329] dark:text-[#e6e8ec]">
|
||||
{rule.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start gap-x-8 gap-y-3">
|
||||
{rule.threshold && (
|
||||
<Form.Item
|
||||
label={rule.thresholdLabel}
|
||||
label={rule.threshold.label}
|
||||
name={['rules', `${rule.key}Threshold`]}
|
||||
className="mb-0"
|
||||
tooltip={rule.threshold.tooltip}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={rule.max}
|
||||
style={{ width: '100%' }}
|
||||
min={rule.threshold.min}
|
||||
max={rule.threshold.max}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{rule.duration && (
|
||||
<Form.Item
|
||||
label="持续时间(秒)"
|
||||
name={['rules', `${rule.key}Duration`]}
|
||||
className="mb-0"
|
||||
tooltip={rule.duration.tooltip}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber min={1} max={3600} style={{ width: '100%' }}
|
||||
disabled={!enabled} />
|
||||
<InputNumber min={1} max={3600} disabled={!enabled}/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Card title="HTTPS 证书告警规则" type="inner">
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({ getFieldValue }) => {
|
||||
const enabled = getFieldValue(['rules', 'certEnabled']);
|
||||
return (
|
||||
<div className="flex items-center gap-8">
|
||||
<Form.Item
|
||||
label="开关"
|
||||
name={['rules', 'certEnabled']}
|
||||
valuePropName="checked"
|
||||
className="mb-0"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="证书剩余天数阈值(天)"
|
||||
name={['rules', 'certThreshold']}
|
||||
className="mb-0"
|
||||
tooltip="当证书剩余天数低于此阈值时触发告警"
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={365}
|
||||
style={{ width: '100%' }}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</Card>
|
||||
</SettingsSection>
|
||||
|
||||
<Card title="服务下线告警规则" type="inner">
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({ getFieldValue }) => {
|
||||
const enabled = getFieldValue(['rules', 'serviceEnabled']);
|
||||
return (
|
||||
<div className="flex items-center gap-8">
|
||||
<Form.Item
|
||||
label="开关"
|
||||
name={['rules', 'serviceEnabled']}
|
||||
valuePropName="checked"
|
||||
className="mb-0"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="持续时间(秒)"
|
||||
name={['rules', 'serviceDuration']}
|
||||
className="mb-0"
|
||||
tooltip="服务持续离线多久后触发告警"
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={3600}
|
||||
style={{ width: '100%' }}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Card title="探针离线告警规则" type="inner">
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({ getFieldValue }) => {
|
||||
const enabled = getFieldValue(['rules', 'agentOfflineEnabled']);
|
||||
return (
|
||||
<div className="flex items-center gap-8">
|
||||
<Form.Item
|
||||
label="开关"
|
||||
name={['rules', 'agentOfflineEnabled']}
|
||||
valuePropName="checked"
|
||||
className="mb-0"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="持续时间(秒)"
|
||||
name={['rules', 'agentOfflineDuration']}
|
||||
className="mb-0"
|
||||
tooltip="探针持续离线多久后触发告警"
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={3600}
|
||||
style={{ width: '100%' }}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
loading={saveMutation.isPending}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
<SettingsActions>
|
||||
<Button type="primary" htmlType="submit" loading={saveMutation.isPending}>
|
||||
保存配置
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsActions>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Alert, App, Button, Card, Form, Input, InputNumber, Select, Space, Spin, Switch} from 'antd';
|
||||
import {TestTube} from 'lucide-react';
|
||||
import {App, Button, Form, Input, InputNumber, Select, Space, Spin, Switch, Tag} from 'antd';
|
||||
import {Bell, Bot, Building2, ExternalLink, Mail, MessageSquare, Smartphone, TestTube, Webhook, type LucideIcon} from 'lucide-react';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {
|
||||
getNotificationChannels,
|
||||
@@ -8,14 +8,91 @@ import {
|
||||
saveNotificationChannels,
|
||||
testNotificationChannel,
|
||||
} from '@/api/property.ts';
|
||||
import {getErrorMessage} from '@/lib/utils';
|
||||
import {cn, getErrorMessage} from '@/lib/utils';
|
||||
import NotificationCustomHelp from "@/pages/Settings/NotificationCustomHelp.tsx";
|
||||
|
||||
type ChannelType = 'dingtalk' | 'wecom' | 'wecomApp' | 'feishu' | 'telegram' | 'email' | 'webhook';
|
||||
|
||||
interface ChannelDefinition {
|
||||
type: ChannelType;
|
||||
name: string;
|
||||
description: string;
|
||||
enabledField: string;
|
||||
fieldPrefix: string;
|
||||
icon: LucideIcon;
|
||||
docsUrl?: string;
|
||||
}
|
||||
|
||||
const CHANNELS: ChannelDefinition[] = [
|
||||
{
|
||||
type: 'dingtalk',
|
||||
name: '钉钉',
|
||||
description: '自定义机器人通知',
|
||||
enabledField: 'dingtalkEnabled',
|
||||
fieldPrefix: 'dingtalk',
|
||||
icon: Bell,
|
||||
docsUrl: 'https://open.dingtalk.com/document/robots/custom-robot-access',
|
||||
},
|
||||
{
|
||||
type: 'wecom',
|
||||
name: '企业微信',
|
||||
description: '群机器人通知',
|
||||
enabledField: 'wecomEnabled',
|
||||
fieldPrefix: 'wecomSecretKey',
|
||||
icon: Building2,
|
||||
docsUrl: 'https://work.weixin.qq.com/api/doc/90000/90136/91770',
|
||||
},
|
||||
{
|
||||
type: 'wecomApp',
|
||||
name: '企业微信应用',
|
||||
description: '应用消息通知',
|
||||
enabledField: 'wecomAppEnabled',
|
||||
fieldPrefix: 'wecomApp',
|
||||
icon: Smartphone,
|
||||
docsUrl: 'https://developer.work.weixin.qq.com/document/path/90236',
|
||||
},
|
||||
{
|
||||
type: 'feishu',
|
||||
name: '飞书',
|
||||
description: '自定义机器人通知',
|
||||
enabledField: 'feishuEnabled',
|
||||
fieldPrefix: 'feishu',
|
||||
icon: MessageSquare,
|
||||
docsUrl: 'https://www.feishu.cn/hc/zh-CN/articles/360024984973',
|
||||
},
|
||||
{
|
||||
type: 'telegram',
|
||||
name: 'Telegram',
|
||||
description: 'Bot 消息通知',
|
||||
enabledField: 'telegramEnabled',
|
||||
fieldPrefix: 'telegram',
|
||||
icon: Bot,
|
||||
docsUrl: 'https://core.telegram.org/bots/api',
|
||||
},
|
||||
{
|
||||
type: 'email',
|
||||
name: '邮件',
|
||||
description: 'SMTP 邮件通知',
|
||||
enabledField: 'emailEnabled',
|
||||
fieldPrefix: 'email',
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
type: 'webhook',
|
||||
name: '自定义 Webhook',
|
||||
description: '自定义 HTTP 请求',
|
||||
enabledField: 'webhookEnabled',
|
||||
fieldPrefix: 'webhook',
|
||||
icon: Webhook,
|
||||
},
|
||||
];
|
||||
|
||||
const NotificationChannels = () => {
|
||||
const [form] = Form.useForm();
|
||||
const {message: messageApi} = App.useApp();
|
||||
const queryClient = useQueryClient();
|
||||
const [savedValues, setSavedValues] = useState<Record<string, any>>({});
|
||||
const [selectedType, setSelectedType] = useState<ChannelType>('dingtalk');
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
// 验证 token 字段,检查是否误输入了完整的 URL
|
||||
const validateToken = (_: any, value: string) => {
|
||||
@@ -34,6 +111,16 @@ const NotificationChannels = () => {
|
||||
const emailEnabled = Form.useWatch('emailEnabled', form);
|
||||
const webhookEnabled = Form.useWatch('webhookEnabled', form);
|
||||
|
||||
const enabledMap: Record<ChannelType, boolean | undefined> = {
|
||||
dingtalk: dingtalkEnabled,
|
||||
wecom: wecomEnabled,
|
||||
wecomApp: wecomAppEnabled,
|
||||
feishu: feishuEnabled,
|
||||
telegram: telegramEnabled,
|
||||
email: emailEnabled,
|
||||
webhook: webhookEnabled,
|
||||
};
|
||||
|
||||
// 获取通知渠道列表
|
||||
const {data: channels = [], isLoading} = useQuery({
|
||||
queryKey: ['notificationChannels'],
|
||||
@@ -45,6 +132,7 @@ const NotificationChannels = () => {
|
||||
mutationFn: saveNotificationChannels,
|
||||
onSuccess: () => {
|
||||
messageApi.success('保存成功');
|
||||
setDirty(false);
|
||||
queryClient.invalidateQueries({queryKey: ['notificationChannels']});
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
@@ -115,15 +203,11 @@ const NotificationChannels = () => {
|
||||
});
|
||||
|
||||
form.setFieldsValue(formValues);
|
||||
// 保存当前值作为"已保存"的值,用于检测是否有未保存的修改
|
||||
setSavedValues(formValues);
|
||||
}
|
||||
}, [channels, form]);
|
||||
|
||||
// 将表单值转换回渠道数组
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const handleSave = (values: any) => {
|
||||
const newChannels: NotificationChannel[] = [];
|
||||
|
||||
// 钉钉
|
||||
@@ -229,15 +313,26 @@ const NotificationChannels = () => {
|
||||
}
|
||||
|
||||
saveMutation.mutate(newChannels);
|
||||
} catch (error) {
|
||||
// 表单验证失败
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = (type: string) => {
|
||||
testMutation.mutate(type);
|
||||
// 校验失败时跳转到第一个出错的渠道(错误可能位于未选中的渠道分组中)
|
||||
const handleValidateFailed = (errorFields: { name: (string | number)[] }[]) => {
|
||||
const firstField = errorFields?.[0]?.name?.[0];
|
||||
if (typeof firstField === 'string') {
|
||||
const matched = [...CHANNELS]
|
||||
.sort((a, b) => b.fieldPrefix.length - a.fieldPrefix.length)
|
||||
.find((channel) => firstField.startsWith(channel.fieldPrefix));
|
||||
if (matched) {
|
||||
setSelectedType(matched.type);
|
||||
}
|
||||
}
|
||||
messageApi.warning('存在未填写的必填项,请检查对应渠道的配置');
|
||||
};
|
||||
|
||||
const selectedChannel = CHANNELS.find((channel) => channel.type === selectedType) ?? CHANNELS[0];
|
||||
const selectedEnabled = enabledMap[selectedChannel.type];
|
||||
const testingSelected = testMutation.isPending && testMutation.variables === selectedChannel.type;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-20">
|
||||
@@ -246,53 +341,13 @@ const NotificationChannels = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const fieldGridClass = 'grid gap-x-4 sm:grid-cols-2';
|
||||
|
||||
const renderChannelFields = (channel: ChannelDefinition) => {
|
||||
switch (channel.type) {
|
||||
case 'dingtalk':
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<Alert title="更改配置后,请点击下方的保存后再进行测试。" type="info" showIcon/>
|
||||
</div>
|
||||
|
||||
<Form form={form} layout="vertical" onFinish={handleSave}>
|
||||
<div className="flex w-full min-w-0 flex-col gap-4">
|
||||
{/* 钉钉通知 */}
|
||||
<Card
|
||||
title={
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<div>钉钉通知</div>
|
||||
<div className={'text-xs font-normal'}>
|
||||
了解更多:<a href="https://open.dingtalk.com/document/robots/custom-robot-access"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">https://open.dingtalk.com/document/robots/custom-robot-access</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
type="inner"
|
||||
extra={
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<TestTube size={14}/>}
|
||||
onClick={() => handleTest('dingtalk')}
|
||||
loading={testMutation.isPending}
|
||||
disabled={!dingtalkEnabled}
|
||||
>
|
||||
测试
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form.Item label="启用钉钉通知" name="dingtalkEnabled" valuePropName="checked">
|
||||
<Switch/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.dingtalkEnabled !== currentValues.dingtalkEnabled
|
||||
}
|
||||
>
|
||||
{({getFieldValue}) =>
|
||||
getFieldValue('dingtalkEnabled') ? (
|
||||
<>
|
||||
<div className={fieldGridClass}>
|
||||
<Form.Item
|
||||
label="访问令牌 (Access Token)"
|
||||
name="dingtalkSecretKey"
|
||||
@@ -311,48 +366,10 @@ const NotificationChannels = () => {
|
||||
>
|
||||
<Input.Password placeholder="SEC 开头的加签密钥"/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 企业微信通知 */}
|
||||
<Card
|
||||
title={
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<div>企业微信通知</div>
|
||||
<div className={'text-xs font-normal'}>
|
||||
了解更多:<a href="https://work.weixin.qq.com/api/doc/90000/90136/91770"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">https://work.weixin.qq.com/api/doc/90000/90136/91770</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
type="inner"
|
||||
extra={
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<TestTube size={14}/>}
|
||||
onClick={() => handleTest('wecom')}
|
||||
loading={testMutation.isPending}
|
||||
disabled={!wecomEnabled}
|
||||
>
|
||||
测试
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form.Item label="启用企业微信通知" name="wecomEnabled" valuePropName="checked">
|
||||
<Switch/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) => prevValues.wecomEnabled !== currentValues.wecomEnabled}
|
||||
>
|
||||
{({getFieldValue}) =>
|
||||
getFieldValue('wecomEnabled') ? (
|
||||
);
|
||||
case 'wecom':
|
||||
return (
|
||||
<Form.Item
|
||||
label="Webhook Key"
|
||||
name="wecomSecretKey"
|
||||
@@ -364,48 +381,10 @@ const NotificationChannels = () => {
|
||||
>
|
||||
<Input placeholder="输入 Webhook Key"/>
|
||||
</Form.Item>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 企业微信应用通知 */}
|
||||
<Card
|
||||
title={
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<div>企业微信应用通知</div>
|
||||
<div className={'text-xs font-normal'}>
|
||||
了解更多:<a href="https://developer.work.weixin.qq.com/document/path/90236"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">https://developer.work.weixin.qq.com/document/path/90236</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
type="inner"
|
||||
extra={
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<TestTube size={14}/>}
|
||||
onClick={() => handleTest('wecomApp')}
|
||||
loading={testMutation.isPending}
|
||||
disabled={!wecomAppEnabled}
|
||||
>
|
||||
测试
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form.Item label="启用企业微信应用通知" name="wecomAppEnabled" valuePropName="checked">
|
||||
<Switch/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) => prevValues.wecomAppEnabled !== currentValues.wecomAppEnabled}
|
||||
>
|
||||
{({getFieldValue}) =>
|
||||
getFieldValue('wecomAppEnabled') ? (
|
||||
<>
|
||||
);
|
||||
case 'wecomApp':
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
<Form.Item
|
||||
label="origin"
|
||||
name="wecomAppOrigin"
|
||||
@@ -415,6 +394,7 @@ const NotificationChannels = () => {
|
||||
>
|
||||
<Input placeholder="https://qyapi.weixin.qq.com"/>
|
||||
</Form.Item>
|
||||
<div className={fieldGridClass}>
|
||||
<Form.Item
|
||||
label="corpid"
|
||||
name="wecomAppCorpId"
|
||||
@@ -423,14 +403,6 @@ const NotificationChannels = () => {
|
||||
>
|
||||
<Input placeholder="输入您的企业的corpid"/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="corpsecret"
|
||||
name="wecomAppCorpSecret"
|
||||
rules={[{required: true, message: '请输入企业微信应用的corpsecret'}]}
|
||||
tooltip="企业微信应用的corpsecret"
|
||||
>
|
||||
<Input.Password placeholder="输入您的企业应用的corpsecret"/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="agentid"
|
||||
name="wecomAppAgentId"
|
||||
@@ -440,6 +412,14 @@ const NotificationChannels = () => {
|
||||
<InputNumber style={{width: '100%'}}
|
||||
placeholder="输入您的企业应用的agentid"/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="corpsecret"
|
||||
name="wecomAppCorpSecret"
|
||||
rules={[{required: true, message: '请输入企业微信应用的corpsecret'}]}
|
||||
tooltip="企业微信应用的corpsecret"
|
||||
>
|
||||
<Input.Password placeholder="输入您的企业应用的corpsecret"/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="touser"
|
||||
name="wecomAppToUser"
|
||||
@@ -449,53 +429,12 @@ const NotificationChannels = () => {
|
||||
>
|
||||
<Input placeholder="输入接收告警消息的用户,全部可填@all"/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 飞书通知 */}
|
||||
<Card
|
||||
title={
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<div>飞书通知</div>
|
||||
<div className={'text-xs font-normal'}>
|
||||
点击 <a
|
||||
href="https://www.feishu.cn/hc/zh-CN/articles/360024984973-%E5%9C%A8%E7%BE%A4%E7%BB%84%E4%B8%AD%E4%BD%BF%E7%94%A8%E6%9C%BA%E5%99%A8%E4%BA%BA"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">这里</a>
|
||||
了解如何获取 Webhook URL。
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
type="inner"
|
||||
extra={
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<TestTube size={14}/>}
|
||||
onClick={() => handleTest('feishu')}
|
||||
loading={testMutation.isPending}
|
||||
disabled={!feishuEnabled}
|
||||
>
|
||||
测试
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form.Item label="启用飞书通知" name="feishuEnabled" valuePropName="checked">
|
||||
<Switch/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.feishuEnabled !== currentValues.feishuEnabled
|
||||
}
|
||||
>
|
||||
{({getFieldValue}) =>
|
||||
getFieldValue('feishuEnabled') ? (
|
||||
<>
|
||||
);
|
||||
case 'feishu':
|
||||
return (
|
||||
<div className={fieldGridClass}>
|
||||
<Form.Item
|
||||
label="Webhook Token"
|
||||
name="feishuSecretKey"
|
||||
@@ -514,51 +453,11 @@ const NotificationChannels = () => {
|
||||
>
|
||||
<Input.Password placeholder="输入签名密钥"/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* Telegram 通知 */}
|
||||
<Card
|
||||
title={
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<div>Telegram 通知</div>
|
||||
<div className={'text-xs font-normal'}>
|
||||
了解更多:<a href="https://core.telegram.org/bots/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">https://core.telegram.org/bots/api</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
type="inner"
|
||||
extra={
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<TestTube size={14}/>}
|
||||
onClick={() => handleTest('telegram')}
|
||||
loading={testMutation.isPending}
|
||||
disabled={!telegramEnabled}
|
||||
>
|
||||
测试
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form.Item label="启用 Telegram 通知" name="telegramEnabled" valuePropName="checked">
|
||||
<Switch/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.telegramEnabled !== currentValues.telegramEnabled
|
||||
}
|
||||
>
|
||||
{({getFieldValue}) =>
|
||||
getFieldValue('telegramEnabled') ? (
|
||||
<>
|
||||
);
|
||||
case 'telegram':
|
||||
return (
|
||||
<div className={fieldGridClass}>
|
||||
<Form.Item
|
||||
label="Bot Token"
|
||||
name="telegramBotToken"
|
||||
@@ -578,49 +477,12 @@ const NotificationChannels = () => {
|
||||
>
|
||||
<Input placeholder="输入 Chat ID,例如:123456789"/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 邮件通知 */}
|
||||
<Card
|
||||
title={
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<div>邮件通知</div>
|
||||
<div className={'text-xs font-normal text-gray-500'}>
|
||||
使用 SMTP 协议发送邮件告警
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
type="inner"
|
||||
extra={
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<TestTube size={14}/>}
|
||||
onClick={() => handleTest('email')}
|
||||
loading={testMutation.isPending}
|
||||
disabled={!emailEnabled}
|
||||
>
|
||||
测试
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form.Item label="启用邮件通知" name="emailEnabled" valuePropName="checked">
|
||||
<Switch/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.emailEnabled !== currentValues.emailEnabled
|
||||
}
|
||||
>
|
||||
{({getFieldValue}) =>
|
||||
getFieldValue('emailEnabled') ? (
|
||||
<>
|
||||
);
|
||||
case 'email':
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
<div className={fieldGridClass}>
|
||||
<Form.Item
|
||||
label="SMTP 服务器"
|
||||
name="emailSmtpHost"
|
||||
@@ -648,14 +510,6 @@ const NotificationChannels = () => {
|
||||
>
|
||||
<Input placeholder="your-email@example.com"/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="邮箱密码/授权码"
|
||||
name="emailPassword"
|
||||
rules={[{required: true, message: '请输入邮箱密码或授权码'}]}
|
||||
tooltip="某些邮件服务商(如 Gmail、QQ 邮箱)需要使用授权码而非密码"
|
||||
>
|
||||
<Input.Password placeholder="输入邮箱密码或授权码"/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="收件人邮箱"
|
||||
name="emailToEmail"
|
||||
@@ -667,6 +521,14 @@ const NotificationChannels = () => {
|
||||
>
|
||||
<Input placeholder="receiver@example.com"/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="邮箱密码/授权码"
|
||||
name="emailPassword"
|
||||
rules={[{required: true, message: '请输入邮箱密码或授权码'}]}
|
||||
tooltip="某些邮件服务商(如 Gmail、QQ 邮箱)需要使用授权码而非密码"
|
||||
>
|
||||
<Input.Password placeholder="输入邮箱密码或授权码"/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="邮件主题"
|
||||
name="emailSubject"
|
||||
@@ -674,42 +536,12 @@ const NotificationChannels = () => {
|
||||
>
|
||||
<Input placeholder="Pika 告警通知"/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 自定义 Webhook */}
|
||||
<Card
|
||||
title="自定义 Webhook"
|
||||
type="inner"
|
||||
extra={
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<TestTube size={14}/>}
|
||||
onClick={() => handleTest('webhook')}
|
||||
loading={testMutation.isPending}
|
||||
disabled={!webhookEnabled}
|
||||
>
|
||||
测试
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form.Item label="启用自定义 Webhook" name="webhookEnabled" valuePropName="checked">
|
||||
<Switch/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.webhookEnabled !== currentValues.webhookEnabled
|
||||
}
|
||||
>
|
||||
{({getFieldValue}) =>
|
||||
getFieldValue('webhookEnabled') ? (
|
||||
<>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'webhook':
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
<Form.Item
|
||||
label="Webhook URL"
|
||||
name="webhookUrl"
|
||||
@@ -823,22 +655,149 @@ const NotificationChannels = () => {
|
||||
)}
|
||||
</Form.List>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const SelectedIcon = selectedChannel.icon;
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSave}
|
||||
onFinishFailed={({errorFields}) => handleValidateFailed(errorFields)}
|
||||
onValuesChange={() => setDirty(true)}
|
||||
>
|
||||
<div className="grid items-start gap-6 xl:grid-cols-[248px_minmax(0,1fr)]">
|
||||
{/* 渠道列表 */}
|
||||
<div>
|
||||
<div className="mb-2 px-2 text-xs text-[#646a73] dark:text-[#9ba1ab]">
|
||||
点击渠道查看配置,开关控制启用状态
|
||||
</div>
|
||||
<div className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-1">
|
||||
{CHANNELS.map((channel) => {
|
||||
const Icon = channel.icon;
|
||||
const enabled = enabledMap[channel.type];
|
||||
const selected = channel.type === selectedType;
|
||||
return (
|
||||
<div
|
||||
key={channel.type}
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-2 rounded-[10px] px-2 py-1.5 transition-colors',
|
||||
selected
|
||||
? 'bg-[#eaf2ff] dark:bg-[#1677ff]/15'
|
||||
: 'hover:bg-[#f5f6f8] dark:hover:bg-[#20242c]',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedType(channel.type)}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2.5 border-0 bg-transparent p-0 text-left"
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
>
|
||||
<span className={cn(
|
||||
'grid h-9 w-9 shrink-0 place-items-center rounded-lg transition-colors',
|
||||
selected
|
||||
? 'bg-white text-[#145dcc] dark:bg-[#2a3038] dark:text-[#75adff]'
|
||||
: 'bg-[#f0f2f5] text-[#646a73] dark:bg-[#20242c] dark:text-[#9ba1ab]',
|
||||
)}>
|
||||
<Icon size={16}/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] font-medium text-[#1f2329] dark:text-[#e6e8ec]">
|
||||
{channel.name}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-[#98a0ab] dark:text-[#7d8590]">
|
||||
{enabled ? '已启用' : '未启用'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<Form.Item noStyle name={channel.enabledField} valuePropName="checked">
|
||||
<Switch size="small" aria-label={`启用${channel.name}`}/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NotificationCustomHelp/>
|
||||
</Card>
|
||||
{/* 选中渠道的配置 */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400">
|
||||
<SelectedIcon size={20}/>
|
||||
</span>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[15px] font-semibold text-[#1f2329] dark:text-[#e6e8ec]">
|
||||
{selectedChannel.name}
|
||||
</span>
|
||||
<Tag color={selectedEnabled ? 'success' : 'default'} style={{margin: 0}}>
|
||||
{selectedEnabled ? '已启用' : '未启用'}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-[#646a73] dark:text-[#9ba1ab]">
|
||||
{selectedChannel.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{selectedChannel.docsUrl && (
|
||||
<Button
|
||||
icon={<ExternalLink size={14}/>}
|
||||
href={selectedChannel.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
接入文档
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
icon={<TestTube size={14}/>}
|
||||
onClick={() => testMutation.mutate(selectedChannel.type)}
|
||||
loading={testingSelected}
|
||||
disabled={!selectedEnabled || dirty}
|
||||
title={dirty ? '配置已修改,请先保存再测试' : undefined}
|
||||
>
|
||||
发送测试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form.Item>
|
||||
<div className="my-4 border-t border-[#e8ebf0] dark:border-[#272b33]"/>
|
||||
|
||||
{!selectedEnabled && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-[#f5f6f8] px-3.5 py-2.5 text-xs text-[#646a73] dark:bg-[#1c2028] dark:text-[#9ba1ab]">
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-current opacity-60"/>
|
||||
当前渠道未启用,打开左侧的开关后即可编辑配置。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已启用渠道的表单项保持挂载(仅隐藏未选中的),保证校验行为一致 */}
|
||||
{CHANNELS.filter((channel) => enabledMap[channel.type]).map((channel) => (
|
||||
<div key={channel.type} className={channel.type === selectedType ? undefined : 'hidden'}>
|
||||
{renderChannelFields(channel)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{selectedType === 'webhook' && <NotificationCustomHelp/>}
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3 border-t border-[#e8ebf0] pt-4 dark:border-[#272b33]">
|
||||
<Button type="primary" htmlType="submit" loading={saveMutation.isPending}>
|
||||
保存配置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
{dirty && (
|
||||
<span className="text-xs text-amber-600 dark:text-amber-400">
|
||||
配置尚未保存,保存后才能发送测试通知。
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {useEffect} from 'react';
|
||||
import {App, Button, Card, Form, Input, InputNumber, Radio, Select, Space, Spin, Switch} from 'antd';
|
||||
import {App, Button, Form, Input, InputNumber, Radio, Select, Spin, Switch} from 'antd';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import type {PublicIPConfig} from '@/api/property';
|
||||
import {getPublicIPConfig, savePublicIPConfig} from '@/api/property';
|
||||
import {listAgentsByAdmin} from '@/api/agent.ts';
|
||||
import {getErrorMessage} from '@/lib/utils';
|
||||
import type {Agent} from '@/types';
|
||||
import {SettingsActions, SettingsSection} from './SettingsSection';
|
||||
|
||||
interface PublicIPConfigProps {
|
||||
defaultIPv4APIs: string[];
|
||||
@@ -136,9 +137,8 @@ const PublicIPConfigComponent = ({defaultIPv4APIs, defaultIPv6APIs}: PublicIPCon
|
||||
return (
|
||||
<div>
|
||||
<Form form={form} layout="vertical" onFinish={handleSave}>
|
||||
<div className="flex w-full min-w-0 flex-col gap-4">
|
||||
<Card title="采集设置" type="inner">
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<SettingsSection title="采集设置" divided={false} description="通过探针定期获取公网出口 IP 地址。">
|
||||
<div className="flex flex-wrap items-start gap-x-10 gap-y-4">
|
||||
<Form.Item label="启用采集" name="enabled" valuePropName="checked">
|
||||
<Switch/>
|
||||
</Form.Item>
|
||||
@@ -150,9 +150,9 @@ const PublicIPConfigComponent = ({defaultIPv4APIs, defaultIPv6APIs}: PublicIPCon
|
||||
<InputNumber min={30} max={86400}/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Card>
|
||||
</SettingsSection>
|
||||
|
||||
<Card title="IPv4 配置" type="inner">
|
||||
<SettingsSection title="IPv4 配置">
|
||||
<Form.Item label="启用 IPv4" name="ipv4Enabled" valuePropName="checked">
|
||||
<Switch/>
|
||||
</Form.Item>
|
||||
@@ -193,9 +193,9 @@ const PublicIPConfigComponent = ({defaultIPv4APIs, defaultIPv6APIs}: PublicIPCon
|
||||
>
|
||||
<Input.TextArea rows={6} placeholder="每行一个 IPv4 API"/>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
</SettingsSection>
|
||||
|
||||
<Card title="IPv6 配置" type="inner">
|
||||
<SettingsSection title="IPv6 配置">
|
||||
<Form.Item label="启用 IPv6" name="ipv6Enabled" valuePropName="checked">
|
||||
<Switch/>
|
||||
</Form.Item>
|
||||
@@ -236,10 +236,9 @@ const PublicIPConfigComponent = ({defaultIPv4APIs, defaultIPv6APIs}: PublicIPCon
|
||||
>
|
||||
<Input.TextArea rows={6} placeholder="每行一个 IPv6 API"/>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
</SettingsSection>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<SettingsActions>
|
||||
<Button type="primary" htmlType="submit" loading={saveMutation.isPending}>
|
||||
保存配置
|
||||
</Button>
|
||||
@@ -249,9 +248,7 @@ const PublicIPConfigComponent = ({defaultIPv4APIs, defaultIPv6APIs}: PublicIPCon
|
||||
<Button onClick={handleUseDefaults}>
|
||||
使用默认 API
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</SettingsActions>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type {PropsWithChildren, ReactNode} from 'react';
|
||||
import {cn} from '@/lib/utils';
|
||||
|
||||
interface SettingsSectionProps {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
/** 渲染与上一区块的分隔线,首个区块传 false */
|
||||
divided?: boolean;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** 设置页内部的平铺分区:标题 + 描述 + 内容,用分隔线取代嵌套卡片。 */
|
||||
export const SettingsSection = ({title, description, divided = true, className, children}: SettingsSectionProps) => (
|
||||
<section className={cn(divided && 'mt-6 border-t border-[#e8ebf0] pt-5 dark:border-[#272b33]', className)}>
|
||||
<h3 className="m-0 text-sm font-semibold text-[#1f2329] dark:text-[#e6e8ec]">{title}</h3>
|
||||
{description ? (
|
||||
<p className="mt-1 mb-0 text-xs text-[#646a73] dark:text-[#9ba1ab]">{description}</p>
|
||||
) : null}
|
||||
<div className="mt-4">{children}</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
/** 设置页底部的操作按钮行。 */
|
||||
export const SettingsActions = ({children}: PropsWithChildren) => (
|
||||
<div className="mt-6 flex flex-wrap items-center gap-3 border-t border-[#e8ebf0] pt-4 dark:border-[#272b33]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface SettingsSwitchRowProps {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
/** 右侧的开关(通常是 noStyle 的 Form.Item + Switch) */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** 带说明文字的开关行:左侧标题与描述,右侧开关。 */
|
||||
export const SettingsSwitchRow = ({title, description, children}: SettingsSwitchRowProps) => (
|
||||
<div className="flex items-center justify-between gap-4 border-t border-[#e8ebf0] py-3.5 first:border-t-0 first:pt-0 dark:border-[#272b33]">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-medium text-[#1f2329] dark:text-[#e6e8ec]">{title}</div>
|
||||
{description ? (
|
||||
<div className="mt-0.5 text-xs text-[#646a73] dark:text-[#9ba1ab]">{description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { App, Button, Card, Form, Input, Radio, Space, Spin, Upload } from 'antd';
|
||||
import { App, Button, Form, Input, Radio, Space, Spin, Upload } from 'antd';
|
||||
import { Upload as UploadIcon, Grid3x3, List } from 'lucide-react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { SystemConfig } from '@/api/property.ts';
|
||||
import { getSystemConfig, saveSystemConfig } from '@/api/property.ts';
|
||||
import { getErrorMessage } from '@/lib/utils.ts';
|
||||
import type { RcFile } from 'antd/es/upload/interface';
|
||||
import {SettingsActions, SettingsSection} from './SettingsSection';
|
||||
|
||||
const SystemConfigComponent = () => {
|
||||
const [form] = Form.useForm();
|
||||
@@ -142,12 +143,12 @@ const SystemConfigComponent = () => {
|
||||
return (
|
||||
<div>
|
||||
<Form form={form} layout="vertical" onFinish={handleSave}>
|
||||
<div className="flex w-full min-w-0 flex-col gap-4">
|
||||
<Card
|
||||
<SettingsSection
|
||||
title="系统基本信息"
|
||||
type="inner"
|
||||
divided={false}
|
||||
description="展示在公开页面顶部的系统名称、Logo 与底部备案信息。"
|
||||
>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||
<Form.Item
|
||||
label="系统英文名称"
|
||||
name="systemNameEn"
|
||||
@@ -223,7 +224,6 @@ const SystemConfigComponent = () => {
|
||||
label="系统 Logo"
|
||||
tooltip="上传系统 Logo,建议使用正方形图片,尺寸为 256x256 或更大,文件大小不超过 500KB"
|
||||
>
|
||||
<Space orientation="vertical" className="w-full">
|
||||
<Upload
|
||||
accept="image/*"
|
||||
showUploadList={false}
|
||||
@@ -234,14 +234,14 @@ const SystemConfigComponent = () => {
|
||||
{uploading ? '处理中...' : '上传 Logo'}
|
||||
</Button>
|
||||
</Upload>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
</SettingsSection>
|
||||
|
||||
<Card
|
||||
<SettingsSection
|
||||
title="自定义代码"
|
||||
type="inner"
|
||||
description="将注入到公开页面顶部的 style 与 script 标签中,修改后刷新公开页面生效。"
|
||||
>
|
||||
<div className="grid gap-x-4 lg:grid-cols-2">
|
||||
<Form.Item
|
||||
label="自定义 CSS"
|
||||
name="customCSS"
|
||||
@@ -263,12 +263,10 @@ const SystemConfigComponent = () => {
|
||||
rows={6}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<Card
|
||||
title="预览效果"
|
||||
type="inner"
|
||||
>
|
||||
<SettingsSection title="预览效果">
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({ getFieldValue }) => {
|
||||
const systemNameEn = getFieldValue('systemNameEn') || '';
|
||||
@@ -314,19 +312,16 @@ const SystemConfigComponent = () => {
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</Card>
|
||||
</SettingsSection>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<SettingsActions>
|
||||
<Button type="primary" htmlType="submit" loading={saveMutation.isPending}>
|
||||
保存配置
|
||||
</Button>
|
||||
<Button onClick={handleReset}>
|
||||
恢复默认
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</SettingsActions>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -123,7 +123,14 @@ const ThemeSettings = () => {
|
||||
|
||||
return (
|
||||
<div className="flex w-full min-w-0 flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
title="只安装你信任的主题:第三方主题会在 Pika 同一域名下执行 JavaScript,安装前请确认来源可信。"
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<Upload
|
||||
accept=".zip,application/zip"
|
||||
@@ -139,12 +146,6 @@ const ThemeSettings = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
title="只安装你信任的主题:第三方主题会在 Pika 同一域名下执行 JavaScript,安装前请确认来源可信。"
|
||||
/>
|
||||
|
||||
{themesQuery.isLoading ? (
|
||||
<div className="flex min-h-72 items-center justify-center">
|
||||
<Spin size="large" tip="正在加载主题...">
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import {Bell, MessageSquare, Palette, Settings2, Wifi} from 'lucide-react';
|
||||
import type {LucideIcon} from 'lucide-react';
|
||||
import AlertSettings from './AlertSettings';
|
||||
import NotificationChannels from './NotificationChannels';
|
||||
import SystemConfig from './SystemConfig';
|
||||
import PublicIPConfig from './PublicIPConfig';
|
||||
import ThemeSettings from './ThemeSettings';
|
||||
import {useSearchParams} from "react-router-dom";
|
||||
import {PageHeader} from '@/components/PageHeader';
|
||||
import {PagePanel} from '@/components/PagePanel';
|
||||
import {useSearchParams} from 'react-router-dom';
|
||||
|
||||
// 默认 IPv4 API 列表
|
||||
export const defaultIPv4APIs = [
|
||||
@@ -23,49 +27,37 @@ export const defaultIPv6APIs = [
|
||||
'https://v6.yinghualuo.cn/bejson',
|
||||
];
|
||||
|
||||
const Settings = () => {
|
||||
interface SettingsTab {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams({tab: 'system'});
|
||||
const items = [
|
||||
const settingsTabs: SettingsTab[] = [
|
||||
{
|
||||
key: 'system',
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
<Settings2 size={16}/>
|
||||
系统配置
|
||||
</span>
|
||||
),
|
||||
children: <SystemConfig/>,
|
||||
label: '系统配置',
|
||||
icon: Settings2,
|
||||
content: <SystemConfig/>,
|
||||
},
|
||||
{
|
||||
key: 'themes',
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
<Palette size={16}/>
|
||||
主题管理
|
||||
</span>
|
||||
),
|
||||
children: <ThemeSettings/>,
|
||||
label: '主题管理',
|
||||
icon: Palette,
|
||||
content: <ThemeSettings/>,
|
||||
},
|
||||
{
|
||||
key: 'channels',
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
<MessageSquare size={16}/>
|
||||
通知渠道
|
||||
</span>
|
||||
),
|
||||
children: <NotificationChannels/>,
|
||||
label: '通知渠道',
|
||||
icon: MessageSquare,
|
||||
content: <NotificationChannels/>,
|
||||
},
|
||||
{
|
||||
key: 'public-ip',
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
<Wifi size={16}/>
|
||||
公网 IP 采集
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
label: '公网 IP 采集',
|
||||
icon: Wifi,
|
||||
content: (
|
||||
<PublicIPConfig
|
||||
defaultIPv4APIs={defaultIPv4APIs}
|
||||
defaultIPv6APIs={defaultIPv6APIs}
|
||||
@@ -74,37 +66,55 @@ const Settings = () => {
|
||||
},
|
||||
{
|
||||
key: 'alert',
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
<Bell size={16}/>
|
||||
告警规则
|
||||
</span>
|
||||
),
|
||||
children: <AlertSettings/>,
|
||||
label: '告警规则',
|
||||
icon: Bell,
|
||||
content: <AlertSettings/>,
|
||||
},
|
||||
];
|
||||
];
|
||||
|
||||
const activeKey = searchParams.get('tab') || 'system';
|
||||
const activeItem = items.find((item) => item.key === activeKey) || items[0];
|
||||
const Settings = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams({tab: 'system'});
|
||||
|
||||
const activeKey = settingsTabs.some((tab) => tab.key === searchParams.get('tab'))
|
||||
? searchParams.get('tab')!
|
||||
: 'system';
|
||||
const activeTab = settingsTabs.find((tab) => tab.key === activeKey) ?? settingsTabs[0];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[208px_minmax(0,1fr)] items-start gap-4 max-md:block">
|
||||
<nav className="flex flex-col gap-1 rounded-[10px] border border-[#e8ebf0] bg-white p-2 dark:border-[#272b33] dark:bg-[#171a21] max-md:mb-3.5 max-md:flex-row max-md:overflow-x-auto max-md:rounded-none max-md:border-0 max-md:bg-transparent max-md:p-0 max-md:pb-1.5 dark:max-md:bg-transparent" aria-label="设置分类">
|
||||
{items.map((item) => (
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<PageHeader title="系统设置"/>
|
||||
|
||||
<div className="grid grid-cols-[208px_minmax(0,1fr)] items-start gap-6 max-md:block">
|
||||
<nav
|
||||
className="flex flex-col gap-1 lg:sticky lg:top-[76px] max-md:mb-2 max-md:flex-row max-md:overflow-x-auto max-md:pb-1.5"
|
||||
aria-label="设置分类"
|
||||
>
|
||||
{settingsTabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const active = tab.key === activeKey;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
key={tab.key}
|
||||
type="button"
|
||||
className={item.key === activeKey
|
||||
className={active
|
||||
? 'flex min-h-10 w-full cursor-pointer items-center rounded-[7px] border-0 bg-[#eaf2ff] px-2.5 py-2 text-left text-[13px] font-semibold text-[#145dcc] dark:bg-[#1677ff]/15 dark:text-[#75adff] max-md:w-auto max-md:shrink-0 max-md:whitespace-nowrap'
|
||||
: 'flex min-h-10 w-full cursor-pointer items-center rounded-[7px] border-0 bg-transparent px-2.5 py-2 text-left text-[13px] text-[#646a73] hover:bg-[#f5f6f8] hover:text-[#1f2329] dark:text-[#9ba1ab] dark:hover:bg-[#20242c] dark:hover:text-[#e6e8ec] max-md:w-auto max-md:shrink-0 max-md:whitespace-nowrap'}
|
||||
onClick={() => setSearchParams({tab: item.key})}
|
||||
aria-current={item.key === activeKey ? 'page' : undefined}
|
||||
onClick={() => setSearchParams({tab: tab.key})}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
{item.label}
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Icon size={16} className="shrink-0"/>
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="min-w-0">{activeItem.children}</div>
|
||||
|
||||
<PagePanel className="min-w-0">
|
||||
{activeTab.content}
|
||||
</PagePanel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user