iT邦幫忙

2025 iThome 鐵人賽

DAY 27
0
Modern Web

30 天製作工作室 SaaS 產品 (前端篇)系列 第 27

Day 27: 30天打造SaaS產品前端篇-即時通知系統與 WebSocket 整合

  • 分享至 

  • xImage
  •  

前情提要

經過 Day 26 的多因素認證建置,我們已經為 Kyo System 建立了企業級的帳號安全系統。今天我們要實作即時通知系統與 WebSocket 整合,這是現代 SaaS 產品不可或缺的功能。當用戶在後台操作、系統狀態變更、或有重要事件發生時,我們需要即時通知用戶,而不是讓他們手動刷新頁面。

即時通訊技術選型比較

/**
 * 即時通訊技術比較
 *
 * ┌──────────────────────────────────────────────┐
 * │    Real-time Communication Technologies     │
 * └──────────────────────────────────────────────┘
 *
 * 1. WebSocket (雙向通訊)
 *    ┌────────┐ ←─ Data ─→ ┌────────┐
 *    │ Client │            │ Server │
 *    └────────┘            └────────┘
 *    ✅ 全雙工、低延遲、持久連線
 *    ✅ 適合: 聊天、協作、即時更新
 *    ❌ 需要特殊基礎設施、連線管理複雜
 *
 * 2. Server-Sent Events (SSE, 單向推送)
 *    ┌────────┐ ←── Data ── ┌────────┐
 *    │ Client │             │ Server │
 *    └────────┘             └────────┘
 *    ✅ 簡單、HTTP 協議、自動重連
 *    ✅ 適合: 通知、進度更新、儀表板
 *    ❌ 單向、瀏覽器連線數限制
 *
 * 3. Long Polling (輪詢)
 *    ┌────────┐  Request → ┌────────┐
 *    │ Client │ ← Response │ Server │
 *    └────────┘            └────────┘
 *    ✅ 簡單、相容性好、無需特殊伺服器
 *    ❌ 效率低、延遲高、伺服器負擔大
 *
 * 4. Socket.io (WebSocket 封裝)
 *    ┌────────┐ ←─ Events ─→ ┌────────┐
 *    │ Client │               │ Server │
 *    └────────┘               └────────┘
 *    ✅ 自動降級、房間概念、重連機制
 *    ✅ 適合: 複雜即時應用
 *    ❌ 體積較大、需要特定伺服器支援
 *
 * Kyo System 選擇: Socket.io
 * 理由:
 * - 自動降級 (WebSocket → Long Polling)
 * - 內建房間與命名空間 (多租戶隔離)
 * - 優秀的重連機制
 * - TypeScript 支援完善
 * - 豐富的生態系統
 */

Socket.io React Hook 封裝

// src/hooks/useSocket.ts
import { useEffect, useRef, useState, useCallback } from 'react';
import io, { Socket } from 'socket.io-client';
import { useAuth } from '../contexts/AuthContext';

export interface SocketConfig {
  autoConnect?: boolean;
  reconnection?: boolean;
  reconnectionDelay?: number;
  reconnectionAttempts?: number;
}

const DEFAULT_CONFIG: SocketConfig = {
  autoConnect: true,
  reconnection: true,
  reconnectionDelay: 1000,
  reconnectionAttempts: 5,
};

/**
 * Socket.io React Hook
 *
 * 功能:
 * - 自動連線管理
 * - JWT 認證
 * - 重連機制
 * - 事件訂閱/取消訂閱
 * - 錯誤處理
 * - 連線狀態追蹤
 */
