Skip to content

Commit dfd7fe1

Browse files
committed
feat(diary): 新增日记未写提醒与手动测试按钮
- 技术实现: - 新增全局客户端组件 DiaryReminder 并挂载到 app/layout.tsx,按「提醒点」轮询而非精确定时器:每 30s 计算当前已到达的最近提醒点(17:00 起固定每 20 分钟),规避浏览器对后台标签页定时器的节流导致的漏提醒与重复提醒。 - 已提醒的提醒点按天写入 localStorage(diary-reminder:YYYY-MM-DD)去重,刷新页面不重复提醒。 - 到点先静默请求 GET /api/diary(自建 fetch,刻意不经 lib/axios —— 其响应拦截器在失败时会 showErrorToast),比对当天 recordDate 是否存在,确认未写才发通知。 - 通知点击后经 router.push 跳转 /personal/diary;授权入口以命令式 DOM 挂载,规避 Notification.permission 的 SSR 首帧不一致,并绕开 react-hooks/set-state-in-effect。 - DiaryClient 新增「测试提醒」按钮:点击时当场请求授权并重新拉取日记,按当天状态发通知。 - Bug 根因(实测得出):Chrome 对相同 tag 的通知执行静默替换、不再弹出横幅,导致重复提醒不可见;故通知一律不设置 tag。 - 资源:新增 public/logo.png(1080x1080 原图缩放至 256x256,1.12MB 降至 95KB)作为通知图标。 - 涉及文件:app/_components/common/DiaryReminder.tsx, app/layout.tsx, app/personal/diary/_components/DiaryClient.tsx, public/logo.png
1 parent 5c35a35 commit dfd7fe1

4 files changed

