v71c:首页「通知消息」改成正规卡片,样式直接复用通知页

用户 2026-10-21:
「先把web端的首页修一下,通知消息这个,也是首页的卡片,可以调整顺序和大小。
 另外,这个样式也不好看,我看个人头像点击后,通知消息那个页面的样式就不错,
 直接复用就可以了。」

① 卡片化(HomeCards 注册表 + 默认布局 + 取数)
   · TYPES 新增 'notif'(w4/h4,route=notifications,more=全部通知)
   · DEFAULT_LAYOUT 钉在 brief 之后,与 days 并一行(宽 4)——
     若插在 brief 与 agenda 之间,agenda/tasks 会被挤成一上一下(实测 C13 顶边 407 vs 751)
   · data() 加 case 'notif' → 新增 notifData():最新 10 条 + 未读数,
     与原来的取数口径一字不差;关掉卡片就不查这两条 SQL
   · templates/home.php 删掉网格前那块写死区块(v30 第 5 条「必须在往年的今天前面」
     这条约束作废 —— 那时还没有卡片体系);index.php 不再单独传 notifs/notifUnread

② 样式复用 templates/notifications/index.php
   · 结构与通知页完全一致:头像在前 → 正文 → kind 图标在右(带 ntf-kind-{kind})
     (旧首页那块是"图标在前、头像第二",正是用户说不好看的那一版)
   · 新增 .hc-ntf 作用域只补"卡片比整页小"的三条差异:正文允许折两行(宽 2 时
     单行省略会把正文全吃掉)、时间挪到正文下一行、未读数做小注脚
   · 不带每条右侧「删除」按钮(4×4 的卡里塞两个按钮会把正文挤成两三个字),
     删除在通知页做,卡片右上角有「全部通知」

③ 顺带修掉 v63 A1 抓住的一处:'notif' 的 mod 给 'home' 而不是空串
   (通知不在 Module::LIST,但它是首页自己的卡片;空串会让「全部通知」成为
   一排同色模块按钮里唯一白底 ghost 的那个)
   ⇒ TYPES docstring 立规矩:route 非空就必须有 mod,认不出归哪个模块就归 home

验证(familybook_push.sh 第 6 轮全绿,PUSH_RC=0,2h11m)
   v62 首页卡片 56/0 · v62 反向验证 14/0(新增 M8–M12 五条注入)
   b30 通知 16/0 · b30 反向验证 18/0
   v63 40/0 · v63 反向验证 51/0 · e2e 1523/0 · 部署静态断言空跑 RC=0

同批带上上一轮 v70/v71 的日历修复(已单独验过,本轮流水线一并回归):
   · v70 日历导入后条目不显示:+N 条从纯文本 span 改成真按钮,点开是当天全部
     条目浮层;AJAX 新建后就地插入与服务端同形(回 entry,不让前端自己拼)
   · v71 ICS 导入顺序:主事件 → 覆盖行 → EXDATE 由 Calendar::importIcs() 统一保证
