v24 第 21 批:无刷新删除与新建日程 / 实际行程不回退 / 行程按时间排 / 统一上传框 / 时间范围控件修复(用户 8 条)

用户 8 条原话要点:
  ①「名册页面,删除一个联系人后,能否不刷新页面,删除成功后,直接把这个人,
     从当前页面的 dom 中删除,页面保持不动。」
  ②「观影模块,海报和票根以及照片都带进来了,但是没有缩放,把页面撑破了,
     应该缩放成缩略图。」
  ③「个人设置页面,天气设置那里,把:只影响你自己首页上的那一格 / 填个城市名就行
     (如「青岛」)… 这些提示,都删除。把城市单独放一行,精度维度放在一行。」
  ④「旅程模块,实际行程编辑后,点击保存,竟然又变成计划行程了,这个不合理,
     实际行程永远是实际行程,不会再变回计划行程了。」
  ⑤「旅程模块,旅程列表,行程列表,都是按照行程的开始时间排序的,最新的在最上面,
     而不是按照编辑修改时间。」
  ⑥「旅程的详情页面,右上角的按钮,设置在最右侧,和其他模块保持统一。」
  ⑦「所有上传图片等附件的地方,都不再放按钮,而是放一个框,点击这个框实现点击上传
     按钮的功能,拖动图片进这个框里,实现拖动多个上传功能。框里面写一句话:
     点击或拖动进行上传,其他的功能和现在保持一致。」
  ⑧「日历中新建日程,无论是点击按钮还是点击日历中间,都是采用弹框的形式进行添加,
     这样不影响当前日历的显示,添加完成后,动态修改当前视图,不要刷新。添加日历日程,
     开始时间和结束时间那里,用我们统一的时间范围选择器重做,现在功能无法使用。
     选择日历本,带上颜色。」

