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
@@ -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[];
}