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:
2026-10-21 13:22:37 +08:00
parent b2b103df0f
commit b45cf671d1
9 changed files with 810 additions and 83 deletions
+262 -5
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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>