From b45cf671d1c1340547da094f9d28269260a5c6be Mon Sep 17 00:00:00 2001
From: laoyang
Date: Wed, 21 Oct 2026 13:22:37 +0800
Subject: [PATCH] =?UTF-8?q?v71c=EF=BC=9A=E9=A6=96=E9=A1=B5=E3=80=8C?=
=?UTF-8?q?=E9=80=9A=E7=9F=A5=E6=B6=88=E6=81=AF=E3=80=8D=E6=94=B9=E6=88=90?=
=?UTF-8?q?=E6=AD=A3=E8=A7=84=E5=8D=A1=E7=89=87=EF=BC=8C=E6=A0=B7=E5=BC=8F?=
=?UTF-8?q?=E7=9B=B4=E6=8E=A5=E5=A4=8D=E7=94=A8=E9=80=9A=E7=9F=A5=E9=A1=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
用户 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() 统一保证
---
app/Calendar.php | 289 ++++++++++++++++++++++++++++++++-
app/HomeCards.php | 56 ++++++-
assets/css/app.css | 67 +++++++-
assets/js/entry_popup.js | 19 +++
index.php | 76 ++++++---
templates/calendar/index.php | 267 +++++++++++++++++++++++++++++-
templates/home.php | 51 +-----
templates/home/_card_notif.php | 66 ++++++++
templates/home_cards.php | 2 +
9 files changed, 810 insertions(+), 83 deletions(-)
create mode 100644 templates/home/_card_notif.php
diff --git a/app/Calendar.php b/app/Calendar.php
index 77767cb..983db7b 100644
--- a/app/Calendar.php
+++ b/app/Calendar.php
@@ -887,23 +887,258 @@ final class Calendar
return $rows;
}
+ /** 按 UID 在本子里找那条主事件的 id(找不到给 0) */
+ private static function eventIdByUid(int $calendarId, string $uid): int
+ {
+ if ($uid === '') {
+ return 0;
+ }
+ $st = Database::get()->prepare(
+ 'SELECT id FROM calendar_events WHERE calendar_id = ? AND uid = ? ORDER BY id LIMIT 1'
+ );
+ $st->execute([$calendarId, $uid]);
+ return (int)$st->fetchColumn();
+ }
+
/**
- * 导入一条 ics 里的 VEVENT。
+ * 覆盖行的 UID —— **只在"正文里有覆盖行、却没有对应主事件"时**才用。
+ *
+ * 为什么必须换 UID 而不能沿用原 UID:原 UID 是主事件的身份证。真拿它去插一条
+ * 独立日程,等主事件后来真的导进来时,`importEvent()` 会按 UID 撞上这条孤儿
+ * ⇒ `updateEvent()` 把主事件(连同 RRULE)改写成这一个孤儿的模样。
+ * 加 `#rid: 还没有通知。别人在观影、旅程里把你加进去时,这里就会有消息。 = (int)$ntfUnread ?> 条未读` 上,卡片里没有那个位置)。
+ ⚠️ 只在 `.hc-ntf` 下改,**不影响**通知页那一整页(那里宽度足够,单行更好读)。 */
+.hc-ntf .ntf-row { align-items: flex-start; }
+.hc-ntf .ntf-main { flex-direction: column; align-items: stretch; gap: 1px; }
+.hc-ntf .ntf-title {
+ white-space: normal;
+ display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
+ line-height: 1.4; word-break: break-word;
+}
+.hc-ntf .ntf-time { font-size: 11.5px; }
+.hc-ntf .ntf-kind { margin-top: 2px; }
+.hc-ntf-note {
+ margin: 0 0 6px; font-size: 12px; color: var(--brand-ink, var(--brand));
+ font-variant-numeric: tabular-nums;
+}
+
@media (max-width: 640px) {
/* 窄屏把时间挪到下一行,别把标题挤成两三个字 */
.ntf-main { flex-wrap: wrap; gap: 2px 10px; }
diff --git a/assets/js/entry_popup.js b/assets/js/entry_popup.js
index 48aa630..bd2fe14 100644
--- a/assets/js/entry_popup.js
+++ b/assets/js/entry_popup.js
@@ -372,6 +372,23 @@
setOpen(true);
}
+ /**
+ * 直接用**数据对象**开详情层(不需要页面上存在对应的 DOM 元素)。
+ *
+ * 起源 v70(用户 2026-10-20 第 2 条):月视图的「+N 条」点开是"当天的清单",
+ * 清单里的每一条并没有自己的 ``(它们只是清单里的一行文字),
+ * 而 `openEntry(el)` 只吃元素 ⇒ 清单项点不进详情。
+ * ⇒ 抽出这个入口,`openEntry()` 也走它,两条路径共用同一套渲染与底栏规则。
+ *
+ * ⚠️ 数据结构必须与 `fb_entry_json()` 产出的一致(服务端保证)。
+ */
+ function openEntryData(data) {
+ if (!data || typeof data !== 'object') { return; }
+ detailCache = data;
+ paintDetail(data);
+ setOpen(true);
+ }
+
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
@@ -436,5 +453,7 @@
这里不再另挂 submit 监听:原生提交就是要它跳走。 */
window.fbEntryOpen = openEntry;
+ /* v70:按**数据对象**开详情层(月视图「+N 条」的清单项用这个入口) */
+ window.fbEntryOpenData = openEntryData;
window.fbEntryClose = function () { setOpen(false); };
})();
\ No newline at end of file
diff --git a/index.php b/index.php
index 724d06d..94416e1 100644
--- a/index.php
+++ b/index.php
@@ -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;
diff --git a/templates/calendar/index.php b/templates/calendar/index.php
index 15790fe..0fb601b 100644
--- a/templates/calendar/index.php
+++ b/templates/calendar/index.php
@@ -451,6 +451,45 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
+ ` 而不是把 JSON 塞进 data-*:
+ data-* 属性里几千条会被 HTML 转义得又长又容易截断;
+ JSON 里若出现 `` 也会截断 —— 所以下面统一走 json_encode 的
+ JSON_HEX_TAG(把 < > 转成 \u003C \u003E,永远不会提前闭合)。
+ ⚠️ 这是**渲染层**的便利数据,字段与 `data-fb-entry` 同一套(fb_entry_json),
+ 所以浮层能沿用全部既有逻辑(详情/编辑/删除/来源判断)。 */
+ $dayData = [];
+ foreach (($weeks ?? []) as $wk) {
+ foreach ($wk as $cell) {
+ if (!$cell['events']) {
+ continue;
+ }
+ $items = [];
+ foreach ($cell['events'] as $ev) {
+ $evKind = (string)($ev['kind'] ?? 'custom');
+ $evRef = (int)($ev['ref_id'] ?? 0);
+ $evEdit = '';
+ if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
+ elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
+ elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
+ elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
+ $items[] = ['_rank' => \App\Calendar::displayRank($ev),
+ '_time' => (string)($ev['time'] ?? '')]
+ + json_decode(
+ fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit),
+ true
+ );
+ }
+ $dayData[(string)$cell['date']] = $items;
+ }
+ }
+ ?>
+
- 通知消息
- 0): ?>= (int)$notifUnread ?> 条未读
-
-
-
- $n['actor_name'] ?? '', 'my_nickname' => $n['actor_nick'] ?? '']
- );
- if (trim((string)$actorName) === '') { $actorName = '有人'; }
- ?>
-
-
-
-
- = e((string)$actorName) ?>= e(\App\Notification::bodyWithoutActor((string)$n['title'])) ?>
-
- = e(fb_time_ago((string)$n['created_at'])) ?>
-
-
+
+ $n['actor_name'] ?? '', 'my_nickname' => $n['actor_nick'] ?? '']
+ );
+ if (trim((string)$ntfActor) === '') { $ntfActor = '有人'; }
+ $ntfKind = (string)($n['kind'] ?? '');
+ ?>
+
+
+
+
+
+ = e((string)$ntfActor) ?>
+ = e(\App\Notification::bodyWithoutActor((string)$n['title'])) ?>
+
+ = e(fb_time_ago((string)$n['created_at'])) ?>
+
+
+
关掉的卡片不会丢数据,只是不摆在首页上。
+
「通知消息」与其它卡片一样可以调顺序和大小 —— 宽 2 时正文会折两行,
+ 想看完整的一句话就给它 4 格或更宽。