Forráskód Böngészése

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
Sentiago 21 órája
szülő
commit
55d08d2ae9

+ 0 - 147
frontend/src/components/ui/EventBusCheckboxes.tsx

@@ -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"
-    />
-  );
-}

+ 0 - 1
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';

+ 94 - 0
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: <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>
+  );
+}

+ 23 - 0
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 (
+    <Card
+      size="small"
+      bordered
+      title={<span>{icon} {title}</span>}
+      extra={extra}
+      style={{ borderWidth: 1 }}
+    >
+      {children}
+    </Card>
+  );
+}

+ 26 - 0
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 (
+    <div>
+      <Checkbox checked={checked} onChange={onToggle}>
+        {t(label)}
+      </Checkbox>
+      {checked && children && (
+        <div style={{ paddingLeft: 24, marginTop: 4 }}>
+          {children}
+        </div>
+      )}
+    </div>
+  );
+}

+ 60 - 0
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<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>
+  );
+}

+ 27 - 0
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<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>
+  );
+}

+ 13 - 0
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 (
+    <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 12 }}>
+      {children}
+    </div>
+  );
+}

+ 94 - 0
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: <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>
+  );
+}

+ 8 - 0
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';

+ 14 - 0
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[];
+}

+ 3 - 7
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: (
           <>
             <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
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
             </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>
           </>
         ),