Files
FamilyBook/assets/js/cal_sync.js
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

338 lines
15 KiB
JavaScript
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.
/* ============================================================================
日程「就地更新」—— 新建 / 修改**不刷新页面**(工单 #37)
----------------------------------------------------------------------------
用户原话(2026-11-01):
「日程又有了新bug,修改一个日程时,页面会刷新,这样如果我新建或者修改的日程
不是今天的,那么当刷新后,就看不到我编辑的日程的变化,不知道修改成功了没有。
修改和新建,都不应该刷新页面,而是使用js动态调整。」
---------------------------------------------------------------- 为什么这么写
历史上「新建不刷新」是靠**手写复刻服务端渲染**做的(`templates/calendar/index.php`
底部那段 `putMonth` / `putTimed` / `relayoutColumn`)。那段代码自己的注释里就写着:
· "插入用的定位方式必须与服务端渲染的完全一致,否则刷新一次小条就会跳位置"
· 一次真实事故:新加一条 07:00 的日程被 append 到末尾 ⇒ 落进「+N 条」看不见,
可刷新一次又跑到第一位
修改要处理的情况比新建多得多(跨天变了 / 全天状态变了 / 换了日历本改了颜色 /
改了重复规则 / 从这天挪到了别月),复刻必然出错。
⇒ 本文件改用**服务端渲染片段 → 前端整块替换**:
改完 → 取一份服务端画好的视图 → 换掉容器里的内容。
只有一处渲染,"插进去的样子"与"刷新一次的样子"**不可能**不一致。
---------------------------------------------------------------- 三个落点
① `[data-cal-view]` 日历页的月/周/日视图(`templates/calendar/_view.php`)
② `#homeCard-agenda` 首页「今天的日程」卡片(`templates/home/_card_agenda.php`)
③ 「+N 条」清单的 `#calDayData` 数据岛(月视图那一份 JSON,换掉后清单才跟着变对)
---------------------------------------------------------------- 硬约束(都是踩过的)
· **只换内容、不换容器**:容器上挂着 `data-cal-view-kind` 之类的上下文,
换掉会让 CSS 的 `.cal-main > *` 重算(视觉会跳一下),而且监听会丢。
· **替换前记滚动位置**:`.hc-agenda [data-ag-scroll]` 进场会滚到"现在",
不恢复的话用户正看着的那条就被弹走了。
· **替换后要补跑三件事**:红线定时器(`data-now-line` 是 app.js 首屏扫的)、
「+N 条」数据岛、以及本文件自己的重绑。否则换进来的新节点是"死的"。
· **幂等**:脚本全站加载,被重复引入(AJAX 换正文、模板重复)不能绑两遍 ——
`window.__fbCalSyncBound` 闸门。
· **拿不到片段就退回整页刷新**:宁可刷一次,不要"没刷新却也没变"。
用户最怕的就是"不知道修改成功了没有",那种状态比刷新更糟。
============================================================================ */
(function () {
'use strict';
if (window.__fbCalSyncBound) { return; }
window.__fbCalSyncBound = true;
/* ---------------------------------------------------------------- 工具 */
function byId(id) { return document.getElementById(id); }
/** 当前页面是哪个视图:从容器上读(服务端渲染时就写好了)。 */
function viewKind() {
var el = document.querySelector('[data-cal-view]');
return el ? (el.getAttribute('data-cal-view-kind') || '') : '';
}
/**
* 拼局部重绘要请求的地址。
*
* ⚠️ 参数**照抄当前页面的 URL**:用户在月视图看 11 月,就还取 11 月;
* 在周视图看某周,就还取那一周。绝不"按改动的那条日程重新算" ——
* 改一条别的月份的日程时,那会把用户正在看的那一屏换掉。
* 这条是用户报的现象的核心(改完刷新,跳到了别的月份/别的视图)。
*/
function viewUrl() {
var kind = viewKind();
if (!kind) { return ''; }
var q = new URLSearchParams();
q.set('r', 'calendar/view');
q.set('view', kind);
/* 上下文全部从**容器自己身上**读(服务端渲染时写的,准)。
不去猜隐藏域、也不从页头翻页链接里抠字符串 —— 那些在"翻过页"之后会对不上
(踩过:`#calJumpD` 带的是 anchor,翻到 12 月它还是 11 月那天的日期)。 */
var el = document.querySelector('[data-cal-view]');
var ym = el ? (el.getAttribute('data-cal-ym') || '') : '';
var anc = el ? (el.getAttribute('data-cal-anchor') || '') : '';
if (kind === 'week' || kind === 'day') {
if (anc) { q.set('d', anc); }
} else if (ym) {
q.set('ym', ym);
}
return 'index.php?' + q.toString();
}
function fetchHtml(url) {
return fetch(url, {
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function (r) {
if (!r.ok) { throw new Error('http ' + r.status); }
return r.text();
});
}
/* ---------------------------------------------------------------- 重绘 */
/** 记录 / 恢复首页日程卡片的滚动位置(它进场会滚到"现在")。 */
function grabScroll() {
var box = document.querySelector('#homeCard-agenda [data-ag-scroll]');
return box ? box.scrollTop : null;
}
function putScroll(top) {
if (top === null || top === undefined) { return; }
var box = document.querySelector('#homeCard-agenda [data-ag-scroll]');
if (box) { box.scrollTop = top; }
}
/**
* 把新片段塞进容器:**只换子节点,不动容器本身**。
*
* 片段顶层是一个 `<div class="cal-view">`(或卡片的 `.hc-body`),
* 拿它的 innerHTML 填进现有容器即可 —— 这样容器上的属性/监听都还在。
*/
function swap(host, html) {
var tmp = document.createElement('div');
tmp.innerHTML = html;
/* 片段顶层可能是 .cal-view(日历)或 .hc-body(卡片),两种都认 */
var src = tmp.querySelector('[data-cal-view], .hc-body') || tmp.firstElementChild;
if (!src) { return false; }
host.innerHTML = src.innerHTML;
return true;
}
/**
* 重画「+N 条」用的数据岛(月视图)。
*
* ⚠️ 为什么必须换它:清单内容来自 `#calDayData` 那份 JSON。
* 改了一条之后如果不更新,点「+N 条」看到的还是改之前的样子 ——
* 这正是本项目 v70 记过的那类"清单与格子不一致"。
* 数据岛由服务端随视图片段一起产出(`_view.php` 里就在网格前面),
* 所以这里只要把新的那份整段搬过来。
*/
function syncDayData(html) {
var host = byId('calDayData');
if (!host) { return; }
var tmp = document.createElement('div');
tmp.innerHTML = html;
var fresh = tmp.querySelector('#calDayData');
if (!fresh) { return; }
try { host.textContent = fresh.textContent || '{}'; } catch (e) { /* 失败就别动 */ }
}
/** 重绘后补跑那些"首屏扫过一次"的初始化。 */
function reinit() {
/* 红线:app.js 里那个 IIFE 只在首屏扫 `[data-now-line]` 并起定时器。
换进来的新节点没人管它 —— 不补的话线会停在服务端给的位置不动
(一小时后再看就明显错了)。这里就地算一次当前位置,并起一个新的定时器
(旧的会因为没有节点而空转,量极小;用标记避免重复起)。 */
var lines = document.querySelectorAll('[data-now-line]');
if (lines.length) {
var paint = function () {
var d = new Date();
var pct = ((d.getHours() * 60 + d.getMinutes()) / 1440) * 100;
Array.prototype.forEach.call(lines, function (el) {
el.style.top = pct.toFixed(4) + '%';
el.setAttribute('title',
'现在 ' + ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2));
});
};
paint();
if (!window.__fbCalNowTimer) {
window.__fbCalNowTimer = setInterval(paint, 60000);
}
}
}
/**
* 局部重绘入口。
*
* @param {Object} [opts]
* @param {string} [opts.msg] 成功提示(落在页面上的轻提示里)
* @param {boolean} [opts.hard] true = 强制走整页刷新(例如整条系列都变了)
* @return {Promise<boolean>} 重绘是否成功
*/
function refresh(opts) {
opts = opts || {};
if (opts.hard || !window.fetch) {
window.location.reload();
return Promise.resolve(false);
}
var hasCal = !!document.querySelector('[data-cal-view]');
var hasCard = !!(byId('homeCard-agenda') || document.querySelector('#homeCard-agenda'));
/* 两个落点都不在(比如在任务页、资源页):这条日程的改动没有可见的地方,
安静地成功即可 —— 不要为了"看不见的东西"刷一次页。 */
if (!hasCal && !hasCard) { return Promise.resolve(true); }
var jobs = [];
if (hasCal) {
var u = viewUrl();
if (!u) { window.location.reload(); return Promise.resolve(false); }
jobs.push(fetchHtml(u).then(function (html) {
var host = document.querySelector('[data-cal-view]');
if (!host || !swap(host, html)) { throw new Error('swap'); }
syncDayData(html);
}));
}
if (hasCard) {
jobs.push(fetchHtml('index.php?r=home/agenda').then(function (html) {
var host = document.querySelector('#homeCard-agenda');
/* 卡片的外层是 `<section id="homeCard-agenda">`,要换的是里面的 `.hc-body`。
当天的日程块本身就是 `.hc-body.hc-agenda` —— 拿它当替换目标。 */
var body = host ? host.querySelector('.hc-body') : null;
if (!body) { throw new Error('no body'); }
var top = grabScroll();
if (!swap(body, html)) { throw new Error('swap'); }
putScroll(top);
}));
}
return Promise.all(jobs).then(function () {
reinit();
if (opts.msg) { toast(opts.msg); }
return true;
}).catch(function () {
/* 任何一步失败 → 退回整页刷新。宁可刷一次,也不要"没刷新却也没变"。 */
window.location.reload();
return false;
});
}
/* ---------------------------------------------------------------- 轻提示 */
/**
* 页面顶部一句轻提示。
*
* ⚠️ 为什么不用 `flash`(服务端那条):flash 要等下一次整页渲染才出现,
* 而本工单的整个目的就是"不刷新"。没有提示的话,用户在原地看不到任何
* 变化时仍然会怀疑"到底存没存上"(他原话:"不知道修改成功了没有")。
*/
var toastTimer = null;
function toast(msg, isErr) {
/* ⚠️ 复用站内**已有的**那套浮动小条(`.tk-flash`,v55 为"任务状态改完给一句反馈"
加的那一个),不另发明一套 —— 两套视觉迟早不一致,深色模式还要各补一遍。
⚠️ 开关只认 `hidden` 属性(与 `templates/tasks/show.php` 同一个用法):
`[hidden]` 的 `display:none` 是浏览器默认样式、优先级最低,
CSS 里必须有一条 `.tk-flash[hidden]{display:none}` 兜着,
否则作者样式里的 `display` 会把它盖掉 ⇒ 提示永远收不起来。 */
var box = byId('fbCalToast');
if (!box) {
box = document.createElement('div');
box.id = 'fbCalToast';
box.className = 'tk-flash';
box.setAttribute('role', 'status');
box.setAttribute('aria-live', 'polite');
box.hidden = true;
document.body.appendChild(box);
}
box.textContent = msg;
box.className = 'tk-flash' + (isErr ? ' is-err' : '');
box.hidden = false;
if (toastTimer) { clearTimeout(toastTimer); }
toastTimer = setTimeout(function () {
box.hidden = true;
}, 3200);
}
/* ---------------------------------------------------------------- 首页新建 */
/**
* 首页那张「新建日程」表单**也要不刷新**。
*
* ⚠️ 只接管"首页那条路":判据是页面上**没有** `.cal-wrap`(那是日历模块页)。
* 日历页自己有完整的无刷新逻辑(模板底部那段),两边都绑会导致
* 提交两次(一次 fetch 一次原生)—— 本项目 `add_modal.js` 的注释里
* 记着同一类"绑两遍"事故。
*/
function bindHomeAddForm() {
if (document.querySelector('.cal-wrap')) { return; } /* 日历模块页:不接管 */
var form = byId('calAddForm');
if (!form || form.getAttribute('data-fb-cal-bound') === '1') { return; }
if (!window.fetch) { return; }
form.setAttribute('data-fb-cal-bound', '1');
var tip = byId('calAddTip');
function sayErr(msg) {
if (tip) { tip.textContent = msg; tip.className = 'cal-modal-tip err'; }
else { toast(msg, true); }
}
form.addEventListener('submit', function (e) {
if (form.getAttribute('data-native-submit') === '1') { return; }
e.preventDefault();
var sub = byId('calAddSubmit');
if (sub) { sub.disabled = true; }
if (tip) { tip.textContent = ''; tip.className = 'cal-modal-tip'; }
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 (sub) { sub.disabled = false; }
if (d && d.ok) {
/* 关浮层 → 清掉"只填一次"的字段(保留日历本选择,用户常连加几条) */
if (window.fbAddModal && form.closest('.cal-modal')) {
window.fbAddModal.close('#' + form.closest('.cal-modal').id);
} else {
var m = form.closest('.cal-modal');
if (m) { m.hidden = true; }
}
['ev-title', 'ev-location', 'ev-note'].forEach(function (id) {
var el = byId(id);
if (el) { el.value = ''; }
});
/* 观影/旅程本是派生的:服务端给了 goto,就别急着说"日程已加上" */
refresh({ msg: (d.msg || '日程已加上。') });
return;
}
if (d && d.msg) { sayErr(d.msg); return; }
/* 拿不到 JSON:会话过期 / 500 → 退回原生提交,让服务端说话 */
form.setAttribute('data-native-submit', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
})
.catch(function () {
if (sub) { sub.disabled = false; }
form.setAttribute('data-native-submit', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
});
});
}
/* ---------------------------------------------------------------- 导出 */
window.fbCalSync = {
refresh: refresh,
viewUrl: viewUrl,
viewKind: viewKind,
toast: toast
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindHomeAddForm);
} else {
bindHomeAddForm();
}
})();