v30 后半(5 条):导入框选中反馈+单选 / 首页日程改色块 / 近期任务主题色 / 时间冲突错开 / 通知消息模块

用户 2026-10-08 第二批 5 条。

1. 导入日程选中文件后没有任何反应 + 不允许多选
   `_stage_drop.php` 现在有两种模式:暂存型(附件,拖进来即刻上传)与导入型
   (`$sdStage=false`,写回 input.files、随表单提交、PHP 读完即弃)。
   暂存型框靠 queueDrop 画缩略图,而**导入型框选中文件后什么都不显示** ——
   框里那句「点击或拖动进行上传」从头到尾不变,用户看不出"到底选上没有"。
   改:app.js 新增 bindFileEcho(),只给**非暂存框**补 change 监听,
   选中后框里的话换成文件名 + 图标变 ✓ + `.upbox.is-picked` 稳定态;
   取消选择则还原原始文案(不是空字符串)。单选由 `$sdMultiple=false` 保证。

2. 首页「今天的日程」用色块表示,和日程保持一致
   原来那套是"白底卡片 + 左边一条 3px 色线",色只占一条线,看不出是哪个日历本。
   改:与日历页 `.tg-block` 同款实心色块(底色=日历本色,白字,圆角,无阴影),
   left/width/top/height 全部由内联给出(冲突分列 + 真实时长)。
   ⚠️ `.ag-ev` 的 CSS 里去掉 `right` —— 绝对定位下 left+right+width 三者同时存在是
      过约束,内联 width 会失效(冲突就不会错开了)。

3. 首页「近期任务」名称与圆点走任务模块主题色
   原来用 `var(--brand)` —— 那是**当前页面**(首页)的主题色,不是任务模块的;
   用户改过模块配色时两者不一致(首页砖红、任务别的色),正是他看到的"不一致"。
   改:两处都走 `--tk-c`(= Module::color('tasks')),与右上角「全部任务」按钮同源。
   逾期仍用红色(状态语义,不能被主题色盖掉)。

4. 日程时间冲突错开(算法借鉴 SyncCalendar)
   用户指定去读 SyncCalendar 的算法。落地自
   `entry/src/main/ets/common/TimelineUtil.ets` 的 build()/groupsOf():
   ① 划分冲突组(与组内最晚结束仍重叠就并入);② 组内贪心分列(区间图着色),
   列数 = **每组各自的最大同时重叠数**(sync 原注释点名的反例:传递性重叠但
   B/C 不重叠 → 只该 2 列)。落成 `App\Calendar::conflictLanes()`(PHP,判据一字未改)。
   ⚠️ 两处比原算法更细,都有单测钉住:
     · 列数按**组**算 —— 否则一个孤立日程会被别处的三方冲突连累成 1/3 宽;
     · "缺结束时间"不能一律按 30 分钟参与冲突(会与后面所有日程假冲突),
       改为优先拿**同列下一条的起点**当它的视觉结束 → 因此必须**先分列再算高度**。
   首页与日历页共用同一套(用户第 2 条要求"和日程保持一致")。

5. 通知消息模块(Schema v30 → v31)
   不在导航显示,入口在头像下拉里、**排在个人设置之前**,名字「通知消息」;
   有人在观影/旅程加我时发通知;通知出现在首页、**往年今天之前**。
   新增:app/Notification.php + notifications 表(唯一键
   (person_id,target_type,target_id) —— 参与人是整组替换,没有它改三次标题就收三条)+
   ?r=notifications{,/del,/clear} + templates/notifications/index.php + 头像下拉未读角标 +
   app/helpers.php 的 fb_time_ago()。
   ⚠️ 三条设计判断:不进 Module::LIST(进去就会被顶栏渲染、被"模块显示与排序"当开关);
      只在 person_id !== actor_id 时发(不给自己发,"通知"≠流水账);
      移出参与人**撤销未读**、**保留已读**(未读是没送达的提醒,已读是看过的历史)。

