feat: 实现浏览器通知提醒功能
- 添加 Service Worker 支持后台定时检查提醒 - 实现浏览器通知 API 集成 - 添加设置页面通知开关和测试功能 - 创建同步服务自动将提醒同步到 SW - 优化提醒检查逻辑(30秒间隔,10分钟宽限期) 文件变更: - public/sw.js: Service Worker 主文件 - public/sw-register.ts: SW 注册脚本 - public/manifest.json: PWA 清单文件 - src/services/notification.ts: 通知权限管理 - src/services/swSync.ts: 提醒同步服务 - src/stores/index.ts: 添加同步调用 - src/pages/SettingsPage.tsx: 添加通知开关和测试按钮 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ab12b0717f
commit
2feb02becf
11
index.html
11
index.html
@ -4,10 +4,21 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#faf9f7" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<title>掐日子 - AI 纪念日提醒</title>
|
<title>掐日子 - AI 纪念日提醒</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||||
|
console.log('SW registration failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
4311
package-lock.json
generated
4311
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -42,6 +42,7 @@
|
|||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.4",
|
"typescript-eslint": "^8.46.4",
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.4",
|
||||||
|
"vite-plugin-pwa": "^1.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
public/manifest.json
Normal file
24
public/manifest.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "掐日子 - AI 纪念日提醒",
|
||||||
|
"short_name": "掐日子",
|
||||||
|
"description": "智能纪念日和提醒管理应用",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#faf9f7",
|
||||||
|
"theme_color": "#faf9f7",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"scope": "/",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/favicon.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/favicon.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
76
public/sw-register.ts
Normal file
76
public/sw-register.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
// Service Worker 注册与生命周期管理
|
||||||
|
|
||||||
|
export async function registerServiceWorker(): Promise<ServiceWorkerRegistration | null> {
|
||||||
|
if (!('serviceWorker' in navigator)) {
|
||||||
|
console.warn('Service Worker 不支持');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.register('/sw.js', {
|
||||||
|
scope: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('SW 注册成功:', registration.scope);
|
||||||
|
|
||||||
|
// 检查更新
|
||||||
|
registration.addEventListener('updatefound', () => {
|
||||||
|
const newWorker = registration.installing;
|
||||||
|
if (newWorker) {
|
||||||
|
newWorker.addEventListener('statechange', () => {
|
||||||
|
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||||
|
// 新版本可用,通知用户刷新
|
||||||
|
console.log('新版本 Service Worker 可用');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return registration;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SW 注册失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unregisterServiceWorker(): Promise<boolean> {
|
||||||
|
if (!('serviceWorker' in navigator)) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
return registration.unregister();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SW 注销失败:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startPeriodicSync(): Promise<void> {
|
||||||
|
// 发送消息启动定时检查
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
navigator.serviceWorker.controller.postMessage({ type: 'START_CHECK' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopPeriodicSync(): Promise<void> {
|
||||||
|
// 发送消息停止定时检查
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
navigator.serviceWorker.controller.postMessage({ type: 'STOP_CHECK' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEventsToSW(events: any[]): Promise<void> {
|
||||||
|
if (!navigator.serviceWorker.controller) return;
|
||||||
|
|
||||||
|
navigator.serviceWorker.controller.postMessage({
|
||||||
|
type: 'UPDATE_EVENTS',
|
||||||
|
events,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function skipWaiting(): Promise<void> {
|
||||||
|
const registration = await navigator.serviceWorker.getRegistration();
|
||||||
|
if (registration?.waiting) {
|
||||||
|
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||||
|
}
|
||||||
|
}
|
||||||
268
public/sw.js
Normal file
268
public/sw.js
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
// Service Worker - 纯 JavaScript 版本
|
||||||
|
|
||||||
|
// 定时检查间隔(毫秒)
|
||||||
|
const CHECK_INTERVAL = 30 * 1000; // 30秒检查一次(更频繁)
|
||||||
|
|
||||||
|
// 已发送的通知标签集合(避免重复发送)
|
||||||
|
const sentNotifications = new Set();
|
||||||
|
|
||||||
|
// 从 IndexedDB 加载提醒事件
|
||||||
|
async function loadEvents() {
|
||||||
|
try {
|
||||||
|
const db = await openDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction('events', 'readonly');
|
||||||
|
const store = tx.objectStore('events');
|
||||||
|
const request = store.getAll();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开 IndexedDB
|
||||||
|
function openDB() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open('qia-notifications', 1);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = event.target.result;
|
||||||
|
if (!db.objectStoreNames.contains('events')) {
|
||||||
|
db.createObjectStore('events', { keyPath: 'id' });
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains('sent-notifications')) {
|
||||||
|
db.createObjectStore('sent-notifications', { keyPath: 'tag' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有提醒时间到达
|
||||||
|
function checkReminders(events) {
|
||||||
|
const now = new Date();
|
||||||
|
const notifications = [];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.type !== 'reminder') continue;
|
||||||
|
if (event.is_completed) continue;
|
||||||
|
if (!event.reminder_times || event.reminder_times.length === 0) continue;
|
||||||
|
|
||||||
|
for (const reminderTime of event.reminder_times) {
|
||||||
|
const rt = new Date(reminderTime);
|
||||||
|
// 检查是否在最近10分钟内(宽限期,处理后台运行不稳定的情况)
|
||||||
|
const diffMs = now.getTime() - rt.getTime();
|
||||||
|
const diffMinutes = diffMs / (1000 * 60);
|
||||||
|
|
||||||
|
// 跳过还未到的提醒
|
||||||
|
if (diffMinutes < 0) continue;
|
||||||
|
|
||||||
|
// 如果在10分钟宽限期内,且还没发送过通知
|
||||||
|
if (diffMinutes < 10) {
|
||||||
|
const tag = `reminder-${event.id}-${reminderTime}`;
|
||||||
|
|
||||||
|
// 避免重复发送同一通知
|
||||||
|
if (!sentNotifications.has(tag)) {
|
||||||
|
sentNotifications.add(tag);
|
||||||
|
notifications.push({
|
||||||
|
id: `${event.id}-${Date.now()}`,
|
||||||
|
title: event.title,
|
||||||
|
body: event.content || getDefaultBodyText(event.date),
|
||||||
|
tag,
|
||||||
|
data: { eventId: event.id, reminderTime },
|
||||||
|
timestamp: now.getTime(),
|
||||||
|
});
|
||||||
|
console.log('SW: 找到到期提醒:', event.title, '- 差值(分钟):', diffMinutes.toFixed(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return notifications;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取默认通知正文
|
||||||
|
function getDefaultBodyText(dateStr) {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示浏览器通知
|
||||||
|
async function showNotification(notification) {
|
||||||
|
if (Notification.permission !== 'granted') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await self.registration.showNotification(notification.title, {
|
||||||
|
body: notification.body,
|
||||||
|
icon: '/favicon.png',
|
||||||
|
badge: '/favicon.png',
|
||||||
|
tag: notification.tag,
|
||||||
|
data: notification.data,
|
||||||
|
requireInteraction: true,
|
||||||
|
actions: [
|
||||||
|
{ action: 'view', title: '查看' },
|
||||||
|
{ action: 'dismiss', title: '关闭' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
console.log('SW: 通知已发送:', notification.title);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SW: Failed to show notification:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主检查函数
|
||||||
|
async function performCheck() {
|
||||||
|
try {
|
||||||
|
const events = await loadEvents();
|
||||||
|
const notifications = checkReminders(events);
|
||||||
|
|
||||||
|
if (notifications.length > 0) {
|
||||||
|
console.log('SW: 检查提醒,已加载事件数:', events.length, '将发送通知数:', notifications.length);
|
||||||
|
for (const notification of notifications) {
|
||||||
|
await showNotification(notification);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SW: Reminder check failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定时检查
|
||||||
|
let checkTimer = null;
|
||||||
|
|
||||||
|
function startPeriodicCheck() {
|
||||||
|
if (checkTimer) clearInterval(checkTimer);
|
||||||
|
// 立即执行一次
|
||||||
|
performCheck();
|
||||||
|
// 然后定期执行
|
||||||
|
checkTimer = self.setInterval(performCheck, CHECK_INTERVAL);
|
||||||
|
console.log('SW: 定时检查已启动,间隔', CHECK_INTERVAL / 1000, '秒');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPeriodicCheck() {
|
||||||
|
if (checkTimer) {
|
||||||
|
clearInterval(checkTimer);
|
||||||
|
checkTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消息处理
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
console.log('SW: 收到消息', event.data);
|
||||||
|
|
||||||
|
if (event.data.type === 'SKIP_WAITING') {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data.type === 'START_CHECK') {
|
||||||
|
startPeriodicCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data.type === 'STOP_CHECK') {
|
||||||
|
stopPeriodicCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data.type === 'UPDATE_EVENTS') {
|
||||||
|
// 事件更新,保存到 IndexedDB 并立即检查
|
||||||
|
saveEventsToDB(event.data.events).then(() => {
|
||||||
|
console.log('SW: 数据已更新,立即检查...');
|
||||||
|
performCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data.type === 'TRIGGER_CHECK') {
|
||||||
|
console.log('SW: 收到 TRIGGER_CHECK,手动执行检查');
|
||||||
|
performCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data.type === 'GET_SENT_TAGS') {
|
||||||
|
if (event.ports && event.ports[0]) {
|
||||||
|
event.ports[0].postMessage({
|
||||||
|
type: 'SENT_TAGS',
|
||||||
|
tags: Array.from(sentNotifications)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data.type === 'CLEAR_SENT_NOTIFICATIONS') {
|
||||||
|
sentNotifications.clear();
|
||||||
|
console.log('SW: 已清除已发送通知记录');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存事件到 IndexedDB
|
||||||
|
async function saveEventsToDB(events) {
|
||||||
|
try {
|
||||||
|
const db = await openDB();
|
||||||
|
const tx = db.transaction('events', 'readwrite');
|
||||||
|
const store = tx.objectStore('events');
|
||||||
|
await store.clear();
|
||||||
|
for (const event of events) {
|
||||||
|
await store.put(event);
|
||||||
|
}
|
||||||
|
console.log('SW: 已保存', events.length, '个事件到 IndexedDB');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SW: Failed to save events:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安装
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
console.log('SW: Installing...');
|
||||||
|
event.waitUntil(self.skipWaiting());
|
||||||
|
});
|
||||||
|
|
||||||
|
// 激活
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
console.log('SW: Activating...');
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.claim().then(() => {
|
||||||
|
console.log('SW: 已获取控制权,启动检查...');
|
||||||
|
startPeriodicCheck();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 通知点击处理
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
if (event.action === 'view') {
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll({ type: 'window' }).then((clientList) => {
|
||||||
|
for (const client of clientList) {
|
||||||
|
if (client.url.includes(self.registration.scope) && 'focus' in client) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (self.clients.openWindow) {
|
||||||
|
return self.clients.openWindow('/');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 定期清理过期的 sentNotifications(保留24小时)
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
// 简单清理:只保留最近的通知记录
|
||||||
|
if (sentNotifications.size > 1000) {
|
||||||
|
const iterator = sentNotifications.keys();
|
||||||
|
while (sentNotifications.size > 500) {
|
||||||
|
sentNotifications.delete(iterator.next().value);
|
||||||
|
}
|
||||||
|
console.log('SW: 已清理过期的通知记录');
|
||||||
|
}
|
||||||
|
}, 60 * 60 * 1000); // 每小时清理一次
|
||||||
|
|
||||||
|
// 启动定时检查
|
||||||
|
startPeriodicCheck();
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Title,
|
Title,
|
||||||
@ -8,15 +8,21 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Group,
|
Group,
|
||||||
Button,
|
Button,
|
||||||
|
Loader,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconArrowLeft, IconSettings } from '@tabler/icons-react';
|
import { IconArrowLeft, IconSettings, IconBell, IconRefresh, IconBellCheck } from '@tabler/icons-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAppStore } from '../stores';
|
import { useAppStore } from '../stores';
|
||||||
|
import { requestNotificationPermission, getNotificationPermission, isNotificationSupported } from '../services/notification';
|
||||||
|
import { syncRemindersToSW, triggerSWCheck } from '../services/swSync';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const settings = useAppStore((state) => state.settings);
|
const settings = useAppStore((state) => state.settings);
|
||||||
const updateSettings = useAppStore((state) => state.updateSettings);
|
const updateSettings = useAppStore((state) => state.updateSettings);
|
||||||
|
const [permissionStatus, setPermissionStatus] = useState<'granted' | 'denied' | 'default'>('default');
|
||||||
|
const [isRequesting, setIsRequesting] = useState(false);
|
||||||
|
|
||||||
// 页面加载时检查登录状态
|
// 页面加载时检查登录状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -26,6 +32,97 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
|
// 初始化权限状态
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNotificationSupported()) {
|
||||||
|
setPermissionStatus(getNotificationPermission());
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 处理浏览器通知开关
|
||||||
|
const handleBrowserNotificationToggle = async (enabled: boolean) => {
|
||||||
|
if (!isNotificationSupported()) {
|
||||||
|
notifications.show({
|
||||||
|
title: '不支持通知',
|
||||||
|
message: '您的浏览器不支持通知功能',
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
// 请求权限
|
||||||
|
setIsRequesting(true);
|
||||||
|
const permission = await requestNotificationPermission();
|
||||||
|
setIsRequesting(false);
|
||||||
|
setPermissionStatus(permission);
|
||||||
|
|
||||||
|
if (permission === 'granted') {
|
||||||
|
updateSettings({ browserNotifications: true });
|
||||||
|
// 同步所有提醒到 Service Worker
|
||||||
|
await syncRemindersToSW();
|
||||||
|
notifications.show({
|
||||||
|
title: '通知已开启',
|
||||||
|
message: '您将收到浏览器的提醒通知',
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 权限被拒绝
|
||||||
|
notifications.show({
|
||||||
|
title: '无法开启通知',
|
||||||
|
message: '请在浏览器设置中允许通知权限',
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateSettings({ browserNotifications: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 同步提醒到 SW
|
||||||
|
const handleSyncReminders = async () => {
|
||||||
|
await syncRemindersToSW();
|
||||||
|
notifications.show({
|
||||||
|
title: '同步完成',
|
||||||
|
message: '提醒已同步到 Service Worker',
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 手动触发 SW 检查
|
||||||
|
const handleTriggerCheck = async () => {
|
||||||
|
await triggerSWCheck();
|
||||||
|
notifications.show({
|
||||||
|
title: '检查已触发',
|
||||||
|
message: 'Service Worker 将立即检查提醒',
|
||||||
|
color: 'blue',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发送测试通知
|
||||||
|
const handleTestNotification = async () => {
|
||||||
|
if (Notification.permission !== 'granted') {
|
||||||
|
notifications.show({
|
||||||
|
title: '请先开启通知',
|
||||||
|
message: '需要先允许通知权限',
|
||||||
|
color: 'yellow',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
new Notification('测试通知', {
|
||||||
|
body: '这是一条测试通知,通知功能正常工作',
|
||||||
|
icon: '/favicon.png',
|
||||||
|
tag: 'test-notification',
|
||||||
|
});
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
title: '测试通知已发送',
|
||||||
|
message: '请查看浏览器通知',
|
||||||
|
color: 'blue',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -84,6 +181,76 @@ export function SettingsPage() {
|
|||||||
color="#1a1a1a"
|
color="#1a1a1a"
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{/* 浏览器通知设置 */}
|
||||||
|
{isNotificationSupported() && (
|
||||||
|
<Group justify="space-between" style={{ marginTop: 16 }}>
|
||||||
|
<Group gap="sm">
|
||||||
|
<IconBell size={18} color="#666" />
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text size="sm" fw={400} style={{ letterSpacing: '0.05em', color: '#1a1a1a' }}>
|
||||||
|
浏览器通知
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="#999" style={{ letterSpacing: '0.03em' }}>
|
||||||
|
{permissionStatus === 'granted'
|
||||||
|
? '已开启 - 通过系统通知提醒您的重要事项'
|
||||||
|
: permissionStatus === 'denied'
|
||||||
|
? '已拒绝 - 请在浏览器设置中允许通知'
|
||||||
|
: '通过系统通知提醒您的重要事项'}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
{isRequesting ? (
|
||||||
|
<Loader size="xs" />
|
||||||
|
) : (
|
||||||
|
<Switch
|
||||||
|
checked={settings.browserNotifications && permissionStatus === 'granted'}
|
||||||
|
onChange={(e) => handleBrowserNotificationToggle(e.currentTarget.checked)}
|
||||||
|
disabled={permissionStatus === 'denied'}
|
||||||
|
size="sm"
|
||||||
|
color="#1a1a1a"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 通知功能测试按钮 */}
|
||||||
|
{settings.browserNotifications && permissionStatus === 'granted' && (
|
||||||
|
<Stack gap="sm" style={{ marginTop: 16 }}>
|
||||||
|
<Text size="sm" fw={400} style={{ letterSpacing: '0.05em', color: '#1a1a1a' }}>
|
||||||
|
通知测试
|
||||||
|
</Text>
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<IconRefresh size={14} />}
|
||||||
|
onClick={handleSyncReminders}
|
||||||
|
style={{ letterSpacing: '0.05em' }}
|
||||||
|
>
|
||||||
|
同步提醒
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<IconBellCheck size={14} />}
|
||||||
|
onClick={handleTriggerCheck}
|
||||||
|
style={{ letterSpacing: '0.05em' }}
|
||||||
|
>
|
||||||
|
立即检查
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<IconBell size={14} />}
|
||||||
|
onClick={handleTestNotification}
|
||||||
|
style={{ letterSpacing: '0.05em' }}
|
||||||
|
>
|
||||||
|
测试通知
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
89
src/services/notification.ts
Normal file
89
src/services/notification.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
// 浏览器通知权限管理服务
|
||||||
|
|
||||||
|
export type NotificationPermission = 'granted' | 'denied' | 'default';
|
||||||
|
|
||||||
|
// 检查当前权限状态
|
||||||
|
export function getNotificationPermission(): NotificationPermission {
|
||||||
|
if (!('Notification' in window)) {
|
||||||
|
return 'denied';
|
||||||
|
}
|
||||||
|
return Notification.permission as NotificationPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求通知权限
|
||||||
|
export async function requestNotificationPermission(): Promise<NotificationPermission> {
|
||||||
|
if (!('Notification' in window)) {
|
||||||
|
console.warn('浏览器不支持通知 API');
|
||||||
|
return 'denied';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
return permission as NotificationPermission;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('请求通知权限失败:', error);
|
||||||
|
return 'denied';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送前台通知
|
||||||
|
export function showForegroundNotification(
|
||||||
|
title: string,
|
||||||
|
options?: NotificationOptions
|
||||||
|
): Notification | null {
|
||||||
|
const permission = getNotificationPermission();
|
||||||
|
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
console.warn('通知权限未授予');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const notification = new Notification(title, {
|
||||||
|
icon: '/favicon.png',
|
||||||
|
badge: '/favicon.png',
|
||||||
|
requireInteraction: true,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
notification.onclick = () => {
|
||||||
|
window.focus();
|
||||||
|
notification.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return notification;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发送通知失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查通知支持
|
||||||
|
export function isNotificationSupported(): boolean {
|
||||||
|
return 'Notification' in window;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取权限状态描述
|
||||||
|
export function getPermissionStatusText(permission: NotificationPermission): string {
|
||||||
|
switch (permission) {
|
||||||
|
case 'granted':
|
||||||
|
return '已开启';
|
||||||
|
case 'denied':
|
||||||
|
return '已拒绝';
|
||||||
|
default:
|
||||||
|
return '未设置';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送提醒通知
|
||||||
|
export function sendReminderNotification(
|
||||||
|
title: string,
|
||||||
|
body: string,
|
||||||
|
tag?: string
|
||||||
|
): Notification | null {
|
||||||
|
return showForegroundNotification(title, {
|
||||||
|
body,
|
||||||
|
tag: tag || `reminder-${Date.now()}`,
|
||||||
|
requireInteraction: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
98
src/services/swSync.ts
Normal file
98
src/services/swSync.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
// Service Worker 同步服务
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步提醒事件到 Service Worker 的 IndexedDB
|
||||||
|
*/
|
||||||
|
export async function syncRemindersToSW(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 动态导入 api
|
||||||
|
const { api } = await import('./api');
|
||||||
|
|
||||||
|
// 获取当前用户的所有提醒事件
|
||||||
|
const events = await api.events.list('reminder');
|
||||||
|
|
||||||
|
// 获取 SW 注册
|
||||||
|
if (!navigator.serviceWorker || !navigator.serviceWorker.controller) {
|
||||||
|
console.warn('SW: Service Worker 未注册,无法同步');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送事件数据到 SW
|
||||||
|
navigator.serviceWorker.controller.postMessage({
|
||||||
|
type: 'UPDATE_EVENTS',
|
||||||
|
events: events
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('SW: 已同步', events.length, '个提醒事件到 SW');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SW: 同步失败', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 SW 中已发送的通知标签
|
||||||
|
*/
|
||||||
|
export async function getSentNotificationTags(): Promise<Set<string>> {
|
||||||
|
try {
|
||||||
|
if (!navigator.serviceWorker || !navigator.serviceWorker.controller) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const channel = new MessageChannel();
|
||||||
|
channel.port1.onmessage = (e) => {
|
||||||
|
resolve(new Set(e.data.tags || []));
|
||||||
|
};
|
||||||
|
|
||||||
|
navigator.serviceWorker.controller.postMessage(
|
||||||
|
{ type: 'GET_SENT_TAGS' },
|
||||||
|
[channel.port2]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 超时返回空集合
|
||||||
|
setTimeout(() => resolve(new Set()), 1000);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SW: 获取已发送标签失败', error);
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除 SW 中的已发送通知记录(用于测试)
|
||||||
|
*/
|
||||||
|
export async function clearSentNotifications(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!navigator.serviceWorker || !navigator.serviceWorker.controller) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.serviceWorker.controller.postMessage({
|
||||||
|
type: 'CLEAR_SENT_NOTIFICATIONS'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('SW: 已清除已发送通知记录');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SW: 清除失败', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动触发 SW 检查
|
||||||
|
*/
|
||||||
|
export async function triggerSWCheck(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!navigator.serviceWorker || !navigator.serviceWorker.controller) {
|
||||||
|
console.warn('SW: Service Worker 未注册');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.serviceWorker.controller.postMessage({
|
||||||
|
type: 'TRIGGER_CHECK'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('SW: 已触发检查');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SW: 触发检查失败', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,16 +2,19 @@ import { create } from 'zustand';
|
|||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import type { User, Event, Note, AIConversation, EventType, RepeatType } from '../types';
|
import type { User, Event, Note, AIConversation, EventType, RepeatType } from '../types';
|
||||||
import { api } from '../services/api';
|
import { api } from '../services/api';
|
||||||
|
import { syncRemindersToSW } from '../services/swSync';
|
||||||
import { calculateNextReminderDate, findNextValidReminderDate, isDuplicateReminder, createNextRecurringEventData } from '../utils/repeatCalculator';
|
import { calculateNextReminderDate, findNextValidReminderDate, isDuplicateReminder, createNextRecurringEventData } from '../utils/repeatCalculator';
|
||||||
|
|
||||||
// 应用设置类型
|
// 应用设置类型
|
||||||
interface AppSettings {
|
interface AppSettings {
|
||||||
showHolidays: boolean; // 是否显示节假日
|
showHolidays: boolean; // 是否显示节假日
|
||||||
|
browserNotifications: boolean; // 是否启用浏览器通知
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认设置
|
// 默认设置
|
||||||
const defaultSettings: AppSettings = {
|
const defaultSettings: AppSettings = {
|
||||||
showHolidays: true,
|
showHolidays: true,
|
||||||
|
browserNotifications: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
@ -127,6 +130,12 @@ export const useAppStore = create<AppState>()(
|
|||||||
try {
|
try {
|
||||||
const newEvent = await api.events.create(event);
|
const newEvent = await api.events.create(event);
|
||||||
set((state) => ({ events: [...state.events, newEvent] }));
|
set((state) => ({ events: [...state.events, newEvent] }));
|
||||||
|
|
||||||
|
// 如果是提醒事件,同步到 Service Worker
|
||||||
|
if (event.type === 'reminder' && event.reminder_times && event.reminder_times.length > 0) {
|
||||||
|
await syncRemindersToSW();
|
||||||
|
}
|
||||||
|
|
||||||
return { error: null };
|
return { error: null };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return { error: error.message || '创建失败' };
|
return { error: error.message || '创建失败' };
|
||||||
@ -211,6 +220,11 @@ export const useAppStore = create<AppState>()(
|
|||||||
await api.events.update(id, event);
|
await api.events.update(id, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果更新涉及提醒设置,同步到 Service Worker
|
||||||
|
if (event.reminder_times || event.type === 'reminder') {
|
||||||
|
await syncRemindersToSW();
|
||||||
|
}
|
||||||
|
|
||||||
return { error: null };
|
return { error: null };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// 失败时回滚,重新获取数据
|
// 失败时回滚,重新获取数据
|
||||||
|
|||||||
115
src/utils/reminderChecker.ts
Normal file
115
src/utils/reminderChecker.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
// 提醒时间检查核心逻辑
|
||||||
|
|
||||||
|
import type { Event } from '../types';
|
||||||
|
|
||||||
|
interface ReminderCheckResult {
|
||||||
|
dueReminders: Event[];
|
||||||
|
now: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormattedReminderNotification {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
tag: string;
|
||||||
|
data?: { eventId: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkDueReminders(events: Event[]): ReminderCheckResult {
|
||||||
|
const now = new Date();
|
||||||
|
const dueReminders: Event[] = [];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.type !== 'reminder') continue;
|
||||||
|
if (event.is_completed) continue;
|
||||||
|
if (!event.reminder_times || event.reminder_times.length === 0) continue;
|
||||||
|
|
||||||
|
for (const reminderTime of event.reminder_times) {
|
||||||
|
const rt = new Date(reminderTime);
|
||||||
|
|
||||||
|
// 检查是否在当前时间点(允许1分钟内的误差)
|
||||||
|
const diffMs = now.getTime() - rt.getTime();
|
||||||
|
const diffMinutes = diffMs / (1000 * 60);
|
||||||
|
|
||||||
|
// 已到期的提醒(过去1分钟内)
|
||||||
|
if (diffMinutes >= 0 && diffMinutes < 1) {
|
||||||
|
if (!dueReminders.find(r => r.id === event.id)) {
|
||||||
|
dueReminders.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dueReminders, now };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化提醒通知内容
|
||||||
|
export function formatReminderNotification(event: Event, reminderTime?: string): FormattedReminderNotification {
|
||||||
|
const date = new Date(event.date);
|
||||||
|
const dateStr = date.toLocaleString('zh-CN', {
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: event.title,
|
||||||
|
body: event.content
|
||||||
|
? `${dateStr}\n${event.content}`
|
||||||
|
: dateStr,
|
||||||
|
tag: `reminder-${event.id}-${reminderTime || Date.now()}`,
|
||||||
|
data: { eventId: event.id },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取即将到来的提醒数量
|
||||||
|
export function getUpcomingCount(events: Event[], minutes: number = 60): number {
|
||||||
|
const now = new Date();
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.type !== 'reminder' || event.is_completed) continue;
|
||||||
|
if (!event.reminder_times || event.reminder_times.length === 0) continue;
|
||||||
|
|
||||||
|
for (const reminderTime of event.reminder_times) {
|
||||||
|
const rt = new Date(reminderTime);
|
||||||
|
const diffMinutes = (rt.getTime() - now.getTime()) / (1000 * 60);
|
||||||
|
|
||||||
|
if (diffMinutes > 0 && diffMinutes <= minutes) {
|
||||||
|
count++;
|
||||||
|
break; // 只计算一次
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有即将到来的提醒(用于显示状态)
|
||||||
|
export function hasUpcomingReminders(events: Event[], minutes: number = 60): boolean {
|
||||||
|
return getUpcomingCount(events, minutes) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有即将到来的提醒
|
||||||
|
export function getUpcomingReminders(events: Event[], minutes: number = 60): Event[] {
|
||||||
|
const now = new Date();
|
||||||
|
const upcoming: Event[] = [];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.type !== 'reminder' || event.is_completed) continue;
|
||||||
|
if (!event.reminder_times || event.reminder_times.length === 0) continue;
|
||||||
|
|
||||||
|
for (const reminderTime of event.reminder_times) {
|
||||||
|
const rt = new Date(reminderTime);
|
||||||
|
const diffMinutes = (rt.getTime() - now.getTime()) / (1000 * 60);
|
||||||
|
|
||||||
|
if (diffMinutes > 0 && diffMinutes <= minutes) {
|
||||||
|
upcoming.push(event);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return upcoming;
|
||||||
|
}
|
||||||
@ -1,9 +1,44 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['favicon.png'],
|
||||||
|
manifest: {
|
||||||
|
name: '掐日子 - AI 纪念日提醒',
|
||||||
|
short_name: '掐日子',
|
||||||
|
description: '智能纪念日和提醒管理应用',
|
||||||
|
theme_color: '#faf9f7',
|
||||||
|
background_color: '#faf9f7',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'portrait-primary',
|
||||||
|
scope: '/',
|
||||||
|
start_url: '/',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: '/favicon.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/favicon.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any maskable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
// 只缓存静态资源,不缓存 API 请求
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user