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。
This commit is contained in:
2026-10-09 07:25:08 +08:00
parent be582569dd
commit df6fe894d0
8 changed files with 572 additions and 23 deletions
+86 -1
View File
@@ -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<int,array{id:int,title:string,starts_at:string,at:string,cal:string,href:string}>
* `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)。
*
+174 -1
View File
@@ -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 的拼法);
// 模板会把 `<b>他的名字</b>` 放在前面,所以这里把开头的"有人"去掉。
return (string)preg_replace('/^有人/u', '', $title);
}
/** 文案截断(多字节安全) */
private static function clip(string $s, int $max): string
{