Lines changed: 278 additions & 2 deletions

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
"use client";
2+
3+
import { useEffect } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { siteConfig } from "@/lib/siteConfig";
6+
7+
/**
8+
* 日记提醒:当天 17:00 起,只要还没有写日记,就用浏览器原生通知反复提醒。
9+
*
10+
* 提醒时刻:17:00、17:20、17:40 …… 固定每 20 分钟一次,直到当天日记被创建。
11+
*
12+
* 设计取舍:
13+
* 1. 用「轮询当前走到了哪个提醒点」而不是给每个点精确 setTimeout ——
14+
* 浏览器会节流后台标签页的定时器(切走后最快 1 分钟才跳一次),
15+
* 精确计时在后台并不可靠;按提醒点去重则节流后既不漏提醒也不重复提醒。
16+
* 2. 自带原生 fetch,刻意不用 lib/axios —— 那个实例的响应拦截器在请求失败时
17+
* 会 showErrorToast,而后端未启动时的静默探测不该弹错误提示打扰用户。
18+
* 3. 只在本地(非静态站)工作:日记数据不上静态站,静态构建下无从判断
19+
* 「今天有没有日记」。日记接口不需要登录(personal 布局、DiaryClient
20+
* 都没有登录门槛),因此这里不做登录态判断,未登录也照常提醒。
21+
* 4. 授权按钮用命令式 DOM 创建,而不是 React 状态:Notification.permission
22+
* 只能在客户端读,写进 state 会造成 SSR 与首帧不一致,
23+
* 而项目启用的 react-hooks/set-state-in-effect 也不允许在 effect 里同步 setState。
24+
*/
25+
26+
/** 起始提醒时刻:17:00 */
27+
const START_HOUR = 17;
28+
/** 提醒间隔:20 分钟 */
29+
const INTERVAL_MS = 20 * 60_000;
30+
/** 轮询间隔:30 秒 */
31+
const TICK_MS = 30_000;
32+
/** localStorage 键前缀,按日期分桶,跨天自动失效 */
33+
const STORAGE_PREFIX = "diary-reminder:";
34+
/** 点击通知后跳转的日记页 */
35+
const DIARY_PATH = "/personal/diary";
36+
37+
/** 本地时区的 YYYY-MM-DD(toISOString 走 UTC,跨时区会算错日期) */
38+
function toDateStr(d: Date): string {
39+
const pad = (n: number) => String(n).padStart(2, "0");
40+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
41+
}
42+
43+
/** 当前时刻已到达的最近一个提醒点;未到 17:00 返回 null */
44+
function latestDueSlot(now: Date): Date | null {
45+
const first = new Date(now);
46+
first.setHours(START_HOUR, 0, 0, 0);
47+
if (now.getTime() < first.getTime()) return null;
48+
49+
const k = Math.floor((now.getTime() - first.getTime()) / INTERVAL_MS);
50+
return new Date(first.getTime() + k * INTERVAL_MS);
51+
}
52+
53+
/** 读取某天已提醒过的提醒点时间戳 */
54+
function readNotified(day: string): number[] {
55+
try {
56+
const raw = localStorage.getItem(STORAGE_PREFIX + day);
57+
const parsed: unknown = raw ? JSON.parse(raw) : [];
58+
return Array.isArray(parsed)
59+
? parsed.filter((v): v is number => typeof v === "number")
60+
: [];
61+
} catch {
62+
return [];
63+
}
64+
}
65+
66+
/** 记录某天某个提醒点已提醒 */
67+
function markNotified(day: string, slotMs: number): void {
68+
try {
69+
const list = readNotified(day);
70+
if (!list.includes(slotMs)) list.push(slotMs);
71+
localStorage.setItem(STORAGE_PREFIX + day, JSON.stringify(list));
72+
} catch {
73+
/* 隐私模式下 localStorage 可能不可用:忽略,退化为本次会话内去重 */
74+
}
75+
}
76+
77+
/**
78+
* 静默查询今天是否已有日记。
79+
* 异常一律向上抛,由调用方吞掉 —— 探测失败不该有任何可见反馈。
80+
*/
81+
async function hasDiaryToday(day: string): Promise<boolean> {
82+
const token = localStorage.getItem("token");
83+
const res = await fetch(`http://${siteConfig.backUrl}/api/diary`, {
84+
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
85+
});
86+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
87+
88+
const body = (await res.json()) as {
89+
code?: number;
90+
message?: string;
91+
data?: { recordDate?: string }[];
92+
};
93+
if (body?.code !== 1) throw new Error(body?.message ?? `code ${body?.code}`);
94+
95+
return (body.data ?? []).some((d) => d?.recordDate === day);
96+
}
97+
98+
/**
99+
* 发原生通知,点击跳到日记页。
100+
*
101+
* 跳转回调由调用方用 router.push 提供:本函数挂在通知的 onclick 上,
102+
* 不是 React 事件处理器,拿不到 router,所以从外面传进来。
103+
*/
104+
function notifyDiaryMissing(openDiary: () => void): void {
105+
const n = new Notification("今天还没写日记", {
106+
body: "随手记两句今天的活动吧 —— 点这里打开日记页。",
107+
icon: "/logo.png",
108+
// 刻意不设 tag:带相同 tag 的新通知会被 Chrome 静默替换、不再弹出横幅,
109+
// 而自动提醒的意义正是每 20 分钟都要真正提醒到人(已实测验证过这一点)
110+
});
111+
n.onclick = () => {
112+
window.focus();
113+
n.close();
114+
// 读 pathname 是安全的,仅用 href 赋值才会触发 no-location-assign-relative-destination
115+
if (window.location.pathname !== DIARY_PATH) openDiary();
116+
};
117+
}
118+
119+
/**
120+
* 挂载一次性授权按钮。
121+
*
122+
* 浏览器规定 requestPermission 必须由用户手势触发,页面加载时自动请求会被忽略,
123+
* 所以这里给一个显式入口;授权后按钮自行消失并回调一次,让提醒立刻生效。
124+
* 位置取左下角,避开右下角的看板娘与右上角的 toast。
125+
*/
126+
function mountPermissionButton(onGranted: () => void): () => void {
127+
const btn = document.createElement("button");
128+
btn.type = "button";
129+
btn.textContent = "🔔 开启日记提醒";
130+
btn.style.cssText =
131+
"position:fixed;left:16px;bottom:16px;z-index:9998;padding:8px 14px;border:none;border-radius:9999px;background:rgba(99,102,241,0.92);backdrop-filter:blur(8px);color:#fff;font-size:12px;font-weight:700;line-height:1.4;cursor:pointer;box-shadow:0 4px 16px rgba(99,102,241,0.35);font-family:system-ui,sans-serif;";
132+
133+
btn.onclick = () => {
134+
btn.remove();
135+
Notification.requestPermission()
136+
.then((permission) => {
137+
if (permission === "granted") onGranted();
138+
})
139+
.catch(() => {
140+
/* 授权失败:保持安静,用户可刷新后重试 */
141+
});
142+
};
143+
144+
document.body.appendChild(btn);
145+
return () => btn.remove();
146+
}
147+
148+
export default function DiaryReminder() {
149+
const router = useRouter();
150+
151+
useEffect(() => {
152+
const isStatic = process.env.NEXT_PUBLIC_IS_STATIC === "true";
153+
if (isStatic) return;
154+
if (typeof Notification === "undefined") return;
155+
156+
/** 本次会话已处理过的提醒点,避免同一个点反复打接口 */
157+
let handledSlot = 0;
158+
let disposed = false;
159+
160+
const tick = async () => {
161+
if (disposed) return;
162+
163+
const now = new Date();
164+
const slot = latestDueSlot(now);
165+
if (!slot) return;
166+
167+
const slotMs = slot.getTime();
168+
if (slotMs === handledSlot) return;
169+
170+
const day = toDateStr(now);
171+
172+
// 该提醒点今天已经提醒过(含本次会话之前的提醒),不再重复
173+
if (readNotified(day).includes(slotMs)) {
174+
handledSlot = slotMs;
175+
return;
176+
}
177+
178+
// 未授权就发不出通知,也不必去探测日记
179+
if (Notification.permission !== "granted") return;
180+
181+
handledSlot = slotMs;
182+
183+
try {
184+
if (await hasDiaryToday(day)) return;
185+
} catch {
186+
// 后端不可达 / 响应异常:静默跳过,等下一个提醒点再试
187+
return;
188+
}
189+
if (disposed) return;
190+
191+
try {
192+
notifyDiaryMissing(() => router.push(DIARY_PATH));
193+
} catch {
194+
// 通知构造失败(例如系统禁用了通知):不留痕迹
195+
return;
196+
}
197+
markNotified(day, slotMs);
198+
};
199+
200+
void tick();
201+
const timer = window.setInterval(() => void tick(), TICK_MS);
202+
const unmountButton =
203+
Notification.permission === "default"
204+
? mountPermissionButton(() => void tick())
205+
: null;
206+
207+
return () => {
208+
disposed = true;
209+
window.clearInterval(timer);
210+
unmountButton?.();
211+
};
212+
}, [router]);
213+
214+
return null;
215+
}