export function useSocket(
  namespace: string = '/',
  config: SocketConfig = {}
) {
  const { user, accessToken } = useAuth();
  const socketRef = useRef<Socket | null>(null);
  const [isConnected, setIsConnected] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const mergedConfig = { ...DEFAULT_CONFIG, ...config };

  /**
   * 初始化 Socket 連線
   */
  useEffect(() => {
    if (!user || !accessToken) {
      // 未登入不建立連線
      return;
    }

    // Socket.io 伺服器位址
    const SOCKET_URL = import.meta.env.VITE_SOCKET_URL || 'http://localhost:3000';

    // 建立連線
    const socket = io(`${SOCKET_URL}${namespace}`, {
      auth: {
        token: accessToken,
      },
      autoConnect: mergedConfig.autoConnect,
      reconnection: mergedConfig.reconnection,
      reconnectionDelay: mergedConfig.reconnectionDelay,
      reconnectionAttempts: mergedConfig.reconnectionAttempts,
      transports: ['websocket', 'polling'], // 優先使用 WebSocket
    });

    // 連線成功
    socket.on('connect', () => {
      console.log('Socket connected:', socket.id);
      setIsConnected(true);
      setError(null);
    });

    // 連線失敗
    socket.on('connect_error', (err) => {
      console.error('Socket connect error:', err);
      setError(err);
      setIsConnected(false);
    });

    // 斷線
    socket.on('disconnect', (reason) => {
      console.log('Socket disconnected:', reason);
      setIsConnected(false);

      // 如果是伺服器主動斷線,需要重新認證
      if (reason === 'io server disconnect') {
        socket.connect();
      }
    });

    // 重連嘗試
    socket.on('reconnect_attempt', (attemptNumber) => {
      console.log('Socket reconnect attempt:', attemptNumber);
    });

    // 重連成功
    socket.on('reconnect', (attemptNumber) => {
      console.log('Socket reconnected after', attemptNumber, 'attempts');
      setIsConnected(true);
      setError(null);
    });

    // 重連失敗
    socket.on('reconnect_failed', () => {
      console.error('Socket reconnect failed');
      setError(new Error('Failed to reconnect after maximum attempts'));
    });

    socketRef.current = socket;

    // 清理
    return () => {
      if (socket) {
        socket.disconnect();
        socketRef.current = null;
      }
    };
  }, [user, accessToken, namespace, mergedConfig]);

  /**
   * 發送事件
   */
  const emit = useCallback((event: string, ...args: any[]) => {
    if (socketRef.current?.connected) {
      socketRef.current.emit(event, ...args);
    } else {
      console.warn('Socket not connected, cannot emit:', event);
    }
  }, []);

  /**
   * 監聽事件
   */
  const on = useCallback((event: string, handler: (...args: any[]) => void) => {
    if (socketRef.current) {
      socketRef.current.on(event, handler);
    }
  }, []);

  /**
   * 取消監聽事件
   */
  const off = useCallback((event: string, handler?: (...args: any[]) => void) => {
    if (socketRef.current) {
      if (handler) {
        socketRef.current.off(event, handler);
      } else {
        socketRef.current.off(event);
      }
    }
  }, []);

  /**
   * 手動連線
   */
  const connect = useCallback(() => {
    if (socketRef.current && !socketRef.current.connected) {
      socketRef.current.connect();
    }
  }, []);

  /**
   * 手動斷線
   */
  const disconnect = useCallback(() => {
    if (socketRef.current && socketRef.current.connected) {
      socketRef.current.disconnect();
    }
  }, []);

  return {
    socket: socketRef.current,
    isConnected,
    error,
    emit,
    on,
    off,
    connect,
    disconnect,
  };
}

通知資料模型與類型

// src/types/notification.ts
export type NotificationType =
  | 'info'
  | 'success'
  | 'warning'
  | 'error'
  | 'system';

export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent';

export interface Notification {
  id: string;
  type: NotificationType;
  priority: NotificationPriority;
  title: string;
  message: string;
  actionUrl?: string;
  actionLabel?: string;
  data?: Record<string, any>;
  read: boolean;
  createdAt: Date;
  expiresAt?: Date;
}

export interface NotificationPreferences {
  email: boolean;
  push: boolean;
  inApp: boolean;
  sound: boolean;
  categories: {
    system: boolean;
    security: boolean;
    updates: boolean;
    marketing: boolean;
  };
}

