Files
laoyang df6fe894d0 v32 第 32 批(4 条):资源共享弹框真落库 / 消息通知覆盖全部共享入口 / 浏览器通知(站内消息+日程到期提醒) / 首页任务随看板色
用户原话:
①「资源共享功能有问题,弹框中,点击共享给某个人后,弹框消失,但是并没有共享成功。」
②「消息通知,在所有引用自己的地方,都通知,包括别人分享了日历本给自己,
   分享了笔记给自己,分享了资源给自己等等。」
③「当有消息通知时,还有日历中日程到期提醒时,比如一个日程,设置了10点开始,
   提前十分钟提醒,则需要在 9:50 时浏览器中发提醒通知。」
④「首页,近期任务,任务的文字和前面的圆点,颜色并没有跟看板颜色一致,这个需要修正。」

四条真根因:
① v28 把资源编辑改成三页签后,每个页签各有自己的 form 与 <input name=id>,
   而设 id 的代码是 panel.querySelector() → 只命中文档序第一个(info 的 update),
   ⇒ share/att 表单的 id 恒空 ⇒ 提交 id=0 ⇒ Resource::find(familyId,0,me) 返回 null
   ⇒ 静默不共享。修:表单打 data-res-idform 标记 + querySelectorAll 逐个填。
② v30 只在 saveAttendees 后调 syncForTarget,6 个共享入口一个都没接。
   新增 Notification::syncShares(家庭,类型,宿主id,动作人) —— 照共享表现状重建差额
   (写表后调用;漏调只会没通知,不会状态不一致)。SHARE_KINDS 抽常量。
③ 完全没有浏览器通知。新增 Calendar::remindersBetween()(窗口留 -2 分钟防后台节流)
   + ?r=notifications/poll JSON 路由 + assets/js/notify.js(轮询 + Notification API
   + id@at 去重)+ 顶栏铃铛(权限只能用户点一下才要,自动申请会被 Chrome 静默拒绝)。
④ v30 把用户那句和主题颜色一样理解成了**任务模块主题色**,用户要的是**看板色**。
   新增 Task::boardColorById(家庭,看板id)(没设色按看板名派生稳定色),home.php 两处改 $tkColor。

验证:诊断探针 18/0、真机 25/0(真点共享→库里真的多一行;三类共享真发通知;
9:50 窗口真算出 10:00 那条;首页每条任务色 == 其看板色)、反向 5/0、
e2e 1502/0、升级 153/0、几何 428+680+92、城市 32/0。
部署脚本静态断言段本地空跑通过;线上站点自检 19 条全过,notifications/poll 未登录 302。
2026-10-09 07:25:08 +08:00