🔴 五个真根因(全都不像 bug):

 ① 🔴 **④「实际行程变回计划」= 表单字段同名合并**(本批最要紧的一条)
    暂存上传的令牌字段原叫 `stage[]`,与行程表单里那个隐藏的 `stage`(plan/actual)
    **同名**。PHP 解析表单时同名会**合并进同一个键**:
        `stage=actual` + `stage[]=tok` → `$_POST['stage'] = ['actual','tok']`
    (`[]` 只是"往同一个键追加"的记号,**不构成另一个键名**。)
    于是路由里 `(string)($_POST['stage'] ?? 'plan')` 得到 `"Array"` →
    `in_array('Array', ['plan','actual'])` 为假 → **静默回落成 'plan'**。
    ⚠️ 只有"拖过图片的行程"会犯(没拖就没有 `stage[]`)→ 症状是"时好时坏"。
    修法:令牌字段改名成 `stage_token[]`(改的是**字段名本身**,不是"是数组就取第一个"
    绕过去 —— 同名合并是 PHP 的表单语义,绕过去只会把坑留给下一次加字段)。
    **另加一道语义保险**:编辑路径**一律沿用库里那条自己的 stage**,不读表单 ——
    编辑表单里根本没有改阶段的控件(改阶段走详情页那几个显式动作)。
    这样即便将来又冒出某个字段名冲突,也不可能把「实际」悄悄变回「计划」。

 ② 🔴 **⑧「时间范围选择器无法使用」= 三道门叠在一起**
    ① `_event_fields.php` 的**结束端默认带 `hidden`**(要点「+ 添加结束时间」才出现);
    ② `app.js` 的 `upgradeRange()` 有「**两端都得可见**才升级」的判断 → 永远不成立
       (实测 `data-semi-range-done` 恒为 `null`);
    ③ CSS `html.js .dt-range:not(.is-semi-range) .dt-input { visibility: hidden }` →
       未升级时原生控件**也是不可见的** ⇒ 屏幕上一片空白、点哪儿都没反应。
    修法:两端**始终可见**("没有结束"由清空后半段表达,不再靠开关);
    并把旧的「+ 添加结束时间 / 去掉结束时间」开关整套删掉。
    ⚠️ 顺带挖出:`upgradeRange()` 里 `group.insertBefore(host, a)` 在"两端各自被
    `.field` 包着"时 `a` 不是 `group` 的子节点 → 直接抛
    `Failed to execute 'insertBefore' on 'Node'`。这个异常**以前从没触发过**
    (被 ② 的提前 return 挡住了)—— 一旦放出来就炸。修成插在"端点最外层包装"之前。
    ⚠️ 观察器加了 `attributes: true, attributeFilter: ['hidden']`(并把 childList
    分支的 `return` 去掉):弹框/折叠面板/tab 里"稍后才露出来"的日期控件从此都能自动升级。

 ③ 🔴 **⑧「新建日程默认建出观影记录」= 产品缺口,不只是显示问题**
    `Calendar::DEFAULT_KINDS` 原来只有 6 本**派生**本(观影/旅行/农历/节假日/传统节日/
    节气),**一本可写的普通本都没有**。于是"放在哪本"里全是派生本、默认选中排最前的
    **观影** → 用户直接打字点「加上」,建出来的是一条**观影记录**
    (日历里看着是多了条日程,观影列表里莫名多一条)。这是"建错东西",事后极难发现。
    → ① 默认本补成「我的日程」(kind='custom'),模板里默认选中也改成**优先 custom 本**;
      ② 选择器里把**纯派生只读本**禁用并标「(自动生成)」
         (直接不列会让人以为本子丢了;列为可选又会白选一次再吃错误)。
    ⚠️ 顺带发现 `Calendar::READONLY_KINDS` 把 `movie`/`trip` 也算进去了,而
    `event_add` 里**紧跟着**就有两段专门处理它们的分支(「已加进观影」/「已加进旅程」)
    —— 那两段**永远走不到**(校验先一步把请求打回去了)。判据要按**能不能写**分,
    不是按"是不是派生"。

 ④ 🔴 **②「观影编辑页图片撑破」= `.mv-slot` 这个类在样式表里根本不存在**
    第 21 批加模板时只写了结构没写样式 → `<img>` 按**原始像素**渲染:600×900 的海报
    在 236px 宽的格子里撑出 942px 高,还把整页横向顶破(实测 docW 1297 > vw 1280)。
    → 补 `.mv-slot` 规格:固定 108×162 缩略图 + `object-fit: cover`。
    ⚠️ **高度必须写死** —— 不写死就又会由原图比例决定,等于没修。

 ⑤ **⑤行程列表排序 = `category, position` 主导**
    `position` 默认同一个数(旧迁移给的多是 0/100),于是顺序被 position 挤乱。
    实测线上 #222 渲染成「返回青岛(10-5) → 出发去菏泽(9-30) → 去姥姥家(10-2)」。
    → 改成 `ORDER BY (starts_at IS NULL), starts_at, id`。
    ⚠️ 方向是**正序**(早的在前,已跟用户确认);旅程列表仍是 DESC(最新在最上)。
       两者方向不同是**刻意的**:一个是"挑哪趟旅行",一个是"读这趟旅行的行程"。
    ⚠️ 详情页**按阶段分三组**渲染(计划/实际/未成行),排序只需在**组内**有序。

