diff --git a/app/Calendar.php b/app/Calendar.php index 89829d9..7ca36fe 100644 --- a/app/Calendar.php +++ b/app/Calendar.php @@ -728,8 +728,93 @@ final class Calendar } /** - * 任意形态的提醒值 → **合法分钟数数组**(升序、去重)。 + * v32 第 3 条:**到点的提醒**(浏览器通知用)。 * + * 用户原话:「还有日历中日程到期提醒时,比如一个日程,设置了10点开始, + * 提前十分钟提醒,则需要在9:50时浏览器中发提醒通知。」 + * + * @param string $from 窗口起点(含),'Y-m-d H:i:s' + * @param string $to 窗口终点(含) + * @return array + * `at` = 该触发提醒的时刻(starts_at - 提前分钟数) + * + * ⚠️ 为什么**只按 starts_at 查**、不做重复日程展开: + * 重复日程(RRULE)会在每周三都"提醒一次",但那需要把系列展开到具体某天, + * 而**同一次发生**在任何一次轮询里都必须算出**同一个提醒时刻**(否则会重复弹)。 + * 窗口很短(前端每次只看"现在前后几分钟"),而"下次发生"恰好落在这几分钟里的 + * 概率极低、影响也只是一个提醒来得晚一点。 + * ⇒ 这一版覆盖"用户举的那个例子"(单次日程 10:00 提前 10 分钟 → 9:50 弹), + * 重复日程的提醒留待需要时再展开(不假装支持)。 + * + * ⚠️ 多档提醒(`reminder_minutes` 是 `'5,15'`)会返回**多条**(每档一条): + * 用户既然设了两档,就该被提醒两次 —— 靠前端按 `id@at` 去重,不会重复弹同一条。 + */ + public static function remindersBetween(int $familyId, int $personId, + string $from, string $to): array + { + // 我可见的日历本 = 我建的 + 共享给我的(与页面口径一致,别另发明一套) + $books = []; + foreach (self::mine($familyId, $personId) as $c) { + if (in_array((string)$c['kind'], ['movie', 'trip'], true)) { + continue; // 派生本没有 calendar_events 行,且它们本就没有提醒 + } + $books[] = (int)$c['id']; + } + if (!$books) { + return []; + } + $in = implode(',', array_fill(0, count($books), '?')); + // ⚠️ 范围要放宽 `最大档位` 分钟:提醒时刻 = 开始 - N 分钟, + // 所以"开始时间"可能在窗口**之后**(10:00 的事件,9:50 时窗口是过去)。 + // 取 REMINDERS 里最大档位当余量,一次查全。 + $maxLead = max(array_keys(self::REMINDERS)); + $st = Database::get()->prepare( + "SELECT e.id, e.title, e.starts_at, e.reminder_minutes, e.all_day, + c.name AS calname + FROM calendar_events e + JOIN calendars c ON c.id = e.calendar_id + WHERE e.calendar_id IN ($in) + AND e.reminder_minutes IS NOT NULL AND e.reminder_minutes <> '' + AND e.status <> 'cancelled' + AND e.starts_at >= ? AND e.starts_at <= ? + ORDER BY e.starts_at" + ); + $st->execute(array_merge($books, + [date('Y-m-d H:i:s', strtotime($from) - $maxLead * 60), + date('Y-m-d H:i:s', strtotime($to) + $maxLead * 60)])); + $out = []; + $fTs = strtotime($from); + $tTs = strtotime($to); + foreach ($st->fetchAll() as $e) { + if (!empty($e['all_day'])) { + continue; // 全天日程没有"开始时点",提前 N 分钟无从计算 + } + $sTs = strtotime((string)$e['starts_at']); + if ($sTs === false) { + continue; + } + foreach (self::reminderList($e['reminder_minutes'] ?? null) as $lead) { + $atTs = $sTs - $lead * 60; + if ($atTs < $fTs || $atTs > $tTs) { + continue; // 这一档的提醒时刻不在本窗口 + } + $out[] = [ + 'id' => (int)$e['id'], + 'title' => (string)$e['title'], + 'starts_at' => (string)$e['starts_at'], + 'at' => date('Y-m-d H:i:s', $atTs), + 'lead' => (int)$lead, + 'cal' => (string)$e['calname'], + 'href' => url('calendar/event', ['id' => (int)$e['id']]), + ]; + } + } + usort($out, static fn(array $a, array $b): int => strcmp($a['at'], $b['at'])); + return $out; + } + + /** + * 任意形态的提醒值 → **合法分钟数数组**(升序、去重)。 * ⚠️ 白名单:只认 `self::REMINDERS` 里那几个档位。手搓请求塞 999999 * 不能变成"提前 999999 分钟提醒"(那会导出一个客户端算不明白的 VALARM)。 * diff --git a/app/Notification.php b/app/Notification.php index 06aca65..92e03e4 100644 --- a/app/Notification.php +++ b/app/Notification.php @@ -38,10 +38,44 @@ namespace App; final class Notification { - /** 宿主类型(与 attachments.target_type 同一套词,便于将来复用跳转逻辑) */ + /** + * 宿主类型(与 attachments.target_type 同一套词,便于将来复用跳转逻辑) + * + * ⚠️ 这里的值有**两种用途**,别混: + * · 前两个(movie / trip)是 `syncForTarget()` 的"组"类型, + * 唯一键 (person_id, target_type, target_id) 靠它去重与撤销; + * · 后面几个是 v32 新增的**其它"别人给我"的场景**,各自对应一张共享表。 + */ public const MOVIE = 'movie'; public const TRIP = 'trip'; + /** v32:别人把东西**共享**给我(日历本 / 笔记 / 资源)—— 各自的宿主类型 */ + public const CALENDAR = 'calendar'; + public const NOTE = 'note'; + public const RESOURCE = 'resource'; + + /** 这几类宿主"保存后同步通知"的注册表:类型 → 表名/列名/跳转/词 */ + public const SHARE_KINDS = [ + self::CALENDAR => [ + 'table' => 'calendar_shares', 'col' => 'calendar_id', + 'sub' => 'calendars', 'sub_id' => 'id', 'sub_name' => 'name', + 'href' => 'calendar/calendars', 'word' => '日历本', + 'icon' => '📅', + ], + self::NOTE => [ + 'table' => 'note_shares', 'col' => 'note_id', + 'sub' => 'notes', 'sub_id' => 'id', 'sub_name' => 'title', + 'href' => 'notes/form', 'word' => '笔记', + 'icon' => '📝', + ], + self::RESOURCE => [ + 'table' => 'resource_shares', 'col' => 'resource_id', + 'sub' => 'resources', 'sub_id' => 'id', 'sub_name' => 'title', + 'href' => 'resources', 'word' => '资源', + 'icon' => '📦', + ], + ]; + /** 列表一次最多显示多少条(首页那块只取前几条,见 unreadCount / recent) */ public const PAGE_SIZE = 50; @@ -111,6 +145,107 @@ final class Notification } } + /** + * v32(用户 2026-10-08 第 2 条):「消息通知,在所有引用自己的地方,都通知, + * 包括别人分享了日历本给自己,分享了笔记给自己,分享了资源给自己等等。」 + * + * 与 `syncForTarget()` 的分工: + * · syncForTarget:**参与人是"整组替换"**的场合(观影/旅程加人)—— + * 每次保存都要"按差额撤销",所以要传全量 keepIds。 + * · 本函数:共享表是**逐条增删**的(一次只动一个人), + * 所以不需要撤销逻辑 —— 谁多了就发一条,谁被移走就删他未读的那条。 + * + * ⚠️ 直接**照共享表现状**重建通知,而不是"按调用方说的动作发": + * 共享的入口不止一个(列表页共享、详情页取消共享、看板/笔记页…), + * 每个入口都记得发通知是不可能的 —— 迟早漏一个(本批就是这么发现的: + * 观影/旅程有通知,日历本/笔记/资源没有)。 + * ⇒ 在**共享表的每次写入之后**调一次本函数,它自己算差额。 + * + * @param string $kind self::CALENDAR / self::NOTE / self::RESOURCE + * @param int $hostId 宿主 id + * @param int $actorId 谁共享的(**不给动作发出者自己发**,见头部第 ②条) + */ + public static function syncShares(int $familyId, string $kind, int $hostId, int $actorId): void + { + $cfg = self::SHARE_KINDS[$kind] ?? null; + if ($cfg === null || $hostId <= 0 || $familyId <= 0) { + return; + } + $db = Database::get(); + + // ---- 现在共享给了谁(共享表为准)---- + $st = $db->prepare( + 'SELECT ' . $cfg['col'] . ' AS hid, person_id, perm FROM ' . $cfg['table'] + . ' WHERE ' . $cfg['col'] . ' = ?' + ); + $st->execute([$hostId]); + $now = []; + foreach ($st->fetchAll() as $r) { + $pid = (int)$r['person_id']; + if ($pid > 0 && $pid !== $actorId) { + $now[$pid] = (string)$r['perm']; + } + } + + // ---- 撤销:不在共享表里、且**未读**的,删掉(已读的是历史,见头部第 ③条)---- + $st = $db->prepare( + 'SELECT id, person_id FROM notifications + WHERE target_type = ? AND target_id = ? AND is_read = 0' + ); + $st->execute([$kind, $hostId]); + $del = $db->prepare('DELETE FROM notifications WHERE id = ?'); + foreach ($st->fetchAll() as $row) { + if (!isset($now[(int)$row['person_id']])) { + $del->execute([(int)$row['id']]); + } + } + if (!$now) { + return; + } + + // ---- 宿主的名字(通知里要显示"「xxx」")---- + $st = $db->prepare( + 'SELECT ' . $cfg['sub_name'] . ' AS nm FROM ' . $cfg['sub'] + . ' WHERE ' . $cfg['sub_id'] . ' = ? LIMIT 1' + ); + $st->execute([$hostId]); + $hostName = (string)($st->fetchColumn() ?: ''); + + // ---- 新增(INSERT IGNORE 靠唯一键去重:反复保存只留一条,见头部第 ④条)---- + // ⚠️ 正文要带**权限**:用户看到"只读 / 可读写"才知道自己能做什么。 + $ins = $db->prepare( + 'INSERT IGNORE INTO notifications + (family_id, person_id, actor_id, kind, title, href, params, + target_type, target_id, is_read, created_at) + VALUES (?,?,?,?,?,?,?,?,?,0,NOW())' + ); + foreach ($now as $pid => $perm) { + $permWord = $perm === 'write' ? '可读写' : '只读'; + $body = '把' . $cfg['word'] . '「' . self::clip($hostName, 60) . '」共享给你(' + . $permWord . ')'; + $ins->execute([ + $familyId, (int)$pid, $actorId, $kind, $body, + $cfg['href'], + $kind === self::NOTE ? json_encode(['id' => $hostId], JSON_UNESCAPED_UNICODE) : null, + $kind, $hostId, + ]); + } + // ⚠️ 权限是"可变"的:用户把只读改成可读写时,上面那条 INSERT IGNORE + // 不会更新已有的正文 ⇒ 通知里还写着"只读"(与实际不符)。 + // ⇒ 未读的那条**顺手把正文刷新一遍**(已读的不动:那是历史)。 + $upd = $db->prepare( + 'UPDATE notifications SET title = ? WHERE target_type = ? AND target_id = ? + AND person_id = ? AND is_read = 0' + ); + foreach ($now as $pid => $perm) { + $permWord = $perm === 'write' ? '可读写' : '只读'; + $upd->execute([ + '把' . $cfg['word'] . '「' . self::clip($hostName, 60) . '」共享给你(' . $permWord . ')', + $kind, $hostId, (int)$pid, + ]); + } + } + /** * 宿主被删除时,把它的通知一并清掉。 * ⚠️ 不做外键(target_type 指向多张表,外键表达不了)→ 必须手动清, @@ -204,6 +339,44 @@ final class Notification return url((string)$n['href'], $params); } + /** + * 通知的图标(按 kind)。 + * + * ⚠️ 抽成一处:通知页与首页那块**都**要画图标,两处各写一套 emoji 分支 + * 迟早漂移(本项目的"设置下拉"就吃过这个亏)。模板里只调这个函数。 + */ + public static function icon(string $kind): string + { + $map = [ + self::MOVIE => '🎬', + self::TRIP => '🧭', + self::CALENDAR => '📅', + self::NOTE => '📝', + self::RESOURCE => '📦', + ]; + if (isset($map[$kind])) { + return $map[$kind]; + } + // 旅程的宿主类型是 'trip_node' 之类(前缀匹配) + if (strpos($kind, 'trip') === 0) { + return '🧭'; + } + return '🔔'; + } + + /** + * 通知里的动作词("有人**把你加进了**观影「x」" vs "有人**把笔记共享给你**")。 + * + * ⚠️ 正文是**存库**的(`title`),这里只负责"把开头的『有人』换成实际名字" + * 这件事在模板里怎么做 —— 见 `bodyWithoutActor()`。 + */ + public static function bodyWithoutActor(string $title): string + { + // 正文都是「有人…」开头(见 syncForTarget / syncShares 的拼法); + // 模板会把 `他的名字` 放在前面,所以这里把开头的"有人"去掉。 + return (string)preg_replace('/^有人/u', '', $title); + } + /** 文案截断(多字节安全) */ private static function clip(string $s, int $max): string { diff --git a/assets/js/notify.js b/assets/js/notify.js new file mode 100644 index 0000000..8e0e276 --- /dev/null +++ b/assets/js/notify.js @@ -0,0 +1,161 @@ +/** + * 浏览器通知(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; +})(); \ No newline at end of file diff --git a/index.php b/index.php index 2372c13..2e916c2 100644 --- a/index.php +++ b/index.php @@ -1419,6 +1419,53 @@ switch ($r) { redirect('notifications'); break; + /** + * v32 第 3 条:**浏览器通知**的轮询口(JSON)。 + * + * 用户原话:「当有消息通知时,还有日历中日程到期提醒时…则需要在 9:50 时 + * 浏览器中发提醒通知。」 + * + * 前端每 60 秒拉一次,本口回两样东西: + * · `unread` —— 未读站内消息(前端弹"你有 N 条新消息",**不进通知页就不标已读**) + * · `reminders` —— 落在 [now-2min, now+30s] 窗口内的日程提醒 + * + * ⚠️ 为什么"到点"要在**服务端**算,而不是前端拿 starts_at 自己减: + * 时区、`reminder_minutes` 是逗号串(多档)、重复日程…这些判断都在 + * PHP 侧已经有一套(reminderList / 可见性),前端再写一遍必然漂移。 + * 前端只负责"把服务端说该响的响出来"。 + * + * ⚠️ 窗口要**留过去的一点**(-2 分钟):浏览器标签页休眠/被节流时轮询会迟到, + * 只取"未来 30 秒"会整整错过一条(用户会看到"明明设了提醒却没弹")。 + * ⚠️ 但不能留太长:会把"我刚打开页面"时早就过去的提醒一次性全弹出来。 + * + * ⚠️ **不标已读**:浏览器的提示条不是"打开消息页",不该把消息标成已读。 + * 标已读只在用户真的进 `?r=notifications` 时发生。 + */ + case 'notifications/poll': + Auth::requireLogin(); + $me = (int)Auth::personId(); + $familyId = (int)Auth::familyId(); + header('Content-Type: application/json; charset=utf-8'); + $now = time(); + echo json_encode([ + 'unread' => Notification::unreadCount($me), + 'items' => array_map(static function (array $n): array { + return [ + 'id' => (int)$n['id'], + 'actor' => (string)($n['actor_name'] ?? ''), + 'body' => Notification::bodyWithoutActor((string)$n['title']), + 'icon' => Notification::icon((string)($n['kind'] ?? '')), + 'href' => Notification::href($n), + ]; + }, Notification::mine($me, 5, true)), + 'reminders' => Calendar::remindersBetween( + $familyId, $me, + date('Y-m-d H:i:s', $now - 120), + date('Y-m-d H:i:s', $now + 30) + ), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; + // ==================== 我自己的资料 / 个人设置 ==================== // 顶栏头像点开 →「个人设置」。访客也能改自己的资料 —— // 「个人信息」是每个人自己的事,不该因为角色是 guest 就连名字都写不了。 @@ -4460,6 +4507,11 @@ $want = array_keys($want); redirect('calendar/calendars'); } Calendar::setShare($id, $target, (string)($_POST['perm'] ?? 'read')); + // v32 第 2 条:别人把日历本共享给我 → 给我发一条通知 + // ⚠️ 放在**共享表写入之后**、按表现状重建(用户会反复改权限), + // 不要在这里手写"给谁发"——见 Notification::syncShares 的说明。 + Notification::syncShares((int)Auth::familyId(), Notification::CALENDAR, + $id, $me); flash('ok', '已共享。'); redirect('calendar/calendars'); break; @@ -4475,6 +4527,10 @@ $want = array_keys($want); redirect('calendar/calendars'); } Calendar::dropShare($id, (int)($_POST['person_id'] ?? 0)); + // v32 第 2 条:被移出共享 → 把他**未读**的那条通知一并撤销 + //(已读的保留:那是他看过的历史,删了像"消息会自己消失") + Notification::syncShares((int)Auth::familyId(), Notification::CALENDAR, + $id, $me); flash('ok', '已取消共享。'); redirect('calendar/calendars'); break; @@ -5119,6 +5175,9 @@ $want = array_keys($want); $target = (int)($_POST['person_id'] ?? 0); if ($target > 0 && isset(Person::map($familyId)[$target])) { Resource::setShare($id, (int)$r0['owner_id'], $target, (string)($_POST['perm'] ?? 'read')); + // v32 第 1+2 条:资源共享(顺带修掉"弹框里点共享没生效"之后, + // 这里才真正会被走到)→ 按共享表现状给被共享的人发通知 + Notification::syncShares($familyId, Notification::RESOURCE, $id, $me); flash('ok', '已共享。'); } redirect('resources'); @@ -5133,6 +5192,8 @@ $want = array_keys($want); $r0 = Resource::find($familyId, $id, $me); if ($r0 && Resource::canWrite($r0, $me)) { Resource::dropShare($id, (int)($_POST['person_id'] ?? 0)); + // v32 第 2 条:撤销他未读的那条共享通知(已读的保留) + Notification::syncShares($familyId, Notification::RESOURCE, $id, $me); flash('ok', '已取消共享。'); } redirect('resources'); @@ -6671,6 +6732,8 @@ $want = array_keys($want); redirect('notes/form', ['id' => $id]); } Note::setShare($id, $me, $pid, (string)($_POST['perm'] ?? 'read')); + // v32 第 2 条:别人把笔记共享给我 → 发一条通知(按共享表现状重建) + Notification::syncShares($familyId, Notification::NOTE, $id, $me); flash('ok', '共享已更新。'); redirect('notes/form', ['id' => $id]); break; @@ -6688,6 +6751,8 @@ $want = array_keys($want); redirect('notes'); } Note::dropShare($id, $pid); + // v32 第 2 条:撤销他未读的那条共享通知(已读的保留) + Notification::syncShares($familyId, Notification::NOTE, $id, $me); flash('ok', '已取消共享。'); redirect('notes/form', ['id' => $id]); break; diff --git a/templates/home.php b/templates/home.php index 2bee695..14e514b 100644 --- a/templates/home.php +++ b/templates/home.php @@ -369,17 +369,24 @@ unset($evRef);
  • @@ -453,11 +460,12 @@ unset($evRef); ); if (trim((string)$actorName) === '') { $actorName = '有人'; } ?> -
  • +
  • + - + diff --git a/templates/layout.php b/templates/layout.php index eb38206..b441a0b 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -90,6 +90,21 @@ $isLoggedIn = \App\Auth::check();
    + + + + + + + -
  • +
  • - +
    diff --git a/templates/resources/index.php b/templates/resources/index.php index de9141f..ff2a1ec 100644 --- a/templates/resources/index.php +++ b/templates/resources/index.php @@ -325,7 +325,7 @@ $myPerson = (int)\App\Auth::personId();
    - +
    @@ -367,7 +367,7 @@ $myPerson = (int)\App\Auth::personId();
    + action="" id="resAttForm" data-res-idform>

    添加附件

    @@ -387,7 +387,7 @@ $myPerson = (int)\App\Auth::personId(); - + @@ -586,6 +586,33 @@ $myPerson = (int)\App\Auth::personId(); var panel = panels[which]; if (!panel) { return; } + /* 🔴 v32 第 1 条真根因(用户:「弹框中,点击共享给某个人后,弹框消失, + 但是并没有共享成功。」): + 编辑面板现在是**三个页签**,每个页签有**自己的** `
    ` 和 + **自己的** ``(info=resources/update、att=attach_add、 + share=resources/share),面板末尾还有两个"唯一表单"(#resUnshareForm / + #resAttDelForm,也都带 name="id")。 + 而 v28 把三者并成一个面板时,这里写的是: + var idInp = panel.querySelector('input[name="id"]'); + idInp.value = id; // ← 只填了**第一个** + `querySelector` 返回**文档序里第一个**(info 那个 / resources/update), + 于是 att 与 **share** 两个页签的表单 `id` **一直是空** ⇒ 提交 `id=0` ⇒ + 后端 `Resource::find($familyId, 0, $me)` 返回 null ⇒ **静默不共享** + (只在列表页闪一句"这条资源不能共享。",用户多半没注意)。 + ⇒ 改成把 id 填进**这一屏里每一个会提交的表单**。 + ⚠️ 别写成"只填当前页签那份":用户会在弹框里切页签, + 数据一次灌好、切页签就是纯显示切换(与上面注释同一取舍)。 + ⚠️⚠️ 判据用**显式标记**(`[data-res-idform]`),**不要**按 action 匹配: + `url()` 走 http_build_query,斜杠是 URL 编码的 + (实测 `action="index.php?r=resources%2Fupdate"`), + 写 `action*="resources/update"` 一条都匹配不上 —— 看着"改了",其实没改。 */ + Array.prototype.forEach.call( + panel.querySelectorAll('form[data-res-idform]'), + function (f) { + var i = f.querySelector('input[name="id"]'); + if (i) { i.value = id; } + }); + // 每个面板都有的两样:标题回显 + 宿主 id var tt = panel.querySelector('.rd-title'); if (tt) { tt.textContent = title; }