refactor: 优化设置页面布局与样式
- 设置页全宽对齐其他页面,左侧分类导航去卡片化并支持桌面端吸顶 - 通知渠道改为「渠道列表 + 配置区」布局,支持未保存提醒与校验失败自动跳转 - 系统配置/公网IP采集/告警规则页去嵌套卡片,改为平铺分区(SettingsSection) - 主题管理页上传按钮与警告提示合并为一行 - 调整全部页面的小布局问题
This commit is contained in:
1 parent
45284d31c6
commit
c730873be3
15 files changed
+1059
-1117
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 form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<SettingsSection title="基本信息" divided={false}>
|
||||
<SettingsSwitchRow
|
||||
title="启用告警"
|
||||
description="总开关,关闭后不再产生和发送任何告警通知。"
|
||||
>
|
||||
<Form.Item noStyle name="enabled" valuePropName="checked">
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭"/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="IP 打码"
|
||||
name="maskIP"
|
||||
valuePropName="checked"
|
||||
tooltip="开启后,通知消息中的 IP 地址将显示为 192.168.*.* 格式"
|
||||
>
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</SettingsSwitchRow>
|
||||
<SettingsSwitchRow
|
||||
title="IP 打码"
|
||||
description="开启后,通知消息中的 IP 地址将显示为 192.168.*.* 格式。"
|
||||
>
|
||||
<Form.Item noStyle name="maskIP" valuePropName="checked">
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭"/>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
</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">
|
||||
<Form.Item
|
||||
label="开关"
|
||||
name={['rules', `${rule.key}Enabled`]}
|
||||
valuePropName="checked"
|
||||
className="mb-0"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={rule.thresholdLabel}
|
||||
name={['rules', `${rule.key}Threshold`]}
|
||||
className="mb-0"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={rule.max}
|
||||
style={{ width: '100%' }}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="持续时间(秒)"
|
||||
name={['rules', `${rule.key}Duration`]}
|
||||
className="mb-0"
|
||||
>
|
||||
<InputNumber min={1} max={3600} style={{ width: '100%' }}
|
||||
disabled={!enabled} />
|
||||
</Form.Item>
|
||||
<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
|
||||
noStyle
|
||||
name={['rules', `${rule.key}Enabled`]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<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.threshold.label}
|
||||
name={['rules', `${rule.key}Threshold`]}
|
||||
tooltip={rule.threshold.tooltip}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={rule.threshold.min}
|
||||
max={rule.threshold.max}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{rule.duration && (
|
||||
<Form.Item
|
||||
label="持续时间(秒)"
|
||||
name={['rules', `${rule.key}Duration`]}
|
||||
tooltip={rule.duration.tooltip}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber min={1} max={3600} disabled={!enabled}/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</Card>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,122 +137,118 @@ 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">
|
||||
<Form.Item label="启用采集" name="enabled" valuePropName="checked">
|
||||
<Switch/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="采集间隔(秒)"
|
||||
name="intervalSeconds"
|
||||
rules={[{type: 'number', min: 30, message: '采集间隔不能小于 30 秒'}]}
|
||||
>
|
||||
<InputNumber min={30} max={86400}/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="IPv4 配置" type="inner">
|
||||
<Form.Item label="启用 IPv4" name="ipv4Enabled" valuePropName="checked">
|
||||
<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>
|
||||
<Form.Item label="IPv4 采集范围" name="ipv4Scope">
|
||||
<Radio.Group>
|
||||
<Radio.Button value="all">全部探针</Radio.Button>
|
||||
<Radio.Button value="custom">自定义探针</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({getFieldValue}) => {
|
||||
const enabled = getFieldValue('ipv4Enabled');
|
||||
const scope = getFieldValue('ipv4Scope');
|
||||
if (!enabled || scope !== 'custom') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Form.Item
|
||||
label="选择 IPv4 探针"
|
||||
name="ipv4AgentIds"
|
||||
rules={[{required: true, message: '请选择至少一个探针'}]}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="选择需要采集 IPv4 的探针"
|
||||
options={agentOptions}
|
||||
optionFilterProp="label"
|
||||
showSearch
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="IPv4 API 列表"
|
||||
name="ipv4ApisText"
|
||||
tooltip="每行一个 HTTP/HTTPS API 地址"
|
||||
label="采集间隔(秒)"
|
||||
name="intervalSeconds"
|
||||
rules={[{type: 'number', min: 30, message: '采集间隔不能小于 30 秒'}]}
|
||||
>
|
||||
<Input.TextArea rows={6} placeholder="每行一个 IPv4 API"/>
|
||||
<InputNumber min={30} max={86400}/>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<Card title="IPv6 配置" type="inner">
|
||||
<Form.Item label="启用 IPv6" name="ipv6Enabled" valuePropName="checked">
|
||||
<Switch/>
|
||||
</Form.Item>
|
||||
<Form.Item label="IPv6 采集范围" name="ipv6Scope">
|
||||
<Radio.Group>
|
||||
<Radio.Button value="all">全部探针</Radio.Button>
|
||||
<Radio.Button value="custom">自定义探针</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({getFieldValue}) => {
|
||||
const enabled = getFieldValue('ipv6Enabled');
|
||||
const scope = getFieldValue('ipv6Scope');
|
||||
if (!enabled || scope !== 'custom') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Form.Item
|
||||
label="选择 IPv6 探针"
|
||||
name="ipv6AgentIds"
|
||||
rules={[{required: true, message: '请选择至少一个探针'}]}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="选择需要采集 IPv6 的探针"
|
||||
options={agentOptions}
|
||||
optionFilterProp="label"
|
||||
showSearch
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="IPv6 API 列表"
|
||||
name="ipv6ApisText"
|
||||
tooltip="每行一个 HTTP/HTTPS API 地址"
|
||||
>
|
||||
<Input.TextArea rows={6} placeholder="每行一个 IPv6 API"/>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={saveMutation.isPending}>
|
||||
保存配置
|
||||
</Button>
|
||||
<Button onClick={handleReset}>
|
||||
恢复当前配置
|
||||
</Button>
|
||||
<Button onClick={handleUseDefaults}>
|
||||
使用默认 API
|
||||
</Button>
|
||||
</Space>
|
||||
<SettingsSection title="IPv4 配置">
|
||||
<Form.Item label="启用 IPv4" name="ipv4Enabled" valuePropName="checked">
|
||||
<Switch/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item label="IPv4 采集范围" name="ipv4Scope">
|
||||
<Radio.Group>
|
||||
<Radio.Button value="all">全部探针</Radio.Button>
|
||||
<Radio.Button value="custom">自定义探针</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({getFieldValue}) => {
|
||||
const enabled = getFieldValue('ipv4Enabled');
|
||||
const scope = getFieldValue('ipv4Scope');
|
||||
if (!enabled || scope !== 'custom') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Form.Item
|
||||
label="选择 IPv4 探针"
|
||||
name="ipv4AgentIds"
|
||||
rules={[{required: true, message: '请选择至少一个探针'}]}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="选择需要采集 IPv4 的探针"
|
||||
options={agentOptions}
|
||||
optionFilterProp="label"
|
||||
showSearch
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="IPv4 API 列表"
|
||||
name="ipv4ApisText"
|
||||
tooltip="每行一个 HTTP/HTTPS API 地址"
|
||||
>
|
||||
<Input.TextArea rows={6} placeholder="每行一个 IPv4 API"/>
|
||||
</Form.Item>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="IPv6 配置">
|
||||
<Form.Item label="启用 IPv6" name="ipv6Enabled" valuePropName="checked">
|
||||
<Switch/>
|
||||
</Form.Item>
|
||||
<Form.Item label="IPv6 采集范围" name="ipv6Scope">
|
||||
<Radio.Group>
|
||||
<Radio.Button value="all">全部探针</Radio.Button>
|
||||
<Radio.Button value="custom">自定义探针</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({getFieldValue}) => {
|
||||
const enabled = getFieldValue('ipv6Enabled');
|
||||
const scope = getFieldValue('ipv6Scope');
|
||||
if (!enabled || scope !== 'custom') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Form.Item
|
||||
label="选择 IPv6 探针"
|
||||
name="ipv6AgentIds"
|
||||
rules={[{required: true, message: '请选择至少一个探针'}]}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="选择需要采集 IPv6 的探针"
|
||||
options={agentOptions}
|
||||
optionFilterProp="label"
|
||||
showSearch
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="IPv6 API 列表"
|
||||
name="ipv6ApisText"
|
||||
tooltip="每行一个 HTTP/HTTPS API 地址"
|
||||
>
|
||||
<Input.TextArea rows={6} placeholder="每行一个 IPv6 API"/>
|
||||
</Form.Item>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsActions>
|
||||
<Button type="primary" htmlType="submit" loading={saveMutation.isPending}>
|
||||
保存配置
|
||||
</Button>
|
||||
<Button onClick={handleReset}>
|
||||
恢复当前配置
|
||||
</Button>
|
||||
<Button onClick={handleUseDefaults}>
|
||||
使用默认 API
|
||||
</Button>
|
||||
</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,106 +143,105 @@ const SystemConfigComponent = () => {
|
||||
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 items-center gap-2'}>
|
||||
<Form.Item
|
||||
label="系统英文名称"
|
||||
name="systemNameEn"
|
||||
dependencies={['systemNameZh']}
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value && !getFieldValue('systemNameZh')) {
|
||||
return Promise.reject(new Error('系统英文名称和中文名称不能同时为空'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
{ max: 50, message: '系统名称不能超过 50 个字符' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="例如:Pika Monitor" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="系统中文名称"
|
||||
name="systemNameZh"
|
||||
dependencies={['systemNameEn']}
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value && !getFieldValue('systemNameEn')) {
|
||||
return Promise.reject(new Error('系统英文名称和中文名称不能同时为空'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
{ max: 50, message: '系统名称不能超过 50 个字符' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="例如:皮卡监控" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
title="系统基本信息"
|
||||
divided={false}
|
||||
description="展示在公开页面顶部的系统名称、Logo 与底部备案信息。"
|
||||
>
|
||||
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||
<Form.Item
|
||||
label="ICP 备案号"
|
||||
name="icpCode"
|
||||
label="系统英文名称"
|
||||
name="systemNameEn"
|
||||
dependencies={['systemNameZh']}
|
||||
rules={[
|
||||
{ max: 50, message: 'ICP 备案号不能超过 50 个字符' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value && !getFieldValue('systemNameZh')) {
|
||||
return Promise.reject(new Error('系统英文名称和中文名称不能同时为空'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
{ max: 50, message: '系统名称不能超过 50 个字符' },
|
||||
]}
|
||||
tooltip="ICP 备案号将显示在公共页面底部,例如:京ICP备12345678号"
|
||||
>
|
||||
<Input placeholder="例如:京ICP备12345678号" />
|
||||
<Input placeholder="例如:Pika Monitor" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="默认视图模式"
|
||||
name="defaultView"
|
||||
tooltip="选择公共页面默认显示的视图模式"
|
||||
label="系统中文名称"
|
||||
name="systemNameZh"
|
||||
dependencies={['systemNameEn']}
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value && !getFieldValue('systemNameEn')) {
|
||||
return Promise.reject(new Error('系统英文名称和中文名称不能同时为空'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
{ max: 50, message: '系统名称不能超过 50 个字符' },
|
||||
]}
|
||||
>
|
||||
<Radio.Group>
|
||||
<Radio.Button value="grid">
|
||||
<Space size={4}>
|
||||
<Grid3x3 size={16} />
|
||||
<span>网格视图</span>
|
||||
</Space>
|
||||
</Radio.Button>
|
||||
<Radio.Button value="list">
|
||||
<Space size={4}>
|
||||
<List size={16} />
|
||||
<span>列表视图</span>
|
||||
</Space>
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
<Input placeholder="例如:皮卡监控" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="系统 Logo"
|
||||
tooltip="上传系统 Logo,建议使用正方形图片,尺寸为 256x256 或更大,文件大小不超过 500KB"
|
||||
>
|
||||
<Space orientation="vertical" className="w-full">
|
||||
<Upload
|
||||
accept="image/*"
|
||||
showUploadList={false}
|
||||
beforeUpload={beforeUpload}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Button icon={<UploadIcon size={16} />} loading={uploading}>
|
||||
{uploading ? '处理中...' : '上传 Logo'}
|
||||
</Button>
|
||||
</Upload>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="自定义代码"
|
||||
type="inner"
|
||||
<Form.Item
|
||||
label="ICP 备案号"
|
||||
name="icpCode"
|
||||
rules={[
|
||||
{ max: 50, message: 'ICP 备案号不能超过 50 个字符' },
|
||||
]}
|
||||
tooltip="ICP 备案号将显示在公共页面底部,例如:京ICP备12345678号"
|
||||
>
|
||||
<Input placeholder="例如:京ICP备12345678号" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="默认视图模式"
|
||||
name="defaultView"
|
||||
tooltip="选择公共页面默认显示的视图模式"
|
||||
>
|
||||
<Radio.Group>
|
||||
<Radio.Button value="grid">
|
||||
<Space size={4}>
|
||||
<Grid3x3 size={16} />
|
||||
<span>网格视图</span>
|
||||
</Space>
|
||||
</Radio.Button>
|
||||
<Radio.Button value="list">
|
||||
<Space size={4}>
|
||||
<List size={16} />
|
||||
<span>列表视图</span>
|
||||
</Space>
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="系统 Logo"
|
||||
tooltip="上传系统 Logo,建议使用正方形图片,尺寸为 256x256 或更大,文件大小不超过 500KB"
|
||||
>
|
||||
<Upload
|
||||
accept="image/*"
|
||||
showUploadList={false}
|
||||
beforeUpload={beforeUpload}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Button icon={<UploadIcon size={16} />} loading={uploading}>
|
||||
{uploading ? '处理中...' : '上传 Logo'}
|
||||
</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="自定义代码"
|
||||
description="将注入到公开页面顶部的 style 与 script 标签中,修改后刷新公开页面生效。"
|
||||
>
|
||||
<div className="grid gap-x-4 lg:grid-cols-2">
|
||||
<Form.Item
|
||||
label="自定义 CSS"
|
||||
name="customCSS"
|
||||
@@ -263,70 +263,65 @@ const SystemConfigComponent = () => {
|
||||
rows={6}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<Card
|
||||
title="预览效果"
|
||||
type="inner"
|
||||
>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({ getFieldValue }) => {
|
||||
const systemNameEn = getFieldValue('systemNameEn') || '';
|
||||
const systemNameZh = getFieldValue('systemNameZh') || '';
|
||||
<SettingsSection title="预览效果">
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({ getFieldValue }) => {
|
||||
const systemNameEn = getFieldValue('systemNameEn') || '';
|
||||
const systemNameZh = getFieldValue('systemNameZh') || '';
|
||||
|
||||
// 与 PublicHeader 相同的分割逻辑
|
||||
let leftName = '';
|
||||
let rightName = '';
|
||||
// 与 PublicHeader 相同的分割逻辑
|
||||
let leftName = '';
|
||||
let rightName = '';
|
||||
|
||||
if (systemNameEn) {
|
||||
// 优先在空格处分割
|
||||
const spaceIndex = systemNameEn.indexOf(' ');
|
||||
if (spaceIndex > 0) {
|
||||
leftName = systemNameEn.substring(0, spaceIndex);
|
||||
rightName = systemNameEn.substring(spaceIndex); // 保留空格
|
||||
} else {
|
||||
// 如果没有空格,从中间分割
|
||||
const mid = Math.floor(systemNameEn.length / 2);
|
||||
leftName = systemNameEn.substring(0, mid);
|
||||
rightName = systemNameEn.substring(mid);
|
||||
}
|
||||
if (systemNameEn) {
|
||||
// 优先在空格处分割
|
||||
const spaceIndex = systemNameEn.indexOf(' ');
|
||||
if (spaceIndex > 0) {
|
||||
leftName = systemNameEn.substring(0, spaceIndex);
|
||||
rightName = systemNameEn.substring(spaceIndex); // 保留空格
|
||||
} else {
|
||||
// 如果没有空格,从中间分割
|
||||
const mid = Math.floor(systemNameEn.length / 2);
|
||||
leftName = systemNameEn.substring(0, mid);
|
||||
rightName = systemNameEn.substring(mid);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<img
|
||||
src={getLogoUrl()}
|
||||
alt="Logo 预览"
|
||||
className="h-8 w-8 sm:h-9 sm:w-9 object-contain rounded-md"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = '/logo.png';
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-2xl font-black tracking-widest text-transparent bg-clip-text bg-gradient-to-r from-cyan-500 via-blue-500 to-purple-500 dark:from-cyan-400 dark:via-blue-400 dark:to-purple-400 uppercase italic">
|
||||
{leftName}<span className="text-slate-800 dark:text-white">{rightName}</span>
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 dark:text-cyan-500 font-mono tracking-[0.3em] uppercase">
|
||||
{systemNameZh}
|
||||
</p>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<img
|
||||
src={getLogoUrl()}
|
||||
alt="Logo 预览"
|
||||
className="h-8 w-8 sm:h-9 sm:w-9 object-contain rounded-md"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = '/logo.png';
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-2xl font-black tracking-widest text-transparent bg-clip-text bg-gradient-to-r from-cyan-500 via-blue-500 to-purple-500 dark:from-cyan-400 dark:via-blue-400 dark:to-purple-400 uppercase italic">
|
||||
{leftName}<span className="text-slate-800 dark:text-white">{rightName}</span>
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 dark:text-cyan-500 font-mono tracking-[0.3em] uppercase">
|
||||
{systemNameZh}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={saveMutation.isPending}>
|
||||
保存配置
|
||||
</Button>
|
||||
<Button onClick={handleReset}>
|
||||
恢复默认
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsActions>
|
||||
<Button type="primary" htmlType="submit" loading={saveMutation.isPending}>
|
||||
保存配置
|
||||
</Button>
|
||||
<Button onClick={handleReset}>
|
||||
恢复默认
|
||||
</Button>
|
||||
</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,88 +27,94 @@ export const defaultIPv6APIs = [
|
||||
'https://v6.yinghualuo.cn/bejson',
|
||||
];
|
||||
|
||||
interface SettingsTab {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
const settingsTabs: SettingsTab[] = [
|
||||
{
|
||||
key: 'system',
|
||||
label: '系统配置',
|
||||
icon: Settings2,
|
||||
content: <SystemConfig/>,
|
||||
},
|
||||
{
|
||||
key: 'themes',
|
||||
label: '主题管理',
|
||||
icon: Palette,
|
||||
content: <ThemeSettings/>,
|
||||
},
|
||||
{
|
||||
key: 'channels',
|
||||
label: '通知渠道',
|
||||
icon: MessageSquare,
|
||||
content: <NotificationChannels/>,
|
||||
},
|
||||
{
|
||||
key: 'public-ip',
|
||||
label: '公网 IP 采集',
|
||||
icon: Wifi,
|
||||
content: (
|
||||
<PublicIPConfig
|
||||
defaultIPv4APIs={defaultIPv4APIs}
|
||||
defaultIPv6APIs={defaultIPv6APIs}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'alert',
|
||||
label: '告警规则',
|
||||
icon: Bell,
|
||||
content: <AlertSettings/>,
|
||||
},
|
||||
];
|
||||
|
||||
const Settings = () => {
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams({tab: 'system'});
|
||||
const items = [
|
||||
{
|
||||
key: 'system',
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
<Settings2 size={16}/>
|
||||
系统配置
|
||||
</span>
|
||||
),
|
||||
children: <SystemConfig/>,
|
||||
},
|
||||
{
|
||||
key: 'themes',
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
<Palette size={16}/>
|
||||
主题管理
|
||||
</span>
|
||||
),
|
||||
children: <ThemeSettings/>,
|
||||
},
|
||||
{
|
||||
key: 'channels',
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
<MessageSquare size={16}/>
|
||||
通知渠道
|
||||
</span>
|
||||
),
|
||||
children: <NotificationChannels/>,
|
||||
},
|
||||
{
|
||||
key: 'public-ip',
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
<Wifi size={16}/>
|
||||
公网 IP 采集
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<PublicIPConfig
|
||||
defaultIPv4APIs={defaultIPv4APIs}
|
||||
defaultIPv6APIs={defaultIPv6APIs}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'alert',
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
<Bell size={16}/>
|
||||
告警规则
|
||||
</span>
|
||||
),
|
||||
children: <AlertSettings/>,
|
||||
},
|
||||
];
|
||||
|
||||
const activeKey = searchParams.get('tab') || 'system';
|
||||
const activeItem = items.find((item) => item.key === activeKey) || items[0];
|
||||
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) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={item.key === activeKey
|
||||
? '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}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="min-w-0">{activeItem.children}</div>
|
||||
<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={tab.key}
|
||||
type="button"
|
||||
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: tab.key})}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
<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>
|
||||
|
||||
<PagePanel className="min-w-0">
|
||||
{activeTab.content}
|
||||
</PagePanel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user