This commit is contained in:
2026-10-21 13:22:37 +08:00
parent b2b103df0f
commit b45cf671d1
9 changed files with 810 additions and 83 deletions
+52 -24
View File
@@ -948,11 +948,14 @@ switch ($r) {
// v62 卡片体系
'homeCards' => $homeCards,
'cardData' => $cardData,
// v30 第 5 条:通知块(放在「往年的今天」之前,见 templates/home.php)
// ⚠️ 首页只给**最新的几条**(10 条)就够 —— 它是"瞥一眼"的地方,
// 真正的消息中心在 ?r=notifications。
'notifs' => Notification::mine($me, 10),
'notifUnread'=> Notification::unreadCount($me),
/* 🔴 v71c(用户 2026-10-21):「通知消息这个,也是首页的卡片,可以调整
顺序和大小。」
⇒ 通知**不再是**这里单独传的两个变量(`notifs` / `notifUnread`),
而是卡片自己的取数(`HomeCards::notifData()`,见 `_card_notif.php`)。
⚠️ 这样就**不可能**再出现"卡片被关掉了、SQL 还在跑"这种白查 ——
`data()` 只对用到的 key 取数。
⚠️ 头像下拉的未读角标**不在这里**:`layout.php` 自己调 unreadCount,
与首页摆不摆这张卡片无关(关掉卡片不该让角标也消失)。 */
], 'home');
break;
@@ -5487,9 +5490,11 @@ $want = array_keys($want);
flash('ok', $okMsg);
redirect($kind === 'movie' ? 'movies/show' : 'trips/show', ['id' => $newId]);
}
$evLoc = trim((string)($_POST['location'] ?? ''));
$evRrule = Repeat::build($_POST);
$evId = Calendar::addEvent((int)$cal['id'], [
'title' => $title,
'location' => trim((string)($_POST['location'] ?? '')),
'location' => $evLoc,
'starts_at' => $startsAt,
'ends_at' => $endsAt,
'all_day' => $allDay,
@@ -5501,7 +5506,7 @@ $want = array_keys($want);
'status' => (string)($_POST['status'] ?? 'confirmed'),
'transp' => !empty($_POST['transp']),
// 用户 2026-10-03 第 5、6 条:重复规则(RFC 5545 RRULE 原文)
'rrule' => Repeat::build($_POST),
'rrule' => $evRrule,
'note' => $note,
'created_by' => $me,
]);
@@ -5516,6 +5521,33 @@ $want = array_keys($want);
· cal —— data-cal,前端/样式按它分组。 */
if ($ajax) {
header('Content-Type: application/json; charset=utf-8');
/* 🔴 v70:「+N 条」点开的是**当天全部条目**浮层,数据来自服务端渲染好的
`#calDayData`。AJAX 新建一条之后必须**同步补进那份数据**,否则
"刚建的这条在 +N 条清单里看不到"(刷新一次又出现)。
⇒ 这里直接回一份 `entry`(就是 `fb_entry_json()` 的产出),
前端原样塞进去 —— **不要**让前端自己按字段拼:漏一个 location
或把 occ/repeat 拼丢,浮层里的详情、删除就会和服务端渲染的不一样。
⚠️ 字段与下面 `Calendar::month()` → `fb_entry_json()` 同一套口径。 */
$newDay = substr((string)$startsAt, 0, 10);
$newTm = ($allDay || $startsAt === '') ? '' : substr((string)$startsAt, 11, 5);
$newEtm = ($allDay || $endsAt === null || $endsAt === '') ? '' : substr((string)$endsAt, 11, 5);
$newHref = url('calendar/event', ['id' => (int)$evId, 'd' => $newDay]);
$newEntry = fb_entry_json([
'title' => $title,
'date' => $newDay,
'time' => $newTm,
'end_time' => $newEtm,
'all_day' => $allDay,
'cal' => (int)$cal['id'],
'calname' => (string)($cal['name'] ?? ''),
'location' => $evLoc,
'note' => $note,
'kind' => 'custom',
'ref_id' => (int)$evId,
'repeat' => $evRrule,
'occ' => $newDay,
'_can_write' => true, /* 刚在这本上建成功 ⇒ 当然可写 */
], $newHref, url('calendar/event', ['id' => (int)$evId, 'embed' => 1]));
echo json_encode([
'ok' => true,
'msg' => '日程已加上。',
@@ -5527,9 +5559,11 @@ $want = array_keys($want);
'ends_at' => (string)($endsAt ?? ''),
'all_day' => $allDay ? 1 : 0,
'cal' => (int)$cal['id'],
'calname' => (string)($cal['name'] ?? ''),
'color' => (string)($cal['color'] ?? ''),
'time' => ($allDay || $startsAt === '') ? '' : substr((string)$startsAt, 11, 5),
'href' => url('calendar/event', ['id' => (int)$evId, 'd' => substr((string)$startsAt, 0, 10)]),
'time' => $newTm,
'href' => $newHref,
'entry' => json_decode($newEntry, true),
],
], JSON_UNESCAPED_UNICODE);
exit;
@@ -5869,22 +5903,16 @@ $want = array_keys($want);
flash('err', '这个文件里没有找到任何日程(VEVENT)。');
redirect('calendar/calendars');
}
$added = 0;
$updated = 0;
foreach ($list as $v) {
if (($v['starts_at'] ?? null) === null) {
continue; // 没有 DTSTART 的 VEVENT 是坏数据,跳过而不是写成 1970 年
}
if (trim((string)$v['title']) === '') {
$v['title'] = '(无标题日程)';
}
if (Calendar::importEvent($calId, $v, $me) === 'added') {
$added++;
} else {
$updated++;
}
/* 🔴 v71(用户 2026-10-19):**不能**在这里 for 循环逐条 importEvent ——
那样同一 UID 下的覆盖行会先于主事件落库(ICS 里主事件不一定排第一),
或者反过来把主事件改掉。顺序由 `Calendar::importIcs()` 统一保证:
主事件 → 覆盖行 → EXDATE。 */
$stat = Calendar::importIcs($calId, $list, $me);
$msg = '导入完成:新增 ' . $stat['added'] . ' 条,更新 ' . $stat['updated'] . ' 条。';
if ($stat['overrides'] > 0) {
$msg .= '其中 ' . $stat['overrides'] . ' 条是重复日程里单独调整的某一次。';
}
flash('ok', '导入完成:新增 ' . $added . ' 条,更新 ' . $updated . ' 条。');
flash('ok', $msg);
redirect('calendar/calendars');
break;