diff --git a/frontend/src/components/ui/EventBusCheckboxes.tsx b/frontend/src/components/ui/EventBusCheckboxes.tsx deleted file mode 100644 index 7b769b2fd..000000000 --- a/frontend/src/components/ui/EventBusCheckboxes.tsx +++ /dev/null @@ -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; - /** 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: ( -
- {t(group.labelKey)} - - {count}/{total} - - 0 && count < total} - onClick={(e) => e.stopPropagation()} - onChange={() => toggleGroup(group)} - /> -
- ), - children: ( - - - {group.events.map((et) => { - const checked = selected.includes(et.value); - const extraConf = extra?.[et.value]; - return ( - - toggle(et.value)}> - {t(et.labelKey)} - - {extraConf && onExtraChange && ( - onExtraChange(extraConf.key, v)} - style={{ width: 60 }} - /> - )} - - ); - })} - - - ), - }; - }); - - const defaultActiveKeys = EVENT_GROUPS - .filter((g) => g.events.some((e) => selected.includes(e.value))) - .map((g) => g.key); - - return ( - 0 ? defaultActiveKeys : ['outbound']} - expandIcon={({ isActive }) => isActive ? : } - size="small" - /> - ); -} diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index 40c08b27b..1e8121b57 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -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'; diff --git a/frontend/src/components/ui/notifications/EmailNotifications.tsx b/frontend/src/components/ui/notifications/EmailNotifications.tsx new file mode 100644 index 000000000..9d760af9a --- /dev/null +++ b/frontend/src/components/ui/notifications/EmailNotifications.tsx @@ -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: , + title: 'eventGroupOutbound', + events: [ + { key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' }, + { key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' }, + ], + }, + { + icon: , + title: 'eventGroupXray', + events: [ + { key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' }, + ], + }, + { + icon: , + title: 'eventGroupNode', + events: [ + { key: 'node.down', label: 'eventNodeDown', settingKey: '' }, + { key: 'node.up', label: 'eventNodeUp', settingKey: '' }, + ], + }, + { + icon: , + title: 'eventGroupSystem', + events: [ + { + key: 'cpu.high', + label: 'eventCPUHigh', + settingKey: 'smtpCpu', + extra: ({ value, onChange }) => ( + + ), + }, + ], + }, + { + icon: , + title: 'eventGroupSecurity', + events: [ + { key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' }, + ], + }, +]; + +interface Props { + allSetting: AllSetting; + updateSetting: (patch: Partial) => 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 ( + + {GROUPS.map((group, i) => ( + + ))} + + ); +} diff --git a/frontend/src/components/ui/notifications/NotificationCard.tsx b/frontend/src/components/ui/notifications/NotificationCard.tsx new file mode 100644 index 000000000..a92d7ee5a --- /dev/null +++ b/frontend/src/components/ui/notifications/NotificationCard.tsx @@ -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 ( + {icon} {title}} + extra={extra} + style={{ borderWidth: 1 }} + > + {children} + + ); +} diff --git a/frontend/src/components/ui/notifications/NotificationEvent.tsx b/frontend/src/components/ui/notifications/NotificationEvent.tsx new file mode 100644 index 000000000..bf8e74775 --- /dev/null +++ b/frontend/src/components/ui/notifications/NotificationEvent.tsx @@ -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 ( +
+ + {t(label)} + + {checked && children && ( +
+ {children} +
+ )} +
+ ); +} diff --git a/frontend/src/components/ui/notifications/NotificationGroup.tsx b/frontend/src/components/ui/notifications/NotificationGroup.tsx new file mode 100644 index 000000000..3c8636b5e --- /dev/null +++ b/frontend/src/components/ui/notifications/NotificationGroup.tsx @@ -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) => 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 ( + 0 && count < total} + onToggleAll={toggleAll} + /> + } + > + + {config.events.map((event) => ( + onToggle(event.key)} + > + {event.extra?.({ + value: Number((allSetting as unknown as Record)[event.settingKey]) || 0, + onChange: (v) => updateSetting({ [event.settingKey]: v }), + })} + + ))} + + + ); +} diff --git a/frontend/src/components/ui/notifications/NotificationHeader.tsx b/frontend/src/components/ui/notifications/NotificationHeader.tsx new file mode 100644 index 000000000..7dc6004fb --- /dev/null +++ b/frontend/src/components/ui/notifications/NotificationHeader.tsx @@ -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(null); + useEffect(() => { + if (ref.current) ref.current.indeterminate = indeterminate; + }, [indeterminate]); + return ; +} + +export function NotificationHeader({ count, total, allSelected, indeterminate, onToggleAll }: Props) { + return ( + + {count}/{total} + + + ); +} diff --git a/frontend/src/components/ui/notifications/NotificationLayout.tsx b/frontend/src/components/ui/notifications/NotificationLayout.tsx new file mode 100644 index 000000000..ecacd0e44 --- /dev/null +++ b/frontend/src/components/ui/notifications/NotificationLayout.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react'; + +interface Props { + children: ReactNode; +} + +export function NotificationLayout({ children }: Props) { + return ( +
+ {children} +
+ ); +} diff --git a/frontend/src/components/ui/notifications/TelegramNotifications.tsx b/frontend/src/components/ui/notifications/TelegramNotifications.tsx new file mode 100644 index 000000000..4fa7dfc6a --- /dev/null +++ b/frontend/src/components/ui/notifications/TelegramNotifications.tsx @@ -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: , + title: 'eventGroupOutbound', + events: [ + { key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' }, + { key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' }, + ], + }, + { + icon: , + title: 'eventGroupXray', + events: [ + { key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' }, + ], + }, + { + icon: , + title: 'eventGroupNode', + events: [ + { key: 'node.down', label: 'eventNodeDown', settingKey: '' }, + { key: 'node.up', label: 'eventNodeUp', settingKey: '' }, + ], + }, + { + icon: , + title: 'eventGroupSystem', + events: [ + { + key: 'cpu.high', + label: 'eventCPUHigh', + settingKey: 'tgCpu', + extra: ({ value, onChange }) => ( + + ), + }, + ], + }, + { + icon: , + title: 'eventGroupSecurity', + events: [ + { key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' }, + ], + }, +]; + +interface Props { + allSetting: AllSetting; + updateSetting: (patch: Partial) => 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 ( + + {GROUPS.map((group, i) => ( + + ))} + + ); +} diff --git a/frontend/src/components/ui/notifications/index.ts b/frontend/src/components/ui/notifications/index.ts new file mode 100644 index 000000000..290445c68 --- /dev/null +++ b/frontend/src/components/ui/notifications/index.ts @@ -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'; diff --git a/frontend/src/components/ui/notifications/types.ts b/frontend/src/components/ui/notifications/types.ts new file mode 100644 index 000000000..baff64b95 --- /dev/null +++ b/frontend/src/components/ui/notifications/types.ts @@ -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[]; +} diff --git a/frontend/src/pages/settings/EmailTab.tsx b/frontend/src/pages/settings/EmailTab.tsx index e70772712..2f830fc5b 100644 --- a/frontend/src/pages/settings/EmailTab.tsx +++ b/frontend/src/pages/settings/EmailTab.tsx @@ -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: ( <> - updateSetting({ smtpEnabledEvents: v })} - extra={{ 'cpu.high': { key: 'smtpCpu', value: allSetting.smtpCpu } }} - onExtraChange={(key, v) => updateSetting({ [key]: Number(v) || 0 })} - /> + ), diff --git a/frontend/src/pages/settings/TelegramTab.tsx b/frontend/src/pages/settings/TelegramTab.tsx index c8576b94a..814cf7f2b 100644 --- a/frontend/src/pages/settings/TelegramTab.tsx +++ b/frontend/src/pages/settings/TelegramTab.tsx @@ -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 - updateSetting({ tgEnabledEvents: v })} - extra={{ 'cpu.high': { key: 'tgCpu', value: allSetting.tgCpu } }} - onExtraChange={(key, v) => updateSetting({ [key]: Number(v) || 0 })} - /> + ),