161 lines
7.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 浏览器通知(v32 第 3 条)。
*
* 用户原话:「当有消息通知时,还有日历中日程到期提醒时,比如一个日程,设置了10点开始,
* 提前十分钟提醒,则需要在 9:50 时浏览器中发提醒通知。」
*
* 做两件事:
* ① 定期问服务端 `?r=notifications/poll`(**到点与否由 PHP 算**,见 index.php 那段);
* ② 把该响的用 Notification API 弹出来(站内消息 + 日程提醒)。
*
* ────────────────────────────────────────────────────────────────────
* 五条设计判断(每条都对应一个"不这么做就会出的问题"):
*
* ① **权限只在用户"动手"时要,绝不在页面加载时自动弹**。
* 浏览器对"未经交互就 requestPermission"的做法会直接**静默拒绝**
* (Chrome 的 abusiveness 规则),而且会把本域名拉进"用户不喜欢"的名单。
* ⇒ 页头放一个小铃铛按钮(`.fb-notify-enable`),用户点了才要权限。
* 用户没开权限时轮询**照常跑**(站内未读数还得更新),只是不弹系统通知。
*
* ② **同一个提醒只弹一次**。轮询窗口故意留了"过去 2 分钟"(见后端注释:
* 防标签页节流导致整条错过),代价是同一档提醒可能连续两轮都落进窗口。
* ⇒ 用 `已弹过的 key 集合`(`事件id@提醒时刻`)去重,且集合只在**内存**里 ——
* 刷新页面会重弹一次"刚刚那条",这反而是对的(用户刷新说明他还没看到)。
*
* ③ **点通知要能回到那个东西**。`new Notification(...).onclick` 里
* `window.focus()` + `location.href = 链接`。
* ⚠️ 用 `tag` 而不是 `new Notification` 的自定义字段:部分浏览器只认 tag,
* 不认自定义属性(用 tag 还能让同一件事的通知**合并**而不是堆一列)。
*
* ④ **后台标签页也要能弹**。`setInterval` 在后台标签页会被浏览器节流到
* 1~2 分钟一次(甚至更慢)。所以**轮询间隔取 60 秒**(与节流后的粒度相当),
* 并把窗口开到"过去 2 分钟"把这层抖动吸收掉 —— 而不是去和浏览器对抗。
*
* ⑤ **不用 Service Worker / 推送服务**。那要 HTTPS + 服务端推送通道,
* 而用户要的是"页面开着时能提醒"。页面关掉还提醒属于 Web Push 的范畴,
* 是另一件事(也超出"站内"的边界,与 Notification 头部第 ⑤条同一取舍)。
*/
(function () {
'use strict';
var API = (window.FB_NOTIFY && window.FB_NOTIFY.poll) || '';
if (!API) { return; }
var POLL_MS = 60000; // 见头部第 ④条
var seen = {}; // 已弹过的提醒 key(见头部第 ②条)
var lastUnread = null; // 上一轮的未读数:只在**变多**时提醒,避免反复骚扰
var timer = null;
function supported() {
return (typeof window.Notification === 'function');
}
function enabled() {
return supported() && window.Notification.permission === 'granted';
}
/** 弹一条系统通知;点了就回到对应的页面 */
function fire(title, body, href, tag, icon) {
try {
var n = new window.Notification(title, {
body: body || '',
tag: tag || '', // 同 tag 合并(见头部第 ③条)
icon: icon || ''
});
n.onclick = function () {
try { window.focus(); } catch (e) { /* 有些浏览器不允许抢焦点 */ }
if (href) { window.location.href = href; }
try { n.close(); } catch (e) { /* 已关闭 */ }
};
// 8 秒后自动收起:提醒条一直在会挡住下一个
setTimeout(function () { try { n.close(); } catch (e) { /* 已关 */ } }, 8000);
} catch (e) {
// 某些浏览器在"页面不可见"时抛(旧实现)—— 忽略,别让它影响轮询
}
}
function poll() {
var xhr = new XMLHttpRequest();
xhr.open('GET', API, true);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.onload = function () {
if (xhr.status !== 200) { return; }
var d;
try { d = JSON.parse(xhr.responseText); } catch (e) { return; }
handle(d);
};
xhr.send();
}
function handle(d) {
if (!d) { return; }
// ---- 站内消息:只在**未读数变多**时弹一次(否则每轮都弹同样的几条)----
var unread = parseInt(d.unread || 0, 10);
if (enabled() && unread > 0 && lastUnread !== null && unread > lastUnread) {
var first = (d.items && d.items[0]) || null;
fire('家书 · 新消息',
first ? (first.actor + first.body) : ('你有 ' + unread + ' 条新消息'),
(window.FB_NOTIFY && window.FB_NOTIFY.list) || '',
'fb-notify', (window.FB_NOTIFY && window.FB_NOTIFY.icon) || '');
}
lastUnread = unread;
// ---- 日程提醒:按 `id@at` 去重(见头部第 ②条)----
var rs = d.reminders || [];
for (var i = 0; i < rs.length; i++) {
var r = rs[i];
var key = r.id + '@' + r.at + '@' + (r.lead || '');
if (seen[key]) { continue; }
seen[key] = true;
if (!enabled()) { continue; }
var t = String(r.starts_at || '').substr(11, 5);
fire('日程提醒:' + (r.title || '(无标题)'),
(r.lead ? ('还有 ' + r.lead + ' 分钟开始') : '马上开始')
+ (t ? ('(' + t + ')') : '') + (r.cal ? (' · ' + r.cal) : ''),
r.href || '', 'fb-remind-' + r.id + '-' + (r.lead || ''), '');
}
}
/** 页头那个小铃铛:点一下要权限(见头部第 ①条) */
function bindButton() {
var btn = document.querySelector('.fb-notify-enable');
if (!btn) { return; }
var paint = function () {
var on = enabled();
btn.hidden = on || !supported();
btn.title = '开启浏览器提醒';
btn.setAttribute('aria-label', '开启浏览器提醒');
};
btn.hidden = enabled() || !supported();
btn.addEventListener('click', function () {
if (!supported()) { return; }
window.Notification.requestPermission().then(function () {
paint();
if (enabled()) {
fire('家书 · 提醒已开启', '有新的消息或日程提醒时,我会在这里通知你。', '', 'fb-on', '');
}
});
});
paint();
}
function start() {
bindButton();
poll(); // 先拉一次(页面刚打开时可能有未读)
timer = setInterval(poll, POLL_MS);
// 页面重新可见时立刻补一次:标签页在后台被节流,回来时可能已经错过窗口
document.addEventListener('visibilitychange', function () {
if (!document.hidden) { poll(); }
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
// 供探针/调试:手动触发一次
window.fbNotifyPoll = poll;
})();