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 格或更宽。