通知中心 Context

// src/contexts/NotificationContext.tsx
import React, {
  createContext,
  useContext,
  useState,
  useEffect,
  useCallback,
  ReactNode,
} from 'react';
import { useSocket } from '../hooks/useSocket';
import { Notification, NotificationPreferences } from '../types/notification';
import { notifications as mantineNotifications } from '@mantine/notifications';
import { IconCheck, IconX, IconAlertTriangle, IconInfoCircle } from '@tabler/icons-react';

interface NotificationContextValue {
  notifications: Notification[];
  unreadCount: number;
  preferences: NotificationPreferences | null;
  markAsRead: (id: string) => Promise<void>;
  markAllAsRead: () => Promise<void>;
  deleteNotification: (id: string) => Promise<void>;
  clearAll: () => Promise<void>;
  updatePreferences: (prefs: Partial<NotificationPreferences>) => Promise<void>;
  playNotificationSound: () => void;
}

const NotificationContext = createContext<NotificationContextValue | undefined>(undefined);

export function NotificationProvider({ children }: { children: ReactNode }) {
  const { socket, isConnected, on, off } = useSocket('/notifications');
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [preferences, setPreferences] = useState<NotificationPreferences | null>(null);

  // 通知音效
  const notificationSound = new Audio('/sounds/notification.mp3');

  /**
   * 播放通知音效
   */
  const playNotificationSound = useCallback(() => {
    if (preferences?.sound) {
      notificationSound.play().catch((err) => {
        console.error('Failed to play notification sound:', err);
      });
    }
  }, [preferences?.sound]);

  /**
   * 顯示瀏覽器原生通知
   */
  const showBrowserNotification = useCallback(
    async (notification: Notification) => {
      if (!preferences?.push) return;

      // 請求通知權限
      if (Notification.permission === 'default') {
        await Notification.requestPermission();
      }

      if (Notification.permission === 'granted') {
        new Notification(notification.title, {
          body: notification.message,
          icon: '/logo.png',
          badge: '/badge.png',
          tag: notification.id,
          requireInteraction: notification.priority === 'urgent',
        });
      }
    },
    [preferences?.push]
  );

  /**
   * 顯示應用內通知 (Mantine)
   */
  const showInAppNotification = useCallback(
    (notification: Notification) => {
      if (!preferences?.inApp) return;

      const icons = {
        info: <IconInfoCircle size={18} />,
        success: <IconCheck size={18} />,
        warning: <IconAlertTriangle size={18} />,
        error: <IconX size={18} />,
        system: <IconInfoCircle size={18} />,
      };

      const colors = {
        info: 'blue',
        success: 'green',
        warning: 'yellow',
        error: 'red',
        system: 'gray',
      };

      mantineNotifications.show({
        id: notification.id,
        title: notification.title,
        message: notification.message,
        color: colors[notification.type],
        icon: icons[notification.type],
        autoClose: notification.priority === 'urgent' ? false : 5000,
        withCloseButton: true,
      });
    },
    [preferences?.inApp]
  );

  /**
   * 載入通知列表
   */
  const loadNotifications = useCallback(async () => {
    try {
      const response = await fetch('/api/notifications', {
        headers: {
          Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
        },
      });

      const data = await response.json();
      setNotifications(data.notifications);
    } catch (error) {
      console.error('Failed to load notifications:', error);
    }
  }, []);

  /**
   * 載入通知偏好設定
   */
  const loadPreferences = useCallback(async () => {
    try {
      const response = await fetch('/api/notifications/preferences', {
        headers: {
          Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
        },
      });

      const data = await response.json();
      setPreferences(data.preferences);
    } catch (error) {
      console.error('Failed to load preferences:', error);
    }
  }, []);

  /**
   * 初始化
   */
  useEffect(() => {
    if (isConnected) {
      loadNotifications();
      loadPreferences();
    }
  }, [isConnected, loadNotifications, loadPreferences]);

  /**
   * 監聽新通知
   */
  useEffect(() => {
    if (!socket) return;

    const handleNewNotification = (notification: Notification) => {
      console.log('New notification:', notification);

      // 加入通知列表
      setNotifications((prev) => [notification, ...prev]);

      // 顯示應用內通知
      showInAppNotification(notification);

      // 顯示瀏覽器通知
      showBrowserNotification(notification);

      // 播放音效
      playNotificationSound();

      // 震動 (如果支援)
      if ('vibrate' in navigator && notification.priority === 'urgent') {
        navigator.vibrate([200, 100, 200]);
      }
    };

    on('notification', handleNewNotification);

    return () => {
      off('notification', handleNewNotification);
    };
  }, [socket, on, off, showInAppNotification, showBrowserNotification, playNotificationSound]);

  /**
   * 標記為已讀
   */
  const markAsRead = useCallback(async (id: string) => {
    try {
      await fetch(`/api/notifications/${id}/read`, {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
        },
      });

      setNotifications((prev) =>
        prev.map((n) => (n.id === id ? { ...n, read: true } : n))
      );
    } catch (error) {
      console.error('Failed to mark as read:', error);
    }
  }, []);

  /**
   * 標記全部已讀
   */
  const markAllAsRead = useCallback(async () => {
    try {
      await fetch('/api/notifications/read-all', {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
        },
      });

      setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
    } catch (error) {
      console.error('Failed to mark all as read:', error);
    }
  }, []);

  /**
   * 刪除通知
   */
  const deleteNotification = useCallback(async (id: string) => {
    try {
      await fetch(`/api/notifications/${id}`, {
        method: 'DELETE',
        headers: {
          Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
        },
      });

      setNotifications((prev) => prev.filter((n) => n.id !== id));
    } catch (error) {
      console.error('Failed to delete notification:', error);
    }
  }, []);

  /**
   * 清空全部
   */
  const clearAll = useCallback(async () => {
    try {
      await fetch('/api/notifications', {
        method: 'DELETE',
        headers: {
          Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
        },
      });

      setNotifications([]);
    } catch (error) {
      console.error('Failed to clear all:', error);
    }
  }, []);

  /**
   * 更新偏好設定
   */
  const updatePreferences = useCallback(
    async (prefs: Partial<NotificationPreferences>) => {
      try {
        const response = await fetch('/api/notifications/preferences', {
          method: 'PATCH',
          headers: {
            'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
            'Content-Type': 'application/json',
          },
          body: JSON.stringify(prefs),
        });

        const data = await response.json();
        setPreferences(data.preferences);
      } catch (error) {
        console.error('Failed to update preferences:', error);
      }
    },
    []
  );

  const unreadCount = notifications.filter((n) => !n.read).length;

  return (
    <NotificationContext.Provider
      value={{
        notifications,
        unreadCount,
        preferences,
        markAsRead,
        markAllAsRead,
        deleteNotification,
        clearAll,
        updatePreferences,
        playNotificationSound,
      }}
    >
      {children}
    </NotificationContext.Provider>
  );
}