验证(全部 0 失败):e2e 1478/0(+14 条新断言)、升级 153/0、vCard 40 项、城市 32/0、
几何 428/0 + 移动 680/0 + 交互 92/0、b24/b27/b28/b29 真机全绿;
冲突分列算法单测 12/0(含 SyncCalendar 点名的反例)、
新探针 _b30_ui2 16/0 + _b30_notify 16/0 + _b30_import 26/0、
反向验证 18/0(五条各自改回旧行为 → 断言全红)、
部署脚本新增 9 条守卫 + 守卫证伪 11/0。线上 11 文件 SHA256 一致,Schema v31 已升级。
This commit is contained in:
2026-10-08 17:41:30 +08:00
parent 91c55038c9
commit 2a10df2557
11 changed files with 918 additions and 31 deletions
+213
View File
@@ -0,0 +1,213 @@
<?php
/**
* 通知消息(v30 第 5 条)。
*
* 用户原话:「引入通知模块,这个模块,不在导航显示,在个人头像点击那里显示,
* 放在个人设置前面,名字:通知消息。就是当有人在观影、旅程等地方
* 加我的时候,发一个通知,通知出现在首页中,往年今天的前面。」
*
* ────────────────────────────────────────────────────────────────
* 五条设计判断(每条都对应一个"不这么做就会出的问题"):
*
* ① **不在 Module::LIST 注册**。它不是"一个可浏览的数据模块",而是"给我看的消息"。
* 注册进 LIST 就会被 `Module::navModules()` 拿去渲染顶栏(用户明确要求"不在导航显示"),
* 还会出现在个人设置的"模块显示与排序"里 —— 那里对它是没有意义的开关。
* ⇒ 它是一个**独立页面**(`?r=notifications`),入口只在头像下拉里。
*
* ② **只通知"别人把我也加进去了",不通知"我自己建的"**。
* 这是"通知"与"流水账"的分界:我自己建一条观影、列表里带上家人,
* 不该给自己发一条"有人加了你"。所以只对 `personId !== actorId` 的收件人写入。
*
* ③ **按"新增"发,按"删除"撤销**。整组替换(`saveAttendees` 先 DELETE 再 INSERT)
* 会产生一个很具体的坏情况:用户把参与人从 [A,B] 改成 [A],
* 如果不撤销,B 的通知还挂在那儿 —— "我明明被移出去了,怎么还说加了我"。
* ⇒ `syncForTarget()` 把"这次要保留的收件人"传进来,多出来的**按未读删掉**。
* ⚠️ 已读的**不删**(那是用户已经看过的历史,删了像"消息会自己消失");
* 未读的才是"还没送达的提醒",撤回它才是对的。
*
* ④ **一次编辑不重复发**。UNIQUE KEY (person_id, target_type, target_id)
* + INSERT IGNORE:同一件事反复保存只会有一条通知。
* 否则用户改三次标题就收到三条"加了你"。
*
* ⑤ **不做邮件/推送**。用户要的是"站内消息"("通知出现在首页中")。
* 发邮件要 SMTP 配置、还要防打扰,那是另一个模块的事,这里只做站内。
*/
declare(strict_types=1);
namespace App;
final class Notification
{
/** 宿主类型(与 attachments.target_type 同一套词,便于将来复用跳转逻辑) */
public const MOVIE = 'movie';
public const TRIP = 'trip';
/** 列表一次最多显示多少条(首页那块只取前几条,见 unreadCount / recent) */
public const PAGE_SIZE = 50;
/**
* 同步某个宿主的"加人"通知。
*
* 调用时机:观影/旅程保存参与人**之后**(见 Movie::saveAttendees / Trip 的对应处)。
*
* @param int $familyId
* @param string $type 宿主类型('movie' / 'trip')
* @param int $targetId 宿主 id
* @param string $title 宿主标题(通知里要显示"《xxx》")
* @param int $actorId 动作发出者(谁把别人加进去的)
* @param int[] $keepIds 这次**保留**的参与人(总集)
* @param string $href 点通知跳哪儿(相对 url() 的 r 值,如 'movies/show')
* @param array $params href 的查询参数(如 ['id'=>12])
*/
public static function syncForTarget(int $familyId, string $type, int $targetId,
string $title, int $actorId, array $keepIds,
string $href, array $params = []): void
{
if ($targetId <= 0 || $familyId <= 0) {
return;
}
$db = Database::get();
// ---- ① 收件人 = 保留列表 - 动作发出者(不给自己发,见头部第 ②条)----
$keep = [];
foreach ($keepIds as $pid) {
$pid = (int)$pid;
if ($pid > 0 && $pid !== $actorId) {
$keep[$pid] = true;
}
}
// ---- ② 撤销:这次不在保留列表里的**未读**通知,删掉(见头部第 ③条)----
$st = $db->prepare(
'SELECT id, person_id FROM notifications
WHERE target_type = ? AND target_id = ? AND is_read = 0'
);
$st->execute([$type, $targetId]);
$del = $db->prepare('DELETE FROM notifications WHERE id = ?');
foreach ($st->fetchAll() as $row) {
if (!isset($keep[(int)$row['person_id']])) {
$del->execute([(int)$row['id']]);
}
}
// ---- ③ 新增:给每个收件人写一条(INSERT IGNORE 靠唯一键去重,见第 ④条)----
if (!$keep) {
return;
}
$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())'
);
$kindWord = $type === self::MOVIE ? '观影' : ($type === self::TRIP ? '旅程' : '记录');
$body = '有人把你加进了' . $kindWord . '「' . self::clip($title, 60) . '」';
foreach (array_keys($keep) as $pid) {
$ins->execute([
$familyId, (int)$pid, $actorId, $type, $body,
$href, $params ? json_encode($params, JSON_UNESCAPED_UNICODE) : null,
$type, $targetId,
]);
}
}
/**
* 宿主被删除时,把它的通知一并清掉。
* ⚠️ 不做外键(target_type 指向多张表,外键表达不了)→ 必须手动清,
* 否则通知点进去 404(本项目在 attachments 上就是这么做的,同一约定)。
*/
public static function removeForTarget(string $type, int $targetId): void
{
if ($targetId <= 0) {
return;
}
Database::get()->prepare(
'DELETE FROM notifications WHERE target_type = ? AND target_id = ?'
)->execute([$type, $targetId]);
}
/**
* 我的通知(最新在前)。
*
* ⚠️ 「我叫他什么」在**另一张表**(`nicknames`,按 viewer+target 存),
* `persons` 上**没有** `my_nickname` 这一列 —— 必须 LEFT JOIN 取。
* (本项目所有"显示某个人的称呼"的地方都是这么做的,见 Movie::attendees。)
* ⚠️ join 条件里要带**收件人**($personId)当 viewer:称呼是"我对他"的,
* 不是"他对我"的 —— 传错方向会让通知里出现别人叫他的名字。
*/
public static function mine(int $personId, int $limit = self::PAGE_SIZE, bool $unreadOnly = false): array
{
$sql = 'SELECT n.*, p.name AS actor_name, p.avatar AS actor_avatar,
nk.nickname AS actor_nick
FROM notifications n
LEFT JOIN persons p ON p.id = n.actor_id
LEFT JOIN nicknames nk ON nk.target_id = n.actor_id AND nk.viewer_id = ?
WHERE n.person_id = ?';
$params = [$personId, $personId];
if ($unreadOnly) {
$sql .= ' AND n.is_read = 0';
}
$sql .= ' ORDER BY n.id DESC LIMIT ' . max(1, min(200, $limit));
$st = Database::get()->prepare($sql);
$st->execute($params);
return $st->fetchAll();
}
/** 未读数(首页那块与头像下拉的角标都用它) */
public static function unreadCount(int $personId): int
{
$st = Database::get()->prepare(
'SELECT COUNT(*) FROM notifications WHERE person_id = ? AND is_read = 0'
);
$st->execute([$personId]);
return (int)$st->fetchColumn();
}
/** 全部标记已读(进通知页就自动全读 —— 与"消息中心"的通用做法一致) */
public static function markAllRead(int $personId): int
{
$st = Database::get()->prepare(
'UPDATE notifications SET is_read = 1 WHERE person_id = ? AND is_read = 0'
);
$st->execute([$personId]);
return $st->rowCount();
}
/** 删除一条(只能删自己的 —— 与其它模块同一道越权栅栏) */
public static function delete(int $id, int $personId): bool
{
$st = Database::get()->prepare(
'DELETE FROM notifications WHERE id = ? AND person_id = ?'
);
$st->execute([$id, $personId]);
return $st->rowCount() > 0;
}
/** 清空我的(只删已读的?不 —— 用户点"清空"就是要清空,未读也清) */
public static function clearMine(int $personId): int
{
$st = Database::get()->prepare('DELETE FROM notifications WHERE person_id = ?');
$st->execute([$personId]);
return $st->rowCount();
}
/** 通知的跳转地址(params 是 json 存的,坏数据就当没有参数) */
public static function href(array $n): string
{
$params = [];
if (!empty($n['params'])) {
$dec = json_decode((string)$n['params'], true);
if (is_array($dec)) {
$params = $dec;
}
}
return url((string)$n['href'], $params);
}
/** 文案截断(多字节安全) */
private static function clip(string $s, int $max): string
{
$s = trim(preg_replace('/\s+/u', ' ', $s));
return $s === '' ? '(无标题)' : mb_substr($s, 0, $max, 'UTF-8');
}
}