Files
FamilyBook/templates/calendar/_view.php
T
laoyang 7f3df7a40b 日程新建/修改不再刷新页面:服务端片段 + 就地重绘 (#37)
用户原话(2026-11-01):
  「修改和新建,都不应该刷新页面,而是使用js动态调整。」

## 改法:不再手写复刻渲染,改成"服务端渲染片段 → 前端整块替换"

历史上「新建不刷新」是靠手写复刻服务端渲染做的(`putMonth` / `putTimed` /
`relayoutColumn`),它自己的注释里就记着一次真实事故:新加一条 07:00 的日程被
append 到末尾 ⇒ 落进「+N 条」看不见,刷新后又跑到第一位。修改要处理的情况更多
(跨天 / 全天状态 / 换日历本改颜色 / 改重复规则 / 挪到别的月),复刻必然出错。

⇒ 现在只有**一处渲染**:前端取服务端画好的视图片段整块替换。
   "插进去的样子"与"刷新一次的样子"必然相同。

## 服务端

- `calendar/event_update` 认 `X-Requested-With`(与 event_add / event_del 同一套约定)
  回 JSON;失败也回 JSON(可预期的拒绝不该把用户甩到一个刷新过的页面上)。
  三处失败出口 + 四处成功出口收口成两个闭包,避免"某条路径仍刷页"。
- 抽出 `calendar_view_data()`:整页渲染与局部重绘共用同一份取数/建网格逻辑。
- 新增 `calendar/view` 片段路由(月/周/日视图片段)。
- 抽出 `home_agenda_events()` + 新增 `home/agenda` 片段路由(首页卡片)。
- 抽出 `templates/calendar/_view.php`:视图区从页面模板里搬出来(原文一字未改,
  已用脚本逐行比对证明 270 行零差异)。
- `templates/layout.php` 加载 `cal_sync.js`。

## 前端

- 新增 `assets/js/cal_sync.js`:`refresh()` 按当前视图取片段替换;
  只换内容不换容器;保首页卡片滚动位置;重绘后补跑红线定时器;
  失败退回整页刷新(宁可刷一次,也不要"没刷新却也没变")。
- `entry_popup.js` 的「保存」改 fetch 提交 → 关浮层 + 轻提示 + 局部重绘。
  名册/观影/行程/任务四个来源保持原生提交,范围不收不必要的回归面。
- 删掉日历页那 ~270 行手写插入,新建统一走同一个 `refresh()`。
- 「点格子新建」的 per-element 绑定改成 document 委托(重绘后新节点才有监听)。

## 本轮实测抓到并修掉的真 bug

`form.id` 被同名 input 遮蔽:日程编辑表里有 `<input name="id">`,
HTMLFormElement 的具名访问优先级高于原型上的 `id` ⇒ `form.id === 'calEditForm'`
恒为 false ⇒ 日程也走原生提交、整页刷新。
改用 `form.getAttribute('id')`。

## 验证(全部做过反向验证)

- 浏览器端到端 28 项:编辑/新建都不导航、改动原地可见、视图不跳、数据岛同步、
  重绘后点格子仍有效、红线定时器重建、无 JS 异常
- 反向验证 7 项:把 refresh 置空 / 改成 reload / 不调 reinit / 抹掉上下文属性,
  判据逐条转红(证明判据有牙齿)
- DOM 等价性 4 项:月/周/日视图与首页卡片,重绘后与刷新后的 DOM **逐字节相同**
  (这条判据本身也做了反向验证:删一个字、多一个节点都能抓到)
- 服务端 11 项:非 AJAX 仍 302 且真存库、AJAX 回 JSON、失败也回 JSON、
  片段路由可用、未登录被拒
- #35 跨天回归 11 项:三段的 seg/cont/is-seg-* 标记全在、贴满整列、不进全天行
- 静态检查:37 个模板片段无作用域违规;20 块内联 JS 语法全过;运行期零 Warning
2026-11-01 09:04:35 +08:00

302 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 日历视图区(月 / 周 / 日)—— 从 templates/calendar/index.php 抽出来的独立片段。
*
* 🔴 工单 #37:新建 / 修改日程后要"不刷新页面、就地更新"。做法是**服务端渲染同一份
* 片段 → 前端整块替换**,而不是让 JS 手写复刻渲染逻辑。
* 这就必须把视图区从页面模板里抽出来,让整页路径与局部重绘走同一份代码 ——
* 只有一处渲染,"插进去的样子"与"刷新一次的样子"才不可能不一致。
*
* 抽出的方式:原文**一字未改**地搬过来,只把外层缩进调平。
* 所以这里用的变量与原来完全一样,由调用方给全:
*
* @var string $view month / week / day
* @var array $cols 周/日视图的列(day_columns 产出)
* @var array $tgrid build_time_grid 产出
* @var array $weeks build_month_grid 产出
* @var bool $canNew 能不能新建
* @var float $nowTop 当前时间红线位置(%,服务端算好)
*
* ⚠️ 调用方必须**在 require 之前**把上面这些变量准备好;本片段自己不做任何兜底
* —— 兜底会让"少给了一个变量"变成静默画错,而不是当场报错。
*/
?>
<?php /* 🔴 工单 #37:这个外层容器是**局部重绘的落点**。
JS 拿到服务端渲染的新片段后,只替换 `[data-cal-view]` 里面的内容
(换的是 .card 那一层,见 assets/js/cal_sync.js 的 swap())。
⚠️ 容器本身**不能**被替换掉:它上面挂着"当前视图"这个上下文,
替换容器会让 CSS 上依赖 .cal-main > * 的选择器重新算一遍(视觉会跳一下)。 */ ?>
<div class="cal-view" data-cal-view data-cal-view-kind="<?= e((string)$view) ?>" data-cal-ym="<?= e((string)($ym ?? '')) ?>" data-cal-anchor="<?= e((string)($anchor ?? '')) ?>">
<?php if ($view === 'week' || $view === 'day'): ?>
<?php /* ===== 周 / 天:按小时铺的时间网格(全天日程单独挂顶上) ===== */ ?>
<div class="card cal-tg-card">
<div class="tg-head">
<div class="tg-gutter"></div>
<?php foreach (($cols ?? []) as $c): ?>
<div class="tg-dayname<?= $c['is_today'] ? ' is-today' : '' ?>">
<?php /* $c['w'] 是 date('w')(0=周日),所以这张索引表保持"日"开头——
它只是把数字换成汉字,与列的排列顺序无关。 */ ?>
<span class="tg-wd"><?= e(['日','一','二','三','四','五','六'][$c['w']]) ?></span>
<span class="tg-dd"><?= (int)$c['day'] ?></span>
</div>
<?php endforeach; ?>
</div>
<?php $tgAll = $tgrid['all_day'] ?? []; ?>
<?php if (array_filter($tgAll)): ?>
<div class="tg-allday">
<div class="tg-gutter"><span class="tg-tag">全天</span></div>
<?php foreach (($cols ?? []) as $i => $c): ?>
<div class="tg-adcell">
<?php foreach (($tgAll[$i] ?? []) as $ev): ?>
<?php
$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]); }
?>
<a class="cal-ev is-allday" data-cal="<?= (int)$ev['cal'] ?>"
data-fb-entry='<?= fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit) ?>'
style="background: <?= e((string)$ev['color']) ?>;"
href="<?= e((string)$ev['href']) ?>"
title="<?= e((string)$ev['title']) ?>"><?= e((string)$ev['title']) ?></a>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="tg-body">
<div class="tg-gutter tg-hours">
<?php for ($h = 0; $h < 24; $h++): ?>
<span class="tg-hr"><?= sprintf('%02d', $h) ?></span>
<?php endfor; ?>
</div>
<?php foreach (($cols ?? []) as $i => $c): ?>
<div class="tg-col<?= $c['is_today'] ? ' is-today' : '' ?><?= $canNew ? ' rt-writable' : '' ?>"
data-new-at="<?= e((string)$c['date']) ?>">
<?php for ($h = 0; $h < 24; $h++): ?>
<span class="tg-line"></span>
<?php endfor; ?>
<?php /* ==================== 当前时间红线(v24 第 20 批 用户第 6 条)====================
用户原话:「日程模块,周视图和日视图,应该在视图中间显示一条红线,
用来表示当前进行的时间,就是说,用一个不断从上到下的红线,
来表示当前时间在视图上的刻度和位置。」
⚠️ 只在**今天那一列**画(别的日子没有"现在"可言)。
⚠️ 位置是服务端算好的百分比(与上面 .tg-block 用的是同一套:
列高 = 24h,所以 1 分钟 = 100/1440 %)。**不在 CSS 里写死**
某个时间 —— 那就成了"固定的一条线",不叫"当前时间"。
⚠️ 线的**移动**由 app.js 里的一分钟定时器负责(`initNowLines()`):
服务端给的是"渲染那一刻"的位置,用户开着页面不走,
过一小时线还停在那儿就荒谬了。
⚠️ 全天日程的"全天"那一行**不画** —— 那条线表达的是"一天之内的时间刻度",
画在全天行上会让人以为全天日程跟时间有关。 */ ?>
<?php if ($c['is_today'] && !empty($nowTop)): ?>
<span class="tg-now" data-now-line style="top: <?= (float)$nowTop ?>%;"
title="现在 <?= e(date('H:i')) ?>"></span>
<?php endif; ?>
<?php /* 🔴 v30 第 4 条:时间冲突的日程**错开画**(不再堆叠)。
用户原话:「如果两个事件时间上有冲突了,现在是堆叠在一起,应该错开,
显示两个色块,如果三个冲突了,就是三个色块,错开显示。」
left/width 由 App\Calendar::conflictLanes 算好(算法借鉴 SyncCalendar
的 TimelineUtil:贪心分列,列数 = 最大同时重叠数)。
⚠️ 用**内联 left/width 百分比**而不是 CSS 类:
列数 1/2/3/4 是运行时才知道的,类名会爆炸;
而且模板已经在内联 top/height 了,同一套坐标系最不容易错。 */ ?>
<?php foreach (($tgrid['timed'][$i] ?? []) as $ev): ?>
<?php /* 🔴 v43(用户第 6 条):点条目弹详情浮层(不再直接跳页)。
数据由 fb_entry_json() 统一编(三个入口共用一个函数)。
「详细信息」跳该来源的详情页;「编辑」取该来源的编辑表单片段。
⚠️ 编辑地址一律带可写判断:只读的(农历/节假日)不给「编辑」。 */ ?>
<?php
$evKind = (string)($ev['kind'] ?? 'custom');
$evRef = (int)($ev['ref_id'] ?? 0);
$evDet = (string)($ev['href'] ?? '');
$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]); }
// trip 的条目指向"某个旅程里的某段行程",编辑器在旅程详情页里 —— 这里不给「编辑」
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
?>
<?php
/* 🔴 v51(用户 2026-10-15 第 3 条):分栏(冲突错开)**在浏览器里重算**要用到
每条色块**声明的起止分钟**(服务端算分栏时用的就是这一对数)。
⚠️ 必须取 `time` / `end_time`,**不能**从 top/height 反推 ——
服务端的 `top/height` 是按**"实际显示结束"**算的(没填结束时可能被
同列下一条的起点截断),那不是它的真实结束分钟;拿它去分栏会把冲突判错。
⚠️ `data-e` 缺失 = "没填结束" ⇒ 前端按 +30′ 处理(与服务端 `$probe` 同口径)。 */
preg_match('/^(\d{1,2}):(\d{2})/', (string)($ev['time'] ?? ''), $mS);
$evS = $mS ? ((int)$mS[1] * 60 + (int)$mS[2]) : 0;
preg_match('/^(\d{1,2}):(\d{2})/', (string)($ev['end_time'] ?? ''), $mE);
$evE = $mE ? ((int)$mE[1] * 60 + (int)$mE[2]) : null;
if ($evE !== null && $evE <= $evS) { $evE = null; }
?>
<?php
/* 🔴 v88(工单 #35):跨天日程的分段。
`seg` 由 `build_time_grid` 给出:
start = 起始段(从开始时刻到当日 24:00)
mid = 中间整天
end = 结束段(00:00 到结束时刻)
only / 空 = 单天日程(原样)
视觉上要让三段"连成一条":把段与段之间的圆角和内侧边框去掉,
只保留最外两端是圆的。具体在 CSS 的 `.tg-block.is-seg-*` 里。 */
$evSeg = (string)($ev['seg'] ?? '');
$evCont = !empty($ev['cont']);
$segCls = '';
if ($evSeg === 'start') { $segCls = ' is-seg-start'; }
elseif ($evSeg === 'mid') { $segCls = ' is-seg-mid'; }
elseif ($evSeg === 'end') { $segCls = ' is-seg-end'; }
/* 分隔线:起始段的下边不画(要跟下一段接上),
中间段上下都不画,结束段上边不画 —— 由 CSS 处理。 */
?>
<a class="tg-block<?= $segCls ?>" data-cal="<?= (int)$ev['cal'] ?>"
data-s="<?= (int)$evS ?>"<?= $evE !== null ? ' data-e="' . (int)$evE . '"' : '' ?>
<?php if ($evSeg !== '') : ?>data-seg="<?= e($evSeg) ?>"<?php endif; ?>
data-fb-entry='<?= fb_entry_json($ev, $evDet, $evEdit) ?>'
<?php
/* 🔴 v88:跨天段用**贴满整列**的定位(left:0 / width:100%),
和单天块的 `calc(2px + N%)` / `calc(N% - 4px)` 不是一套。
单天块要留 2px 好跟列边框区分;跨天段要跟上一列接上,
留缝就"断成三段"了(实测 10.9px 空隙)。
⚠️ 用 CSS 类而不是内联值区分:内联 calc 里做条件太绕,
而且两边混着写以后必然有人改错一边。 */
$evSegStyle = ($evSeg !== '')
? 'left: 0; width: 100%;'
: 'left: calc(2px + ' . (float)($ev['left'] ?? 0) . '%); '
. 'width: calc(' . (float)($ev['width'] ?? 100) . '% - 4px);';
?>
style="background: <?= e((string)$ev['color']) ?>;
top: <?= (float)$ev['top'] ?>%; height: <?= (float)$ev['height'] ?>%;
<?= $evSegStyle ?>"
href="<?= e((string)$ev['href']) ?>"
title="<?= e((string)$ev['title']) ?><?= $evCont ? '(接前一日)' : '' ?>">
<?php if (!$evCont && (string)($ev['time'] ?? '') !== '') : ?>
<b><?= e((string)$ev['time']) ?></b>
<?php endif; ?>
<?php if ($evCont) : ?><span class="tg-cont">↳</span><?php endif; ?><?= e((string)$ev['title']) ?>
</a>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</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:「月视图,从周一开始。而不是周日。」
表头顺序必须与 month_grid_range() 的起算规则一致(那里已改成周一)。 */ ?>
<?php foreach (['一', '二', '三', '四', '五', '六', '日'] as $wd): ?>
<div class="cal-wd"><?= e($wd) ?></div>
<?php endforeach; ?>
</div>
<div class="cal-grid">
<?php foreach ($weeks as $wk): ?>
<?php foreach ($wk as $cell): ?>
<?php /* 用户第 5 条:点格子里的空白 = 在这天建日程。
用 data-new-at 把日期带过去,由底部 JS 交给新建表单
(点了小条本身是"打开那条日程",由 <a> 自己处理,JS 会排除掉)。 */ ?>
<div class="cal-cell<?= $cell['in_month'] ? '' : ' is-out' ?><?= $cell['is_today'] ? ' is-today' : '' ?><?= $canNew ? ' rt-writable' : '' ?>"
data-new-at="<?= e((string)$cell['date']) ?>">
<div class="cal-dnum"><?= (int)$cell['day'] ?></div>
<div class="cal-evs">
<?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);
$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]); }
?>
<a class="cal-ev<?= empty($ev['all_day']) ? '' : ' is-allday' ?>"
data-cal="<?= (int)$ev['cal'] ?>"
data-fb-entry='<?= fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit) ?>'
style="background: <?= e((string)$ev['color']) ?>;"
href="<?= e((string)$ev['href']) ?>"
title="<?= e((string)$ev['title']) ?>">
<?php if (($ev['time'] ?? '') !== ''): ?><b><?= e((string)$ev['time']) ?></b> <?php endif; ?>
<?= e((string)$ev['title']) ?>
</a>
<?php endif; ?>
<?php endforeach; ?>
<?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>
<?php endforeach; ?>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>