export function useNotifications() {
  const context = useContext(NotificationContext);
  if (!context) {
    throw new Error('useNotifications must be used within NotificationProvider');
  }
  return context;
}

通知中心 UI 元件

// src/components/NotificationCenter/NotificationCenter.tsx
import { useState } from 'react';
import {
  Popover,
  ActionIcon,
  Indicator,
  Stack,
  Text,
  Button,
  Group,
  ScrollArea,
  Badge,
  Menu,
  Divider,
  Tabs,
  UnstyledButton,
  Box,
  Center,
} from '@mantine/core';
import {
  IconBell,
  IconSettings,
  IconCheck,
  IconTrash,
  IconDots,
} from '@tabler/icons-react';
import { useNotifications } from '../../contexts/NotificationContext';
import { NotificationItem } from './NotificationItem';

export function NotificationCenter() {
  const {
    notifications,
    unreadCount,
    markAllAsRead,
    clearAll,
  } = useNotifications();
  const [opened, setOpened] = useState(false);
  const [activeTab, setActiveTab] = useState<string | null>('all');

  // 按類型篩選
  const filteredNotifications =
    activeTab === 'all'
      ? notifications
      : notifications.filter((n) => n.type === activeTab);

  return (
    <Popover
      width={400}
      position="bottom-end"
      shadow="md"
      opened={opened}
      onChange={setOpened}
    >
      <Popover.Target>
        <Indicator
          inline
          label={unreadCount > 99 ? '99+' : unreadCount}
          size={16}
          disabled={unreadCount === 0}
          color="red"
        >
          <ActionIcon
            variant="subtle"
            size="lg"
            onClick={() => setOpened((o) => !o)}
          >
            <IconBell size={20} />
          </ActionIcon>
        </Indicator>
      </Popover.Target>

      <Popover.Dropdown p={0}>
        <Stack spacing={0}>
          {/* 標題列 */}
          <Group position="apart" p="md" pb="xs">
            <div>
              <Text weight={600} size="lg">
                通知中心
              </Text>
              {unreadCount > 0 && (
                <Text size="xs" color="dimmed">
                  {unreadCount} 則未讀
                </Text>
              )}
            </div>

            <Menu position="bottom-end" shadow="sm">
              <Menu.Target>
                <ActionIcon variant="subtle">
                  <IconDots size={18} />
                </ActionIcon>
              </Menu.Target>

              <Menu.Dropdown>
                <Menu.Item
                  icon={<IconCheck size={14} />}
                  onClick={markAllAsRead}
                  disabled={unreadCount === 0}
                >
                  標記全部已讀
                </Menu.Item>
                <Menu.Item
                  icon={<IconSettings size={14} />}
                  onClick={() => {
                    setOpened(false);
                    window.location.href = '/settings/notifications';
                  }}
                >
                  通知設定
                </Menu.Item>
                <Menu.Divider />
                <Menu.Item
                  icon={<IconTrash size={14} />}
                  color="red"
                  onClick={clearAll}
                  disabled={notifications.length === 0}
                >
                  清空全部
                </Menu.Item>
              </Menu.Dropdown>
            </Menu>
          </Group>

          <Divider />

          {/* 分類標籤 */}
          <Tabs value={activeTab} onTabChange={setActiveTab} px="md" pt="xs">
            <Tabs.List>
              <Tabs.Tab value="all">
                全部 ({notifications.length})
              </Tabs.Tab>
              <Tabs.Tab value="system">系統</Tabs.Tab>
              <Tabs.Tab value="success">成功</Tabs.Tab>
              <Tabs.Tab value="warning">警告</Tabs.Tab>
              <Tabs.Tab value="error">錯誤</Tabs.Tab>
            </Tabs.List>
          </Tabs>

          {/* 通知列表 */}
          {filteredNotifications.length === 0 ? (
            <Center py={60}>
              <Stack align="center" spacing="xs">
                <IconBell size={48} color="gray" opacity={0.3} />
                <Text color="dimmed" size="sm">
                  目前沒有通知
                </Text>
              </Stack>
            </Center>
          ) : (
            <ScrollArea h={400} type="auto">
              <Stack spacing={0}>
                {filteredNotifications.map((notification) => (
                  <NotificationItem
                    key={notification.id}
                    notification={notification}
                    onClose={() => setOpened(false)}
                  />
                ))}
              </Stack>
            </ScrollArea>
          )}
        </Stack>
      </Popover.Dropdown>
    </Popover>
  );
}

