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:` 后缀既保证**跨次导入稳定**(不会重复插), + * 又让两件事在 UID 上彻底分开。 + */ + private static function orphanUid(string $uid, string $rid): string + { + $suffix = '#rid:' . substr($rid, 0, 32); + if ($uid === '') { + return self::newUid(); + } + // `calendar_events.uid` 是 VARCHAR(255):先按余量截断再拼后缀 + $keep = 255 - mb_strlen($suffix, 'UTF-8'); + return mb_substr($uid, 0, max(1, $keep), 'UTF-8') . $suffix; + } + + /** + * 写一条**覆盖行**(重复系列里被改写的那一次)。 + * + * 语义(RFC 5545 §3.8.4.4):`RECURRENCE-ID` 指明替换的是**原发生时刻**, + * 这一行自己的 `starts_at` 是**改到的新时间**。所以: + * · 主事件(`calendar_events`)**一个字都不动** —— 尤其不能碰它的 `rrule`; + * · 覆盖行落进 `calendar_event_overrides`(卫星表,既有的取数链已经全认它); + * · 顺手把**原发生那一天**记进 skips,否则网页/APP/CalDAV 会同时画出 + * "原时间那次"和"改后那次",变成一天两条。 + */ + public static function upsertOverride(int $eventId, array $v, string $rid): void + { + $rid = substr(trim($rid), 0, 64); + $title = trim((string)($v['title'] ?? '')); + Database::get()->prepare( + 'INSERT INTO calendar_event_overrides + (event_id, recurrence_id, title, location, starts_at, ends_at, all_day, + note, status, sequence, extra) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE + title = VALUES(title), location = VALUES(location), + starts_at = VALUES(starts_at), ends_at = VALUES(ends_at), + all_day = VALUES(all_day), note = VALUES(note), status = VALUES(status), + sequence = VALUES(sequence), extra = VALUES(extra)' + )->execute([ + $eventId, + $rid, + mb_substr($title !== '' ? $title : '(无标题日程)', 0, 200, 'UTF-8'), + self::textOrNull((string)($v['location'] ?? ''), 255), + (string)$v['starts_at'], + ($v['ends_at'] ?? null) ?: null, + !empty($v['all_day']) ? 1 : 0, + self::textOrNull((string)($v['note'] ?? '')), + self::statusOf($v['status'] ?? null), + (int)($v['sequence'] ?? 0), + self::textOrNull((string)($v['extra'] ?? '')), + ]); + $day = self::ridDay($rid); + if ($day !== '') { + self::skipOnce($eventId, $day); + } + } + + /** + * 导入一份 ics 的**全部 VEVENT**(网页「导入 .ics」走这个,别自己 for 循环调 + * `importEvent`)。 + * + * 🔴🔴 v71(用户 2026-10-19)修的真 bug —— 三个日历本导入后"日程不全": + * + * 用户原话:「日历本共享后,双方都能看到对方共享给自己的日历本,但是有两个 + * 日历本,里面的日程是不对的,或者说不全的。有很多日程都没有…我把这三个 + * 日历本的原来的ics给你,你可以比对一下,这三个ics和数据库中是否一致。」 + * + * 实测三份 ics 与线上库逐条对账(2026-10-19): + * + * 本子 VEVENT 不同UID 有RID 库内条数 库里rrule + * Bowieの课表 28 14 14 14 7 + * 工作学习 411 385 28 385 19 + * 重要 _ 健康 204 148 60 148 29 + * + * `库内条数 == 不同 UID 数` 是铁证:这些文件里**同一个 UID 下有多个 VEVENT** + * (1 条带 RRULE 的主事件 + 若干条带 `RECURRENCE-ID` 的**覆盖行**,也就是 + * "这几节课单独调课/补课")。而 `importEvent()` 只按 `(calendar_id, uid)` 判重 + * ⇒ 第 2 条起全走 `updateEvent()`,把主事件**覆盖行一条条改掉**。 + * 后果有两层,第二层更致命: + * ① 覆盖行本身全丢(Bowie 的课表少 14 条、工作学习少 26 条、重要/健康少 56 条); + * ② **主事件被改写成最后一条覆盖行的样子**(时间挪了、`rrule` 被清空) + * ⇒ 一整个"每周三的系列"退化成"某一天的一条日程",系列里其余几十次 + * 一起消失。库里残留 RRULE 数 = ICS 的 RRULE 数 − 有多 VEVENT 的 UID 数, + * 三个本子分别 13−6=7 / 25−6=19 / 53−24=29,与实测 7/19/29 **完全吻合**。 + * + * 所以正确做法是分三遍走:主事件先落 → 覆盖行挂到主事件上 → EXDATE 最后替换。 + * + * @param array $list `Ics::parse()` 的返回值 + * @return array{added:int,updated:int,overrides:int,skips:int} + */ + public static function importIcs(int $calendarId, array $list, int $createdBy): array + { + $added = 0; + $updated = 0; + $ovrN = 0; + + $skipByUid = []; // uid => [Y-m-d…](EXDATE 要先等主事件有了 id 才能落) + $defer = []; // 覆盖行留到第二遍(主事件必须已经在库里) + + /* ---- 第一遍:主事件(没有 RECURRENCE-ID 的那些)---- */ + $seenUid = []; + foreach ($list as $v) { + if (($v['starts_at'] ?? null) === null) { + continue; // 没有 DTSTART 的 VEVENT 是坏数据,跳过而不是写成 1970 年 + } + if (trim((string)$v['title']) === '') { + $v['title'] = '(无标题日程)'; + } + $rid = trim((string)($v['recurrence_id'] ?? '')); + if ($rid !== '') { + $defer[] = $v; + continue; + } + $uid = (string)($v['uid'] ?? ''); + if ($uid !== '') { + if (isset($seenUid[$uid])) { + /* 同一份文件里同 UID 出现**多个无 RECURRENCE-ID** 的 VEVENT: + 协议上说不通(一个系列只能有一个主事件),当成覆盖行更错 + (没有 RECURRENCE-ID 就不知道该替换哪一次)。 + 宁可只收第一条,也不要把主事件越改越歪。 */ + continue; + } + $seenUid[$uid] = true; + } + if (self::importEvent($calendarId, $v, $createdBy) === 'added') { + $added++; + } else { + $updated++; + } + if ($uid !== '' && !empty($v['skip_dates'])) { + $skipByUid[$uid] = array_merge($skipByUid[$uid] ?? [], (array)$v['skip_dates']); + } + } + + /* ---- 第二遍:覆盖行 ---- */ + foreach ($defer as $v) { + $uid = (string)($v['uid'] ?? ''); + if ($uid !== '' && !empty($v['skip_dates'])) { + $skipByUid[$uid] = array_merge($skipByUid[$uid] ?? [], (array)$v['skip_dates']); + } + $r = self::importEvent($calendarId, $v, $createdBy); + if ($r === 'override') { + $ovrN++; + } elseif ($r === 'added') { + $added++; + } else { + $updated++; + } + } + + /* ---- 第三遍:EXDATE(`calendar_event_skips`)---- + ⚠️ **整组替换**而不是逐条追加:ics 是这本日期的权威快照,重新导入 + 就该是文件里写的那些。但**文件完全没写 EXDATE 的 UID 一律不动** —— + 很多导出格式只在需要时才吐 EXDATE,无脑清空会把用户在网页上 + 手动取消的那些"这一次"全抹掉。 + 🔴 必须把**第二遍写进去的覆盖行日期并回来**:`upsertOverride()` 为了 + "别一天画两条"会把原发生日记进 skips,而 `setSkips()` 是整组替换 + ⇒ 直接拿 EXDATE 覆盖会把它们全删掉,于是原时间那次和改后那次 + 一起画出来(本轮实测就是这么翻的:覆盖行 56 条,skip 却只剩 EXDATE 那些)。 */ + $skipN = 0; + foreach ($skipByUid as $uid => $dates) { + $eid = self::eventIdByUid($calendarId, (string)$uid); + if ($eid <= 0) { + continue; + } + $clean = array_values(array_unique(array_filter(array_map( + static fn($d) => substr(trim((string)$d), 0, 10), + (array)$dates + ), static fn($d) => (bool)preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)))); + if (!$clean) { + continue; + } + // 覆盖行占掉的那些天:它们必须继续被跳过 + foreach (self::overridesOf($eid) as $o) { + $od = self::ridDay((string)($o['recurrence_id'] ?? '')); + if ($od !== '' && !in_array($od, $clean, true)) { + $clean[] = $od; + } + } + self::setSkips($eid, $clean); + $skipN += count($clean); + } + + return [ + 'added' => $added, + 'updated' => $updated, + 'overrides' => $ovrN, + 'skips' => $skipN, + ]; + } + + /** + * 导入**一条** ics 里的 VEVENT。 * * ⚠️ 判重用 UID + 日历本:同一本日历里 UID 已存在就**更新**而不是再插一条 —— * 否则同一个 .ics 导入两次,日历里就是两份一模一样的日程(用户最常见的操作 * "导一次没看清,再导一次"会立刻翻车)。 * - * @return string 'added' | 'updated' + * 🔴 v71:带 `RECURRENCE-ID` 的 VEVENT **不是**一条独立日程,而是"系列里某一次" + * 的替换 ⇒ 必须落进 `calendar_event_overrides`,**绝不能** `updateEvent()` + * 去改主事件(那会把主事件的 RRULE 和时刻一起改掉)。 + * ⚠️ 只有这里返回 `'override'` —— 网页导入请走 `importIcs()`, + * 它负责顺序(主事件先落、覆盖行后挂),单独调本函数会漏掉顺序。 + * + * @return string 'added' | 'updated' | 'override' */ public static function importEvent(int $calendarId, array $v, int $createdBy): string { $db = Database::get(); $uid = (string)($v['uid'] ?? ''); - if ($uid !== '') { - $st = $db->prepare('SELECT id FROM calendar_events WHERE calendar_id = ? AND uid = ?'); - $st->execute([$calendarId, $uid]); - $old = (int)$st->fetchColumn(); + $rid = trim((string)($v['recurrence_id'] ?? '')); + + if ($rid !== '') { + $master = self::eventIdByUid($calendarId, $uid); + if ($master > 0) { + self::upsertOverride($master, $v, $rid); + return 'override'; + } + /* 只有覆盖行、没有主事件的残缺文件(实测「重要/健康」有 4 组、 + 「工作学习」有 2 组):退化成一条独立日程,但 **UID 要换掉** —— + 沿用原 UID 的话,主事件日后真导进来会被这条撞成"改掉主事件"。 + ⚠️ 换过之后**必须再按新 UID 找一次**:否则同一份文件导第二次时, + 按原 UID 永远找不到上一轮那条孤儿 ⇒ 每次都再插一条(实测 +4 / +2)。 */ + $uid = self::orphanUid($uid, $rid); + $v['uid'] = $uid; + unset($v['recurrence_id']); + $again = self::eventIdByUid($calendarId, $uid); + if ($again > 0) { + self::updateEvent($again, $v); + return 'updated'; + } + } elseif ($uid !== '') { + $old = self::eventIdByUid($calendarId, $uid); if ($old > 0) { self::updateEvent($old, $v); return 'updated'; @@ -1511,12 +1746,54 @@ final class Calendar usort($out, static function ($a, $b) { return strcmp($a['date'], $b['date']) + ?: (self::displayRank($a) - self::displayRank($b)) ?: strcmp((string)$a['time'], (string)$b['time']) ?: strcmp($a['title'], $b['title']); }); return $out; } + /** + * 同一天里的**展示优先级**(小者靠前)。 + * + * 🔴 v70(用户 2026-10-20 第 2 条):「刚导入进去一些日历,新增加了三个日历本, + * 有两个导入的日程,完全看不到,Bowie的课表和工作学习,这两个。 + * 日历导入进去了,但是不显示,看看咋回事。」 + * + * 真根因**不在导入、也不在共享**(数据都在库里、`mine()` 与 `month()` 都取得到): + * 月视图每格只画前 3 条(模板 `$n <= 3`),而原来的排序是 + * 「日期 → 时间 → 标题」,**全天条目时间空串排最前** ⇒ 每天固定有三条 + * 「农历 / 节假日 / 节气」「纪念日」这类派生条目霸占头三格, + * 用户自己导入的时间型日程一律落到 "+N 条" 里 —— 而那个 "+N 条" + * 只是一颗**没有任何点击行为**的 ,点不开,于是"完全看不到"。 + * + * ⇒ 让**用户真正关心的东西**(自己/共享进来的日程、任务、观影、旅程)先上, + * 把每天都会自动生成、信息密度最低的派生条目压到最后: + * 0 有时间安排的自己的日程(带时刻的,最需要"今天几点做什么") + * 1 全天日程(生日/请假这类,也是用户自己填的) + * 2 任务 / 观影 / 旅程(来自别的模块,但仍是"实事") + * 3 纪念日(自动推导,但一年一次、有意义) + * 4 农历 / 节假日 / 传统节日 / 节气(**每天都有一条**,压到最后) + * + * ⚠️ 只在**同一天内**比较(外面那层先按 date 排),不会把不同日子的顺序搞乱。 + * ⚠️ 与月份/时间无关,是纯函数 —— 守卫与反向验证可以直接断言这张表。 + */ + public static function displayRank(array $ev): int + { + $kind = (string)($ev['kind'] ?? 'custom'); + if ($kind === 'custom') { + return !empty($ev['all_day']) ? 1 : 0; + } + if ($kind === 'task' || $kind === 'movie' || $kind === 'trip') { + return 2; + } + if ($kind === 'anniv') { + return 3; + } + // lunar / holiday / traditional / solarterm 以及将来新增的派生本子 + return 4; + } + /** * 🌟 时间冲突分列(v30 第 4 条)—— 「两个日程撞在一起不再堆叠,要错开画」。 * diff --git a/app/HomeCards.php b/app/HomeCards.php index e3246be..ef4d7d0 100644 --- a/app/HomeCards.php +++ b/app/HomeCards.php @@ -56,6 +56,13 @@ final class HomeCards * 那张白名单放在模板里 —— 新加一张带按钮的卡片就忘了加,按钮默默变灰。 * 现在登记在这里:**有 route 就该有 mod**,两件事一次改完。 * ⚠️ `mod` 只影响按钮配色,不参与权限/路由;认不出的 key 走 `Module::color` 的兜底。 + * 🔴 v71c 补一条硬规矩(拿 v63 的 A1 换来的): + * **`route` 非空就必须有 `mod`**,而且 `mod` 必须是 `Module::LIST` 里的键 + * (认不出归哪个模块就归 `home`)。 + * 反例:通知卡片当初 `route='notifications'` 却把 `mod` 留成空串, + * 于是它成了整排按钮里**唯一白底 ghost** 的那个 —— + * 而 "通知" 本来就不在 `Module::LIST`,空串看着"很合理"。 + * 判据不能靠自觉,A1 那条断言(带按钮的卡片都得有色)就是它的守卫。 */ public const TYPES = [ 'brief' => [ @@ -72,6 +79,20 @@ final class HomeCards 'w' => 4, 'h' => 8, 'route' => 'tasks', 'more' => '全部任务', 'n' => 0, 'mod' => 'tasks', ], + /* 🔴 v71c(用户 2026-10-21):「通知消息这个,也是首页的卡片,可以调整顺序和大小。」 + ⇒ 它从"网格外的写死区块"变成**正规卡片**:可拖顺序、可调宽高、可关掉。 + 🔴 `mod` 给 **`home`**,不是空串: + 通知**不是** `Module::LIST` 里的模块(见 Notification 头部第 ①条), + 但它是**首页自己**的一张卡片 ⇒ 右上角「全部通知」按钮用**首页的主题色**。 + 早先留空串(想让它走 `btn-ghost`)被 v63 的 A1 当场抓住: + 「带按钮的卡片都得有模块色」—— 空串会让它变成白底 ghost, + 在一排同色的模块按钮里**唯独它不一样**,那正是 v63 要消灭的形态。 + ⇒ 规矩落地成:**`route` 非空就必须有 `mod`**,认不出归哪个模块就归 `home`。 */ + 'notif' => [ + 'name' => '通知消息', 'desc' => '谁把你加进了哪件事(与通知页同一套样子)', + 'w' => 4, 'h' => 4, 'route' => 'notifications', 'more' => '全部通知', 'n' => 0, + 'mod' => 'home', + ], 'memories' => [ 'name' => '往年的今天', 'desc' => '往年同一天的观影与旅程', 'w' => 4, 'h' => 4, 'route' => '', 'more' => '', 'n' => 0, 'mod' => '', @@ -116,9 +137,17 @@ final class HomeCards */ public const DEFAULT_LAYOUT = [ ['k' => 'brief', 'w' => 8, 'h' => 2], + /* v71c(用户 2026-10-21):通知卡片钉在**概述下面第一张**。两个约束同时满足: + ① v30 第 5 条"通知出现在首页,往年的今天前面" —— 默认摆位就满足; + ② 日程与任务必须**并排**(都是 w4/h8,是"今天要做什么"的一对)—— + 所以通知占 4 宽、与另一张 4 宽的(纪念日)并成一行,**不插在它们中间**。 + 早先直接写在 brief 与 agenda 之间、宽 4,则 agenda/tasks 被挤成 + 一上一下(实测 C13:顶边 407 vs 751)—— 那是纯视觉回归,必须在默认值里避免。 + ⚠️ 用户把它拖到别处、改成别的尺寸都允许,这里只是"第一次打开看到的样子"。 */ + ['k' => 'notif', 'w' => 4, 'h' => 4], + ['k' => 'days', 'w' => 4, 'h' => 4], ['k' => 'agenda', 'w' => 4, 'h' => 8], ['k' => 'tasks', 'w' => 4, 'h' => 8], - ['k' => 'days', 'w' => 4, 'h' => 4], ['k' => 'memories', 'w' => 4, 'h' => 4], ['k' => 'movies', 'w' => 4, 'h' => 4], ['k' => 'books', 'w' => 4, 'h' => 4], @@ -261,6 +290,9 @@ final class HomeCards foreach ($layout as $c) { $k = (string)$c['k']; switch ($k) { + case 'notif': + $out['notif'] = self::notifData($me); + break; case 'days': $out['days'] = self::daysData($familyId, $me); break; @@ -280,6 +312,28 @@ final class HomeCards return $out; } + /** + * 通知卡片(v71c 用户 2026-10-21)。 + * + * 用户原话:「通知消息这个,也是首页的卡片,可以调整顺序和大小。」 + * ⇒ 它原来在 `templates/home.php` 里是一块**网格外的写死区块**(v30 第 5 条那会儿 + * 首页还没有卡片概念),只有"摆不摆"没有"摆哪儿"。现在是卡片:位置与尺寸 + * 都由 `home_cards` 决定。 + * + * ⚠️ 取数的口径与原来**一字不差**:最新 10 条 + 未读数。首页是"瞥一眼"的地方, + * 真正的消息中心在 `?r=notifications`(那一页才 markAllRead)。 + * ⚠️ 关掉卡片 = 不查这两条 SQL(`data()` 只对用到的 key 取数)。 + * + * @return array{rows:array,unread:int} + */ + public static function notifData(int $me, int $limit = 10): array + { + return [ + 'rows' => Notification::mine($me, max(1, min(self::N_MAX, $limit))), + 'unread' => Notification::unreadCount($me), + ]; + } + /** 接下来 60 天的纪念日(按"还有几天"升序,最多 12 条 —— 卡片就那么大) */ public static function daysData(int $familyId, int $me): array { diff --git a/assets/css/app.css b/assets/css/app.css index c7ad2f5..dad6561 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -2724,7 +2724,45 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, } .cal-ev:hover { text-decoration: none; filter: brightness(1.08); } .cal-ev.is-allday { font-weight: 500; } -.cal-more { font-size: 11px; color: var(--faint); padding-left: 2px; } +/* 🔴 v70(用户 2026-10-20 第 2 条):「日历导入进去了,但是不显示」。 + 原来这里是 `.cal-more` 一颗**纯文本 ** —— 点不动,于是被它折叠掉的条目 + (用户自己导入的日程全在里面)**永远看不到**。 + 现在改成真正的按钮:可点、有热区、有 hover 反馈,点开是"当天全部条目"浮层。 */ +.cal-more { + display: block; width: 100%; margin: 0; padding: 1px 4px; + border: 0; border-radius: 5px; background: transparent; + font: inherit; font-size: 11px; line-height: 1.5; text-align: left; + color: var(--faint); cursor: pointer; +} +.cal-more:hover { background: var(--hov-veil); color: var(--brand); } +.cal-more:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; } + +/* ---- 当天全部条目浮层(v70)---- + ⚠️ 复用 `.cal-modal` 那一套(遮罩 / 居中面板 / 头尾不滚动), + 只补列表本身。为什么还要独立一份而不是套用条目详情层: + 那是"一条"的详情,这是"一天"的清单 —— 两者可以叠用(清单 → 某条详情)。 */ +.cal-day-modal .cal-modal-panel { width: min(460px, 100%); max-height: calc(100vh - 80px); } +.cal-day-body { padding: 8px 10px 10px; } +.cal-day-item { + display: flex; align-items: baseline; gap: 10px; width: 100%; + padding: 9px 10px; margin: 0; border: 0; border-radius: 8px; + background: transparent; font: inherit; text-align: left; cursor: pointer; + color: var(--ink); +} +.cal-day-item + .cal-day-item { margin-top: 2px; } +.cal-day-item:hover { background: var(--hov-veil); } +.cal-day-when { + flex: 0 0 auto; min-width: 74px; font-size: 12px; color: var(--muted); + font-variant-numeric: tabular-nums; +} +.cal-day-name { flex: 1 1 auto; font-size: 13.5px; line-height: 1.45; word-break: break-word; } +/* 派生条目(农历/节气/节假日)略淡:它们天天都有、信息密度低, + 视觉上让位给用户自己填的日程 */ +.cal-day-item.is-derived .cal-day-name { color: var(--muted); } +/* 底栏三样:左「加一条」、中提示、右「在日视图里看」。 + 名单长的时候底栏会被挤 → 允许换行,别让 HarfBuzz 把按钮压扁。 */ +.cal-day-foot { flex-wrap: wrap; gap: 8px; } +.cal-day-foot .cal-modal-tip { flex: 1 1 auto; } .ev-del { margin-top: 16px; } .cal-share-name { font-size: 13px; color: var(--muted); } @@ -5414,9 +5452,34 @@ h5.md-h, h6.md-h { font-size: 14px; } .ntf-time { flex: 0 0 auto; font-size: 12px; color: var(--faint); font-variant-numeric: tabular-nums; } .ntf-kind { flex: 0 0 auto; font-size: 15px; line-height: 1; } .ntf-del { flex: 0 0 auto; } -/* 首页那块:行距紧一点(它只是"瞥一眼",不是消息中心) */ +/* 旧首页那块用的紧凑行距类 —— v71c 之后**没人用了**(通知已改成卡片,走 `.hc-ntf`)。 + 留着这条是为了让"线上老页面截屏/老缓存"不突然变松;**新代码不要再挂这个类**。 */ .ntf-list-home .ntf-row { padding: 6px 8px; } +/* ---- v71c(用户 2026-10-21):通知消息变成首页卡片 ---- + 用户原话:「通知消息这个,也是首页的卡片,可以调整顺序和大小。另外,这个样式也 + 不好看,我看个人头像点击后,通知消息那个页面的样式就不错,直接复用就可以了。」 + ⇒ 结构与 `.ntf-*` **完全复用**(头像在前、kind 图标在右),这里只补"卡片比整页小" + 带来的三条差异: + ① `.hc-ntf` 的作用域里**允许正文折行** —— 卡片可能只有 2 格宽(一排四张里的一张), + 单行省略会把「有人把你加进了观影「…」」全吃掉,只剩一个名字(那就是"不好看"); + ② 时间挪到正文下面自己一行(窄卡里横着排会把正文挤没); + ③ 未读数做成一条小注脚(原来它挂在卡片外的 `

` 上,卡片里没有那个位置)。 + ⚠️ 只在 `.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; + } + } + ?> +
">
- - + ,点不开 = 完全看不到。 + 两处都已修:① `Calendar::displayRank()` 让派生条目排最后; + ② 下面把 "+N 条" 换成**真按钮**,点开当天的全部条目浮层。 + ⚠️ 保留 3 条不改成"全画":格子是固定高度(min-height:104px), + 15 条硬塞会把月网格撑得不像月历;超出部分交给浮层看。 */ + $cellEvents = $cell['events']; + $shown = 3; + $hiddenCnt = count($cellEvents) - $shown; + ?> + + - 3): ?> - + 条 + 0): ?> + ` 兜住无 JS 的情况:直接跳那天的日视图 + (日视图按时间网格铺开,不会再截断)。 */ ?> + +
@@ -611,12 +672,19 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts) var t = document.getElementById('ev-title'); if (t) { t.focus(); } } + /* v70:「当天全部条目」浮层里有「在这天加一条」,也要走同一个 openNew + (它在该 IIFE 外面,看不到闭包里的 openNew)⇒ 暴露一个窗口入口。 */ + window.fbCalOpenNew = openNew; /* 月视图格子 */ Array.prototype.forEach.call(document.querySelectorAll('.cal-cell[data-new-at]'), function (cell) { cell.addEventListener('click', function (ev) { // 点在小条上 = 想打开那条日程(
的默认行为),不要抢 if (ev.target.closest && ev.target.closest('.cal-ev')) { return; } + /* 🔴 v70:点「+N 条」= 要看**当天的全部条目**,不是要新建日程。 + ⚠️ 必须显式排除:它是个
- - -
- -
-
    - - $n['actor_name'] ?? '', 'my_nickname' => $n['actor_nick'] ?? ''] - ); - if (trim((string)$actorName) === '') { $actorName = '有人'; } - ?> -
  • - - - - - - - - -
  • - -
-
-
- - 通知行(最新在前), 'unread' => 未读数] + */ +$ntfRows = (array)($cardData['notif']['rows'] ?? []); +$ntfUnread = (int)($cardData['notif']['unread'] ?? 0); +?> +
+ +

还没有通知。别人在观影、旅程里把你加进去时,这里就会有消息。

+ + 0): ?> +

条未读

+ +
    + + $n['actor_name'] ?? '', 'my_nickname' => $n['actor_nick'] ?? ''] + ); + if (trim((string)$ntfActor) === '') { $ntfActor = '有人'; } + $ntfKind = (string)($n['kind'] ?? ''); + ?> +
  • + + + + + + + + + +
  • + +
+ +
\ No newline at end of file diff --git a/templates/home_cards.php b/templates/home_cards.php index 32dca94..f80b593 100644 --- a/templates/home_cards.php +++ b/templates/home_cards.php @@ -41,6 +41,8 @@ $hcHs = \App\HomeCards::HEIGHTS; 宽:2 = 一排 4 张,4 = 一排 2 张,6 = 一大一小,8 = 独占一排。 高:2 / 4 / 6 / 8 行(一行 76px),卡片内容多的时候内部会自己滚。
关掉的卡片不会丢数据,只是不摆在首页上。 +
「通知消息」与其它卡片一样可以调顺序和大小 —— 宽 2 时正文会折两行, + 想看完整的一句话就给它 4 格或更宽。