本批新增能力:
- **统一上传框**(⑦):`templates/_stage_drop.php` 现在**同时**负责
  「sr-only 的真文件框 + 看得见的上传框 + 暂存清单」,调用方只 require 一行。
  框是 `role="button" tabindex="0"`,JS 转发 `input.click()`(键盘 Enter/Space 也通),
  拖文件进框走既有的暂存上传(拖一个立刻传、可续拖、失败标红、未传完拦住保存)。
  ⚠️ 框用 `<div>` 不是 `<button>`:里面要放暂存清单(含「撤掉这张」按钮),
     按钮里嵌按钮是非法 HTML(第 21 批刚在 `<form>` 嵌套上栽过同一个跟头)。
  ⚠️ 文件框用 `.sr-only` 不是 `display:none`:后者不可聚焦,带 `required` 时
     Chrome 会报 "An invalid form control is not focusable" —— 点了什么都不发生。
  落了 11 处:行程照片 / 观影海报·票根·合影 / 资源 / 任务 / 日程附件 / 书籍 /
  联系人导入 / ics 导入 / 观影补传 / 资源补传。
  ⚠️ 清单与提示行移到框**外面**(放框内会变成"一个大框套五张图"),
     所以 app.js 改从 `.upfield` 容器里找它们。
- **无刷新删除(①)与无刷新新建日程(⑧)**:路由按 `X-Requested-With`
  (或 `Accept: application/json`)区分调用者 —— 带它回 JSON、不带照旧 302
  (无 JS 的兜底**不变**,两边的反向都写了断言)。
  · 名册删除:成功 → 摘卡片 + 就地改「共 N 人」与分组人数 + 派发 `input` 让
    filter.js 自己重算(**不抄它的判据**);失败 → 把原因说出来(不刷页);
    拿不到 JSON → **退回原生提交**(绝不静默失败)。
  · 日历新建:JSON 带齐前端插入当前视图要用的字段(时间/颜色/链接/是否全天/cal)。
    月视图**按时间插入**(不能 append —— 服务端只渲染前 3 条、按时间序取,
    append 会让"新加的早间日程"掉进「+N 条」里看不见,刷新后又跑到第一位);
    周/日视图按"24h = 100%"算 top/height 插块。
  · 观影 / 旅行本是**可写派生本**:AJAX 时不再跳详情页(跳走就离开了日历),
    改在提示行给一个「去看详情 →」链接。
- **⑧日历本带颜色**:原生 `<option>` 画不出色块(浏览器不允许在 option 里放元素),
  所以做成「色块 + 下拉」并排,色块跟着当前选中的本子变色。
- **③天气卡片**:三句提示全删;城市单独一行、纬度/经度同一行(`.loc-row`)。
  删的只是说明文字,三个输入框 + 保存 + 状态回显都保留。
- **⑥设置按钮贴最右**:原来顺序是「… → 设置 ☰ → 删除 🗑」,设置被删除键挤到**倒数第二**;
  改成删除在前、设置恒为最后一个(与观影/日程/任务/资源同一个手势)。

验证(本地全套):
- e2e **1059/0**(本批新增约 40 条断言)· 结构升级 149/0 · 桌面几何 428/0
- **真机探针 75/0**(`_fb_b21_ui.py`):AJAX 删除真的没导航 / 卡片真被摘掉 /
  滚动位置保持;缩略图几何(108×162,不再溢出);上传框(框在、原生框不可见、
  点框真打开文件选择框、连拖两张都上去且清单可见);日历弹框(打开时网格一动不动、
  范围控件真的挂上且可见、能弹面板、色块跟着换、无刷新提交后当前视图里立刻出现、
  周视图也能插时间块、全程无 JS 报错)。
- **反向验证 12 条**(`_fb_b21_falsify.py`):逐条把修复改回旧行为看断言是否变红 ——
  令牌字段改回 stage[] / 编辑读表单 stage / 排序改回 position / 设置按钮挪走 /
  框不转发点击 / 文件框不再 sr-only / 删缩略图规格 / 删除不回 JSON /
  结束端改回 hidden / 日历本默认改回第一个 / 天气提示加回 / 弹框改回内联。
