日程新建/修改不再刷新页面:服务端片段 + 就地重绘 (#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
+338
View File
@@ -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; }
}
/**
* 把新片段塞进容器:**只换子节点,不动容器本身**。
*
* 片段顶层是一个 `<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();
}
})();
+88 -5
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; }
/* 只有日程那张表有 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(); }
});
}
/* ---- ③ 详情:就地展开更多信息 ----------------------------------------------
+301 -89
View File
@@ -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<int,int>,hiddenCals:array<int,int>,
* 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=<starts_at 的月份>` —— 在首页 / 周视图里改一条
别的月份的日程时,刷新后连原视图都不在了,用户根本看不出改成功没有。
⚠️ 判据不能用「有没有某个字段」之类的猜测 —— 两条路的参数完全一样。 */
$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;
+301
View File
@@ -0,0 +1,301 @@
<?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>
+76 -552
View File
@@ -187,276 +187,13 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
unset($calCanNew, $calWritable, $calCtx);
?>
<?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; ?>
<?php /* 🔵 工单 #37:视图区抽成独立片段 `_view.php`。
整页路径与「新建/修改后的局部重绘」**必须**走同一份模板 ——
这样"插进去的样子"与"刷新一次的样子"不可能不一致。
⚠️ 原来这里是一大段内联 HTML(月/周/日三套)。抽走时**一字未改**,
只把外层缩进调平;变量的给出方式也照旧($view/$cols/$tgrid/$weeks/
$canNew/$nowTop 都由上面备好)。 */ ?>
<?php require __DIR__ . '/_view.php'; ?>
</div>
</div>
@@ -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) {
/* ============================================================
「点格子 / 点列空白 = 在那天(那个时刻)新建日程」
------------------------------------------------------------
🔵 工单 #37:**改成 document 上的事件委托**,不再逐元素绑定。
为什么必须改:原来是 `querySelectorAll(...).forEach(el => addEventListener(...))`,
只对"渲染那一刻存在"的节点生效。而本工单让视图区可以整块重绘(新建/修改后
取服务端片段替换),重绘后换进来的新格子**没人绑** ⇒ 点一下没反应。
委托之后一根监听管全部(含以后换进来的),重绘也不需要重新绑定。
⚠️ 顺序有意义:先判 `.cal-ev` / `.tg-block`(点条目 = 打开那条,不抢),
再判 `.cal-more`(点 +N 条 = 看清单,不新建),最后才落到"新建"。
============================================================ */
(function () {
/* 服务端就把"能不能写"烘进这段脚本 —— 只读账号点了不该有反应 */
var CAN_WRITE = <?= $canWrite && $writable ? 'true' : 'false' ?>;
document.addEventListener('click', function (ev) {
var t = ev.target;
if (!t || !t.closest) { return; }
if (!CAN_WRITE) { return; }
/* ① 月视图格子:一格 = 一天 → 09:00 起("当日默认"比 00:00 更像个日程) */
var cell = t.closest('.cal-cell[data-new-at]');
if (cell) {
// 点在小条上 = 想打开那条日程(<a> 的默认行为),不要抢
if (ev.target.closest && ev.target.closest('.cal-ev')) { return; }
if (t.closest('.cal-ev')) { return; }
/* 🔴 v70:点「+N 条」= 要看**当天的全部条目**,不是要新建日程。
⚠️ 必须显式排除:它是个 <button>,不排除的话会**同时**触发
"新建日程浮层" 与 "当天全部条目浮层" 两个浮层叠在一起。 */
if (ev.target.closest && ev.target.closest('.cal-more')) { return; }
if (!<?= $canWrite && $writable ? 'true' : 'false' ?>) { return; }
"新建日程浮层" 与 "当天全部条目浮层" 两个浮层。 */
if (t.closest('.cal-more')) { return; }
openNew(cell.getAttribute('data-new-at'), '09', '00');
});
});
return;
}
/* 周/日视图的列:纵向位置 → 时刻。
/* ② 周/日视图的列:按鼠标在**那一列里的纵向位置**反算时刻,最接近半小时。
列高 1056px = 24 小时(见 .tg-col 的 CSS),所以 1 分钟 = 1056/1440 px。 */
Array.prototype.forEach.call(document.querySelectorAll('.tg-col[data-new-at]'), function (col) {
col.addEventListener('click', function (ev) {
if (ev.target.closest && ev.target.closest('.tg-block')) { return; }
if (!<?= $canWrite && $writable ? 'true' : 'false' ?>) { return; }
var col = t.closest('.tg-col[data-new-at]');
if (col) {
if (t.closest('.tg-block')) { return; }
var box = col.getBoundingClientRect();
var ratio = (ev.clientY - box.top) / Math.max(1, box.height);
var mins = Math.max(0, Math.min(24 * 60 - 30, Math.round(ratio * 1440 / 30) * 30));
var hh = String(Math.floor(mins / 60)).padStart(2, '0');
var mm = String(mins % 60).padStart(2, '0');
openNew(col.getAttribute('data-new-at'), hh, mm);
}
});
});
})();
/* 从 ?new_at= 进来(比如别处链过来)→ 直接开弹窗并预填 */
var preset = <?= json_encode($newAt, JSON_UNESCAPED_UNICODE) ?>;
@@ -659,268 +417,22 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
if (tip) { tip.textContent = msg; tip.className = 'cal-modal-tip err'; }
}
/** 把一条 `fb_entry_json` 形态的条目补进 `#calDayData`(按天分组,幂等)。
* ⚠️ 只补**还没在里面**的那条(同 ref + 同 occ 视为同一条)——
* 否则连点两次「加上」或前端重放会把同一条塞两遍,清单里出现两条一样的。 */
function pushDayData(day, entry) {
var host = document.getElementById('calDayData');
if (!host || !day || !entry) { return; }
var all;
try { all = JSON.parse(host.textContent || '{}') || {}; } catch (e) { return; }
var list = all[day] || [];
var same = false;
for (var i = 0; i < list.length; i++) {
if (String(list[i].ref || '') === String(entry.ref || '')
&& String(list[i].occ || '') === String(entry.occ || '')
&& String(list[i].title || '') === String(entry.title || '')) { same = true; break; }
}
if (!same) {
/* 位置按服务端那套 `displayRank + 时间` 插(派生条目垫底),
免得清单里的顺序与刷新后的顺序不一致。 */
var rk = function (o) {
var s = String(o.source || '');
return s === 'event' ? 0 : 4; /* 派生条目一律 4,与服务端 displayRank 的兜底一致 */
};
var tm = function (o) {
var rows = o.rows || [];
for (var k = 0; k < rows.length; k++) {
if (rows[k][0] === '时间') {
var v = String(rows[k][1] || '');
/* "YYYY-MM-DD HH:MM–HH:MM" / "YYYY-MM-DD 全天" → 取时刻部分比较 */
var seg = v.slice(day.length).trim();
return seg === '全天' ? '' : seg.slice(0, 5);
}
}
return '';
};
var pos = list.length;
for (var j = 0; j < list.length; j++) {
if (rk(list[j]) > rk(entry)) { pos = j; break; }
if (rk(list[j]) === rk(entry) && tm(list[j]) > tm(entry)) { pos = j; break; }
}
list.splice(pos, 0, entry);
all[day] = list;
try { host.textContent = JSON.stringify(all); } catch (e2) { /* 序列化失败就别动 */ }
}
}
/* 🔵 工单 #37(用户 2026-11-01):「修改和新建,都不应该刷新页面,
而是使用js动态调整。」
/** 月视图:往 data-new-at="YYYY-MM-DD" 那个格子里塞一条小条 */
function putMonth(ev) {
var day = String(ev.starts_at || '').slice(0, 10);
var cell = document.querySelector('.cal-cell[data-new-at="' + day + '"]');
if (!cell) { return false; }
var box = cell.querySelector('.cal-evs');
if (!box) { return false; }
var a = document.createElement('a');
a.className = 'cal-ev' + (ev.all_day ? ' is-allday' : '');
a.setAttribute('data-cal', String(ev.cal || ''));
a.style.background = ev.color || '';
a.setAttribute('href', ev.href || '#');
a.setAttribute('title', ev.title || '');
/* ⚠️ 时间前缀的形状必须与服务端一致(<b>HH:MM</b> 后面一个空格)——
不一致的话刷新一次就会看出差别。 */
if (ev.time) {
var b = document.createElement('b');
b.textContent = ev.time;
a.appendChild(b);
a.appendChild(document.createTextNode(' '));
}
a.appendChild(document.createTextNode(ev.title || ''));
/* 🔴 插到**正确的时间位置**,不能 append 到末尾。
为什么必须按时间插:服务端只渲染前 3 条(第 4 条起收进"+N 条"),
而"哪 3 条"是按**时间顺序**取的。若这边一律 append:
· 新加一条**早上 7 点**的日程,它会被排到最后 → 落到"+N 条"里看不见,
可刷新一次又会跑到第一位 —— 这正是"插进去和刷新后长得不一样"。
⚠️ 判据与服务端渲染同一套:先比 <b>HH:MM</b>,没有时间前缀的(全天)排前面
(与 Calendar::month 给 all_day 的处理一致),再比标题兜底稳定性。 */
var newTime = ev.time || '';
var placed = false;
var existing = Array.prototype.slice.call(box.querySelectorAll('.cal-ev'));
for (var k = 0; k < existing.length; k++) {
var tEl = existing[k].querySelector('b');
var tTxt = tEl ? tEl.textContent.trim() : '';
if (tTxt === '' && newTime !== '') { continue; } /* 空时间(全天)在前 */
if (tTxt !== '' && newTime === '') { box.insertBefore(a, existing[k]); placed = true; break; }
if (tTxt > newTime) { box.insertBefore(a, existing[k]); placed = true; break; }
}
if (!placed) { box.appendChild(a); }
这里**原来有一整套手写复刻服务端渲染的代码**(`pushDayData` / `putMonth` /
`relayoutColumn` / `putTimed` / `insert`,约 270 行):月视图往格子里塞小条
并按时间插到正确位置、周/日视图按"24h=100%"算 top/height、插完重算整列分栏…
/* 再按"只显示前 3 条"收口,与服务端渲染对齐(否则会多出一条)。 */
var links = box.querySelectorAll('.cal-ev');
var more = box.querySelector('.cal-more');
/* 🔴 v70:新条目同步进 `#calDayData` —— 否则「+N 条」点开是**旧**清单,
刚建的这条不在里面(刷新一次又有了)。放在收口之前做,跟服务端渲染时
"先把当天全部条目收进数据岛、再只画前 3 条"的顺序一致。 */
if (ev.entry) { pushDayData(day, ev.entry); }
for (var j = 0; j < links.length; j++) {
links[j].style.display = (j < 3) ? '' : 'none';
}
if (links.length > 3) {
var extra = links.length - 3;
if (!more) {
/* 🔴 v70(用户 2026-10-20 第 2 条):「+N 条」原来是颗 `<span>`,
**点不动** ⇒ 被它折叠掉的条目永远看不到("导进去了但不显示")。
这里必须造**按钮**并带 `data-cal-day`(与模板渲染同形),
否则新建一条之后,这个格子就退回"点不动"。 */
more = document.createElement('button');
more.type = 'button';
more.className = 'cal-more';
box.appendChild(more);
}
more.setAttribute('data-cal-day', day);
more.setAttribute('aria-label', '看这一天全部 ' + links.length + ' 条');
more.textContent = '+' + extra + ' 条';
} else if (more) {
more.parentNode.removeChild(more);
}
return true;
}
它自己的注释里就记着一次真实事故:新加一条 07:00 的日程被 append 到末尾 ⇒
落进「+N 条」看不见,可刷新一次又跑到第一位。
这类 bug 的根子是"两处渲染必须同步",只要两处存在就一定会有偏差。
/* 🔴🔴 v51(用户 2026-10-15 第 3 条):「添加一个日程之后,只是把这个日程放在了
周视图或者是日视图上面。如果它这个时间段已经有日程了,它会**直接盖在上面**,
却并没有按照**分栏**的方式左右显示。这个需要再调整一下。」
真根因:`putTimed()` 只把自己那条色块按 top/height **整宽铺上去**,
完全不知道同一列里已有的日程 —— 于是两条重叠时后加的直接压在先有的上面
(服务端渲染时是有分栏的:`App\Calendar::conflictLanes`,但那是**整页重绘**时才算的)。
⇒ 修法:**在浏览器里重算整列的分栏**(与服务端同一套算法),插入后重排该列。
为什么"重算整列"而不是"给新块腾个位置":
新日程可能同时与**多条**已有日程冲突(比如插进一个 3 段重叠的时段),
只挪新块会让已有多条的宽度/位置也不对(它们现在是 2 列、重算后该是 3 列)
—— 必须让整列一起重排才是自洽的。
⚠️ 算法**照抄** app/Calendar.php 的 conflictLanes(贪心分列 + 分组复列):
两边算错一个边界,刷新一次位置就会跳。
⚠️ 高度仍按各自"声明的起止"算(没结束的用 +30′)—— 与服务端那套一致。
(服务端在"没填结束"时还会拿同列下一条的起点当视觉结束;这里简化成 +30′,
差别只在"未填结束"这一种情况,且插入后用户一刷新就归位。 */
function minsAttr(el, attr) {
var v = el.getAttribute(attr);
if (v === null || v === '') { return null; }
var n = parseInt(v, 10);
return isNaN(n) ? null : n;
}
/** 重算某一列里**所有** .tg-block 的分栏(left/width)与 top/height */
function relayoutColumn(col) {
var blocks = Array.prototype.slice.call(col.querySelectorAll('.tg-block'));
if (!blocks.length) { return; }
// ① 归一成 [{el, s, e}](缺 data-e → +30′;缺 data-s → 从 top 反推兜底)
var rows = [];
blocks.forEach(function (el) {
var s = minsAttr(el, 'data-s');
if (s === null) {
var tp = parseFloat(el.style.top) || 0;
s = Math.round(tp / 100 * 1440);
}
var e = minsAttr(el, 'data-e');
if (e === null || e <= s) { e = s + 30; }
rows.push({ el: el, s: s, e: Math.min(e, 1440) });
});
// ② 按开始时间升序(同开始则长的在前)—— 与服务端 usort 同序
rows.sort(function (a, b) { return a.s - b.s || b.e - a.e; });
// ③ 划分冲突组(与组内最晚结束仍重叠就并组)
var groups = [], cur = [], endMax = -1;
rows.forEach(function (r) {
if (cur.length && r.s >= endMax) { groups.push(cur); cur = []; endMax = -1; }
cur.push(r);
endMax = Math.max(endMax, r.e);
});
if (cur.length) { groups.push(cur); }
// ④ 组内贪心分列(每列记最后结束分钟,放进编号最小的可用列)
groups.forEach(function (g) {
var laneEnd = [], laneOf = [];
g.forEach(function (r, k) {
var lane = -1;
for (var li = 0; li < laneEnd.length; li++) {
if (laneEnd[li] <= r.s) { lane = li; break; }
}
if (lane < 0) { lane = laneEnd.length; laneEnd.push(r.e); }
else { laneEnd[lane] = r.e; }
laneOf[k] = lane;
});
var cols = Math.max(1, laneEnd.length);
g.forEach(function (r, k) {
var left = laneOf[k] / cols * 100, width = 100 / cols;
r.el.style.left = 'calc(2px + ' + left.toFixed(4) + '%)';
r.el.style.width = 'calc(' + width.toFixed(4) + '% - 4px)';
r.el.style.top = (r.s / 1440 * 100).toFixed(4) + '%';
r.el.style.height = ((Math.max(15, r.e - r.s)) / 1440 * 100).toFixed(4) + '%';
});
});
}
/** 周/日视图:按"列高 = 24 小时"算 top/height 插时间块 */
function putTimed(ev) {
if (ev.all_day) {
var ad = document.querySelector('.tg-adcell');
if (!ad) { return false; }
var box2 = document.querySelector('.tg-allday');
var col = null;
if (box2) {
/* 找到"那一天"那一列:列的顺序与 .tg-dayname 一致,
用 data-new-at 反查(服务端给每列写了当天日期)。 */
var cols = Array.prototype.slice.call(document.querySelectorAll('.tg-col[data-new-at]'));
var day2 = String(ev.starts_at || '').slice(0, 10);
var idx = -1;
cols.forEach(function (c, i) { if (c.getAttribute('data-new-at') === day2) { idx = i; } });
if (idx >= 0) {
var cells = document.querySelectorAll('.tg-adcell');
col = cells[idx] || null;
}
}
var host = col || ad;
if (!host) { return false; }
var a2 = document.createElement('a');
a2.className = 'cal-ev is-allday';
a2.setAttribute('data-cal', String(ev.cal || ''));
a2.style.background = ev.color || '';
a2.setAttribute('href', ev.href || '#');
a2.setAttribute('title', ev.title || '');
a2.textContent = ev.title || '';
host.appendChild(a2);
return true;
}
var cols3 = Array.prototype.slice.call(document.querySelectorAll('.tg-col[data-new-at]'));
var day3 = String(ev.starts_at || '').slice(0, 10);
var ci = -1;
cols3.forEach(function (c, i) { if (c.getAttribute('data-new-at') === day3) { ci = i; } });
if (ci < 0) { return false; }
var target = cols3[ci];
var minsOf = function (t) {
var m = String(t || '').match(/[ T](\d{1,2}):(\d{1,2})/);
return m ? (+m[1]) * 60 + (+m[2]) : 0;
};
var st = minsOf(ev.starts_at), en = minsOf(ev.ends_at);
if (en <= st) { en = st + 30; } /* 没填结束就给半小时 —— 与服务端一致 */
var top = (st / 1440) * 100, hgt = ((en - st) / 1440) * 100;
var blk = document.createElement('a');
blk.className = 'tg-block';
blk.setAttribute('data-cal', String(ev.cal || ''));
/* 🔴 v51:带上"声明的起止分钟",重算分栏要用(服务端渲染的同名属性)。
⚠️ 与服务端口径一致:结束缺失/倒挂 → 不带 data-e(按 +30′ 处理)。 */
blk.setAttribute('data-s', String(st));
var declE = minsOf(ev.ends_at);
if (declE > st) { blk.setAttribute('data-e', String(declE)); }
blk.style.background = ev.color || '';
blk.style.top = top.toFixed(4) + '%';
blk.style.height = hgt.toFixed(4) + '%';
blk.setAttribute('href', ev.href || '#');
blk.setAttribute('title', ev.title || '');
blk.textContent = ev.title || '';
target.appendChild(blk);
/* 🔴 v51(第 3 条):插完**重排整列** —— 否则这条会整宽盖在已有日程上(用户报的现象)。 */
relayoutColumn(target);
return true;
}
function insert(ev) {
/* 先看当前视图是哪一种:页面上有 .cal-grid 就是月视图;
有 .tg-col 就是周/日视图。两者都不在(理论上不会)→ 退回刷新。 */
if (document.querySelector('.cal-grid')) { return putMonth(ev); }
if (document.querySelector('.tg-col[data-new-at]')) { return putTimed(ev); }
return false;
}
⇒ **整段删掉**,改用 `assets/js/cal_sync.js` 的 `refresh()`:
新建成功后取一份服务端渲染好的视图片段整块替换。
这样"加进去的样子"与"刷新一次的样子"**必然**相同,因为只有一处渲染。
日历页新建 / 首页新建 / 浮层改完,三条路走同一个函数。
*/
form.addEventListener('submit', function (e) {
if (form.getAttribute('data-native-submit') === '1') { return; }
@@ -936,9 +448,20 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
.then(function (d) {
if (sub) { sub.disabled = false; }
if (d && d.ok && d.event) {
var ok = false;
try { ok = insert(d.event); } catch (err) { ok = false; }
if (ok) {
/* 🔵 工单 #37:不再手写插 DOM,改为让 cal_sync 取片段重绘。
`d.msg` 里带着服务端那句人话(含"已加进观影/旅程"这类说明)。
⚠️ 观影/旅程本是**派生**的:服务端给了 `goto` 时反而**不能**
立刻重绘收提示 —— 用户可能想点那个"去看详情"。所以这两条路分开:
有 goto(派生本)→ 保留提示行 + 链接,重绘照做;
没有 goto(普通日程)→ 关浮层 + 轻提示 + 重绘。 */
var sync = window.fbCalSync;
if (!sync || typeof sync.refresh !== 'function') {
/* 老缓存没加载到脚本 → 退回原生提交,别假装成功 */
form.setAttribute('data-native-submit', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
return;
}
/* 观影 / 旅行本是**派生**的(内容来自观影/旅程表):AJAX 时服务端
不再自动跳详情页(跳走就离开了日历),改在提示行给一个链接。 */
if (d.goto && tip) {
@@ -961,13 +484,14 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
if (t) { t.value = ''; }
if (lo) { lo.value = ''; }
if (no) { no.value = ''; }
return;
/* 重绘当前视图(不刷新页面)。有 goto 的是派生本,提示留 6 秒让人点链接。 */
var opts = { msg: (d.msg || '已加上。') };
if (d.goto) {
opts.msg = (d.msg || '已加上。') + ' 去看详情 →';
}
/* 插入不成功 = "没刷新却也没看到" → 宁可刷新一次,别假装成功 */
form.setAttribute('data-native-submit', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
sync.refresh(opts);
return;
}
if (d && d.msg) { sayErr(d.msg); return; }
/* 拿不到 JSON:会话过期 / 500 → 退回原生提交,让服务端说话 */
+11
View File
@@ -251,6 +251,17 @@ require __DIR__ . '/_entry_popup.php'; ?>
三处入口分散在首页/日程页,按需加载的判据会在这些页面之间来回漏;
它只有几 KB,不值得为省这点流量赌"某个页面漏加载"。 */ ?>
<script src="<?= e(asset('/assets/js/entry_popup.js')) ?>" defer></script>
<?php /* 🔵 工单 #37(用户 2026-11-01):「修改和新建,都不应该刷新页面,
而是使用js动态调整。」
⇒ 新建/修改成功后**取服务端渲染好的片段整块替换**,不手写复刻渲染。
历史教训:手写复刻必然出错(`templates/calendar/index.php` 里 `putMonth` 的注释记着
一次真实事故 —— 新加一条 07:00 的日程被 append 到末尾,落进「+N 条」看不见,
刷新后却又跑到第一位)。
⚠️ 全站加载:它在**首页**和**日程页**各有落点,按需加载的判据会来回漏;
自带"两个落点都不在就什么都不做"的判据,开销可忽略。
⚠️ 必须排在 `entry_popup.js` 之后(浮层保存成功后要调它的 refresh)。
`defer` 保证按文档顺序执行,所以这两行的先后就是执行先后。 */ ?>
<script src="<?= e(asset('/assets/js/cal_sync.js')) ?>" defer></script>
<?php /* 🔴 v78:首页任务面板的动作脚本(状态/看板/子任务/附件/评论)。
同样全站加载:面板是浮层的一部分,而浮层本身就挂在本布局里;
脚本自带"面板不在就什么都不做"的判据。 */ ?>