/* ============================================================================ 日程「就地更新」—— 新建 / 修改**不刷新页面**(工单 #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; } } /** * 把新片段塞进容器:**只换子节点,不动容器本身**。 * * 片段顶层是一个 `
`(或卡片的 `.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} 重绘是否成功 */ 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'); /* 卡片的外层是 `
`,要换的是里面的 `.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(); } })();