‎app/layout.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { siteConfig } from "@/lib/siteConfig";
1212
import { defaultOgImage, SITE_KEYWORDS, SITE_URL } from "@/lib/seo";
1313
import BackgroundSlider from "@/app/_components/layout/BackgroundSwitcher";
1414
import AuthSessionRenewer from "@/app/_components/layout/AuthSessionRenewer";
15+
import DiaryReminder from "@/app/_components/common/DiaryReminder";
1516

1617
const geistSans = localFont({
1718
src: "../public/fonts/geist-latin.woff2",
@@ -89,6 +90,7 @@ export default function RootLayout({
8990
<ThemeProvider>
9091
<SplashScreen />
9192
<AuthSessionRenewer />
93+
<DiaryReminder />
9294
<div id="app-mount-root" className="flex-1 flex flex-col min-h-0 transition-opacity duration-1000">
9395
{/* Background layers */}
9496
<div className="fixed inset-0 z-[-1] pointer-events-none overflow-hidden">

‎app/personal/diary/_components/DiaryClient.tsx‎

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import { useCallback, useEffect, useRef, useState } from "react";
4-
import { Loader2, RefreshCw } from "lucide-react";
4+
import { Bell, Loader2, RefreshCw } from "lucide-react";
55
import type { Diary, DiaryStats } from "@/lib/types";
66
import { getStats, listAll } from "@/lib/api/diary";
77
import DiaryTab from "./DiaryTab";
@@ -16,6 +16,12 @@ const TABS: { key: TabKey; label: string }[] = [
1616
{ key: "history", label: "历史" },
1717
];
1818

19+
/** 本地时区的 YYYY-MM-DD,与后端 recordDate 的格式对齐(toISOString 走 UTC 会算错) */
20+
function toDateStr(d: Date): string {
21+
const pad = (n: number) => String(n).padStart(2, "0");
22+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
23+
}
24+
1925
/**
2026
* 日记模块容器
2127
*
@@ -35,6 +41,8 @@ export default function DiaryClient() {
3541
const [loading, setLoading] = useState(!isStatic);
3642
/** 手动刷新按钮的状态 */
3743
const [refreshing, setRefreshing] = useState(false);
44+
/** 测试提醒按钮的状态:请求权限与接口期间禁用,防连点 */
45+
const [testing, setTesting] = useState(false);
3846
const [failed, setFailed] = useState(false);
3947
/** stats 数据是否已过期,切到统计 tab 时按需重取 */
4048
const statsStale = useRef(true);
@@ -73,6 +81,46 @@ export default function DiaryClient() {
7381
statsStale.current = true;
7482
}, []);
7583

84+
/**
85+
* 测试提醒:手动发一条通知,内容为「今天有没有写日记」,用于验证通知链路。
86+
*
87+
* 与 DiaryReminder 的自动提醒刻意分开实现:
88+
* - 由点击触发,所以能顺带完成授权 —— 浏览器只允许在用户手势里请求通知权限;
89+
* - 通知 tag 独立,避免这条测试通知把自动提醒的通知顶掉;
90+
* - 每次重新请求接口,不依赖页面上可能已过期的列表。
91+
*/
92+
const testNotify = useCallback(async () => {
93+
if (typeof Notification === "undefined") return;
94+
95+
setTesting(true);
96+
try {
97+
// 未授权时当场请求:点击就是用户手势,这是唯一能弹出授权框的时机
98+
if (Notification.permission === "default") {
99+
const permission = await Notification.requestPermission();
100+
if (permission !== "granted") return;
101+
}
102+
if (Notification.permission !== "granted") return;
103+
104+
// 重新拉一次拿最新结果;请求失败会由 axios 拦截器弹错误提示
105+
const list = await listAll();
106+
const today = toDateStr(new Date());
107+
const hasDiary = (list ?? []).some((d) => d.recordDate === today);
108+
109+
// 刻意不设 tag:带相同 tag 的新通知会被 Chrome 静默替换而不是重新弹出横幅,
110+
// 而测试按钮的意义就在于每点一次都能亲眼看到一条新通知
111+
new Notification(hasDiary ? "今天已经写过日记了" : "今天还没写日记", {
112+
body: hasDiary
113+
? `${today} 的日记已存在,17:00 起不会触发自动提醒。`
114+
: `${today} 还没有日记,17:00 起每 20 分钟会自动提醒一次。`,
115+
icon: "/logo.png",
116+
});
117+
} catch {
118+
/* 接口失败已由 axios 拦截器提示;通知构造失败也无需额外反馈 */
119+
} finally {
120+
setTesting(false);
121+
}
122+
}, []);
123+
76124
// 首屏加载:两条请求并行,完成后落状态
77125
useEffect(() => {
78126
if (isStatic) return;
@@ -143,12 +191,23 @@ export default function DiaryClient() {
143191
);
144192
})}
145193

194+
<button
195+
type="button"
196+
onClick={() => void testNotify()}
197+
disabled={testing}
198+
title="测试日记提醒通知:点击后会弹出「今天有没有写日记」的通知"
199+
className="ml-auto flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-bold text-slate-400 transition-colors hover:bg-slate-100 hover:text-indigo-500 disabled:opacity-50 dark:hover:bg-slate-700"
200+
>
201+
<Bell size={13} />
202+
测试提醒
203+
</button>
204+
146205
<button
147206
type="button"
148207
onClick={() => void refresh()}
149208
disabled={refreshing}
150209
title="刷新"
151-
className="ml-auto rounded-full p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-indigo-500 disabled:opacity-50 dark:hover:bg-slate-700"
210+
className="rounded-full p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-indigo-500 disabled:opacity-50 dark:hover:bg-slate-700"
152211
>
153212
<RefreshCw size={14} className={refreshing ? "animate-spin" : ""} />
154213
</button>

‎public/logo.png‎

95.1 KB
Loading

0 commit comments

Comments
 (0)