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 条)—— 「两个日程撞在一起不再堆叠,要错开画」。
|
||||
*
|
||||
|
||||
+55
-1
@@ -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
|
||||
{
|
||||
|
||||
+65
-2
@@ -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` 一颗**纯文本 <span>** —— 点不动,于是被它折叠掉的条目
|
||||
(用户自己导入的日程全在里面)**永远看不到**。
|
||||
现在改成真正的按钮:可点、有热区、有 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 格宽(一排四张里的一张),
|
||||
单行省略会把「有人把你加进了观影「…」」全吃掉,只剩一个名字(那就是"不好看");
|
||||
② 时间挪到正文下面自己一行(窄卡里横着排会把正文挤没);
|
||||
③ 未读数做成一条小注脚(原来它挂在卡片外的 `<h1>` 上,卡片里没有那个位置)。
|
||||
⚠️ 只在 `.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; }
|
||||
|
||||
@@ -372,6 +372,23 @@
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接用**数据对象**开详情层(不需要页面上存在对应的 DOM 元素)。
|
||||
*
|
||||
* 起源 v70(用户 2026-10-20 第 2 条):月视图的「+N 条」点开是"当天的清单",
|
||||
* 清单里的每一条并没有自己的 `<a data-fb-entry>`(它们只是清单里的一行文字),
|
||||
* 而 `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); };
|
||||
})();
|
||||
@@ -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 年
|
||||
/* 🔴 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'] . ' 条是重复日程里单独调整的某一次。';
|
||||
}
|
||||
if (trim((string)$v['title']) === '') {
|
||||
$v['title'] = '(无标题日程)';
|
||||
}
|
||||
if (Calendar::importEvent($calId, $v, $me) === 'added') {
|
||||
$added++;
|
||||
} else {
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
flash('ok', '导入完成:新增 ' . $added . ' 条,更新 ' . $updated . ' 条。');
|
||||
flash('ok', $msg);
|
||||
redirect('calendar/calendars');
|
||||
break;
|
||||
|
||||
|
||||
@@ -451,6 +451,45 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<?php
|
||||
/* 🔴 v70:把「每格全部条目」一次性送到前端(点 "+N 条" 时浮层直接用,
|
||||
不再发请求)。**只送当月格子里的日期**,量很小(一个月最多几百条)。
|
||||
⚠️ 用 `<script type="application/json">` 而不是把 JSON 塞进 data-*:
|
||||
data-* 属性里几千条会被 HTML 转义得又长又容易截断;
|
||||
JSON 里若出现 `</script>` 也会截断 —— 所以下面统一走 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;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<script type="application/json" id="calDayData"><?= json_encode(
|
||||
$dayData,
|
||||
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP
|
||||
) ?></script>
|
||||
<div class="card cal-grid-card">
|
||||
<div class="cal-head">
|
||||
<?php /* ⚠️ 用户第 11 批 #6:「月视图,从周一开始。而不是周日。」
|
||||
@@ -469,8 +508,22 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
||||
data-new-at="<?= e((string)$cell['date']) ?>">
|
||||
<div class="cal-dnum"><?= (int)$cell['day'] ?></div>
|
||||
<div class="cal-evs">
|
||||
<?php $n = 0; foreach ($cell['events'] as $ev): $n++; ?>
|
||||
<?php if ($n <= 3): ?>
|
||||
<?php
|
||||
/* 🔴 v70(用户 2026-10-20 第 2 条):「日历导入进去了,但是不显示」。
|
||||
真根因不在这里的 3 条,而在**排序 + "+N 条"点不开**的组合:
|
||||
每格只画 3 条,而旧排序把"全天"(时间空串)排最前 ⇒ 农历/节气/
|
||||
节假日天天占满前三,用户自己导入的日程全被挤进 "+N 条",
|
||||
而那个 "+N 条" 只是个 <span>,点不开 = 完全看不到。
|
||||
两处都已修:① `Calendar::displayRank()` 让派生条目排最后;
|
||||
② 下面把 "+N 条" 换成**真按钮**,点开当天的全部条目浮层。
|
||||
⚠️ 保留 3 条不改成"全画":格子是固定高度(min-height:104px),
|
||||
15 条硬塞会把月网格撑得不像月历;超出部分交给浮层看。 */
|
||||
$cellEvents = $cell['events'];
|
||||
$shown = 3;
|
||||
$hiddenCnt = count($cellEvents) - $shown;
|
||||
?>
|
||||
<?php $n = 0; foreach ($cellEvents as $ev): $n++; ?>
|
||||
<?php if ($n <= $shown): ?>
|
||||
<?php
|
||||
$evKind = (string)($ev['kind'] ?? 'custom');
|
||||
$evRef = (int)($ev['ref_id'] ?? 0);
|
||||
@@ -491,8 +544,16 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<?php if (count($cell['events']) > 3): ?>
|
||||
<span class="cal-more">+<?= count($cell['events']) - 3 ?> 条</span>
|
||||
<?php if ($hiddenCnt > 0): ?>
|
||||
<?php /* 点开 = 当天全部条目(含被折叠的),一律走全站的条目详情浮层;
|
||||
每条都带 data-fb-entry,所以在浮层里还能继续点进详情/编辑/删除。
|
||||
⚠️ 用一个 `<a href>` 兜住无 JS 的情况:直接跳那天的日视图
|
||||
(日视图按时间网格铺开,不会再截断)。 */ ?>
|
||||
<button type="button" class="cal-more"
|
||||
data-cal-day="<?= e((string)$cell['date']) ?>"
|
||||
aria-label="看这一天全部 <?= count($cellEvents) ?> 条">+<?= $hiddenCnt ?> 条</button>
|
||||
<?php /* 浮层用的全部条目数据(服务端渲染好,点开时不需要再请求)。
|
||||
放在格子外面(display:none 的 script 里)—— 塞进格子会撑高月网格。 */ ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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) {
|
||||
// 点在小条上 = 想打开那条日程(<a> 的默认行为),不要抢
|
||||
if (ev.target.closest && ev.target.closest('.cal-ev')) { return; }
|
||||
/* 🔴 v70:点「+N 条」= 要看**当天的全部条目**,不是要新建日程。
|
||||
⚠️ 必须显式排除:它是个 <button>,不排除的话会**同时**触发
|
||||
"新建日程浮层" 与 "当天全部条目浮层" 两个浮层叠在一起。 */
|
||||
if (ev.target.closest && ev.target.closest('.cal-more')) { return; }
|
||||
if (!<?= $canWrite && $writable ? 'true' : 'false' ?>) { return; }
|
||||
openNew(cell.getAttribute('data-new-at'), '09', '00');
|
||||
});
|
||||
@@ -776,6 +844,51 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
||||
if (tip) { tip.textContent = msg; tip.className = 'cal-modal-tip err'; }
|
||||
}
|
||||
|
||||
/** 把一条 `fb_entry_json` 形态的条目补进 `#calDayData`(按天分组,幂等)。
|
||||
* ⚠️ 只补**还没在里面**的那条(同 ref + 同 occ 视为同一条)——
|
||||
* 否则连点两次「加上」或前端重放会把同一条塞两遍,清单里出现两条一样的。 */
|
||||
function pushDayData(day, entry) {
|
||||
var host = document.getElementById('calDayData');
|
||||
if (!host || !day || !entry) { return; }
|
||||
var all;
|
||||
try { all = JSON.parse(host.textContent || '{}') || {}; } catch (e) { return; }
|
||||
var list = all[day] || [];
|
||||
var same = false;
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (String(list[i].ref || '') === String(entry.ref || '')
|
||||
&& String(list[i].occ || '') === String(entry.occ || '')
|
||||
&& String(list[i].title || '') === String(entry.title || '')) { same = true; break; }
|
||||
}
|
||||
if (!same) {
|
||||
/* 位置按服务端那套 `displayRank + 时间` 插(派生条目垫底),
|
||||
免得清单里的顺序与刷新后的顺序不一致。 */
|
||||
var rk = function (o) {
|
||||
var s = String(o.source || '');
|
||||
return s === 'event' ? 0 : 4; /* 派生条目一律 4,与服务端 displayRank 的兜底一致 */
|
||||
};
|
||||
var tm = function (o) {
|
||||
var rows = o.rows || [];
|
||||
for (var k = 0; k < rows.length; k++) {
|
||||
if (rows[k][0] === '时间') {
|
||||
var v = String(rows[k][1] || '');
|
||||
/* "YYYY-MM-DD HH:MM–HH:MM" / "YYYY-MM-DD 全天" → 取时刻部分比较 */
|
||||
var seg = v.slice(day.length).trim();
|
||||
return seg === '全天' ? '' : seg.slice(0, 5);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
var pos = list.length;
|
||||
for (var j = 0; j < list.length; j++) {
|
||||
if (rk(list[j]) > rk(entry)) { pos = j; break; }
|
||||
if (rk(list[j]) === rk(entry) && tm(list[j]) > tm(entry)) { pos = j; break; }
|
||||
}
|
||||
list.splice(pos, 0, entry);
|
||||
all[day] = list;
|
||||
try { host.textContent = JSON.stringify(all); } catch (e2) { /* 序列化失败就别动 */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** 月视图:往 data-new-at="YYYY-MM-DD" 那个格子里塞一条小条 */
|
||||
function putMonth(ev) {
|
||||
var day = String(ev.starts_at || '').slice(0, 10);
|
||||
@@ -820,16 +933,27 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
||||
/* 再按"只显示前 3 条"收口,与服务端渲染对齐(否则会多出一条)。 */
|
||||
var links = box.querySelectorAll('.cal-ev');
|
||||
var more = box.querySelector('.cal-more');
|
||||
/* 🔴 v70:新条目同步进 `#calDayData` —— 否则「+N 条」点开是**旧**清单,
|
||||
刚建的这条不在里面(刷新一次又有了)。放在收口之前做,跟服务端渲染时
|
||||
"先把当天全部条目收进数据岛、再只画前 3 条"的顺序一致。 */
|
||||
if (ev.entry) { pushDayData(day, ev.entry); }
|
||||
for (var j = 0; j < links.length; j++) {
|
||||
links[j].style.display = (j < 3) ? '' : 'none';
|
||||
}
|
||||
if (links.length > 3) {
|
||||
var extra = links.length - 3;
|
||||
if (!more) {
|
||||
more = document.createElement('span');
|
||||
/* 🔴 v70(用户 2026-10-20 第 2 条):「+N 条」原来是颗 `<span>`,
|
||||
**点不动** ⇒ 被它折叠掉的条目永远看不到("导进去了但不显示")。
|
||||
这里必须造**按钮**并带 `data-cal-day`(与模板渲染同形),
|
||||
否则新建一条之后,这个格子就退回"点不动"。 */
|
||||
more = document.createElement('button');
|
||||
more.type = 'button';
|
||||
more.className = 'cal-more';
|
||||
box.appendChild(more);
|
||||
}
|
||||
more.setAttribute('data-cal-day', day);
|
||||
more.setAttribute('aria-label', '看这一天全部 ' + links.length + ' 条');
|
||||
more.textContent = '+' + extra + ' 条';
|
||||
} else if (more) {
|
||||
more.parentNode.removeChild(more);
|
||||
@@ -1208,3 +1332,136 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php /* ==================== 当天全部条目浮层(v70) ====================
|
||||
用户 2026-10-20 第 2 条:「日历导入进去了,但是不显示」。
|
||||
月视图每格只画 3 条(格子是固定高度,全画会把月历撑变形),
|
||||
但原来的 "+N 条" 是**一颗点不动的 <span>** ⇒ 被折叠的条目**永远看不到**。
|
||||
这里给那颗按钮一个真正的去处。
|
||||
|
||||
⚠️ 为什么不直接复用条目详情浮层(`fbEntryModal`):
|
||||
那是"**一条**日程的详情/编辑/删除",一次只画一条;
|
||||
这里要的是"**当天的清单**",点其中任意一条才进那条的详情。
|
||||
两者叠用是对的:先出清单,点清单里某条 → 调 `window.fbEntryOpen()` 进详情层。
|
||||
⚠️ 详情层是 `position:fixed` 的高层,清单层在它下面 —— 关掉详情层会**回到清单**,
|
||||
符合"我从当天的清单里点进来的"这个来路(不用再点一次 +N 条)。
|
||||
⚠️ 数据直接读上面那个 `<script id="calDayData">`:不发请求、点开即时。
|
||||
⚠️ 无 JS 兜底:那颗按钮外面本来是 <button>,禁用 JS 时它不会有反应 —
|
||||
这是可接受的降级(此时"+N 条"本来也没有别的语义),
|
||||
因为**没有 JS 的浏览器根本也不会有"点格子新建日程"那一套**,
|
||||
用户会直接用「周 / 天」视图看全部条目(那两个视图不截断)。 */ ?>
|
||||
<div class="cal-modal cal-day-modal" id="calDayModal" role="dialog" aria-modal="true"
|
||||
aria-label="当天全部日程" hidden>
|
||||
<div class="cal-modal-panel">
|
||||
<div class="cal-modal-head">
|
||||
<b id="calDayTitle">当天的日程</b>
|
||||
<button type="button" class="cal-modal-x" id="calDayClose" aria-label="关闭">✕</button>
|
||||
</div>
|
||||
<div class="cal-modal-body cal-day-body" id="calDayBody"></div>
|
||||
<div class="cal-modal-foot cal-day-foot">
|
||||
<?php /* 浮层里就地再建一条 —— 清单本来就在"这天",比退出去点格子顺手。
|
||||
⚠️ 没写权限时**不渲染**(不是 hidden):只读家庭里这颗按钮点了也没用,
|
||||
留着反而像坏了。 */ ?>
|
||||
<?php if ($canNew): ?>
|
||||
<button type="button" class="btn btn-sm" id="calDayAdd">+ 在这天加一条</button>
|
||||
<?php endif; ?>
|
||||
<span class="cal-modal-tip" id="calDayTip"></span>
|
||||
<a class="btn btn-ghost btn-sm" id="calDayMore" href="#">在日视图里看</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var host = document.getElementById('calDayModal');
|
||||
if (!host) { return; }
|
||||
var body = document.getElementById('calDayBody');
|
||||
var title = document.getElementById('calDayTitle');
|
||||
var link = document.getElementById('calDayMore');
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function dayData() {
|
||||
var el = document.getElementById('calDayData');
|
||||
if (!el) { return {}; }
|
||||
try { return JSON.parse(el.textContent || '{}') || {}; } catch (e) { return {}; }
|
||||
}
|
||||
|
||||
function openDay(day) {
|
||||
var all = dayData()[day] || [];
|
||||
if (!all.length) { return; }
|
||||
title.textContent = day + ' · 共 ' + all.length + ' 条';
|
||||
var html = '';
|
||||
all.forEach(function (it, i) {
|
||||
/* 时间行:全天显示"全天",有时刻显示 HH:MM–HH:MM(与详情浮层同一口径) */
|
||||
var when = '';
|
||||
var rows = it.rows || [];
|
||||
for (var k = 0; k < rows.length; k++) {
|
||||
if (rows[k][0] === '时间') { when = rows[k][1]; break; }
|
||||
}
|
||||
if (when.indexOf(day) === 0) { when = when.slice(day.length).trim(); }
|
||||
var src = (it.source === 'event') ? '' : ' is-derived';
|
||||
html += '<button type="button" class="cal-day-item' + src + '" data-day-idx="' + i + '">'
|
||||
+ '<span class="cal-day-when">' + esc(when || '全天') + '</span>'
|
||||
+ '<span class="cal-day-name">' + esc(it.title || '(无标题)') + '</span>'
|
||||
+ '</button>';
|
||||
});
|
||||
body.innerHTML = html;
|
||||
document.getElementById('calDayTip').textContent = '';
|
||||
link.setAttribute('href', <?= json_encode(url('calendar', ['view' => 'day'])) ?> + '&d=' + encodeURIComponent(day));
|
||||
host.hidden = false;
|
||||
/* 记录当前是哪一天,点清单项时要按它取数据 */
|
||||
host.setAttribute('data-day', day);
|
||||
}
|
||||
|
||||
function closeDay() { host.hidden = true; }
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest && e.target.closest('.cal-more');
|
||||
if (btn) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); /* 别让"点格子新建日程"也跟着触发 */
|
||||
openDay(btn.getAttribute('data-cal-day') || '');
|
||||
return;
|
||||
}
|
||||
if (e.target.closest && e.target.closest('#calDayClose')) {
|
||||
e.preventDefault(); closeDay(); return;
|
||||
}
|
||||
/* 清单里"在这天加一条":把当前这天交给新建浮层(09:00 起,与服务端
|
||||
"点月格"同口径),并先收起清单 —— 两层浮层不能叠着。 */
|
||||
if (e.target.closest && e.target.closest('#calDayAdd')) {
|
||||
e.preventDefault();
|
||||
var d0 = host.getAttribute('data-day') || '';
|
||||
closeDay();
|
||||
if (d0 && typeof window.fbCalOpenNew === 'function') { window.fbCalOpenNew(d0, '09', '00'); }
|
||||
return;
|
||||
}
|
||||
/* 点清单里某一条 → 交给条目详情浮层。
|
||||
走 `fbEntryOpenData`(v70 新增的"按数据对象开"入口)——
|
||||
清单项只是文字行,页面上没有对应的 `[data-fb-entry]` 元素可点。 */
|
||||
var item = e.target.closest && e.target.closest('.cal-day-item');
|
||||
if (item) {
|
||||
e.preventDefault();
|
||||
var day = host.getAttribute('data-day') || '';
|
||||
var list = dayData()[day] || [];
|
||||
var one = list[parseInt(item.getAttribute('data-day-idx'), 10)];
|
||||
if (one && typeof window.fbEntryOpenData === 'function') {
|
||||
closeDay(); /* 先收清单,再开详情 —— 否则两层叠在一起 */
|
||||
window.fbEntryOpenData(one);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.target === host) { closeDay(); }
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'Escape') { return; }
|
||||
if (host.hidden) { return; }
|
||||
e.preventDefault(); closeDay();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
+6
-45
@@ -19,9 +19,12 @@
|
||||
*
|
||||
* ⚠️ 别在模板里自己算"该多宽":宽高由 `$homeCards`(用户设置)给,
|
||||
* 模板只负责把类名拼对(`hc-w4 hc-h8`)。
|
||||
* ⚠️ 通知块仍在**卡片网格之前**:v30 第 5 条要求它出现在「往年的今天」前面,
|
||||
* 而"往年的今天"现在是网格里的一张卡片,位置由用户定 —— 放在网格前是唯一
|
||||
* 无论用户怎么摆都成立的方案。
|
||||
*
|
||||
* 🔴 v71c(用户 2026-10-21)推翻了一条旧约束:「通知消息这个,也是首页的卡片,
|
||||
* 可以调整顺序和大小。」—— 它原来在本文件里是一块**网格之前**的写死区块
|
||||
* (v30 第 5 条当时还没有卡片体系,只能用"排在网格前"保证它在「往年的今天」上面)。
|
||||
* 现在它是一张**正规卡片**(`_card_notif.php`):位置/宽高/开不开都由用户定。
|
||||
* ⇒ 这条"必须在网格前"的约束**到此为止**,别再按它往网格上加写死区块。
|
||||
*
|
||||
* @var array $homeCards HomeCards::layout() 的结果(每项 k/w/h/n)
|
||||
* @var array $cardData HomeCards::data() 取的那几张卡片的额外数据
|
||||
@@ -78,48 +81,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php /* ==================== 通知消息(v30 第 5 条)====================
|
||||
用户原话:「…发一个通知,通知出现在首页中,**往年今天的前面**。」
|
||||
⇒ 放在卡片网格**之前**:往年的今天是网格里的一张卡片,位置由用户摆,
|
||||
只有"在网格前面"才能保证永远在它上面。
|
||||
⚠️ 没有通知时整块不渲染 —— 首页是"每天第一眼看的地方",不要用一个空盒子占一屏。 */ ?>
|
||||
<?php if (!empty($notifs)): ?>
|
||||
<section class="section">
|
||||
<div class="page-head" style="margin-bottom:12px">
|
||||
<h1 style="font-size:15px">
|
||||
通知消息
|
||||
<?php if ($notifUnread > 0): ?><span class="sub"><?= (int)$notifUnread ?> 条未读</span><?php endif; ?>
|
||||
</h1>
|
||||
<div class="actions">
|
||||
<a class="btn btn-ghost btn-sm" href="<?= e(url('notifications')) ?>">全部通知</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card card-pad">
|
||||
<ul class="ntf-list ntf-list-home">
|
||||
<?php foreach ($notifs as $n): ?>
|
||||
<?php
|
||||
$actorName = \App\Nickname::display(
|
||||
(int)$meId,
|
||||
['name' => $n['actor_name'] ?? '', 'my_nickname' => $n['actor_nick'] ?? '']
|
||||
);
|
||||
if (trim((string)$actorName) === '') { $actorName = '有人'; }
|
||||
?>
|
||||
<li class="ntf-row ntf-row-<?= e((string)($n['kind'] ?? '')) ?><?= empty($n['is_read']) ? ' is-unread' : '' ?>">
|
||||
<span class="ntf-kind" aria-hidden="true"><?= \App\Notification::icon((string)($n['kind'] ?? '')) ?></span>
|
||||
<img class="ntf-av" src="<?= e(avatar_url($n['actor_avatar'] ?? null)) ?>" alt="">
|
||||
<a class="ntf-main" href="<?= e(\App\Notification::href($n)) ?>">
|
||||
<span class="ntf-title">
|
||||
<b><?= e((string)$actorName) ?></b><?= e(\App\Notification::bodyWithoutActor((string)$n['title'])) ?>
|
||||
</span>
|
||||
<span class="ntf-time"><?= e(fb_time_ago((string)$n['created_at'])) ?></span>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /* ==================== 卡片网格(v62)====================
|
||||
⚠️ `require` 进来的每个 `_card_*.php` 与这里**同一作用域**(共享 $tasks / $todayEvents
|
||||
/ $cardData …):这是刻意的 —— 卡片的取数与首页的取数必须同源,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
/**
|
||||
* 首页卡片 · 通知消息(v71c 用户 2026-10-21 从 templates/home.php 搬进来)。
|
||||
*
|
||||
* 用户原话:「先把 web 端的首页修一下,通知消息这个,也是首页的卡片,可以调整顺序
|
||||
* 和大小。另外,这个样式也不好看,我看个人头像点击后,通知消息那个页面的
|
||||
* 样式就不错,直接复用就可以了。」
|
||||
*
|
||||
* ⇒ 两件事一起做了:
|
||||
* ① **卡片化**:不再是网格外那块写死区块,位置/宽高都由 `home_cards` 决定;
|
||||
* ② **样式复用**:结构与 `templates/notifications/index.php` **一模一样** ——
|
||||
* 头像在最前、正文在中间、kind 图标在右(带 `ntf-kind-{kind}` 修饰)。
|
||||
* 旧版首页那块是"图标在最前、头像在第二位",正是用户说"不好看"的那一版。
|
||||
*
|
||||
* ⚠️ 三处与通知页**故意不同**(都是"卡片比整页小"带来的):
|
||||
* · 不带每条右侧的「删除」按钮 —— 4 宽 × 4 高的卡片里塞两个按钮,正文会被挤成
|
||||
* 两三个字(用户抱怨的正是"不好看")。删除在通知页做,卡片右上角有「全部通知」。
|
||||
* · 标题不加旧首页那块用的 `ntf-list` home 修饰类 —— 那一类会把行距压到 6px,
|
||||
* 是给"网格外那条细长清单"用的;卡片里沿用通知页的行距,才叫"样式直接复用"。
|
||||
* · 正文允许折两行(`.hc-ntf` 作用域,见 app.css):卡片宽度可能只有 2 格,
|
||||
* 单行省略会把「有人把你加进了观影「…」」全吃掉,只剩一个名字。
|
||||
*
|
||||
* ⚠️ 没有通知时**整张卡片都不渲染**(返回空串是做不到的 —— 卡片外壳由 home.php 拼)。
|
||||
* 这里给一个空提示,与其它卡片(观影/读书/纪念日)的空态同一套写法。
|
||||
* 真正"不想要它"的人,去「首页卡片」里把它关掉(那张卡片会从网格里消失)。
|
||||
*
|
||||
* @var array $cardData['notif'] ['rows' => 通知行(最新在前), 'unread' => 未读数]
|
||||
*/
|
||||
$ntfRows = (array)($cardData['notif']['rows'] ?? []);
|
||||
$ntfUnread = (int)($cardData['notif']['unread'] ?? 0);
|
||||
?>
|
||||
<div class="hc-body">
|
||||
<?php if (!$ntfRows): ?>
|
||||
<p class="empty-hint">还没有通知。别人在观影、旅程里把你加进去时,这里就会有消息。</p>
|
||||
<?php else: ?>
|
||||
<?php if ($ntfUnread > 0): ?>
|
||||
<p class="hc-ntf-note"><?= (int)$ntfUnread ?> 条未读</p>
|
||||
<?php endif; ?>
|
||||
<ul class="ntf-list hc-ntf">
|
||||
<?php foreach ($ntfRows as $n): ?>
|
||||
<?php
|
||||
// 谁发起的:优先用"我叫他什么"(与全站称呼口径一致)
|
||||
$ntfActor = \App\Nickname::display(
|
||||
(int)$meId,
|
||||
['name' => $n['actor_name'] ?? '', 'my_nickname' => $n['actor_nick'] ?? '']
|
||||
);
|
||||
if (trim((string)$ntfActor) === '') { $ntfActor = '有人'; }
|
||||
$ntfKind = (string)($n['kind'] ?? '');
|
||||
?>
|
||||
<li class="ntf-row ntf-row-<?= e($ntfKind) ?><?= empty($n['is_read']) ? ' is-unread' : '' ?>">
|
||||
<img class="ntf-av" src="<?= e(avatar_url($n['actor_avatar'] ?? null)) ?>" alt="">
|
||||
<a class="ntf-main" href="<?= e(\App\Notification::href($n)) ?>">
|
||||
<span class="ntf-title">
|
||||
<b><?= e((string)$ntfActor) ?></b>
|
||||
<?= e(\App\Notification::bodyWithoutActor((string)$n['title'])) ?>
|
||||
</span>
|
||||
<span class="ntf-time"><?= e(fb_time_ago((string)$n['created_at'])) ?></span>
|
||||
</a>
|
||||
<span class="ntf-kind ntf-kind-<?= e($ntfKind) ?>" aria-hidden="true">
|
||||
<?= \App\Notification::icon($ntfKind) ?>
|
||||
</span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -41,6 +41,8 @@ $hcHs = \App\HomeCards::HEIGHTS;
|
||||
<b>宽</b>:2 = 一排 4 张,4 = 一排 2 张,6 = 一大一小,8 = 独占一排。
|
||||
<b>高</b>:2 / 4 / 6 / 8 行(一行 76px),卡片内容多的时候内部会自己滚。
|
||||
<br>关掉的卡片<b>不会丢数据</b>,只是不摆在首页上。
|
||||
<br>「通知消息」与其它卡片一样可以调顺序和大小 —— 宽 2 时正文会折两行,
|
||||
想看完整的一句话就给它 4 格或更宽。
|
||||
</p>
|
||||
|
||||
<ol class="hc-set-list" id="hcSetList">
|
||||
|
||||
Reference in New Issue
Block a user