通知項目元件

// src/components/NotificationCenter/NotificationItem.tsx
import {
  UnstyledButton,
  Group,
  Stack,
  Text,
  ActionIcon,
  Badge,
  Box,
  ThemeIcon,
} from '@mantine/core';
import {
  IconCheck,
  IconX,
  IconAlertTriangle,
  IconInfoCircle,
  IconBell,
  IconTrash,
} from '@tabler/icons-react';
import { formatDistanceToNow } from 'date-fns';
import { zhTW } from 'date-fns/locale';
import { Notification } from '../../types/notification';
import { useNotifications } from '../../contexts/NotificationContext';

interface NotificationItemProps {
  notification: Notification;
  onClose?: () => void;
}

export function NotificationItem({ notification, onClose }: NotificationItemProps) {
  const { markAsRead, deleteNotification } = useNotifications();

  const icons = {
    info: <IconInfoCircle size={18} />,
    success: <IconCheck size={18} />,
    warning: <IconAlertTriangle size={18} />,
    error: <IconX size={18} />,
    system: <IconBell size={18} />,
  };

  const colors = {
    info: 'blue',
    success: 'green',
    warning: 'yellow',
    error: 'red',
    system: 'gray',
  };

  const handleClick = async () => {
    if (!notification.read) {
      await markAsRead(notification.id);
    }

    if (notification.actionUrl) {
      window.location.href = notification.actionUrl;
      onClose?.();
    }
  };

  const handleDelete = async (e: React.MouseEvent) => {
    e.stopPropagation();
    await deleteNotification(notification.id);
  };

  return (
    <UnstyledButton
      onClick={handleClick}
      sx={(theme) => ({
        display: 'block',
        width: '100%',
        padding: theme.spacing.md,
        backgroundColor: notification.read
          ? 'transparent'
          : theme.colorScheme === 'dark'
          ? theme.colors.dark[6]
          : theme.colors.gray[0],
        borderBottom: `1px solid ${
          theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[2]
        }`,
        '&:hover': {
          backgroundColor:
            theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
        },
      })}
    >
      <Group position="apart" noWrap>
        <Group noWrap spacing="md" style={{ flex: 1 }}>
          <ThemeIcon
            color={colors[notification.type]}
            variant="light"
            size="lg"
            radius="md"
          >
            {icons[notification.type]}
          </ThemeIcon>

          <Stack spacing={4} style={{ flex: 1 }}>
            <Group spacing="xs">
              <Text size="sm" weight={notification.read ? 400 : 600} lineClamp={1}>
                {notification.title}
              </Text>
              {notification.priority === 'urgent' && (
                <Badge size="xs" color="red">
                  緊急
                </Badge>
              )}
              {!notification.read && (
                <Box
                  sx={(theme) => ({
                    width: 8,
                    height: 8,
                    borderRadius: '50%',
                    backgroundColor: theme.colors.blue[6],
                  })}
                />
              )}
            </Group>

            <Text size="xs" color="dimmed" lineClamp={2}>
              {notification.message}
            </Text>

            <Group spacing="xs">
              <Text size="xs" color="dimmed">
                {formatDistanceToNow(new Date(notification.createdAt), {
                  addSuffix: true,
                  locale: zhTW,
                })}
              </Text>
              {notification.actionLabel && (
                <Text size="xs" color="blue" weight={500}>
                  • {notification.actionLabel}
                </Text>
              )}
            </Group>
          </Stack>
        </Group>

        <ActionIcon
          size="sm"
          variant="subtle"
          color="gray"
          onClick={handleDelete}
        >
          <IconTrash size={16} />
        </ActionIcon>
      </Group>
    </UnstyledButton>
  );
}