- 部署脚本静态断言段本地空跑 **307/0**。
This commit is contained in:
2026-10-06 12:08:40 +08:00
parent ea99968035
commit f0437e6c93
19 changed files with 1209 additions and 270 deletions
+46 -91
View File
@@ -59,31 +59,43 @@ $evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evA
不再拆成"日期一格 + 时刻一格"。两个控件长得一样,扫一眼就知道哪个是开始。 */ ?>
<?php /* 用户 2026-10-04 第 7 条:日程的起止也是一个**范围** ——
升级后由一个 Semi dateTimeRange 一次选完。服务端仍收两组 *_date/_hour/… 字段。 */ ?>
<?php /* 🔴 v24 第 21 批 第 7 条:「开始时间和结束时间那里,用我们统一的时间范围选择器
重做,现在功能无法使用。」
「功能无法使用」的真根因(真机探针实测出来的,不是猜的):
app.js 的 upgradeRange() 有一道「**两端都得可见**才升级成范围控件」的判断
(`boxes.every(bx => bx.offsetParent !== null)`)。而这一版**结束那一端默认
带 `hidden`**(要点「+ 添加结束时间」才出现)→ 判断永远不成立 →
范围控件**从来没被挂上去过**(实测 data-semi-range-done 恒为 null)。
更糟的是 CSS 还有 `html.js .dt-range:not(.is-semi-range) > .dt-input
{ visibility: hidden }` —— 两端于是都是**不可见**的:
屏幕上一片空白、点哪儿都没反应。这就是「无法使用」的全部。
修法:**结束那一端不再默认藏起来**,范围控件一次性就挂上。
起止成对本来就该一起填。想表达"只有一个时刻"的话,范围控件 clear 一下即可。
⚠️ 顺带删掉「+ 添加结束时间 / 去掉结束时间」那套开关:
它切换显隐会让范围控件反复重挂;范围控件自己就能表达"没有结束"。 */ ?>
<div class="dt-range">
<div class="field ev-dt" data-dt>
<label>开始 *</label>
<?php $dtName = 'starts_at'; $dtId = 'ev-start';
$dtValue = $evF['starts_at'] ?? null; $dtRequired = true; $dtSeconds = true;
$dtRange = 'start';
require __DIR__ . '/../_datetime.php'; ?>
</div>
<?php /* 用户 2026-10-03 第 6 条:「添加结束日期与时间」——
结束是可选的:没填就表示"就一个时刻/就这一天",填了才画成一段。
所以给一个显式开关,而不是让人对着空控件猜"不填行不行"。 */ ?>
<?php $hasEnd = trim((string)($evF['ends_at'] ?? '')) !== ''; ?>
<div class="field ev-dt" data-dt id="ev-end-field"<?= $hasEnd ? '' : ' hidden' ?>>
<label>结束</label>
<?php $dtName = 'ends_at'; $dtId = 'ev-end';
$dtValue = $evF['ends_at'] ?? null; $dtRequired = false; $dtSeconds = true;
$dtRange = 'end';
require __DIR__ . '/../_datetime.php'; ?>
</div>
<?php /* ⚠️ 标签只有**一个**,挂在范围容器上(不是各挂各的):
升级成一个范围控件之后,"开始"与"结束"就是同一个控件的两个端点,
两个标签会变成"看着像两个字段、其实是一个"的错觉。 */ ?>
<span class="dt-range-label">开始 / 结束 *</span>
<div class="field ev-dt" data-dt>
<?php $dtName = 'starts_at'; $dtId = 'ev-start';
$dtValue = $evF['starts_at'] ?? null; $dtRequired = true; $dtSeconds = true;
$dtRange = 'start';
require __DIR__ . '/../_datetime.php'; ?>
</div>
<div class="field ev-dt" data-dt id="ev-end-field">
<?php $dtName = 'ends_at'; $dtId = 'ev-end';
$dtValue = $evF['ends_at'] ?? null; $dtRequired = false; $dtSeconds = true;
$dtRange = 'end';
require __DIR__ . '/../_datetime.php'; ?>
</div>
</div><?php /* /dt-range */ ?>
<div class="field" id="ev-end-add">
<label>结束时间</label>
<button type="button" class="btn btn-ghost btn-sm" id="ev-end-add-btn">+ 添加结束时间</button>
<span class="field-hint">不填就表示没有明确的结束(只占开始那一刻/那一天)</span>
</div>
<span class="field-hint" style="display:block;margin:-4px 0 10px">
结束留空就表示没有明确的结束(只占开始那一刻/那一天)。
</span>
<div class="field">
<label>
@@ -154,8 +166,12 @@ $evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evA
</div>
<div class="field full">
<label for="ev-attach">附件(可多选)</label>
<input class="input" type="file" id="ev-attach" name="attach[]" multiple>
<label>附件(可多选)</label>
<?php /* v24 第 21 批 第 7 条:统一成「点击或拖动进行上传」的框。
⚠️ 文件框的 id 保持 `ev-attach`(原来就叫这个)—— e2e 与部署断言里
有按 id 定位的写法,改名会把它们静默打成假红。 */
$sdName = 'attach[]'; $sdId = 'ev-attach'; $sdSlot = 'photo';
require __DIR__ . '/../_stage_drop.php'; ?>
</div>
<script>
@@ -172,72 +188,11 @@ $evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evA
box.addEventListener('change', sync);
sync();
/* ---- 用户第 6 条:结束时间 ----
① 「+ 添加结束时间」把结束控件放出来;
② 结束时间**默认跟着开始时间走**:改了开始,若结束还没填(或原本等于旧开始),
就把结束同步过去 —— 常见用法是"改个开始,结束忘了改",结果时长莫名其妙。
但只要用户**自己动过结束**,就不再自动同步(别抢用户的输入)。 */
var startBox = document.querySelector('[data-dt] input.dt-date');
var endField = document.getElementById('ev-end-field');
var endAdd = document.getElementById('ev-end-add');
var endAddBtn = document.getElementById('ev-end-add-btn');
var startField = document.querySelector('.field.ev-dt');
function endInputs() {
if (!endField) return [];
return Array.prototype.slice.call(endField.querySelectorAll('input, select'));
}
function showEnd(on) {
if (endField) { endField.hidden = !on; }
if (endAdd) { endAdd.hidden = on; }
// 结束时间刚被显示出来 → 现在两端都可见了,可以合并成一个范围控件(用户第 7 条)。
// 收起时不动已有的范围(它本来就只显示在第一端上,不影响使用)。
if (on && typeof window.fbUpgradeRange === 'function') {
window.fbUpgradeRange();
}
}
var endTouched = false;
endInputs().forEach(function (el) {
el.addEventListener('change', function () { endTouched = true; });
});
if (endAddBtn) {
endAddBtn.addEventListener('click', function () { showEnd(true); endTouched = false; });
}
// 移除结束(改回"没有结束")
if (endField && !endField.querySelector('.ev-end-del')) {
var del = document.createElement('button');
del.type = 'button';
del.className = 'btn btn-ghost btn-sm ev-end-del';
del.textContent = '去掉结束时间';
del.addEventListener('click', function () {
endInputs().forEach(function (el) {
if (el.tagName === 'SELECT') { el.value = '00'; }
else { el.value = ''; }
});
showEnd(false);
endTouched = false;
});
endField.appendChild(del);
}
// 开始变了 → 结束(若没被手动改过)跟着走同一时刻
if (startField) {
startField.addEventListener('change', function () {
if (endTouched || !endField || endField.hidden) return;
var sd = startField.querySelector('input.dt-date');
var sh = startField.querySelector('select.dt-hour');
var sm = startField.querySelector('select.dt-minute');
var ss = startField.querySelector('select.dt-second');
var ed = endField.querySelector('input.dt-date');
var eh = endField.querySelector('select.dt-hour');
var em = endField.querySelector('select.dt-minute');
var es = endField.querySelector('select.dt-second');
if (sd && ed) { ed.value = sd.value; }
if (sh && eh) { eh.value = sh.value; }
if (sm && em) { em.value = sm.value; }
if (ss && es) { es.value = ss.value; }
});
}
// 初值由服务端决定(有 ends_at 就是展开的),JS 只负责"之后"的显隐
showEnd(<?= $hasEnd ? 'true' : 'false' ?>);
/* ⚠️ v24 第 21 批 第 7 条:原来这里有一整段「结束时间显隐 + 跟着开始时间走」的 JS。
现在起止两端**始终可见**,并且由 Semi 的范围控件统一接管(一次选完两端),
所以那段"同步两端"的逻辑整个删掉 —— 留着不但没用,还会和范围控件抢着改那两个
原生字段(两处都写 → 值互相覆盖)。范围控件选完会自己把两端分别写回原生字段
(见 app.js 的 onRange → writeDT),服务端契约不变。 */
/* 重复:选了周期才显示"结束条件";结束条件选"次数"才显示次数框、选"到某天"才显示日期框。
三个框默认都 hidden —— 不重复时一个都不该出现(少即是清楚)。 */
+3 -1
View File
@@ -187,7 +187,9 @@ $canWrite = \App\Auth::canWrite();
</div>
<div class="field">
<label for="im-file">.ics 文件 *</label>
<input class="input" type="file" id="im-file" name="ics" accept=".ics,text/calendar" required>
<?php $sdName = 'ics'; $sdId = 'im-file'; $sdAccept = '.ics,text/calendar';
$sdMultiple = false; $sdRequired = true;
require __DIR__ . '/../_stage_drop.php'; ?>
</div>
</div>
<div class="btn-row">
+327 -29
View File
@@ -130,22 +130,76 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
用户第 4 条:左边那一栏「我的日历本」已经撤掉(功能搬进右上角 ☰),
所以这里直接就是主区域,`.cal-wrap-full` 把网格调成"只有一列"。 */ ?>
<div class="cal-main">
<?php /* 用户第 5 条:这张表单既服务「新建日程」按钮,也服务「点日历空白处」——
后者由 JS 现填开始时间,所以表单本身不再写死 09:00。 */ ?>
<?php /* 🔴 v24 第 21 批 第 7 条:新建日程改成**弹框**(用户原话:
「日历中新建日程,无论是点击按钮还是点击日历中间,都是采用弹框的形式进行添加,
这样不影响当前日历的显示,添加完成后,动态修改当前视图,不要刷新。」
⚠️ 原来是**内联卡片**(`#calAddCard`,就长在日历网格的正上方,展开时把整个
月视图往下推)—— 用户点一下"新建",正在看的日历就被顶走了,
填完还得自己找回来。这正是他说的"影响当前日历的显示"。
⚠️ 改成弹框用**既有的浮层形态**(与选人弹窗、名册动作框同一套 `.upfield`
不是,是 `.rm-modal`/`.crop-overlay` 那一路视觉:遮罩 + 居中面板),
不引第三方库 —— 原生 `<dialog>` 在部分旧 WebView 上行为不一致,
而且我们已有的浮层已经解决了"点遮罩关、Esc 关、滚动锁"这几件事。
⚠️ 表单本身**一个字没动**(字段名/服务端契约/CSRF 全照旧),
只把它的容器从"页面上的一格"换成"浮层里的一块"。
`calendar_id` 那个 select 也保留(第 7 条要求"选择日历本,带上颜色",
见下面给每个 option 加的色块)。 */ ?>
<?php if ($canWrite && $writable): ?>
<div class="card card-pad cal-add" id="calAddCard" hidden>
<h2 class="cinema-h2">新建日程</h2>
<form method="post" action="<?= e(url('calendar/event_add')) ?>" enctype="multipart/form-data">
<?= csrf_field() ?>
<input type="hidden" name="ym" value="<?= e($ym) ?>">
<div class="form-grid">
<div class="field">
<div class="cal-modal" id="calAddCard" role="dialog" aria-modal="true"
aria-label="新建日程" hidden>
<div class="cal-modal-panel">
<div class="cal-modal-head">
<b>新建日程</b>
<button type="button" class="cal-modal-x" id="calAddCancel"
aria-label="关闭">✕</button>
</div>
<form method="post" action="<?= e(url('calendar/event_add')) ?>"
enctype="multipart/form-data" id="calAddForm">
<?= csrf_field() ?>
<input type="hidden" name="ym" value="<?= e($ym) ?>">
<input type="hidden" name="back_view" value="<?= e($view) ?>">
<input type="hidden" name="back_anchor" value="<?= e($anchor) ?>">
<div class="cal-modal-body">
<div class="field full">
<label for="ce-cal">放在哪本</label>
<select class="select" id="ce-cal" name="calendar_id">
<?php foreach ($writable as $c): ?>
<option value="<?= (int)$c['id'] ?>"><?= e((string)$c['name']) ?></option>
<?php endforeach; ?>
</select>
<?php /* v24 第 21 批 第 7 条:「选择日历本,带上颜色」。
⚠️ 原生 <select> 的 <option> **画不出色块**(浏览器不让你在
option 里放元素,background 也基本不生效)→ 所以做成
「色块 + 下拉」并排:左边一块跟着当前选中的本子变色,
右边还是那个原生下拉(保持零依赖、手机上也能滚)。
切换时由下面那段 JS 同步色块颜色。 */
/* 🔴 v24 第 21 批 第 7 条:**默认必须选"我自己的普通日历本"**。
原来直接取 `$writable[0]` —— 而顺序是按 id,观影 / 旅行两本
通常排在最前面。于是用户打开"新建日程"、直接打字、点"加上",
建出来的其实是一条**观影记录**(或旅程)。
这不是"显示不对",是**建错东西**,而且事后很难发现
—— 用户以为日历里多了条日程,其实观影列表里多了一条记录。
所以:优先把自己建的 kind='custom' 本子排在前面当默认;
没有普通本时才退到第一个可写的本子。 */
$firstCal = null;
foreach ($writable as $c) {
if ((string)($c['kind'] ?? '') === 'custom') { $firstCal = $c; break; }
}
if ($firstCal === null) { $firstCal = $writable ? $writable[0] : null; } ?>
<div class="cal-book-row">
<span class="cal-book-dot" id="ce-cal-dot"
style="background: <?= e((string)($firstCal['color'] ?? '#4A7C82')) ?>;"></span>
<select class="select" id="ce-cal" name="calendar_id">
<?php foreach ($writable as $c): $ro = \App\Calendar::isReadonlyKind((string)$c['kind']);
$isDef = $firstCal && (int)$c['id'] === (int)$firstCal['id']; ?>
<?php /* v24 第 21 批 第 7 条:纯派生的本子(农历/节假日/传统节日/节气)
列出来但**禁用** —— 直接不列的话,用户会以为"我的本子丢了";
列为可选项又会让他白选一次再吃一个错误提示。
禁用 + 一句 title 说明,是这个取舍里最不费解的一种。
观影 / 旅行**不禁用**:它们可写(加一条 = 建一条观影/旅程)。 */ ?>
<option value="<?= (int)$c['id'] ?>"
data-color="<?= e((string)$c['color']) ?>"
<?= $isDef ? ' selected' : '' ?>
<?= $ro ? 'disabled title="系统自动生成,不能直接加日程"' : '' ?>><?= e((string)$c['name']) ?><?= $ro ? '(自动生成)' : '' ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<?php /* v24 第 6 条:字段按 iCalendar / CalDAV 的 VEVENT 定(见 _event_fields.php)。
新建时「全天」默认**不勾**(用户原话)。开始时间默认:
@@ -163,11 +217,14 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
require __DIR__ . '/_event_fields.php';
?>
</div>
<div class="btn-row">
<button class="btn btn-sm" type="submit">加上</button>
<button class="btn btn-ghost btn-sm" type="button" id="calAddCancel">取消</button>
</div>
</form>
</div><?php /* /cal-modal-body */ ?>
<div class="cal-modal-foot">
<span class="cal-modal-tip" id="calAddTip"></span>
<button class="btn btn-sm" type="submit" id="calAddSubmit">加上</button>
<button class="btn btn-ghost btn-sm" type="button" id="calAddCancel2">取消</button>
</div>
</form>
</div>
</div>
<?php endif; ?>
@@ -296,15 +353,23 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
(Calendar::hiddenCalendars,见路由里的说明),前端不再需要它。
留着会让人以为"改一下勾选就能切换",但那些勾选框已经不在这儿了。 */
var btn = document.getElementById('calAddBtn');
var card = document.getElementById('calAddCard');
var cancel = document.getElementById('calAddCancel');
var card = document.getElementById('calAddCard'); /* 现在是一个浮层 */
var cancel = document.getElementById('calAddCancel'); /* 头部 ✕ */
var cancel2 = document.getElementById('calAddCancel2'); /* 底部「取消」 */
if (btn && card) {
btn.addEventListener('click', function () {
card.hidden = !card.hidden;
if (!card.hidden) { card.scrollIntoView({ block: 'nearest' }); }
});
btn.addEventListener('click', function () { openNew(null, null, null); });
}
if (cancel && card) { cancel.addEventListener('click', function () { card.hidden = true; }); }
/* 关:✕ / 取消 / 点遮罩 / 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 更像个日程)。
@@ -332,12 +397,21 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
if (startSec) { startSec.value = '00'; }
}
/**
* 打开新建日程的**弹框**(v24 第 21 批 第 7 条)。
*
* ⚠️ 不再 `scrollIntoView` —— 那是"内联卡片"时代为了让用户找到卡片才需要的动作;
* 浮层固定在视口中央,底下的日历**一动不动**(这正是用户要的
* "不影响当前日历的显示")。
* ⚠️ dateStr 传 null 表示"从按钮进来":那时**不要再覆盖**已填的时间
* (用户可能刚填完改主意关掉、又点开;重置会把他填的东西抹掉)。
* 只有"点日历某一格/某一列"才带着具体时间进来,那次才 setStart。
*/
function openNew(dateStr, hh, mm) {
if (!card) return;
card.hidden = false;
setStart(dateStr, hh, mm);
card.scrollIntoView({ block: 'nearest' });
// 标题是第一个输入框,直接把光标放过去 —— 点完就能打字
if (dateStr) { setStart(dateStr, hh, mm); }
// 标题是第一个输入框,直接把光标放过去 —— 打开就能打字
var t = document.getElementById('ev-title');
if (t) { t.focus(); }
}
@@ -376,6 +450,230 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
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();
}
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'; }
}
/** 月视图:往 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); }
/* 再按"只显示前 3 条"收口,与服务端渲染对齐(否则会多出一条)。 */
var links = box.querySelectorAll('.cal-ev');
var more = box.querySelector('.cal-more');
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) {
more = document.createElement('span');
more.className = 'cal-more';
box.appendChild(more);
}
more.textContent = '+' + extra + ' 条';
} else if (more) {
more.parentNode.removeChild(more);
}
return true;
}
/** 周/日视图:按"列高 = 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 || ''));
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);
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;
}
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) {
var ok = false;
try { ok = insert(d.event); } catch (err) { ok = false; }
if (ok) {
/* 观影 / 旅行本是**派生**的(内容来自观影/旅程表):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 = ''; }
return;
}
/* 插入不成功 = "没刷新却也没看到" → 宁可刷新一次,别假装成功 */
form.setAttribute('data-native-submit', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
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 里(两个页面共用一份) */
})();