Compare commits
No commits in common. "2feb02becf6a83bc5b82150747951d9f8a6aa22a" and "6fd1acc9993ff3a1088d3d80c0127ccdb6bacb44" have entirely different histories.
2feb02becf
...
6fd1acc999
17
index.html
17
index.html
@ -1,24 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<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" />
|
<title>client</title>
|
||||||
<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,7 +42,6 @@
|
|||||||
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 97 KiB |
@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
// 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
268
public/sw.js
@ -1,268 +0,0 @@
|
|||||||
// 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,7 +26,6 @@ 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';
|
||||||
|
|
||||||
@ -308,16 +307,6 @@ 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 }}>
|
||||||
<Group gap="sm">
|
|
||||||
<img
|
|
||||||
src={appIcon}
|
|
||||||
alt="掐日子"
|
|
||||||
style={{
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
borderRadius: '50%',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Title
|
<Title
|
||||||
order={2}
|
order={2}
|
||||||
style={{
|
style={{
|
||||||
@ -329,7 +318,6 @@ export function HomePage() {
|
|||||||
>
|
>
|
||||||
掐日子
|
掐日子
|
||||||
</Title>
|
</Title>
|
||||||
</Group>
|
|
||||||
<Group>
|
<Group>
|
||||||
{/* 设置入口 */}
|
{/* 设置入口 */}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -22,10 +22,12 @@ 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;
|
||||||
};
|
};
|
||||||
@ -46,8 +48,9 @@ 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);
|
||||||
@ -62,17 +65,19 @@ 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);
|
||||||
}
|
}
|
||||||
@ -81,20 +86,25 @@ 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();
|
||||||
@ -102,18 +112,22 @@ 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);
|
||||||
@ -129,13 +143,16 @@ 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);
|
||||||
@ -144,6 +161,7 @@ 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);
|
||||||
@ -154,11 +172,12 @@ 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();
|
||||||
@ -304,7 +323,6 @@ export function LandingPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 主标题 */}
|
|
||||||
<Title
|
<Title
|
||||||
order={1}
|
order={1}
|
||||||
style={{
|
style={{
|
||||||
@ -312,39 +330,30 @@ 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: 'Georgia, serif',
|
fontFamily: 'Noto Serif SC, serif',
|
||||||
lineHeight: 1.3,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
掐日子
|
掐日子
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
{/* 副标题 - 增强可读性 */}
|
|
||||||
<Text
|
<Text
|
||||||
size="lg"
|
size="sm"
|
||||||
style={{
|
style={{
|
||||||
letterSpacing: '0.35em',
|
letterSpacing: '0.35em',
|
||||||
color: '#444',
|
color: '#888',
|
||||||
fontWeight: 400,
|
fontWeight: 300,
|
||||||
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: '#777',
|
color: '#999',
|
||||||
maxWidth: 320,
|
maxWidth: 300,
|
||||||
lineHeight: 2,
|
lineHeight: 1.9,
|
||||||
fontWeight: 300,
|
fontWeight: 300,
|
||||||
fontSize: '0.85rem',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
轻便、灵活的倒数日和提醒应用
|
轻便、灵活的倒数日和提醒应用
|
||||||
@ -352,7 +361,7 @@ export function LandingPage() {
|
|||||||
让每一个重要的日子都被铭记
|
让每一个重要的日子都被铭记
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Group gap="md" mt="md">
|
<Group gap="md" mt="lg">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => navigate('/login')}
|
onClick={() => navigate('/login')}
|
||||||
@ -384,7 +393,7 @@ export function LandingPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap={40} mt={40} style={{ opacity: 0.6 }}>
|
<Group gap={40} mt={50} style={{ opacity: 0.7 }}>
|
||||||
<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, useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Title,
|
Title,
|
||||||
@ -8,21 +8,15 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Group,
|
Group,
|
||||||
Button,
|
Button,
|
||||||
Loader,
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconArrowLeft, IconSettings, IconBell, IconRefresh, IconBellCheck } from '@tabler/icons-react';
|
import { IconArrowLeft, IconSettings } 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(() => {
|
||||||
@ -32,97 +26,6 @@ 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={{
|
||||||
@ -181,76 +84,6 @@ 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>
|
||||||
|
|||||||
@ -1,89 +0,0 @@
|
|||||||
// 浏览器通知权限管理服务
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
// 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,19 +2,16 @@ 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 {
|
||||||
@ -130,12 +127,6 @@ 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 || '创建失败' };
|
||||||
@ -220,11 +211,6 @@ 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) {
|
||||||
// 失败时回滚,重新获取数据
|
// 失败时回滚,重新获取数据
|
||||||
|
|||||||
@ -1,115 +0,0 @@
|
|||||||
// 提醒时间检查核心逻辑
|
|
||||||
|
|
||||||
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,44 +1,9 @@
|
|||||||
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: [
|
plugins: [react()],
|
||||||
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