通知偏好設定頁面

// src/pages/Settings/NotificationSettings.tsx
import { useState, useEffect } from 'react';
import {
  Container,
  Paper,
  Title,
  Text,
  Stack,
  Switch,
  Group,
  Button,
  Divider,
  Alert,
  Card,
} from '@mantine/core';
import { IconBell, IconInfoCircle, IconCheck } from '@tabler/icons-react';
import { useNotifications } from '../../contexts/NotificationContext';
import { notifications } from '@mantine/notifications';

export function NotificationSettingsPage() {
  const { preferences, updatePreferences } = useNotifications();
  const [loading, setLoading] = useState(false);
  const [browserPermission, setBrowserPermission] = useState<NotificationPermission>('default');

  useEffect(() => {
    if ('Notification' in window) {
      setBrowserPermission(Notification.permission);
    }
  }, []);

  const handleRequestPermission = async () => {
    const permission = await Notification.requestPermission();
    setBrowserPermission(permission);

    if (permission === 'granted') {
      notifications.show({
        title: '權限已授予',
        message: '您將收到瀏覽器推送通知',
        color: 'green',
        icon: <IconCheck />,
      });
    }
  };

  const handleSave = async () => {
    setLoading(true);
    try {
      await updatePreferences(preferences!);
      notifications.show({
        title: '設定已儲存',
        message: '通知偏好已更新',
        color: 'green',
      });
    } catch (error) {
      notifications.show({
        title: '儲存失敗',
        message: '無法更新設定',
        color: 'red',
      });
    } finally {
      setLoading(false);
    }
  };

  if (!preferences) {
    return null;
  }

  return (
    <Container size="md" my={40}>
      <Stack spacing="xl">
        <div>
          <Title order={2} mb="xs">
            通知設定
          </Title>
          <Text color="dimmed">管理您的通知偏好</Text>
        </div>

        {/* 瀏覽器通知權限 */}
        {browserPermission !== 'granted' && (
          <Alert icon={<IconInfoCircle size={16} />} color="blue">
            <Group position="apart">
              <Text size="sm">
                啟用瀏覽器推送通知,即使未開啟網頁也能收到重要訊息
              </Text>
              <Button size="xs" onClick={handleRequestPermission}>
                啟用
              </Button>
            </Group>
          </Alert>
        )}

        {/* 通知管道 */}
        <Paper withBorder p="lg">
          <Text weight={500} mb="md">
            通知管道
          </Text>

          <Stack spacing="md">
            <Group position="apart">
              <div>
                <Text size="sm" weight={500}>
                  應用內通知
                </Text>
                <Text size="xs" color="dimmed">
                  在網頁右上角顯示通知訊息
                </Text>
              </div>
              <Switch
                checked={preferences.inApp}
                onChange={(e) =>
                  updatePreferences({ inApp: e.currentTarget.checked })
                }
              />
            </Group>

            <Divider />

            <Group position="apart">
              <div>
                <Text size="sm" weight={500}>
                  瀏覽器推送
                </Text>
                <Text size="xs" color="dimmed">
                  即使未開啟網頁也能收到通知
                </Text>
              </div>
              <Switch
                checked={preferences.push}
                onChange={(e) =>
                  updatePreferences({ push: e.currentTarget.checked })
                }
                disabled={browserPermission !== 'granted'}
              />
            </Group>

            <Divider />

            <Group position="apart">
              <div>
                <Text size="sm" weight={500}>
                  電子信箱
                </Text>
                <Text size="xs" color="dimmed">
                  發送重要通知到您的信箱
                </Text>
              </div>
              <Switch
                checked={preferences.email}
                onChange={(e) =>
                  updatePreferences({ email: e.currentTarget.checked })
                }
              />
            </Group>

            <Divider />

            <Group position="apart">
              <div>
                <Text size="sm" weight={500}>
                  音效提示
                </Text>
                <Text size="xs" color="dimmed">
                  收到通知時播放聲音
                </Text>
              </div>
              <Switch
                checked={preferences.sound}
                onChange={(e) =>
                  updatePreferences({ sound: e.currentTarget.checked })
                }
              />
            </Group>
          </Stack>
        </Paper>

        {/* 通知類別 */}
        <Paper withBorder p="lg">
          <Text weight={500} mb="md">
            通知類別
          </Text>

          <Stack spacing="md">
            <Group position="apart">
              <div>
                <Text size="sm" weight={500}>
                  系統通知
                </Text>
                <Text size="xs" color="dimmed">
                  系統維護、更新等重要訊息
                </Text>
              </div>
              <Switch
                checked={preferences.categories.system}
                onChange={(e) =>
                  updatePreferences({
                    categories: {
                      ...preferences.categories,
                      system: e.currentTarget.checked,
                    },
                  })
                }
              />
            </Group>

            <Divider />

            <Group position="apart">
              <div>
                <Text size="sm" weight={500}>
                  安全通知
                </Text>
                <Text size="xs" color="dimmed">
                  登入、密碼變更等安全相關事件
                </Text>
              </div>
              <Switch
                checked={preferences.categories.security}
                onChange={(e) =>
                  updatePreferences({
                    categories: {
                      ...preferences.categories,
                      security: e.currentTarget.checked,
                    },
                  })
                }
              />
            </Group>

            <Divider />

            <Group position="apart">
              <div>
                <Text size="sm" weight={500}>
                  功能更新
                </Text>
                <Text size="xs" color="dimmed">
                  新功能、改進等產品更新資訊
                </Text>
              </div>
              <Switch
                checked={preferences.categories.updates}
                onChange={(e) =>
                  updatePreferences({
                    categories: {
                      ...preferences.categories,
                      updates: e.currentTarget.checked,
                    },
                  })
                }
              />
            </Group>

            <Divider />

            <Group position="apart">
              <div>
                <Text size="sm" weight={500}>
                  行銷訊息
                </Text>
                <Text size="xs" color="dimmed">
                  優惠、活動等行銷資訊
                </Text>
              </div>
              <Switch
                checked={preferences.categories.marketing}
                onChange={(e) =>
                  updatePreferences({
                    categories: {
                      ...preferences.categories,
                      marketing: e.currentTarget.checked,
                    },
                  })
                }
              />
            </Group>
          </Stack>
        </Paper>

        <Button onClick={handleSave} loading={loading} leftIcon={<IconCheck />}>
          儲存設定
        </Button>
      </Stack>
    </Container>
  );
}

