Compare commits
6 Commits
6fd1acc999
...
2feb02becf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2feb02becf | ||
|
|
ab12b0717f | ||
|
|
9a46aead11 | ||
|
|
289d81180d | ||
|
|
69953c43cf | ||
|
|
25f999cb1f |
17
index.html
17
index.html
@ -1,13 +1,24 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<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" />
|
||||||
<title>client</title>
|
<meta name="theme-color" content="#faf9f7" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
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();
|
||||||
@ -26,6 +26,7 @@ import { AnniversaryList } from '../components/anniversary/AnniversaryList';
|
|||||||
import { ReminderList } from '../components/reminder/ReminderList';
|
import { ReminderList } from '../components/reminder/ReminderList';
|
||||||
import { NoteEditor } from '../components/note/NoteEditor';
|
import { NoteEditor } from '../components/note/NoteEditor';
|
||||||
import { FloatingAIChat } from '../components/ai/FloatingAIChat';
|
import { FloatingAIChat } from '../components/ai/FloatingAIChat';
|
||||||
|
import appIcon from '../assets/icon.png';
|
||||||
import type { Event, EventType, RepeatType, PriorityType } from '../types';
|
import type { Event, EventType, RepeatType, PriorityType } from '../types';
|
||||||
import { calculateNextReminderDate, getReminderOptions, getDefaultReminderValue, calculateReminderTimes, getReminderValueFromTimes, formatReminderTimeDisplay } from '../utils/repeatCalculator';
|
import { calculateNextReminderDate, getReminderOptions, getDefaultReminderValue, calculateReminderTimes, getReminderValueFromTimes, formatReminderTimeDisplay } from '../utils/repeatCalculator';
|
||||||
|
|
||||||
@ -307,17 +308,28 @@ export function HomePage() {
|
|||||||
<Container size="xl" py="md" h="100vh" style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
<Container size="xl" py="md" h="100vh" style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Group justify="space-between" mb="md" style={{ flexShrink: 0 }}>
|
<Group justify="space-between" mb="md" style={{ flexShrink: 0 }}>
|
||||||
<Title
|
<Group gap="sm">
|
||||||
order={2}
|
<img
|
||||||
style={{
|
src={appIcon}
|
||||||
fontWeight: 300,
|
alt="掐日子"
|
||||||
fontSize: '1.25rem',
|
style={{
|
||||||
letterSpacing: '0.15em',
|
width: 28,
|
||||||
color: '#1a1a1a',
|
height: 28,
|
||||||
}}
|
borderRadius: '50%',
|
||||||
>
|
}}
|
||||||
掐日子
|
/>
|
||||||
</Title>
|
<Title
|
||||||
|
order={2}
|
||||||
|
style={{
|
||||||
|
fontWeight: 300,
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
letterSpacing: '0.15em',
|
||||||
|
color: '#1a1a1a',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
掐日子
|
||||||
|
</Title>
|
||||||
|
</Group>
|
||||||
<Group>
|
<Group>
|
||||||
{/* 设置入口 */}
|
{/* 设置入口 */}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -22,12 +22,10 @@ function ZenBackground() {
|
|||||||
resize();
|
resize();
|
||||||
window.addEventListener('resize', resize);
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
// 简化的伪随机
|
|
||||||
const random = (min = 0, max = 1) => {
|
const random = (min = 0, max = 1) => {
|
||||||
return min + Math.random() * (max - min);
|
return min + Math.random() * (max - min);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 简化的噪声函数
|
|
||||||
const noise = (x: number, y: number, t: number) => {
|
const noise = (x: number, y: number, t: number) => {
|
||||||
return (Math.sin(x * 0.01 + t) * Math.cos(y * 0.01 + t * 0.7) + 1) * 0.5;
|
return (Math.sin(x * 0.01 + t) * Math.cos(y * 0.01 + t * 0.7) + 1) * 0.5;
|
||||||
};
|
};
|
||||||
@ -48,9 +46,8 @@ function ZenBackground() {
|
|||||||
let strokes: InkStroke[] = [];
|
let strokes: InkStroke[] = [];
|
||||||
let time = 0;
|
let time = 0;
|
||||||
let lastStrokeTime = 0;
|
let lastStrokeTime = 0;
|
||||||
const strokeInterval = 30; // 每隔一段时间生成新笔触
|
const strokeInterval = 30;
|
||||||
|
|
||||||
// 生成随机笔触
|
|
||||||
const createStroke = (): InkStroke | null => {
|
const createStroke = (): InkStroke | null => {
|
||||||
const angle = random(0, Math.PI * 2);
|
const angle = random(0, Math.PI * 2);
|
||||||
const radius = random(canvas.width * 0.1, canvas.width * 0.4);
|
const radius = random(canvas.width * 0.1, canvas.width * 0.4);
|
||||||
@ -65,19 +62,17 @@ function ZenBackground() {
|
|||||||
speed: random(0.4, 1.0),
|
speed: random(0.4, 1.0),
|
||||||
inkAlpha: random(10, 30),
|
inkAlpha: random(10, 30),
|
||||||
baseWeight: random(0.3, 1.5),
|
baseWeight: random(0.3, 1.5),
|
||||||
maxLength: random(30, 90), // 缩短笔触长度,加快消失
|
maxLength: random(30, 90),
|
||||||
currentLength: 0,
|
currentLength: 0,
|
||||||
complete: false,
|
complete: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// 初始化一些笔触
|
|
||||||
const initStrokes = () => {
|
const initStrokes = () => {
|
||||||
strokes = [];
|
strokes = [];
|
||||||
for (let i = 0; i < 8; i++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
const stroke = createStroke();
|
const stroke = createStroke();
|
||||||
if (stroke) {
|
if (stroke) {
|
||||||
// 随机偏移起始位置
|
|
||||||
stroke.currentLength = random(0, stroke.maxLength * 0.5);
|
stroke.currentLength = random(0, stroke.maxLength * 0.5);
|
||||||
strokes.push(stroke);
|
strokes.push(stroke);
|
||||||
}
|
}
|
||||||
@ -86,25 +81,20 @@ function ZenBackground() {
|
|||||||
|
|
||||||
initStrokes();
|
initStrokes();
|
||||||
|
|
||||||
// 清空画布
|
|
||||||
ctx.fillStyle = '#faf9f7';
|
ctx.fillStyle = '#faf9f7';
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
const animate = () => {
|
const animate = () => {
|
||||||
time += 0.008;
|
time += 0.008;
|
||||||
|
|
||||||
// 快速淡出背景 - 让水墨痕迹更快消失
|
|
||||||
if (Math.floor(time * 125) % 3 === 0) {
|
if (Math.floor(time * 125) % 3 === 0) {
|
||||||
ctx.fillStyle = 'rgba(250, 249, 247, 0.12)';
|
ctx.fillStyle = 'rgba(250, 249, 247, 0.12)';
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定期生成新笔触
|
|
||||||
if (time - lastStrokeTime > strokeInterval * 0.01) {
|
if (time - lastStrokeTime > strokeInterval * 0.01) {
|
||||||
lastStrokeTime = time;
|
lastStrokeTime = time;
|
||||||
// 移除已完成太久的笔触
|
|
||||||
strokes = strokes.filter(s => !s.complete || s.currentLength < s.maxLength);
|
strokes = strokes.filter(s => !s.complete || s.currentLength < s.maxLength);
|
||||||
// 添加新笔触(随机数量)
|
|
||||||
const newCount = Math.floor(random(0, 2));
|
const newCount = Math.floor(random(0, 2));
|
||||||
for (let i = 0; i < newCount; i++) {
|
for (let i = 0; i < newCount; i++) {
|
||||||
const stroke = createStroke();
|
const stroke = createStroke();
|
||||||
@ -112,22 +102,18 @@ function ZenBackground() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新和绘制笔触
|
|
||||||
for (const stroke of strokes) {
|
for (const stroke of strokes) {
|
||||||
if (stroke.complete) continue;
|
if (stroke.complete) continue;
|
||||||
|
|
||||||
// 噪声驱动
|
|
||||||
const n = noise(stroke.x, stroke.y, time);
|
const n = noise(stroke.x, stroke.y, time);
|
||||||
stroke.angle += (n - 0.5) * 0.12;
|
stroke.angle += (n - 0.5) * 0.12;
|
||||||
|
|
||||||
// 呼吸感 - 更柔和
|
|
||||||
const breath = Math.sin(time * 1.5 + stroke.x * 0.01) * 0.25;
|
const breath = Math.sin(time * 1.5 + stroke.x * 0.01) * 0.25;
|
||||||
const currentSpeed = stroke.speed * (1 + breath * 0.2);
|
const currentSpeed = stroke.speed * (1 + breath * 0.2);
|
||||||
|
|
||||||
stroke.x += Math.cos(stroke.angle) * currentSpeed;
|
stroke.x += Math.cos(stroke.angle) * currentSpeed;
|
||||||
stroke.y += Math.sin(stroke.angle) * currentSpeed;
|
stroke.y += Math.sin(stroke.angle) * currentSpeed;
|
||||||
|
|
||||||
// 笔触粗细 - 模拟提按
|
|
||||||
const progress = stroke.currentLength / stroke.maxLength;
|
const progress = stroke.currentLength / stroke.maxLength;
|
||||||
const weightVar = Math.sin(progress * Math.PI) * 1.0;
|
const weightVar = Math.sin(progress * Math.PI) * 1.0;
|
||||||
const weight = Math.max(0.2, stroke.baseWeight + weightVar);
|
const weight = Math.max(0.2, stroke.baseWeight + weightVar);
|
||||||
@ -143,16 +129,13 @@ function ZenBackground() {
|
|||||||
stroke.complete = true;
|
stroke.complete = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 绘制 - 水墨晕染效果
|
|
||||||
if (stroke.points.length > 1) {
|
if (stroke.points.length > 1) {
|
||||||
for (let i = 1; i < stroke.points.length; i++) {
|
for (let i = 1; i < stroke.points.length; i++) {
|
||||||
const p1 = stroke.points[i - 1];
|
const p1 = stroke.points[i - 1];
|
||||||
const p2 = stroke.points[i];
|
const p2 = stroke.points[i];
|
||||||
// 渐变透明度
|
|
||||||
const alpha = stroke.inkAlpha * (1 - i / stroke.points.length * 0.4) / 100;
|
const alpha = stroke.inkAlpha * (1 - i / stroke.points.length * 0.4) / 100;
|
||||||
const size = p2.weight * random(0.8, 1.2);
|
const size = p2.weight * random(0.8, 1.2);
|
||||||
|
|
||||||
// 绘制柔和的笔触
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(p1.x, p1.y);
|
ctx.moveTo(p1.x, p1.y);
|
||||||
ctx.lineTo(p2.x, p2.y);
|
ctx.lineTo(p2.x, p2.y);
|
||||||
@ -161,7 +144,6 @@ function ZenBackground() {
|
|||||||
ctx.lineCap = 'round';
|
ctx.lineCap = 'round';
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// 添加淡淡的墨点晕染
|
|
||||||
if (random(0, 1) < 0.3) {
|
if (random(0, 1) < 0.3) {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(p2.x, p2.y, size * random(0.5, 1.5), 0, Math.PI * 2);
|
ctx.arc(p2.x, p2.y, size * random(0.5, 1.5), 0, Math.PI * 2);
|
||||||
@ -172,12 +154,11 @@ function ZenBackground() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 绘制圆相(Ensō)- 缓慢呼吸
|
|
||||||
const centerX = canvas.width / 2;
|
const centerX = canvas.width / 2;
|
||||||
const centerY = canvas.height / 2;
|
const centerY = canvas.height / 2;
|
||||||
const breathScale = 1 + Math.sin(time * 0.3) * 0.015;
|
const breathScale = 1 + Math.sin(time * 0.3) * 0.015;
|
||||||
const radius = Math.min(canvas.width, canvas.height) * 0.18 * breathScale;
|
const radius = Math.min(canvas.width, canvas.height) * 0.18 * breathScale;
|
||||||
const gap = Math.PI * 0.18; // 开口
|
const gap = Math.PI * 0.18;
|
||||||
const startAngle = -Math.PI / 2 + time * 0.05;
|
const startAngle = -Math.PI / 2 + time * 0.05;
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@ -323,6 +304,7 @@ export function LandingPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 主标题 */}
|
||||||
<Title
|
<Title
|
||||||
order={1}
|
order={1}
|
||||||
style={{
|
style={{
|
||||||
@ -330,30 +312,39 @@ export function LandingPage() {
|
|||||||
fontSize: 'clamp(2.2rem, 7vw, 3.2rem)',
|
fontSize: 'clamp(2.2rem, 7vw, 3.2rem)',
|
||||||
letterSpacing: '0.25em',
|
letterSpacing: '0.25em',
|
||||||
color: '#1a1a1a',
|
color: '#1a1a1a',
|
||||||
fontFamily: 'Noto Serif SC, serif',
|
fontFamily: 'Georgia, serif',
|
||||||
|
lineHeight: 1.3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
掐日子
|
掐日子
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
|
{/* 副标题 - 增强可读性 */}
|
||||||
<Text
|
<Text
|
||||||
size="sm"
|
size="lg"
|
||||||
style={{
|
style={{
|
||||||
letterSpacing: '0.35em',
|
letterSpacing: '0.35em',
|
||||||
color: '#888',
|
color: '#444',
|
||||||
fontWeight: 300,
|
fontWeight: 400,
|
||||||
|
fontSize: '1rem',
|
||||||
|
fontFamily: 'Noto Serif SC, serif',
|
||||||
|
padding: '0.4rem 1rem',
|
||||||
|
background: 'rgba(250, 250, 250, 0.8)',
|
||||||
|
borderRadius: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
AI 纪念日 · 提醒
|
AI 纪念日 · 提醒
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{/* 描述文案 */}
|
||||||
<Text
|
<Text
|
||||||
size="xs"
|
size="xs"
|
||||||
style={{
|
style={{
|
||||||
color: '#999',
|
color: '#777',
|
||||||
maxWidth: 300,
|
maxWidth: 320,
|
||||||
lineHeight: 1.9,
|
lineHeight: 2,
|
||||||
fontWeight: 300,
|
fontWeight: 300,
|
||||||
|
fontSize: '0.85rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
轻便、灵活的倒数日和提醒应用
|
轻便、灵活的倒数日和提醒应用
|
||||||
@ -361,7 +352,7 @@ export function LandingPage() {
|
|||||||
让每一个重要的日子都被铭记
|
让每一个重要的日子都被铭记
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Group gap="md" mt="lg">
|
<Group gap="md" mt="md">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => navigate('/login')}
|
onClick={() => navigate('/login')}
|
||||||
@ -393,7 +384,7 @@ export function LandingPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap={40} mt={50} style={{ opacity: 0.7 }}>
|
<Group gap={40} mt={40} style={{ opacity: 0.6 }}>
|
||||||
<Stack gap={3} align="center">
|
<Stack gap={3} align="center">
|
||||||
<Text size="xs" fw={300} c="#444">◯</Text>
|
<Text size="xs" fw={300} c="#444">◯</Text>
|
||||||
<Text size="xs" c="#666" style={{ letterSpacing: '0.1em' }}>纪念日</Text>
|
<Text size="xs" c="#666" style={{ letterSpacing: '0.1em' }}>纪念日</Text>
|
||||||
|
|||||||
@ -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