From 7f3df7a40be47788c8df5e94e6e158bc91859822 Mon Sep 17 00:00:00 2001 From: laoyang Date: Sun, 1 Nov 2026 09:04:35 +0800 Subject: [PATCH] =?UTF-8?q?=E6=97=A5=E7=A8=8B=E6=96=B0=E5=BB=BA/=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E4=B8=8D=E5=86=8D=E5=88=B7=E6=96=B0=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=EF=BC=9A=E6=9C=8D=E5=8A=A1=E7=AB=AF=E7=89=87=E6=AE=B5=20+=20?= =?UTF-8?q?=E5=B0=B1=E5=9C=B0=E9=87=8D=E7=BB=98=20(#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户原话(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 遮蔽:日程编辑表里有 ``, 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 --- assets/js/cal_sync.js | 338 +++++++++++++++++ assets/js/entry_popup.js | 99 ++++- index.php | 390 +++++++++++++++----- templates/calendar/_view.php | 301 +++++++++++++++ templates/calendar/index.php | 692 ++++++----------------------------- templates/layout.php | 11 + 6 files changed, 1150 insertions(+), 681 deletions(-) create mode 100644 assets/js/cal_sync.js create mode 100644 templates/calendar/_view.php diff --git a/assets/js/cal_sync.js b/assets/js/cal_sync.js new file mode 100644 index 0000000..06d298d --- /dev/null +++ b/assets/js/cal_sync.js @@ -0,0 +1,338 @@ +/* ============================================================================ + 日程「就地更新」—— 新建 / 修改**不刷新页面**(工单 #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(); + } +})(); \ No newline at end of file diff --git a/assets/js/entry_popup.js b/assets/js/entry_popup.js index 0cc2737..73b9603 100644 --- a/assets/js/entry_popup.js +++ b/assets/js/entry_popup.js @@ -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`: + 日程编辑表里有 ``,而 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(); } + }); } /* ---- ③ 详情:就地展开更多信息 ---------------------------------------------- diff --git a/index.php b/index.php index 5afd486..8e5b798 100644 --- a/index.php +++ b/index.php @@ -845,6 +845,154 @@ function build_time_grid(array $events, array $cols): array return ['all_day' => $allDay, 'timed' => $timedOut]; } +/** + * 日历视图的全部数据(工单 #37 抽出)。 + * + * 整页渲染(`calendar` 路由)与局部重绘(`calendar/view` 路由)共用这一个函数: + * 只有一处取数、一处建网格,"片段与整页不一致"这类 bug 就无从产生。 + * + * @return array{view:string,ym:string,anchor:string,nowTop:float,newAt:string, + * cals:array,visCals:array,hiddenCals:array, + * cols:array,weeks:array,tgrid:?array,canNew:bool} + */ +function calendar_view_data(int $familyId, int $me): array +{ + // v24 第 4 条:月 / 周 / 天三种看法。view 只认这三个,认不出就回落**默认视图**。 + // ⚠️ 默认视图是**每人一份**的设置(用户第 5 条:设置里选月/周/天)。 + // 但只在**没带 view 参数**时才生效 —— 手点了「周」按钮之后, + // 那个选择必须优先,否则每次翻页都被默认值拽回去。 + $viewIn = trim((string)($_GET['view'] ?? '')); + if ($viewIn === '') { + $view = \App\Calendar::defaultView($me); + } else { + $view = in_array($viewIn, ['month', 'week', 'day'], true) ? $viewIn : 'month'; + } + $anchor = trim((string)($_GET['d'] ?? '')); + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $anchor)) { + $anchor = date('Y-m-d'); + } + $ym = substr($anchor, 0, 7); + + /* 🔴 v24 第 21 批 第 7 条:**打开日历顺手补一本「我的日程」**。 + 为什么必须在这儿补(而不是只改 DEFAULT_KINDS): + `Calendar::seedDefaults()` 只在**安装**与**结构升级**两条路径上跑过, + 线上这套库早就升完级了 → 老用户永远拿不到那一本。 + 结果:可写的普通本 0 本、"放在哪本"全是派生本、默认还选中排最前的 + **观影** → 用户直接点「加上」建出来的是一条**观影记录**(建错东西)。 + ⚠️ 幂等(owner_id + kind='custom' 有就不再补),一次请求只多一条 SELECT。 */ + \App\Calendar::ensurePersonalBook($familyId, $me); + $cals = \App\Calendar::mine($familyId, $me); + // 用户第 4 条:哪些本子显示,改由设置里的复选框决定(以前是左侧那一栏)。 + // 藏掉的本子**不参与取数**,而不是"取出来再 display:none" —— + // 前端藏的话,关掉一本之后月视图的"+N 条"仍然按它算,数字对不上。 + $hiddenCals = \App\Calendar::hiddenCalendars($me); + $visCals = []; + foreach ($cals as $c) { + if (!isset($hiddenCals[(int)$c['id']])) { + $visCals[] = $c; + } + } + + $cols = []; + $weeks = []; + $tgrid = null; + if ($view === 'month') { + $gym = trim((string)($_GET['ym'] ?? '')); + if (preg_match('/^\d{4}-\d{2}$/', $gym)) { + $ym = $gym; + } + [$from, $to] = month_grid_range($ym); + $events = \App\Calendar::month($familyId, $visCals, $me, $from, $to); + $weeks = build_month_grid($ym, $events); + } else { + // 周视图 7 列、天视图 1 列,共用同一个时间网格 + $cols = day_columns($anchor, $view === 'week' ? 7 : 1); + $from = $cols[0]['date']; + $to = $cols[count($cols) - 1]['date']; + $events = \App\Calendar::month($familyId, $visCals, $me, $from, $to); + $tgrid = build_time_grid($events, $cols); + } + + // 用户第 5 条:在日历里点一下就建日程,**开始时间 = 点的那一格**。 + // 点空白处只带日期/时刻过来,标题等还是空的(不能替用户瞎填)。 + $newAt = trim((string)($_GET['new_at'] ?? '')); + + /* ---- 当前时间红线(v24 第 20 批 用户第 6 条)---- + 用户原话:「周视图和日视图,应该在视图中间显示一条红线…来表示当前时间 + 在视图上的刻度和位置。」 + ⚠️ 位置在**服务端**算好(与 .tg-block 的 top/height 同一套算法: + 列高固定 24 小时 → 1 分钟 = 100/1440 %),前端只负责"过一会儿自己挪一下"。 + 不在 CSS 里写死时间,也不在前端算 —— 算法只有一处,不会两边对不上。 */ + $nowMinutes = ((int)date('G')) * 60 + (int)date('i'); + $nowTop = round($nowMinutes / 1440 * 100, 4); + + /* 能不能"点一下建日程" —— 与模板里那个 $canNew 同一条判据。 + ⚠️ 必须在**同一个地方**算:片段重绘时也要知道这一列能不能点。 */ + $writable = \App\Calendar::filterWritableForEvent($cals, $me); + $canNew = \App\Auth::canWrite() && (bool)$writable; + + return [ + 'view' => $view, 'ym' => $ym, 'anchor' => $anchor, + 'nowTop' => $nowTop, 'newAt' => $newAt, + 'cals' => $cals, + 'visCals' => array_map('intval', array_column($visCals, 'id')), + 'hiddenCals' => array_keys($hiddenCals), + 'cols' => $view === 'month' ? [] : $cols, + 'weeks' => $view === 'month' ? $weeks : [], + 'tgrid' => $view === 'month' ? null : $tgrid, + 'canNew' => $canNew, + ]; +} + +/** + * 首页「今天的日程」卡片的事件清单(工单 #37 抽出)。 + * + * @param array $soon 已经算好的"未来七天"汇总(Calendar::month 产出,已滤掉农历) + * @param string $today 'Y-m-d' + * @return array 只含今天、全天在前、其余按时刻升序 + * + * ⚠️ 与「未来七天」不同:这里**只要今天**,而且要按时间排好 + * (日视图从上往下就是时间序)。 + */ +function home_agenda_events(array $soon, string $today): array +{ + $evs = array_values(array_filter($soon, static function (array $e) use ($today): bool { + return (string)($e['date'] ?? '') === $today; + })); + usort($evs, static function (array $a, array $b): int { + // 全天事件(无时间)排最前 —— 日视图的习惯就是这样 + $ta = (string)($a['time'] ?? ''); + $tb = (string)($b['time'] ?? ''); + if ($ta === '' && $tb !== '') { return -1; } + if ($tb === '' && $ta !== '') { return 1; } + return strcmp($ta, $tb); + }); + return $evs; +} + +/** + * 取「今天」的日程原始清单(给卡片片段路由用)。 + * + * ⚠️ 与 `home` 路由里那段取数的口径必须一致(含"未来七天"的范围、滤掉农历)—— + * 所以这里**照抄**同一套调用,而不是另写一个"更简单"的版本。 + * 将来 home 路由那边改了取数口径,这里必须跟着改(两处都指向 Calendar::month)。 + * + * 为什么不把 home 路由的取数也抽成一个函数:home 一次要算概述/任务/纪念日/天气 + * 十几样东西,为了一条卡片抽出整块反而让那个路由更难读。这里只保留"卡片需要的 + * 那两行",口径靠注释钉住。 + */ +function home_agenda_events_live(int $familyId, int $me, string $today): array +{ + $cals = \App\Calendar::mine($familyId, $me); + $soon = \App\Calendar::month($familyId, $cals, $me, $today, date('Y-m-d', strtotime('+7 days'))); + $soon = array_values(array_filter($soon, static function (array $e): bool { + return (string)($e['kind'] ?? '') !== 'lunar'; + })); + return home_agenda_events($soon, $today); +} + + + /** * 读任务的「截止日期(+ 可选时刻)」。 * @@ -1112,19 +1260,10 @@ switch ($r) { . Lunar::dayName((int)$lunarToday['day'])); $todayMarks = Home::todayMarks($familyId, $me, $today); - // 今天的日程(v24 第 24 批 用户第 3 条:左侧那块要"今天的日程,日视图那样") - // ⚠️ 与「未来七天」不同:这里**只要今天**,而且要按时间排好(日视图从上往下就是时间序)。 - $todayEvents = array_values(array_filter($soon, static function (array $e) use ($today): bool { - return (string)($e['date'] ?? '') === $today; - })); - usort($todayEvents, static function (array $a, array $b): int { - // 全天事件(无时间)排最前 —— 日视图的习惯就是这样 - $ta = (string)($a['time'] ?? ''); - $tb = (string)($b['time'] ?? ''); - if ($ta === '' && $tb !== '') { return -1; } - if ($tb === '' && $ta !== '') { return 1; } - return strcmp($ta, $tb); - }); + /* 今天的日程(v24 第 24 批 用户第 3 条:左侧那块要"今天的日程,日视图那样")。 + 🔵 工单 #37:抽成 `home_agenda_events()`,卡片局部重绘时调同一个函数 —— + 否则"新建一条后卡片上出现的"与"刷新一次后的"会是两套算法算出来的。 */ + $todayEvents = home_agenda_events($soon, $today); // 天气:地点在**个人设置**里(v24 用户 2026-10-03 第 1 条),每人一份。 // 三级回落都在这一个调用里:自定义经纬度 → 内置城市表 → 不显示。 @@ -5317,70 +5456,23 @@ $want = array_keys($want); case 'calendar': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); - // v24 第 4 条:月 / 周 / 天三种看法。view 只认这三个,认不出就回落**默认视图**。 - // ⚠️ 默认视图是**每人一份**的设置(用户第 5 条:设置里选月/周/天)。 - // 但只在**没带 view 参数**时才生效 —— 手点了「周」按钮之后, - // 那个选择必须优先,否则每次翻页都被默认值拽回去。 - $viewIn = trim((string)($_GET['view'] ?? '')); - if ($viewIn === '') { - $view = Calendar::defaultView($me); - } else { - $view = in_array($viewIn, ['month', 'week', 'day'], true) ? $viewIn : 'month'; - } - $anchor = trim((string)($_GET['d'] ?? '')); - if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $anchor)) { - $anchor = date('Y-m-d'); - } - $ym = substr($anchor, 0, 7); - /* 🔴 v24 第 21 批 第 7 条:**打开日历顺手补一本「我的日程」**。 - 为什么必须在这儿补(而不是只改 DEFAULT_KINDS): - `Calendar::seedDefaults()` 只在**安装**与**结构升级**两条路径上跑过, - 线上这套库早就升完级了 → 老用户永远拿不到那一本。 - 结果:可写的普通本 0 本、"放在哪本"全是派生本、默认还选中排最前的 - **观影** → 用户直接点「加上」建出来的是一条**观影记录**(建错东西)。 - ⚠️ 幂等(owner_id + kind='custom' 有就不再补),一次请求只多一条 SELECT。 */ - Calendar::ensurePersonalBook($familyId, $me); - $cals = Calendar::mine($familyId, $me); - // 用户第 4 条:哪些本子显示,改由设置里的复选框决定(以前是左侧那一栏)。 - // 藏掉的本子**不参与取数**,而不是"取出来再 display:none" —— - // 前端藏的话,关掉一本之后月视图的"+N 条"仍然按它算,数字对不上。 - $hiddenCals = Calendar::hiddenCalendars($me); - $visCals = []; - foreach ($cals as $c) { - if (!isset($hiddenCals[(int)$c['id']])) { - $visCals[] = $c; - } - } - - if ($view === 'month') { - $gym = trim((string)($_GET['ym'] ?? '')); - if (preg_match('/^\d{4}-\d{2}$/', $gym)) { - $ym = $gym; - } - [$from, $to] = month_grid_range($ym); - $events = Calendar::month($familyId, $visCals, $me, $from, $to); - $grid = build_month_grid($ym, $events); - } else { - // 周视图 7 列、天视图 1 列,共用同一个时间网格 - $cols = day_columns($anchor, $view === 'week' ? 7 : 1); - $from = $cols[0]['date']; - $to = $cols[count($cols) - 1]['date']; - $events = Calendar::month($familyId, $visCals, $me, $from, $to); - $grid = build_time_grid($events, $cols); - } - - // 用户第 5 条:在日历里点一下就建日程,**开始时间 = 点的那一格**。 - // 点空白处只带日期/时刻过来,标题等还是空的(不能替用户瞎填)。 - $newAt = trim((string)($_GET['new_at'] ?? '')); - - /* ---- 当前时间红线(v24 第 20 批 用户第 6 条)---- - 用户原话:「周视图和日视图,应该在视图中间显示一条红线…来表示当前时间 - 在视图上的刻度和位置。」 - ⚠️ 位置在**服务端**算好(与 .tg-block 的 top/height 同一套算法: - 列高固定 24 小时 → 1 分钟 = 100/1440 %),前端只负责"过一会儿自己挪一下"。 - 不在 CSS 里写死时间,也不在前端算 —— 算法只有一处,不会两边对不上。 */ - $nowMinutes = ((int)date('G')) * 60 + (int)date('i'); - $nowTop = round($nowMinutes / 1440 * 100, 4); + /* 🔵 工单 #37:取数 + 建网格整套逻辑抽到 `calendar_view_data()`。 + 整页渲染与「新建/修改后的局部重绘」**必须**用同一个函数 —— + 两处各算一遍必然有一天只改到一半("整页对、片段错"或反之), + 而那类 bug 表现为"改完刷新一下才显示对",极难被发现。 + ⚠️ 参数从 `$_GET` 取还是从别处取,交给函数自己决定(它认 array 入参)。 */ + $cd = calendar_view_data($familyId, $me); + $view = $cd['view']; + $ym = $cd['ym']; + $anchor = $cd['anchor']; + $nowTop = $cd['nowTop']; + $newAt = $cd['newAt']; + $cals = $cd['cals']; + $visCals = $cd['visCals']; + $hiddenCals = array_flip($cd['hiddenCals']); + $cols = $cd['cols']; + $weeks = $cd['weeks']; + $tgrid = $cd['tgrid']; View::render('calendar/index', [ 'title' => '日程', @@ -5388,17 +5480,68 @@ $want = array_keys($want); 'view' => $view, 'anchor' => $anchor, 'nowTop' => $nowTop, - 'cols' => $view === 'month' ? [] : ($cols ?? []), - 'weeks' => $view === 'month' ? $grid : [], - 'tgrid' => $view === 'month' ? null : $grid, + 'cols' => $cols, + 'weeks' => $weeks, + 'tgrid' => $tgrid, 'cals' => $cals, 'visCals'=> array_map('intval', array_column($visCals, 'id')), - 'hiddenCals' => array_keys($hiddenCals), + 'hiddenCals' => $cd['hiddenCals'], 'newAt' => $newAt, 'people' => Person::roster($familyId, $me, 'member', ''), ], 'calendar'); break; + /** + * 只回**日历视图区**的片段(工单 #37)。 + * + * 用户原话:「修改和新建,都不应该刷新页面,而是使用js动态调整。」 + * ⇒ 新建 / 修改成功后,前端按当前视图来这里取一份"服务端渲染好的视图", + * 整块替换页面上的 `[data-cal-view]`。**不手写复刻渲染**,所以 + * "插进去的样子"必然等于"刷新一次的样子"。 + * + * 参数与 `calendar` 路由完全一致(view / d / ym),所以前端只要把当前 + * URL 的这几个参数原样带回来即可。 + * ⚠️ 用 `View::renderPartial`,不套 layout —— 塞进 innerHTML 的只能是片段。 + */ + case 'calendar/view': + Auth::requireLogin(); + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + $cd = calendar_view_data($familyId, $me); + header('Content-Type: text/html; charset=utf-8'); + header('Cache-Control: no-store'); + echo View::renderPartial('calendar/_view', [ + 'view' => $cd['view'], + 'nowTop' => $cd['nowTop'], + 'cols' => $cd['cols'], + 'weeks' => $cd['weeks'], + 'tgrid' => $cd['tgrid'], + 'canNew' => (bool)$cd['canNew'], + ]); + exit; + + /** + * 只回**首页日程卡片**的片段(工单 #37)。 + * + * 首页「新建日程」/ 浮层里改完日程之后,卡片要立刻反映出来。 + * 与 `calendar/view` 同一套思路:取服务端渲染好的片段整块替换, + * 不手写复刻 —— 卡片里那套「掐头去尾 / 红线定位 / 冲突分列」的算法 + * 复刻一遍必然对不上(`_card_agenda.php` 里记着 v80/v85 两次由算法不一致 + * 引起的真实事故)。 + */ + case 'home/agenda': + Auth::requireLogin(); + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + $today = date('Y-m-d'); + $todayEvents = home_agenda_events_live($familyId, $me, $today); + header('Content-Type: text/html; charset=utf-8'); + header('Cache-Control: no-store'); + echo View::renderPartial('home/_card_agenda', [ + 'todayEvents' => $todayEvents, + ]); + exit; + /** * 保存「日程显示设置」(v24 用户 2026-10-03 第 4、5 条)—— 每人一份。 * @@ -6040,6 +6183,40 @@ $want = array_keys($want); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); + /* 🔵 工单 #37(用户 2026-11-01)——**用户原话**: + 「修改和新建,都不应该刷新页面,而是使用js动态调整。」 + + 同一个路由要服务两种调用者 ⇒ 与 `event_add` / `event_del` 同一套约定, + 认 `X-Requested-With`: + · 条目浮层的「保存」带它 → 回 JSON,前端就地更新当前视图; + · 无 JS 的原生表单 → 照旧 flash + 302(渐进增强的兜底不变)。 + ⚠️ 改动前这条路由**一条 AJAX 出口都没有**:浮层里编辑完必然整页跳走, + 而跳去的是 `calendar?ym=` —— 在首页 / 周视图里改一条 + 别的月份的日程时,刷新后连原视图都不在了,用户根本看不出改成功没有。 + ⚠️ 判据不能用「有没有某个字段」之类的猜测 —— 两条路的参数完全一样。 */ + $ajax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') + || (strpos((string)($_SERVER['HTTP_ACCEPT'] ?? ''), 'application/json') !== false); + /* 失败出口:AJAX 回 JSON(可预期的拒绝不该把用户甩到一个刷新过的页面上)。 */ + $evFail = static function (string $msg) use ($ajax, &$ym) { + if ($ajax) { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => false, 'msg' => $msg], JSON_UNESCAPED_UNICODE); + exit; + } + flash('err', $msg); + redirect('calendar', ['ym' => $ym]); + }; + /* 成功出口:AJAX 回 JSON 并 exit;非 AJAX 只记 flash,**不 exit** —— + 调用处后面的 `redirect('calendar', [...])` 照旧执行,与原行为完全一致。 + `$extra` 带"前端要据此决定重绘范围"的标记(如 `moved_series`)。 */ + $evOk = static function (string $msg, array $extra = []) use ($ajax) { + if ($ajax) { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => true, 'msg' => $msg] + $extra, JSON_UNESCAPED_UNICODE); + exit; + } + flash('ok', $msg); + }; $id = (int)($_POST['id'] ?? 0); $ym = trim((string)($_POST['ym'] ?? '')); $ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m'); @@ -6055,19 +6232,16 @@ $want = array_keys($want); $ev = $st->fetch(); $ev2 = $ev; // 旧内容(scope 分支要用,别被后面的 $d 覆盖) if (!$ev) { - flash('err', '找不到这条日程。'); - redirect('calendar', ['ym' => $ym]); + $evFail('找不到这条日程。'); } $cal = Calendar::mineOne($familyId, $me, (int)$ev['calendar_id']) ?: []; if (!Calendar::canWrite($cal, $me)) { - flash('err', '这本日历你没有写权限。'); - redirect('calendar', ['ym' => $ym]); + $evFail('这本日历你没有写权限。'); } // v24 第 5、6 条:起止各是一个完整时刻(精确到秒),见上面 event_add 的说明。 $startsAt = read_dt_input('starts_at'); if ($startsAt === null) { - flash('err', '开始时间不对。'); - redirect('calendar', ['ym' => $ym]); + $evFail('开始时间不对。'); } $endsAt = read_dt_input('ends_at'); $allDay = !empty($_POST['all_day']); @@ -6112,7 +6286,10 @@ $want = array_keys($want); ['id' => $id, 'calendar_id' => (int)$ev['calendar_id']], $occDay, $d, $me ); - flash('ok', '只改了这一次。'); + /* ⚠️ `moved_series`:这一档除了改本条,还在系列里**新落了一条** + (原系列跳掉这一天)。前端据此知道整条系列都变了, + 重绘范围要覆盖所有可能出现这一天的地方,不能只改那一个块。 */ + $evOk('只改了这一次。', ['moved_series' => true]); } else { // 这一次及以后:老系列截到前一天,新系列从这天起 $rp = Repeat::parse($rruleOld); @@ -6143,7 +6320,8 @@ $want = array_keys($want); unset($offsetDays); $d2['created_by'] = $me; Calendar::addEvent((int)$ev['calendar_id'], $d2); - flash('ok', '这一次及以后的都改好了(过去的保持不变)。'); + $evOk('这一次及以后的都改好了(过去的保持不变)。', + ['moved_series' => true]); } redirect('calendar', ['ym' => substr($startsAt, 0, 7)]); } @@ -6153,6 +6331,40 @@ $want = array_keys($want); if ($warns) { flash('err', '有附件没有保存:' . implode(';', $warns)); } + /* 🔵 工单 #37:AJAX 时回**够前端整块重绘当前视图**的信息。 + ⚠️ 为什么不回"新那条的字段让前端自己插进 DOM"(`event_add` 那条老路子): + 那条路子要求前端**手写复刻服务端渲染**(月视图塞小条按时间插、周/日视图 + 按 24h 算 top/height、冲突重算分栏…),`putMonth` 的注释里就记着一次 + 真实事故(新加一条 07:00 的日程被 append 到末尾 ⇒ 落进「+N 条」看不见, + 刷新后又跑到第一位)。修改要处理的情况更多(跨天变了 / 全天状态变了 / + 换了日历本改了颜色 / 改了重复规则),复刻必然出错。 + ⇒ 改成"回一个信号,前端按当前视图去取服务端渲染好的片段来替换", + 只有一处渲染,插进去的样子**必然**等于刷新一次的样子。 + 这里给的字段是给"提示行 + 兜底"用的(比如前端要知道改到哪一天了)。 */ + if ($ajax) { + $newDay = substr((string)$startsAt, 0, 10); + $newTm = ($allDay || $startsAt === '') ? '' : substr((string)$startsAt, 11, 5); + $newEtm = ($allDay || $endsAt === null || $endsAt === '') ? '' : substr((string)$endsAt, 11, 5); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'ok' => true, + 'msg' => $warns ? '已保存,但有附件没传上去。' : '日程已保存。', + 'warns' => $warns, + 'event' => [ + 'id' => (int)$id, + 'title' => (string)$d['title'], + 'starts_at' => (string)$startsAt, + 'ends_at' => (string)($endsAt ?? ''), + 'all_day' => $allDay ? 1 : 0, + 'date' => $newDay, + 'time' => $newTm, + 'end_time' => $newEtm, + 'cal' => (int)$ev['calendar_id'], + 'ym' => substr($newDay, 0, 7), + ], + ], JSON_UNESCAPED_UNICODE); + exit; + } flash('ok', '日程已保存。'); redirect('calendar', ['ym' => substr($startsAt, 0, 7)]); break; diff --git a/templates/calendar/_view.php b/templates/calendar/_view.php new file mode 100644 index 0000000..431119b --- /dev/null +++ b/templates/calendar/_view.php @@ -0,0 +1,301 @@ + + * 的选择器重新算一遍(视觉会跳一下)。 */ ?> +
+ + +
+
+
+ +
+ + + +
+ +
+ + + +
+
全天
+ $c): ?> +
+ + 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]); } + ?> + ' + style="background: ;" + href="" + title=""> + +
+ +
+ + +
+
+ + + +
+ $c): ?> +
+ + + + + + + + + + + 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]); } + ?> + + + + data-seg="" + data-fb-entry='' + + style="background: ; + top: %; height: %; + " + href="" + title=""> + + + + ↳ + + +
+ +
+
+ + ` 而不是把 JSON 塞进 data-*: + data-* 属性里几千条会被 HTML 转义得又长又容易截断; + JSON 里若出现 `` 也会截断 —— 所以下面统一走 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; + } + } + ?> + +
+
+ + +
+ +
+
+ + + 自己处理,JS 会排除掉)。 */ ?> +
+
+
+ ,点不开 = 完全看不到。 + 两处都已修:① `Calendar::displayRank()` 让派生条目排最后; + ② 下面把 "+N 条" 换成**真按钮**,点开当天的全部条目浮层。 + ⚠️ 保留 3 条不改成"全画":格子是固定高度(min-height:104px), + 15 条硬塞会把月网格撑得不像月历;超出部分交给浮层看。 */ + $cellEvents = $cell['events']; + $shown = 3; + $hiddenCnt = count($cellEvents) - $shown; + ?> + + + 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]); } + ?> + ' + style="background: ;" + href="" + title=""> + + + + + + 0): ?> + ` 兜住无 JS 的情况:直接跳那天的日视图 + (日视图按时间网格铺开,不会再截断)。 */ ?> + + + +
+
+ + +
+
+ + +
diff --git a/templates/calendar/index.php b/templates/calendar/index.php index 17964b9..98e656f 100644 --- a/templates/calendar/index.php +++ b/templates/calendar/index.php @@ -187,276 +187,13 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts) unset($calCanNew, $calWritable, $calCtx); ?> - - -
-
-
- -
- - - -
- -
- - - -
-
全天
- $c): ?> -
- - 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]); } - ?> - ' - style="background: ;" - href="" - title=""> - -
- -
- - -
-
- - - -
- $c): ?> -
- - - - - - - - - - - 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]); } - ?> - - - - data-seg="" - data-fb-entry='' - - style="background: ; - top: %; height: %; - " - href="" - title=""> - - - - ↳ - - -
- -
-
- - ` 而不是把 JSON 塞进 data-*: - data-* 属性里几千条会被 HTML 转义得又长又容易截断; - JSON 里若出现 `` 也会截断 —— 所以下面统一走 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; - } - } - ?> - -
-
- - -
- -
-
- - - 自己处理,JS 会排除掉)。 */ ?> -
-
-
- ,点不开 = 完全看不到。 - 两处都已修:① `Calendar::displayRank()` 让派生条目排最后; - ② 下面把 "+N 条" 换成**真按钮**,点开当天的全部条目浮层。 - ⚠️ 保留 3 条不改成"全画":格子是固定高度(min-height:104px), - 15 条硬塞会把月网格撑得不像月历;超出部分交给浮层看。 */ - $cellEvents = $cell['events']; - $shown = 3; - $hiddenCnt = count($cellEvents) - $shown; - ?> - - - 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]); } - ?> - ' - style="background: ;" - href="" - title=""> - - - - - - 0): ?> - ` 兜住无 JS 的情况:直接跳那天的日视图 - (日视图按时间网格铺开,不会再截断)。 */ ?> - - - -
-
- - -
-
- + +
@@ -571,34 +308,55 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts) (它在该 IIFE 外面,看不到闭包里的 openNew)⇒ 暴露一个窗口入口。 */ window.fbCalOpenNew = openNew; - /* 月视图格子 */ - Array.prototype.forEach.call(document.querySelectorAll('.cal-cell[data-new-at]'), function (cell) { - cell.addEventListener('click', function (ev) { - // 点在小条上 = 想打开那条日程( 的默认行为),不要抢 - if (ev.target.closest && ev.target.closest('.cal-ev')) { return; } - /* 🔴 v70:点「+N 条」= 要看**当天的全部条目**,不是要新建日程。 - ⚠️ 必须显式排除:它是个