今日總結

我們今天完成了 Kyo System 的即時通知系統:

核心功能

  1. Socket.io 整合: 完整的 React Hook 封裝
  2. 通知中心: 優雅的 UI 與互動設計
  3. 多管道通知: 應用內、瀏覽器、信箱
  4. 通知分類: 系統、安全、更新、行銷
  5. 偏好管理: 細緻的用戶控制
  6. 瀏覽器通知: Notification API 整合
  7. 音效震動: 多感官提示

技術分析

WebSocket vs SSE:

  • WebSocket: 雙向、複雜應用
  • SSE: 單向、簡單推送
  • 💡 Kyo 選擇 Socket.io (WebSocket 封裝)

Socket.io 優勢:

  • 自動降級 (WebSocket → Polling)
  • 房間與命名空間 (多租戶隔離)
  • 重連機制
  • 💡 企業級可靠性

通知優先級設計:

  • Low: 靜默通知
  • Normal: 應用內提示
  • High: 音效 + 瀏覽器通知
  • Urgent: 強制互動 + 震動
  • 💡 平衡體驗與重要性

離線通知策略:

  • Service Worker 快取
  • 重連後同步
  • 本地儲存備份
  • 💡 確保不遺漏

即時通知檢查清單

  • ✅ Socket.io 客戶端整合
  • ✅ 認證與授權
  • ✅ 重連機制
  • ✅ 通知資料模型
  • ✅ 通知中心 UI
  • ✅ 未讀計數 Badge
  • ✅ 標記已讀/刪除
  • ✅ 瀏覽器通知
  • ✅ 音效與震動
  • ✅ 偏好設定
  • ✅ 分類篩選

上一篇
Day 26: 30天打造SaaS產品前端篇-多因素認證 (MFA) 與帳號安全強化
系列文
30 天製作工作室 SaaS 產品 (前端篇)27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言