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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user