Compare commits

..

6 Commits

Author SHA1 Message Date
ddshi
2feb02becf feat: 实现浏览器通知提醒功能
- 添加 Service Worker 支持后台定时检查提醒
- 实现浏览器通知 API 集成
- 添加设置页面通知开关和测试功能
- 创建同步服务自动将提醒同步到 SW
- 优化提醒检查逻辑(30秒间隔,10分钟宽限期)

文件变更:
- public/sw.js: Service Worker 主文件
- public/sw-register.ts: SW 注册脚本
- public/manifest.json: PWA 清单文件
- src/services/notification.ts: 通知权限管理
- src/services/swSync.ts: 提醒同步服务
- src/stores/index.ts: 添加同步调用
- src/pages/SettingsPage.tsx: 添加通知开关和测试按钮

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 14:42:36 +08:00
ddshi
ab12b0717f feat: 添加网站图标和浏览器标题
- 添加 favicon.png
- 更新浏览器标题为"掐日子 - AI 纪念日提醒"
- 在 HomePage 标题旁添加 logo 图标

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 11:22:14 +08:00
ddshi
9a46aead11 revert: 主标题样式回退 2026-02-11 11:06:53 +08:00
ddshi
289d81180d refactor: 优化主标题书法风格
- 使用柔和的 text-shadow 模拟书法笔触效果
- 调整字重和字间距
- 保持简洁不做过度设计

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 11:04:46 +08:00
ddshi
69953c43cf refactor: 简化 landing page 优化字体样式
- 回退复杂的功能介绍区域,保留简洁设计
- 优化副标题可读性:添加柔和背景 + serif 字体
- 保持整体禅意风格不变

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 17:22:27 +08:00
ddshi
25f999cb1f feat: 优化 landing page 字体样式和功能介绍
- 主标题使用更优雅的字体样式和柔和阴影
- 副标题添加半透明背景提升可读性
- 新增四个功能模块的详细介绍卡片(纪念日/提醒/AI/便签)
- 使用毛玻璃效果增强视觉层次
- 响应式布局适配不同屏幕

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 17:15:23 +08:00
15 changed files with 5235 additions and 75 deletions

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

24
public/manifest.json Normal file
View 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
View 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
View 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();

View File

@ -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

View File

@ -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>

View File

@ -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>

View 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
View 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);
}
}

View File

@ -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) {
// 失败时回滚,重新获取数据 // 失败时回滚,重新获取数据

View 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;
}

View File

@ -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,