feat: replace notification checkboxes with card-based layout (#5421)

Replace EventBusCheckboxes with card-based notification settings:
- Each event group gets its own card with responsive grid layout
- Master checkbox per group with indeterminate state
- Inline parameter inputs (CPU threshold) appear when enabled
- Theme-adaptive via Ant Design Card component

Components:
- NotificationLayout, NotificationCard, NotificationHeader, NotificationEvent
- TelegramNotifications, EmailNotifications with explicit event configs
This commit is contained in:
Sentiago
2026-06-20 23:13:58 +03:00
committed by GitHub
parent 1259c20e5f
commit 55d08d2ae9
13 changed files with 365 additions and 162 deletions
@@ -1,147 +0,0 @@
import { Checkbox, Collapse, InputNumber, Space } from 'antd';
import { DownOutlined, RightOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
interface EventGroup {
key: string;
labelKey: string;
events: { value: string; labelKey: string }[];
}
const EVENT_GROUPS: EventGroup[] = [
{
key: 'outbound',
labelKey: 'pages.settings.eventGroupOutbound',
events: [
{ value: 'outbound.down', labelKey: 'pages.settings.eventOutboundDown' },
{ value: 'outbound.up', labelKey: 'pages.settings.eventOutboundUp' },
],
},
{
key: 'xray',
labelKey: 'pages.settings.eventGroupXray',
events: [
{ value: 'xray.crash', labelKey: 'pages.settings.eventXrayCrash' },
],
},
{
key: 'node',
labelKey: 'pages.settings.eventGroupNode',
events: [
{ value: 'node.down', labelKey: 'pages.settings.eventNodeDown' },
{ value: 'node.up', labelKey: 'pages.settings.eventNodeUp' },
],
},
{
key: 'system',
labelKey: 'pages.settings.eventGroupSystem',
events: [
{ value: 'cpu.high', labelKey: 'pages.settings.eventCPUHigh' },
],
},
{
key: 'security',
labelKey: 'pages.settings.eventGroupSecurity',
events: [
{ value: 'login.attempt', labelKey: 'pages.settings.eventLoginAttempt' },
],
},
];
interface EventBusCheckboxesProps {
value: string;
onChange: (v: string) => void;
/** Maps event value → { key: setting field name, value: current value } for inline inputs */
extra?: Record<string, { key: string; value: number }>;
/** Callback when extra input changes: (settingKey, newValue) => void */
onExtraChange?: (key: string, v: number | null) => void;
}
export function EventBusCheckboxes({ value, onChange, extra, onExtraChange }: EventBusCheckboxesProps) {
const { t } = useTranslation();
const selected = value ? value.split(',').map((s) => s.trim()).filter(Boolean) : [];
function toggle(eventType: string) {
const next = selected.includes(eventType)
? selected.filter((e) => e !== eventType)
: [...selected, eventType];
onChange(next.join(','));
}
function toggleGroup(group: EventGroup) {
const groupValues = group.events.map((e) => e.value);
const allSelected = groupValues.every((v) => selected.includes(v));
let next: string[];
if (allSelected) {
next = selected.filter((v) => !groupValues.includes(v));
} else {
next = [...new Set([...selected, ...groupValues])];
}
onChange(next.join(','));
}
const items = EVENT_GROUPS.map((group) => {
const count = group.events.filter((e) => selected.includes(e.value)).length;
const total = group.events.length;
const allSelected = count === total;
return {
key: group.key,
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 500 }}>{t(group.labelKey)}</span>
<span style={{ color: '#999', fontSize: 12 }}>
{count}/{total}
</span>
<Checkbox
checked={allSelected}
indeterminate={count > 0 && count < total}
onClick={(e) => e.stopPropagation()}
onChange={() => toggleGroup(group)}
/>
</div>
),
children: (
<Checkbox.Group value={selected} style={{ width: '100%' }}>
<Space wrap size={[16, 4]}>
{group.events.map((et) => {
const checked = selected.includes(et.value);
const extraConf = extra?.[et.value];
return (
<span key={et.value} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<Checkbox value={et.value} onChange={() => toggle(et.value)}>
{t(et.labelKey)}
</Checkbox>
{extraConf && onExtraChange && (
<InputNumber
size="small"
min={0}
max={100}
value={extraConf.value}
disabled={!checked}
onChange={(v) => onExtraChange(extraConf.key, v)}
style={{ width: 60 }}
/>
)}
</span>
);
})}
</Space>
</Checkbox.Group>
),
};
});
const defaultActiveKeys = EVENT_GROUPS
.filter((g) => g.events.some((e) => selected.includes(e.value)))
.map((g) => g.key);
return (
<Collapse
items={items}
defaultActiveKey={defaultActiveKeys.length > 0 ? defaultActiveKeys : ['outbound']}
expandIcon={({ isActive }) => isActive ? <DownOutlined /> : <RightOutlined />}
size="small"
/>
);
}
-1
View File
@@ -1,4 +1,3 @@
export { default as InputAddon } from './InputAddon';
export { default as InfinityIcon } from './InfinityIcon';
export { default as SettingListItem } from './SettingListItem';
export { EventBusCheckboxes } from './EventBusCheckboxes';
@@ -0,0 +1,94 @@
import { InputNumber } from 'antd';
import { CloudServerOutlined, ThunderboltOutlined, DesktopOutlined, DashboardOutlined, SafetyOutlined } from '@ant-design/icons';
import type { AllSetting } from '@/models/setting';
import { NotificationLayout } from './NotificationLayout';
import { NotificationGroup } from './NotificationGroup';
import type { NotificationGroupConfig } from './types';
const GROUPS: NotificationGroupConfig[] = [
{
icon: <CloudServerOutlined />,
title: 'eventGroupOutbound',
events: [
{ key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' },
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
],
},
{
icon: <ThunderboltOutlined />,
title: 'eventGroupXray',
events: [
{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' },
],
},
{
icon: <DesktopOutlined />,
title: 'eventGroupNode',
events: [
{ key: 'node.down', label: 'eventNodeDown', settingKey: '' },
{ key: 'node.up', label: 'eventNodeUp', settingKey: '' },
],
},
{
icon: <DashboardOutlined />,
title: 'eventGroupSystem',
events: [
{
key: 'cpu.high',
label: 'eventCPUHigh',
settingKey: 'smtpCpu',
extra: ({ value, onChange }) => (
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
),
},
],
},
{
icon: <SafetyOutlined />,
title: 'eventGroupSecurity',
events: [
{ key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' },
],
},
];
interface Props {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
}
export function EmailNotifications({ allSetting, updateSetting }: Props) {
const events = allSetting.smtpEnabledEvents || '';
const selected = events ? events.split(',').map((s) => s.trim()).filter(Boolean) : [];
function toggle(key: string) {
const next = selected.includes(key)
? selected.filter((e) => e !== key)
: [...selected, key];
updateSetting({ smtpEnabledEvents: next.join(',') });
}
function toggleAll(keys: string[]) {
const allSelected = keys.every((v) => selected.includes(v));
const next = allSelected
? selected.filter((v) => !keys.includes(v))
: [...new Set([...selected, ...keys])];
updateSetting({ smtpEnabledEvents: next.join(',') });
}
return (
<NotificationLayout>
{GROUPS.map((group, i) => (
<NotificationGroup
key={i}
config={group}
selected={selected}
onToggle={toggle}
onToggleAll={toggleAll}
allSetting={allSetting}
updateSetting={updateSetting}
/>
))}
</NotificationLayout>
);
}
@@ -0,0 +1,23 @@
import type { ReactNode } from 'react';
import { Card } from 'antd';
interface Props {
icon: ReactNode;
title: ReactNode;
extra: ReactNode;
children: ReactNode;
}
export function NotificationCard({ icon, title, extra, children }: Props) {
return (
<Card
size="small"
bordered
title={<span>{icon} {title}</span>}
extra={extra}
style={{ borderWidth: 1 }}
>
{children}
</Card>
);
}
@@ -0,0 +1,26 @@
import type { ReactNode } from 'react';
import { Checkbox } from 'antd';
import { useTranslation } from 'react-i18next';
interface Props {
label: string;
checked: boolean;
onToggle: () => void;
children?: ReactNode;
}
export function NotificationEvent({ label, checked, onToggle, children }: Props) {
const { t } = useTranslation();
return (
<div>
<Checkbox checked={checked} onChange={onToggle}>
{t(label)}
</Checkbox>
{checked && children && (
<div style={{ paddingLeft: 24, marginTop: 4 }}>
{children}
</div>
)}
</div>
);
}
@@ -0,0 +1,60 @@
import { Space } from 'antd';
import { useTranslation } from 'react-i18next';
import type { AllSetting } from '@/models/setting';
import type { NotificationGroupConfig } from './types';
import { NotificationCard } from './NotificationCard';
import { NotificationHeader } from './NotificationHeader';
import { NotificationEvent } from './NotificationEvent';
interface Props {
config: NotificationGroupConfig;
selected: string[];
onToggle: (key: string) => void;
onToggleAll: (keys: string[]) => void;
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
}
export function NotificationGroup({ config, selected, onToggle, onToggleAll, allSetting, updateSetting }: Props) {
const { t } = useTranslation();
const count = config.events.filter((e) => selected.includes(e.key)).length;
const total = config.events.length;
function toggleAll() {
const values = config.events.map((e) => e.key);
onToggleAll(values);
}
return (
<NotificationCard
icon={config.icon}
title={t(`pages.settings.${config.title}`)}
extra={
<NotificationHeader
count={count}
total={total}
allSelected={count === total}
indeterminate={count > 0 && count < total}
onToggleAll={toggleAll}
/>
}
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{config.events.map((event) => (
<NotificationEvent
key={event.key}
label={t(`pages.settings.${event.label}`)}
checked={selected.includes(event.key)}
onToggle={() => onToggle(event.key)}
>
{event.extra?.({
value: Number((allSetting as unknown as Record<string, unknown>)[event.settingKey]) || 0,
onChange: (v) => updateSetting({ [event.settingKey]: v }),
})}
</NotificationEvent>
))}
</Space>
</NotificationCard>
);
}
@@ -0,0 +1,27 @@
import { useRef, useEffect } from 'react';
import { Tag } from 'antd';
interface Props {
count: number;
total: number;
allSelected: boolean;
indeterminate: boolean;
onToggleAll: () => void;
}
function MasterCheckbox({ checked, indeterminate, onChange }: { checked: boolean; indeterminate: boolean; onChange: () => void }) {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (ref.current) ref.current.indeterminate = indeterminate;
}, [indeterminate]);
return <input ref={ref} type="checkbox" checked={checked} onChange={onChange} style={{ cursor: 'pointer' }} />;
}
export function NotificationHeader({ count, total, allSelected, indeterminate, onToggleAll }: Props) {
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<Tag>{count}/{total}</Tag>
<MasterCheckbox checked={allSelected} indeterminate={indeterminate} onChange={onToggleAll} />
</span>
);
}
@@ -0,0 +1,13 @@
import type { ReactNode } from 'react';
interface Props {
children: ReactNode;
}
export function NotificationLayout({ children }: Props) {
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 12 }}>
{children}
</div>
);
}
@@ -0,0 +1,94 @@
import { InputNumber } from 'antd';
import { CloudServerOutlined, ThunderboltOutlined, DesktopOutlined, DashboardOutlined, SafetyOutlined } from '@ant-design/icons';
import type { AllSetting } from '@/models/setting';
import { NotificationLayout } from './NotificationLayout';
import { NotificationGroup } from './NotificationGroup';
import type { NotificationGroupConfig } from './types';
const GROUPS: NotificationGroupConfig[] = [
{
icon: <CloudServerOutlined />,
title: 'eventGroupOutbound',
events: [
{ key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' },
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
],
},
{
icon: <ThunderboltOutlined />,
title: 'eventGroupXray',
events: [
{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' },
],
},
{
icon: <DesktopOutlined />,
title: 'eventGroupNode',
events: [
{ key: 'node.down', label: 'eventNodeDown', settingKey: '' },
{ key: 'node.up', label: 'eventNodeUp', settingKey: '' },
],
},
{
icon: <DashboardOutlined />,
title: 'eventGroupSystem',
events: [
{
key: 'cpu.high',
label: 'eventCPUHigh',
settingKey: 'tgCpu',
extra: ({ value, onChange }) => (
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
),
},
],
},
{
icon: <SafetyOutlined />,
title: 'eventGroupSecurity',
events: [
{ key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' },
],
},
];
interface Props {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
}
export function TelegramNotifications({ allSetting, updateSetting }: Props) {
const events = allSetting.tgEnabledEvents || '';
const selected = events ? events.split(',').map((s) => s.trim()).filter(Boolean) : [];
function toggle(key: string) {
const next = selected.includes(key)
? selected.filter((e) => e !== key)
: [...selected, key];
updateSetting({ tgEnabledEvents: next.join(',') });
}
function toggleAll(keys: string[]) {
const allSelected = keys.every((v) => selected.includes(v));
const next = allSelected
? selected.filter((v) => !keys.includes(v))
: [...new Set([...selected, ...keys])];
updateSetting({ tgEnabledEvents: next.join(',') });
}
return (
<NotificationLayout>
{GROUPS.map((group, i) => (
<NotificationGroup
key={i}
config={group}
selected={selected}
onToggle={toggle}
onToggleAll={toggleAll}
allSetting={allSetting}
updateSetting={updateSetting}
/>
))}
</NotificationLayout>
);
}
@@ -0,0 +1,8 @@
export type { NotificationEventConfig, NotificationGroupConfig } from './types';
export { NotificationLayout } from './NotificationLayout';
export { NotificationCard } from './NotificationCard';
export { NotificationHeader } from './NotificationHeader';
export { NotificationEvent } from './NotificationEvent';
export { NotificationGroup } from './NotificationGroup';
export { TelegramNotifications } from './TelegramNotifications';
export { EmailNotifications } from './EmailNotifications';
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react';
export interface NotificationEventConfig {
key: string;
label: string;
settingKey: string;
extra?: (props: { value: number; onChange: (v: number | null) => void }) => ReactNode;
}
export interface NotificationGroupConfig {
icon: ReactNode;
title: string;
events: NotificationEventConfig[];
}
+3 -7
View File
@@ -4,7 +4,8 @@ import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from '
import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import type { AllSetting } from '@/models/setting';
import { SettingListItem, EventBusCheckboxes } from '@/components/ui';
import { SettingListItem } from '@/components/ui';
import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
@@ -122,12 +123,7 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.smtpEventBusNotify')} description={t('pages.settings.smtpEventBusNotifyDesc')}>
<EventBusCheckboxes
value={allSetting.smtpEnabledEvents}
onChange={(v) => updateSetting({ smtpEnabledEvents: v })}
extra={{ 'cpu.high': { key: 'smtpCpu', value: allSetting.smtpCpu } }}
onExtraChange={(key, v) => updateSetting({ [key]: Number(v) || 0 })}
/>
<EmailNotifications allSetting={allSetting} updateSetting={updateSetting} />
</SettingListItem>
</>
),
+3 -7
View File
@@ -5,7 +5,8 @@ import { BellOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
import { LanguageManager } from '@/utils';
import { HttpUtil } from '@/utils';
import type { AllSetting } from '@/models/setting';
import { SettingListItem, EventBusCheckboxes } from '@/components/ui';
import { SettingListItem } from '@/components/ui';
import { TelegramNotifications } from '@/components/ui/notifications/TelegramNotifications';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
@@ -245,12 +246,7 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.tgEventBusNotify')} description={t('pages.settings.tgEventBusNotifyDesc')}>
<EventBusCheckboxes
value={allSetting.tgEnabledEvents}
onChange={(v) => updateSetting({ tgEnabledEvents: v })}
extra={{ 'cpu.high': { key: 'tgCpu', value: allSetting.tgCpu } }}
onExtraChange={(key, v) => updateSetting({ [key]: Number(v) || 0 })}
/>
<TelegramNotifications allSetting={allSetting} updateSetting={updateSetting} />
</SettingListItem>
</>
),