經過 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 支援完善
* - 豐富的生態系統
*/
// 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;
};
}
// 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;
}
// 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 的即時通知系統:
WebSocket vs SSE:
Socket.io 優勢:
通知優先級設計:
離線通知策略: