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:
+283
-6
@@ -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:<RECURRENCE-ID>` 后缀既保证**跨次导入稳定**(不会重复插),
|
||||
* 又让两件事在 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<int,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 条"
|
||||
* 只是一颗**没有任何点击行为**的 <span>,点不开,于是"完全看不到"。
|
||||
*
|
||||
* ⇒ 让**用户真正关心的东西**(自己/共享进来的日程、任务、观影、旅程)先上,
|
||||
* 把每天都会自动生成、信息密度最低的派生条目压到最后:
|
||||
* 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 条)—— 「两个日程撞在一起不再堆叠,要错开画」。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user