Files
FamilyBook/templates/calendar/index.php
T
laoyang 7f3df7a40b 日程新建/修改不再刷新页面:服务端片段 + 就地重绘 (#37)
用户原话(2026-11-01):
  「修改和新建,都不应该刷新页面,而是使用js动态调整。」

## 改法:不再手写复刻渲染,改成"服务端渲染片段 → 前端整块替换"

历史上「新建不刷新」是靠手写复刻服务端渲染做的(`putMonth` / `putTimed` /
`relayoutColumn`),它自己的注释里就记着一次真实事故:新加一条 07:00 的日程被
append 到末尾 ⇒ 落进「+N 条」看不见,刷新后又跑到第一位。修改要处理的情况更多
(跨天 / 全天状态 / 换日历本改颜色 / 改重复规则 / 挪到别的月),复刻必然出错。

⇒ 现在只有**一处渲染**:前端取服务端画好的视图片段整块替换。
   "插进去的样子"与"刷新一次的样子"必然相同。

## 服务端

- `calendar/event_update` 认 `X-Requested-With`(与 event_add / event_del 同一套约定)
  回 JSON;失败也回 JSON(可预期的拒绝不该把用户甩到一个刷新过的页面上)。
  三处失败出口 + 四处成功出口收口成两个闭包,避免"某条路径仍刷页"。
- 抽出 `calendar_view_data()`:整页渲染与局部重绘共用同一份取数/建网格逻辑。
- 新增 `calendar/view` 片段路由(月/周/日视图片段)。
- 抽出 `home_agenda_events()` + 新增 `home/agenda` 片段路由(首页卡片)。
- 抽出 `templates/calendar/_view.php`:视图区从页面模板里搬出来(原文一字未改,
  已用脚本逐行比对证明 270 行零差异)。
- `templates/layout.php` 加载 `cal_sync.js`。

## 前端

- 新增 `assets/js/cal_sync.js`:`refresh()` 按当前视图取片段替换;
  只换内容不换容器;保首页卡片滚动位置;重绘后补跑红线定时器;
  失败退回整页刷新(宁可刷一次,也不要"没刷新却也没变")。
- `entry_popup.js` 的「保存」改 fetch 提交 → 关浮层 + 轻提示 + 局部重绘。
  名册/观影/行程/任务四个来源保持原生提交,范围不收不必要的回归面。
- 删掉日历页那 ~270 行手写插入,新建统一走同一个 `refresh()`。
- 「点格子新建」的 per-element 绑定改成 document 委托(重绘后新节点才有监听)。

## 本轮实测抓到并修掉的真 bug

`form.id` 被同名 input 遮蔽:日程编辑表里有 `<input name="id">`,
HTMLFormElement 的具名访问优先级高于原型上的 `id` ⇒ `form.id === 'calEditForm'`
恒为 false ⇒ 日程也走原生提交、整页刷新。
改用 `form.getAttribute('id')`。

## 验证(全部做过反向验证)

- 浏览器端到端 28 项:编辑/新建都不导航、改动原地可见、视图不跳、数据岛同步、
  重绘后点格子仍有效、红线定时器重建、无 JS 异常
- 反向验证 7 项:把 refresh 置空 / 改成 reload / 不调 reinit / 抹掉上下文属性,
  判据逐条转红(证明判据有牙齿)
- DOM 等价性 4 项:月/周/日视图与首页卡片,重绘后与刷新后的 DOM **逐字节相同**
  (这条判据本身也做了反向验证:删一个字、多一个节点都能抓到)
- 服务端 11 项:非 AJAX 仍 302 且真存库、AJAX 回 JSON、失败也回 JSON、
  片段路由可用、未登录被拒
- #35 跨天回归 11 项:三段的 seg/cont/is-seg-* 标记全在、贴满整列、不进全天行
- 静态检查:37 个模板片段无作用域违规;20 块内联 JS 语法全过;运行期零 Warning
2026-11-01 09:04:35 +08:00

807 lines
45 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 日程 · 月 / 周 / 天 三视图(v12,v24 第 4、5 条重排)。
*
* ⚠️ v24 用户 2026-10-03 第 4 条:**左侧那一栏「我的日历本」撤掉了**,日历占满整宽。
* 原话:「日程模块,左侧的日历本显示与否,去掉,让日历充满整个宽度。
* 在右侧,也加一个设置按钮,设置中,可以设置哪些日历本显示,
* 调整日历的颜色,导入日历等功能,都放在这个设置下。」
* 所以那些功能全搬进了页头最右边那个 ☰ 里(见 _settings_menu.php)。
* 显隐改由**服务端**处理(Calendar::hiddenCalendars):藏掉的本子不参与取数,
* "+N 条"之类按可见条目算 —— 前端 display:none 会让这些数字对不上。
*
* ⚠️ 用户第 5 条:在日历里点一下就建日程(开始时间 = 点的那一格)。
* 月视图的格子按天、周/日视图按半小时——
* 后者用鼠标在列里的纵向位置反算时刻(见页面底部那两段 JS)。
*
* @param string $ym 'Y-m'
* @param array $weeks 月网格(build_month_grid 产出)
* @param array $cals 我可见的日历本(含 perm);$visCals 是其中显示着的 id
* @param string $newAt 点进来时预填的开始时间('Y-m-d H:i' 或 'Y-m-d')
*/
$canWrite = \App\Auth::canWrite();
$view = (string)($view ?? 'month');
$anchor = (string)($anchor ?? date('Y-m-d'));
$ts = strtotime($ym . '-01') ?: time();
$prevYm = date('Y-m', strtotime('-1 month', $ts));
$nextYm = date('Y-m', strtotime('+1 month', $ts));
$thisYm = date('Y-m');
/* 🔴 v95(用户 2026-10-26):只列**能放日程**的本子。
⚠️ 原来是 `array_filter($cals, canWrite)` —— 拦不住观影/旅行派生本(它们可写)
和系统只读本(perm 是 owner,canWrite 恒真)。统一走 `Calendar::writableForEvent()`,
详见那个方法的注释(里面记着实测的 perm/kind 对照表)。 */
$writable = \App\Calendar::filterWritableForEvent($cals, (int)\App\Auth::personId());
$visCals = array_map('intval', (array)($visCals ?? []));
$hiddenCals = array_map('intval', (array)($hiddenCals ?? []));
$newAt = (string)($newAt ?? '');
// 能不能"点一下建日程":只读账号点了没反应,所以连 rt-writable 标记都不给。
// 🔴 v48(用户 2026-10-14 第 3 条):**十字光标已去掉**(CSS 里不再有 cursor:crosshair),
// 但 `rt-writable` 这个**类名保留** —— 它是"这个人能不能写"在 DOM 上的标记位,
// 点击建日程的判断逻辑与此无关(见底部 JS 的 $canWrite && $writable)。
$canNew = $canWrite && (bool)$writable;
// 三种看法各自的「上一段 / 下一段」:月按整月走,周按 7 天走,天按 1 天走
$step = $view === 'week' ? 7 : ($view === 'day' ? 1 : 0);
$prevD = date('Y-m-d', strtotime('-' . $step . ' days', strtotime($anchor)));
$nextD = date('Y-m-d', strtotime('+' . $step . ' days', strtotime($anchor)));
$todayD = date('Y-m-d');
$headText = $view === 'month' ? date('Y 年 n 月', $ts)
: ($view === 'week' ? date('Y 年 n 月 j 日', strtotime($anchor)) . ' 起这一周'
: date('Y 年 n 月 j 日', strtotime($anchor)));
?>
<div class="page-head">
<h1>日程</h1>
<span class="sub"><?= e($headText) ?></span>
<div class="actions">
<?php /* v24 第 4 条:月 / 周 / 天三选一 */ ?>
<span class="seg" role="group" aria-label="看法">
<a class="seg-btn<?= $view === 'month' ? ' is-on' : '' ?>"
href="<?= e(url('calendar', ['view' => 'month', 'ym' => $ym])) ?>">月</a>
<a class="seg-btn<?= $view === 'week' ? ' is-on' : '' ?>"
href="<?= e(url('calendar', ['view' => 'week', 'd' => $anchor])) ?>">周</a>
<a class="seg-btn<?= $view === 'day' ? ' is-on' : '' ?>"
href="<?= e(url('calendar', ['view' => 'day', 'd' => $anchor])) ?>">天</a>
</span>
<?php if ($view === 'month'): ?>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => 'month', 'ym' => $prevYm])) ?>">上一月</a>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => 'month', 'ym' => $thisYm])) ?>">今天</a>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => 'month', 'ym' => $nextYm])) ?>">下一月</a>
<?php else: ?>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => $view, 'd' => $prevD])) ?>">
<?= $view === 'week' ? '上一周' : '前一天' ?>
</a>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => $view, 'd' => $todayD])) ?>">今天</a>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => $view, 'd' => $nextD])) ?>">
<?= $view === 'week' ? '下一周' : '后一天' ?>
</a>
<?php endif; ?>
<?php if ($canWrite && $writable): ?>
<button class="btn" type="button" id="calAddBtn">新建日程</button>
<?php endif; ?>
<?php /* 用户 2026-10-04 第 6 条原话:「日程页面,在设置按钮的前面,加一个日历按钮,
选择日历后,跳转到那一天。」
→ 一个带日历图标的按钮,点开是日期选择;选中即跳到那天的**日视图**。
⚠️ 用原生 <input type="date"> 而不是再引一个弹层:它的原生日历足够用,
而且**选中就有值**(change 事件)→ 直接跳转,不用再点一次"确定"。
桌面/手机/无 JS 三种情况下的行为都是可预期的。 */ ?>
<?php /* 用户 2026-10-05 第 12 批 #3 原话(三句):
· 「跳转到某一天时,不要进入日视图,保留当前本来的视图,只不过是跳转到这一天,
如果是周视图,那么就是跳转到这一天所在的周,如果是月视图,则跳转到这一天所在的月」
· 「选择这个日期,不用弹窗,直接把日历选择,弹出在这个按钮下面就可以了」
→ ① 不再固定 view=day:带上**当前视图**(月/周/日各自跳到对应跨度);
② 日期选择从"居中弹窗"改成**贴着按钮下方展开的下拉日历**。
🔴 2026-10-05 第 14 批 #1(用户报严重 bug):「点上一月,页面直接跳转,
我还没选择日期呢」。根因是这一版下拉里挂的是 Semi 的 DatePicker ——
它的日历面板**渲染在 body 的 portal 里**(不在下拉容器内),
而它在面板上翻月时会把"面板当前显示的日期"当成新值回调 onChange,
于是"翻月"被当成了"选中"。第三方控件把"翻页"与"选中"混在一个回调里,
这个语义我们改不了。
→ 改成**自绘内联日历**(几十行、零依赖):翻月/翻年只重绘面板、绝不提交,
只有点**某一天**才跳转,语义 100% 由我们自己定。 */ ?>
<form class="cal-jump" method="get" action="index.php">
<input type="hidden" name="r" value="calendar">
<?php /* 保留当前视图:月→该月 / 周→那一周 / 日→那一天。
⚠️ 原来写死 view=day,用户点"跳到 10 月 20 日"就被踢进日视图,
而他本来在月视图里 —— 这正是不满的点。 */ ?>
<input type="hidden" name="view" value="<?= e($view) ?>">
<input type="hidden" name="d" id="calJumpD" value="<?= e($anchor) ?>">
<?php /* 月视图用的是 ym(年月),跳转时也要带上,否则服务端会退回默认月 */ ?>
<?php if ($view === 'month'): ?>
<input type="hidden" name="ym" id="calJumpYM" value="<?= e(substr($anchor, 0, 7)) ?>">
<?php endif; ?>
<button type="button" class="btn btn-icon cal-jump-btn" id="calJumpBtn"
title="跳到某一天" aria-label="跳到某一天" aria-haspopup="dialog" aria-expanded="false">
<svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.7" stroke-linecap="round">
<rect x="2" y="3.2" width="12" height="10.8" rx="1.6"/>
<path d="M2 6.6h12M5.2 1.8v2.6M10.8 1.8v2.6"/>
</svg>
</button>
<?php /* 下拉日历的宿主:紧贴按钮下方展开(不是弹窗)。内容由下面那段 JS 画;
JS 不可用时它是空的、退到下面的 noscript(原生日期框 + 跳转按钮)。 */ ?>
<div class="cal-jump-drop" id="calJumpDrop" hidden></div>
<noscript>
<input class="input" type="date" name="d" value="<?= e($anchor) ?>" aria-label="选择日期">
<button class="btn btn-sm" type="submit">跳转</button>
</noscript>
</form>
<?php /* 用户第 4、5 条:日历本 / 颜色 / 导入 / 默认视图 全收进这个 ☰ ——
它顶替了原来左边那一栏,所以排在**最右**(靠 .page-head .actions 的顺序)。 */ ?>
<?php $setCurrent = 'view'; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
<div class="cal-wrap cal-wrap-full">
<?php /* ---- 右:月网格 / 时间网格 ----
用户第 4 条:左边那一栏「我的日历本」已经撤掉(功能搬进右上角 ☰),
所以这里直接就是主区域,`.cal-wrap-full` 把网格调成"只有一列"。 */ ?>
<div class="cal-main">
<?php /* 🔴 v24 第 21 批 第 7 条:新建日程改成**弹框**(用户原话:
「日历中新建日程,无论是点击按钮还是点击日历中间,都是采用弹框的形式进行添加,
这样不影响当前日历的显示,添加完成后,动态修改当前视图,不要刷新。」
⚠️ 原来是**内联卡片**(`#calAddCard`,就长在日历网格的正上方,展开时把整个
月视图往下推)—— 用户点一下"新建",正在看的日历就被顶走了,
填完还得自己找回来。这正是他说的"影响当前日历的显示"。
⚠️ 改成弹框用**既有的浮层形态**(与选人弹窗、名册动作框同一套 `.upfield`
不是,是 `.rm-modal`/`.crop-overlay` 那一路视觉:遮罩 + 居中面板),
不引第三方库 —— 原生 `<dialog>` 在部分旧 WebView 上行为不一致,
而且我们已有的浮层已经解决了"点遮罩关、Esc 关、滚动锁"这几件事。
⚠️ 表单本身**一个字没动**(字段名/服务端契约/CSRF 全照旧),
只把它的容器从"页面上的一格"换成"浮层里的一块"。
`calendar_id` 那个 select 也保留(第 7 条要求"选择日历本,带上颜色",
见下面给每个 option 加的色块)。 */ ?>
<?php /* 🔴 v82(用户 2026-10-23):这一大段弹框**抽成公共片段**了 ——
首页日程卡片要"在首页就能新建日程",两处必须用同一张表单。
片段:`templates/calendar/_add_modal.php`(表单契约一字未改)。
⚠️ 抽片段后**必须**同步改部署脚本的静态断言:那批断言原来按
`calendar/index.php` 找 `id="calPicker"` / `id="cbDot"` /
`class="cs-dot" style="background:` / `<span class="mv-lb">日历本</span>`
/ `$evMvStyle = true` —— 现在这些串在新片段里。
不改的话:正向断言当场中止部署,负向断言恒真没牙齿(v78 原话)。 */
?>
<?php
$calCanNew = $canWrite && (bool)$writable;
$calWritable = $writable;
$calCtx = [
'ym' => $ym, 'view' => $view, 'anchor' => $anchor,
'new_at' => $newAt,
// 模块页只管自己:'' = 提交完回日程模块(不写 'home')
'back' => '',
];
require __DIR__ . '/_add_modal.php';
/* 🔴🔴 v82:**只清掉三个"调用参数"**,别的什么都不许 unset。
为什么:`require` 与宿主**同一作用域**,片段里赋的每个变量都会留下来。
我第一版顺手把 `$ym/$view/$anchor/$newAt/$writable` 也 unset 了 ——
而这五个正是**模块页自己后文还要用的**(页头标题、翻页链接、周/日网格、
`.tg-now` 的 top 计算全依赖它们)。unset 掉不会报错,只会让后半页
静默变成空值:页头显示"1970 年"、翻页链接丢参数、时间网格画不出来。
这类"清了别人的变量"比"片段拿不到变量"更难查 —— 页面能开,只是全错。
⇒ 判据:unset 之前先 grep 一下后文还用不用它(本项目吃过"片段覆盖宿主变量"的亏)。
🔴 v82 再修(同日):片段内部现在已经**改用 `$cal*` 前缀**,不再覆盖
宿主的 `$anchor/$newAt/$writable/$view/$ym`(见该片段头部那段说明)。
所以这里连"片段残留物"都不必再清 —— 真正要清的只有本模板自己传进去的
三个 `$cal*` 入参。 */
unset($calCanNew, $calWritable, $calCtx);
?>
<?php /* 🔵 工单 #37:视图区抽成独立片段 `_view.php`。
整页路径与「新建/修改后的局部重绘」**必须**走同一份模板 ——
这样"插进去的样子"与"刷新一次的样子"不可能不一致。
⚠️ 原来这里是一大段内联 HTML(月/周/日三套)。抽走时**一字未改**,
只把外层缩进调平;变量的给出方式也照旧($view/$cols/$tgrid/$weeks/
$canNew/$nowTop 都由上面备好)。 */ ?>
<?php require __DIR__ . '/_view.php'; ?>
</div>
</div>
<script>
(function () {
/* ---- 新建日程:按钮开合 + 「点日历空白处」预填开始时间 ----
⚠️ 上一版的「日历本显隐」JS 已经**整段删掉**:显隐改成服务端处理
(Calendar::hiddenCalendars,见路由里的说明),前端不再需要它。
留着会让人以为"改一下勾选就能切换",但那些勾选框已经不在这儿了。 */
var btn = document.getElementById('calAddBtn');
var card = document.getElementById('calAddCard'); /* 现在是一个浮层 */
var cancel = document.getElementById('calAddCancel'); /* 头部 ✕ */
var cancel2 = document.getElementById('calAddCancel2'); /* 底部「取消」 */
if (btn && card) {
btn.addEventListener('click', function () { openNew(null, null, null); });
}
/* 关:✕ / 取消 / 点遮罩 / Esc 四条路都要通(与站内其它浮层一致)。
⚠️ 点遮罩才关、点面板内不关 —— 用 e.target === card 判,不用"点哪儿都关"。 */
function closeNew() { if (card) { card.hidden = true; } }
if (cancel) { cancel.addEventListener('click', closeNew); }
if (cancel2) { cancel2.addEventListener('click', closeNew); }
if (card) {
card.addEventListener('click', function (e) { if (e.target === card) { closeNew(); } });
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && card && !card.hidden) { closeNew(); }
});
/* ---- 用户第 5 条:在日历里点一下 → 弹新建,开始时间 = 点的位置 ----
月视图:一格 = 一天 → 开始时间用那天 09:00("当日默认"比 00:00 更像个日程)。
周/日视图:按鼠标在**那一列里的纵向位置**反算时刻,最接近的半小时 ——
点在"下午三点那一段"就该得到 15:00,这是"点哪儿建哪儿"的全部意义。 */
var startField = document.querySelector('.ev-dt[data-dt]');
var startDate = startField ? startField.querySelector('input.dt-date') : null;
var startHour = startField ? startField.querySelector('select.dt-hour') : null;
var startMin = startField ? startField.querySelector('select.dt-minute') : null;
var startSec = startField ? startField.querySelector('select.dt-second') : null;
function setStart(dateStr, hh, mm) {
var H = (hh === null || hh === undefined || hh === '') ? '09' : String(hh);
var M = (mm === null || mm === undefined || mm === '') ? '00' : String(mm);
// ⚠️ 优先走全局接口:它会把原生字段与 Semi/antd 控件**一起**更新。
// 直接写 startDate.value 的话,非受控控件显示不会跟着变 ——
// 用户会看到"我点了 15:00,控件里还写着 09:00"(提交的却是 15:00)。
var okStart = (typeof window.fbSetDT === 'function'
&& window.fbSetDT('starts_at', dateStr + ' ' + H + ':' + M + ':00'));
if (!okStart) {
// 兜底:Semi/antd 还没加载完(或加载失败)→ 原生控件照旧能用
if (!startDate) return;
startDate.value = dateStr;
if (hh !== null && hh !== undefined && startHour) { startHour.value = hh; }
if (mm !== null && mm !== undefined && startMin) { startMin.value = mm; }
if (startSec) { startSec.value = '00'; }
}
/* 🔴🔴 v51(用户 2026-10-15 第 2 条):「我们的时间范围**默认给它设 1 个小时**,
就是说你点了之后带进来的是 8 点钟,那么它结束时间应该是当天的 9 点钟,
就按照 1 个小时把结束时间也给它放上去。」
⇒ 带入开始时间时**同时**把结束设为「开始 + 1 小时」。
⚠️ 跨午夜要顺延到次日(23:30 点下去 → 次日 00:30),不能让结束时间
在日期上跑回"比开始还早的今天"(那种提交上去是负时长)。
⚠️ 结束时间的设置**只在这里**做(用户"点了某一处"这个动作)——
不要捆进 fbSetDT:那个接口也被"编辑已有日程"复用,
把它的结束时间也强改成 +1h 会把用户原本填的时长弄丢。 */
var st = new Date(dateStr + 'T' + H + ':' + M + ':00');
if (!isNaN(st.getTime())) {
st.setHours(st.getHours() + 1);
var pad2 = function (n) { return (n < 10 ? '0' : '') + n; };
var eDate = st.getFullYear() + '-' + pad2(st.getMonth() + 1) + '-' + pad2(st.getDate());
var eStr = eDate + ' ' + pad2(st.getHours()) + ':' + pad2(st.getMinutes()) + ':00';
if (typeof window.fbSetDT !== 'function' || !window.fbSetDT('ends_at', eStr)) {
/* 兜底:按 data-dt 里的 JSON `name` 找结束端点(**不能**用 `[data-dt*="ends_at"]`
—— 那个属性是一整段 JSON,里面含 `name` 键,朴素子串匹配能中但语义不对,
将来加个字段就误命中)。 */
var endField = null;
Array.prototype.forEach.call(document.querySelectorAll('.dt-input[data-dt]'), function (bx) {
if (endField) { return; }
var cfg = {};
try { cfg = JSON.parse(bx.getAttribute('data-dt') || '{}'); } catch (e) {}
if (cfg.name === 'ends_at') { endField = bx; }
});
if (endField) {
var ed = endField.querySelector('input.dt-date');
var eh = endField.querySelector('select.dt-hour');
var em = endField.querySelector('select.dt-minute');
if (ed) { ed.value = eDate; }
if (eh) { eh.value = pad2(st.getHours()); }
if (em) { em.value = pad2(st.getMinutes()); }
}
}
}
}
/**
* 打开新建日程的**弹框**(v24 第 21 批 第 7 条)。
*
* ⚠️ 不再 `scrollIntoView` —— 那是"内联卡片"时代为了让用户找到卡片才需要的动作;
* 浮层固定在视口中央,底下的日历**一动不动**(这正是用户要的
* "不影响当前日历的显示")。
* ⚠️ dateStr 传 null 表示"从按钮进来":那时**不要再覆盖**已填的时间
* (用户可能刚填完改主意关掉、又点开;重置会把他填的东西抹掉)。
* 只有"点日历某一格/某一列"才带着具体时间进来,那次才 setStart。
*/
function openNew(dateStr, hh, mm) {
if (!card) return;
card.hidden = false;
if (dateStr) { setStart(dateStr, hh, mm); }
// 标题是第一个输入框,直接把光标放过去 —— 打开就能打字
var t = document.getElementById('ev-title');
if (t) { t.focus(); }
}
/* v70:「当天全部条目」浮层里有「在这天加一条」,也要走同一个 openNew
(它在该 IIFE 外面,看不到闭包里的 openNew)⇒ 暴露一个窗口入口。 */
window.fbCalOpenNew = openNew;
/* ============================================================
「点格子 / 点列空白 = 在那天(那个时刻)新建日程」
------------------------------------------------------------
🔵 工单 #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 (t.closest('.cal-ev')) { return; }
/* 🔴 v70:点「+N 条」= 要看**当天的全部条目**,不是要新建日程。
⚠️ 必须显式排除:它是个 <button>,不排除的话会**同时**触发
"新建日程浮层" 与 "当天全部条目浮层" 两个浮层。 */
if (t.closest('.cal-more')) { return; }
openNew(cell.getAttribute('data-new-at'), '09', '00');
return;
}
/* ② 周/日视图的列:按鼠标在**那一列里的纵向位置**反算时刻,最接近半小时。
列高 1056px = 24 小时(见 .tg-col 的 CSS),所以 1 分钟 = 1056/1440 px。 */
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) ?>;
if (preset) {
var parts = String(preset).split(/[ T]/);
if (parts[0]) {
var hm = (parts[1] || '').split(':');
openNew(parts[0], hm[0] || '09', hm[1] || '00');
}
}
/* ============================================================
v24 第 21 批 第 7 条:日历本色块 + **无刷新提交 + 动态改当前视图**
------------------------------------------------------------
用户原话:「添加完成后,动态修改当前视图,不要刷新。」
⚠️ 做法:拦掉表单提交 → fetch POST(带 X-Requested-With,服务端据此回 JSON)
→ 拿到新日程的 {starts_at, ends_at, title, all_day, color, href, cal} →
**直接往当前视图里插入那一条**(月视图找对应格子塞小条、周/日视图按时间
算 top/height 插块),然后关掉弹框。整个过程中页面不重载、滚动位置不动。
⚠️ 任何一步不成立(拿不到 JSON / 字段缺 / 当前视图不好插)→ **退回原生提交**。
"插入失败还假装成功"是最坏的结果:用户以为加上了、切到别的月份才发现没有。
⚠️ 插入用的定位方式必须与**服务端渲染的完全一致**(月视图按 data-new-at 找格子、
周/日视图按"24h = 100%"算 top/height),否则刷新一次小条就会跳位置。
这里刻意照抄模板里的选择器与算式,别另发明一套。
============================================================ */
(function () {
var calDot = document.getElementById('ce-cal-dot');
var calSel = document.getElementById('ce-cal');
if (calDot && calSel) {
var syncDot = function () {
var o = calSel.options[calSel.selectedIndex];
var c = o ? o.getAttribute('data-color') : '';
if (c) { calDot.style.background = c; }
};
calSel.addEventListener('change', syncDot);
syncDot();
}
/* 🔴🔴 v93(用户 2026-10-25):日历本自绘下拉的整套交互**已抽到**
`assets/js/cs_picker.js`(全站加载,按容器上的 `data-cs-picker` 初始化)。
为什么必须抽走:首页「今天的日程」卡片那颗「新建日程」用的是**与本页同一份**
模板块(`templates/calendar/_add_modal.php`),HTML 一模一样;
交互却只写在下面这一段里 ⇒ 首页那张弹框的下拉**点了不展开**,
用户看到的就是"日历本拉不下来,只有一个『工作』"。
⚠️ 这里只留一句兜底调用。正常情况下通用脚本在 DOM ready 时已经扫过
(那时元素在、只是被 `hidden` 藏着,不影响绑定),这里什么都绑不上 ——
它是幂等的,绑过一次的容器自带 `data-cs-ready`,不会被绑第二遍。
⚠️ **不要**把交互搬回这里"顺便兼容一下":搬回来就等于又只有本页能用。 */
if (typeof window.fbInitCsPicker === 'function') { window.fbInitCsPicker(card || document); }
var form = document.getElementById('calAddForm');
var tip = document.getElementById('calAddTip');
if (!form || !card || !window.fetch) { return; }
function sayErr(msg) {
if (tip) { tip.textContent = msg; tip.className = 'cal-modal-tip err'; }
}
/* 🔵 工单 #37(用户 2026-11-01):「修改和新建,都不应该刷新页面,
而是使用js动态调整。」
这里**原来有一整套手写复刻服务端渲染的代码**(`pushDayData` / `putMonth` /
`relayoutColumn` / `putTimed` / `insert`,约 270 行):月视图往格子里塞小条
并按时间插到正确位置、周/日视图按"24h=100%"算 top/height、插完重算整列分栏…
它自己的注释里就记着一次真实事故:新加一条 07:00 的日程被 append 到末尾 ⇒
落进「+N 条」看不见,可刷新一次又跑到第一位。
这类 bug 的根子是"两处渲染必须同步",只要两处存在就一定会有偏差。
⇒ **整段删掉**,改用 `assets/js/cal_sync.js` 的 `refresh()`:
新建成功后取一份服务端渲染好的视图片段整块替换。
这样"加进去的样子"与"刷新一次的样子"**必然**相同,因为只有一处渲染。
日历页新建 / 首页新建 / 浮层改完,三条路走同一个函数。
*/
form.addEventListener('submit', function (e) {
if (form.getAttribute('data-native-submit') === '1') { return; }
e.preventDefault();
var sub = document.getElementById('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 && d.event) {
/* 🔵 工单 #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) {
tip.className = 'cal-modal-tip';
tip.textContent = (d.msg || '已加上。') + ' ';
var ga = document.createElement('a');
ga.href = d.goto;
ga.textContent = '去看详情 →';
tip.appendChild(ga);
setTimeout(function () {
if (tip) { tip.textContent = ''; }
}, 6000);
}
closeNew();
/* 重置"只填一次的字段",但**保留**日历本的选择(用户往往连加几条)。
⚠️ 标题必须清掉,否则下一条会带着上一条的标题(很容易漏掉)。 */
var t = document.getElementById('ev-title');
var lo = document.getElementById('ev-location');
var no = document.getElementById('ev-note');
if (t) { t.value = ''; }
if (lo) { lo.value = ''; }
if (no) { no.value = ''; }
/* 重绘当前视图(不刷新页面)。有 goto 的是派生本,提示留 6 秒让人点链接。 */
var opts = { msg: (d.msg || '已加上。') };
if (d.goto) {
opts.msg = (d.msg || '已加上。') + ' 去看详情 →';
}
sync.refresh(opts);
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(); }
});
});
})();
/* 「全天」勾上藏掉时刻那一段的逻辑在 _event_fields.php 里(两个页面共用一份) */
})();
/* ---- v24 第 14 批 #1:「跳到某一天」—— 图标按钮 + **按钮下方自绘的下拉日历** ----
原话(两条,都要满足):
· 「选择这个日期,不用弹窗,直接把日历选择,弹出在这个按钮下面就可以了」
· 「我点击上一个月,页面直接跳转,我还没选择日期呢」(严重 bug)
🔴 为什么**不用** Semi 的 DatePicker(上一版就是它):
Semi 的日历面板渲染在 **body 的 portal** 里(不在我们的下拉容器内),
而且它把"翻月/翻年"与"选中某天"混在同一个 onChange 回调里 ——
于是点"上一月"也被当成"选中了这一天",页面立刻提交跳转。
第三方控件的这个语义我们改不了;而这个交互又是"翻月不跳、点天才跳",
必须 100% 由我们自己定。所以这里自绘:
· « ‹ 年/月 › » → **只重绘面板**,绝不提交;
· 点某一天 → 立刻提交跳转(保留当前视图);
· 底部「跳到这天」→ 明确的确认入口(键盘/读屏用户也能用)。
⚠️ jump() 提交时带上当前 view(月的 ym / 周、日的 d),
由服务端决定该视图下这个日期落在哪一屏 —— 见 index.php 的 calendar 路由。 */
(function () {
var form = document.querySelector('.cal-jump');
var btn = document.getElementById('calJumpBtn');
var hid = document.getElementById('calJumpD');
var drop = document.getElementById('calJumpDrop');
if (!form || !btn || !hid) { return; }
var WD = ['一', '二', '三', '四', '五', '六', '日']; // 与月视图一致:周一起
var curY = 0, curM = 0; // 面板当前显示的年月
var sel = ''; // 已选日期('Y-m-d')
function ymd(y, m, d) {
return y + '-' + String(m).padStart(2, '0') + '-' + String(d).padStart(2, '0');
}
function jump(v) {
if (!v) { return; }
hid.value = v;
// 月视图还要带上 ym(服务端按月取数),否则会跳回默认月
var ym = document.getElementById('calJumpYM');
if (ym) { ym.value = String(v).slice(0, 7); }
form.submit();
}
function closeDrop() {
if (!drop) { return; }
drop.hidden = true;
btn.setAttribute('aria-expanded', 'false');
drop.innerHTML = '';
}
/** 画一个月:**只动面板内容**,不做任何跳转 */
function paint() {
var head = document.createElement('div');
head.className = 'cjd-head';
head.innerHTML =
'<button type="button" class="cjd-nav" data-step="-12" title="上一年" aria-label="上一年">«</button>' +
'<button type="button" class="cjd-nav" data-step="-1" title="上一月" aria-label="上一月">‹</button>' +
'<span class="cjd-title"></span>' +
'<button type="button" class="cjd-nav" data-step="1" title="下一月" aria-label="下一月">›</button>' +
'<button type="button" class="cjd-nav" data-step="12" title="下一年" aria-label="下一年">»</button>';
head.querySelector('.cjd-title').textContent = curY + ' 年 ' + curM + ' 月';
var grid = document.createElement('div');
grid.className = 'cjd-grid';
for (var w = 0; w < 7; w++) {
var wd = document.createElement('div');
wd.className = 'cjd-wd';
wd.textContent = WD[w];
grid.appendChild(wd);
}
// 起算:本月 1 号是周几(周一=0 … 周日=6),前面补上月尾巴
var first = new Date(curY, curM - 1, 1);
var lead = (first.getDay() + 6) % 7;
var days = new Date(curY, curM, 0).getDate();
var today = new Date();
var todayStr = ymd(today.getFullYear(), today.getMonth() + 1, today.getDate());
var start = new Date(curY, curM - 1, 1 - lead);
for (var i = 0; i < 42; i++) {
var dt = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
var s = ymd(dt.getFullYear(), dt.getMonth() + 1, dt.getDate());
var cell = document.createElement('button');
cell.type = 'button';
cell.className = 'cjd-day';
if (dt.getMonth() + 1 !== curM) { cell.className += ' is-out'; }
if (s === todayStr) { cell.className += ' is-today'; }
if (s === sel) { cell.className += ' is-on'; }
cell.textContent = String(dt.getDate());
cell.setAttribute('data-date', s);
grid.appendChild(cell);
}
var foot = document.createElement('div');
foot.className = 'cjd-foot';
foot.innerHTML =
'<span class="cjd-sel small muted"></span>' +
'<button type="button" class="btn btn-sm cjd-today">今天</button>' +
'<button type="button" class="btn btn-sm cjd-go">跳到这天</button>';
foot.querySelector('.cjd-sel').textContent = sel ? ('已选 ' + sel) : '点一天就跳过去';
drop.innerHTML = '';
drop.appendChild(head);
drop.appendChild(grid);
drop.appendChild(foot);
// 翻月 / 翻年:**只重画**
// ⚠️ 必须 stopPropagation:paint() 会把面板里的节点换掉,等这次 click 冒泡到
// document 上的「点外面就关」时,e.target 已经不在 DOM 里了 ——
// `drop.contains(e.target)` 为假 → 面板当场被关掉(实测:点一下"上一月",
// 面板立刻消失,看起来像"按钮没反应")。
Array.prototype.forEach.call(drop.querySelectorAll('.cjd-nav'), function (b) {
b.addEventListener('click', function (e) {
e.stopPropagation();
var step = parseInt(b.getAttribute('data-step'), 10);
var t = new Date(curY, curM - 1 + step, 1);
curY = t.getFullYear();
curM = t.getMonth() + 1;
paint();
});
});
// 点某一天 → 跳转(这是**唯一**会自动提交的地方)
Array.prototype.forEach.call(drop.querySelectorAll('.cjd-day'), function (b) {
b.addEventListener('click', function (e) {
e.stopPropagation();
sel = b.getAttribute('data-date');
jump(sel);
});
});
// 「跳到这天」:给不需要/不方便"点了就跳"的场景一个明确入口
drop.querySelector('.cjd-go').addEventListener('click', function (e) {
e.stopPropagation(); jump(sel);
});
drop.querySelector('.cjd-today').addEventListener('click', function (e) {
e.stopPropagation();
var t = new Date();
sel = ymd(t.getFullYear(), t.getMonth() + 1, t.getDate());
jump(sel);
});
}
btn.addEventListener('click', function (e) {
e.stopPropagation();
if (!drop) { return; }
if (!drop.hidden) { closeDrop(); return; }
var seed = String(hid.value || '').slice(0, 10);
var d0 = /^\d{4}-\d{2}-\d{2}$/.test(seed) ? new Date(seed + 'T00:00:00') : new Date();
if (isNaN(d0.getTime())) { d0 = new Date(); }
curY = d0.getFullYear();
curM = d0.getMonth() + 1;
sel = ymd(curY, curM, d0.getDate());
drop.hidden = false;
btn.setAttribute('aria-expanded', 'true');
paint();
});
// 点外面 / Esc 收起(下拉的常规行为)
document.addEventListener('click', function (e) {
if (!drop || drop.hidden) { return; }
if (drop.contains(e.target) || btn.contains(e.target)) { return; }
closeDrop();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { closeDrop(); }
});
})();
</script>
<?php /* ==================== 当天全部条目浮层(v70) ====================
用户 2026-10-20 第 2 条:「日历导入进去了,但是不显示」。
月视图每格只画 3 条(格子是固定高度,全画会把月历撑变形),
但原来的 "+N 条" 是**一颗点不动的 <span>** ⇒ 被折叠的条目**永远看不到**。
这里给那颗按钮一个真正的去处。
⚠️ 为什么不直接复用条目详情浮层(`fbEntryModal`):
那是"**一条**日程的详情/编辑/删除",一次只画一条;
这里要的是"**当天的清单**",点其中任意一条才进那条的详情。
两者叠用是对的:先出清单,点清单里某条 → 调 `window.fbEntryOpen()` 进详情层。
⚠️ 详情层是 `position:fixed` 的高层,清单层在它下面 —— 关掉详情层会**回到清单**,
符合"我从当天的清单里点进来的"这个来路(不用再点一次 +N 条)。
⚠️ 数据直接读上面那个 `<script id="calDayData">`:不发请求、点开即时。
⚠️ 无 JS 兜底:那颗按钮外面本来是 <button>,禁用 JS 时它不会有反应 —
这是可接受的降级(此时"+N 条"本来也没有别的语义),
因为**没有 JS 的浏览器根本也不会有"点格子新建日程"那一套**,
用户会直接用「周 / 天」视图看全部条目(那两个视图不截断)。 */ ?>
<div class="cal-modal cal-day-modal" id="calDayModal" role="dialog" aria-modal="true"
aria-label="当天全部日程" hidden>
<div class="cal-modal-panel">
<div class="cal-modal-head">
<b id="calDayTitle">当天的日程</b>
<button type="button" class="cal-modal-x" id="calDayClose" aria-label="关闭">✕</button>
</div>
<div class="cal-modal-body cal-day-body" id="calDayBody"></div>
<div class="cal-modal-foot cal-day-foot">
<?php /* 浮层里就地再建一条 —— 清单本来就在"这天",比退出去点格子顺手。
⚠️ 没写权限时**不渲染**(不是 hidden):只读家庭里这颗按钮点了也没用,
留着反而像坏了。 */ ?>
<?php if ($canNew): ?>
<button type="button" class="btn btn-sm" id="calDayAdd">+ 在这天加一条</button>
<?php endif; ?>
<span class="cal-modal-tip" id="calDayTip"></span>
<a class="btn btn-ghost btn-sm" id="calDayMore" href="#">在日视图里看</a>
</div>
</div>
</div>
<script>
(function () {
'use strict';
var host = document.getElementById('calDayModal');
if (!host) { return; }
var body = document.getElementById('calDayBody');
var title = document.getElementById('calDayTitle');
var link = document.getElementById('calDayMore');
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function dayData() {
var el = document.getElementById('calDayData');
if (!el) { return {}; }
try { return JSON.parse(el.textContent || '{}') || {}; } catch (e) { return {}; }
}
function openDay(day) {
var all = dayData()[day] || [];
if (!all.length) { return; }
title.textContent = day + ' · 共 ' + all.length + ' 条';
var html = '';
all.forEach(function (it, i) {
/* 时间行:全天显示"全天",有时刻显示 HH:MM–HH:MM(与详情浮层同一口径) */
var when = '';
var rows = it.rows || [];
for (var k = 0; k < rows.length; k++) {
if (rows[k][0] === '时间') { when = rows[k][1]; break; }
}
if (when.indexOf(day) === 0) { when = when.slice(day.length).trim(); }
var src = (it.source === 'event') ? '' : ' is-derived';
html += '<button type="button" class="cal-day-item' + src + '" data-day-idx="' + i + '">'
+ '<span class="cal-day-when">' + esc(when || '全天') + '</span>'
+ '<span class="cal-day-name">' + esc(it.title || '(无标题)') + '</span>'
+ '</button>';
});
body.innerHTML = html;
document.getElementById('calDayTip').textContent = '';
link.setAttribute('href', <?= json_encode(url('calendar', ['view' => 'day'])) ?> + '&d=' + encodeURIComponent(day));
host.hidden = false;
/* 记录当前是哪一天,点清单项时要按它取数据 */
host.setAttribute('data-day', day);
}
function closeDay() { host.hidden = true; }
document.addEventListener('click', function (e) {
var btn = e.target.closest && e.target.closest('.cal-more');
if (btn) {
e.preventDefault();
e.stopPropagation(); /* 别让"点格子新建日程"也跟着触发 */
openDay(btn.getAttribute('data-cal-day') || '');
return;
}
if (e.target.closest && e.target.closest('#calDayClose')) {
e.preventDefault(); closeDay(); return;
}
/* 清单里"在这天加一条":把当前这天交给新建浮层(09:00 起,与服务端
"点月格"同口径),并先收起清单 —— 两层浮层不能叠着。 */
if (e.target.closest && e.target.closest('#calDayAdd')) {
e.preventDefault();
var d0 = host.getAttribute('data-day') || '';
closeDay();
if (d0 && typeof window.fbCalOpenNew === 'function') { window.fbCalOpenNew(d0, '09', '00'); }
return;
}
/* 点清单里某一条 → 交给条目详情浮层。
走 `fbEntryOpenData`(v70 新增的"按数据对象开"入口)——
清单项只是文字行,页面上没有对应的 `[data-fb-entry]` 元素可点。 */
var item = e.target.closest && e.target.closest('.cal-day-item');
if (item) {
e.preventDefault();
var day = host.getAttribute('data-day') || '';
var list = dayData()[day] || [];
var one = list[parseInt(item.getAttribute('data-day-idx'), 10)];
if (one && typeof window.fbEntryOpenData === 'function') {
closeDay(); /* 先收清单,再开详情 —— 否则两层叠在一起 */
window.fbEntryOpenData(one);
}
return;
}
if (e.target === host) { closeDay(); }
});
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') { return; }
if (host.hidden) { return; }
e.preventDefault(); closeDay();
});
})();
</script>