日程新建/修改不再刷新页面:服务端片段 + 就地重绘 (#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
This commit is contained in:
2026-11-01 09:04:35 +08:00
parent 01d5bc262f
commit 7f3df7a40b
6 changed files with 1150 additions and 681 deletions
+91 -8
View File
@@ -46,8 +46,12 @@
(页面里还有"点某天新建日程"那套逻辑,它同样排除 `.cal-ev`)。
③ **数据全在 `data-fb-entry` 上**:浮层不发请求就能画出来,秒开。
④ **详情/编辑都就地换正文**(用户:「就在当前的页面」)——不叠第二层浮层。
⑤ **保存走原生提交**:四个来源的 update 路由本来就收普通 POST 并跳转
(自己写 AJAX 保存要为四个来源各写一套"成功后回哪",得不偿失)。
⑤ **保存:日程走 AJAX,其余来源走原生提交**(工单 #37 起)。
· 日程(`#calEditForm`)→ fetch → 成功后原地重绘,**不刷新**。
服务端 `calendar/event_update` 认 X-Requested-With 回 JSON(新加的分支)。
· 名册 / 观影 / 行程 / 任务 → 仍是原生提交 + 302,行为与以前完全一致。
它们各自的 update 路由没有 JSON 出口,硬改要把四个模块一起动;
用户报的是日程,范围就收在日程上,不顺手扩大回归面。
⑥ **删除走 AJAX**(v49 新增,服务端 `calendar/event_del` 认 X-Requested-With 回 JSON):
成功后就地摘掉那一条,**不刷页** —— 与编辑不同,删除不需要"回到哪一页"的语义。
⚠️ 两种例外退回整页重载:`removed_forward`(这一次及以后砍掉了整个系列尾段,
@@ -308,14 +312,93 @@
.catch(function () { window.location.href = url; });
}
/** 编辑态「保存」= 原生提交那张表单(服务端 update 路由会跳转,刷新到新页)。 */
/** 找到编辑态那张表单(四个来源的 id 不同,兜底取第一个 form)。 */
function editForm() {
var b = bodyEl();
if (!b) { return null; }
return b.querySelector('form#anForm, form#mvForm, form#nodeForm, form#tripForm, form#calEditForm')
|| b.querySelector('form');
}
/**
* 编辑态「保存」。
*
* 🔵 工单 #37(用户 2026-11-01)——**用户原话**:
* 「修改一个日程时,页面会刷新,这样如果我新建或者修改的日程不是今天的,
* 那么当刷新后,就看不到我编辑的日程的变化,不知道修改成功了没有。
* 修改和新建,都不应该刷新页面,而是使用js动态调整。」
*
* ⇒ **日程**(`#calEditForm`)改走 AJAX + 原地重绘;
* 另外三个来源(名册 / 观影 / 行程 / 任务…)保持原生提交 ——
* 它们各自的 update 路由没有 JSON 出口,硬改就得把四个模块一起动,
* 而用户这次报的是日程。范围收在日程上,其它来源行为一字不变(避免回归)。
*/
function submitEdit() {
var form = bodyEl() && (bodyEl().querySelector('form#anForm, form#mvForm, form#nodeForm, form#tripForm, form#calEditForm')
|| bodyEl().querySelector('form'));
var form = editForm();
if (!form) { setTip('找不到可保存的表单。', true); return; }
form.setAttribute('data-fb-entry-submit', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
/* 只有日程那张表有 AJAX 出口(`calendar/event_update` 认 X-Requested-With)。
其余来源照旧原生提交,与改动前完全一致。 */
/* ⚠️⚠️ 必须用 `getAttribute('id')`,**不能**写 `form.id`:
日程编辑表里有 `<input type="hidden" name="id">`,而 HTMLFormElement 的具名访问
优先级高于原型上的 `id` 属性 ⇒ `form.id` 拿到的是那个 input 元素。
原来那句 `form.id === 'calEditForm'` 因此**恒为 false**,日程也走了原生提交、
整页刷新(本轮实测抓到:请求上没有 X-Requested-With,响应 302)。 */
var isCal = form.getAttribute('id') === 'calEditForm'
&& typeof window.fetch === 'function';
if (!isCal) {
form.setAttribute('data-fb-entry-submit', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
return;
}
submitEditAsync(form);
}
/** 日程:fetch 提交 → 成功则关浮层 + 局部重绘;失败留在浮层里让人改。 */
function submitEditAsync(form) {
var saveBtn = byId('fbEntrySave');
if (saveBtn) { saveBtn.disabled = true; }
setTip('正在保存…');
footNone(); /* 保存中把按钮收起来,防连点(重复提交在重复日程上后果很重) */
fetch(form.getAttribute('action'), {
method: 'POST', body: new FormData(form), credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }
})
.then(function (r) { return r.json().catch(function () { return null; }); })
.then(function (d) {
if (saveBtn) { saveBtn.disabled = false; }
if (d && d.ok) {
setOpen(false);
/* `moved_series`(改「这一次」/「从现在到以后」)= 整条系列都变了,
影响面超出当前这一屏 ⇒ 交给 refresh 自己判断要不要硬刷。 */
var sync = window.fbCalSync;
if (sync && typeof sync.refresh === 'function') {
sync.refresh({ msg: (d.msg || '已保存。'), hard: !!d.moved_series });
} else {
/* 没加载到脚本(老缓存):退回刷新,别当成功 */
window.location.reload();
}
return;
}
/* 业务拒绝(没权限 / 时间不对 / 标题为空):留在浮层里,让用户改完再存 */
if (d && d.msg) {
setTip(d.msg, true);
footShow('edit');
return;
}
/* 拿不到 JSON:会话过期 / 500 → 退回原生提交,让服务端说话(与改动前一致) */
form.setAttribute('data-fb-entry-submit', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
})
.catch(function () {
if (saveBtn) { saveBtn.disabled = false; }
form.setAttribute('data-fb-entry-submit', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
});
}
/* ---- ③ 详情:就地展开更多信息 ----------------------------------------------