Files
FamilyBook/assets/js/entry_popup.js
T
laoyang f1b09a2f41 v93: 首页日历本下拉能拉了 / 日程浮层只留编辑删除 / 重复日程三档 / Markdown 标题四级阶梯补齐
用户两条:
① 「新建日程,日历本是拉不下来的,是空的,好像只有一个"工作"什么的」
② 「点日程后的详情跟原来的弹框几乎一模一样,这个详情就没有意义了」
   ⇒ 只显示「编辑」「删除」;重复日程的删除/编辑要有三档
   (当前 / 从现在到以后 / 全部)

【① 根因】`_add_modal.php` 被日程页与首页共用,HTML 一样,但自绘下拉的整套 JS
只内联在 `templates/calendar/index.php` 底部 ⇒ 首页那份没有交互,点了菜单不展开;
而按钮上是服务端渲染的默认本("工作"),所以看着"只有一个工作"。
修法:抽 `assets/js/cs_picker.js`(全站 defer、按 `[data-cs-picker]` 初始化、
`data-cs-ready` 幂等)。不扫所有 `.cs` —— 观影表单的影院下拉有自己的内联绑定,
两边都绑会"点一次开两次"。

【②】浮层自定义日程不再显示「详情」(派生条目仍只有真链接「详情」);
删除/编辑三档措辞按用户原话;`occ` 为空时只给「删除全部」,绝不让空串触发整条删除。
编辑三档的入口在 `fb_entry_json()` 把 `occ` 拼进 edit 地址(`?d=`)——
不带就只有"修改全部"一档。

【顺带修掉 v85 没做完的一半】全量套件在 v61 A9b 报红,查下去不是本轮改坏的:
CSS 只把 `##`(h4)调到 17px,`###`(h5)留在 15.5px(正文 15px,只差 0.5px)、
`####`(h6)留在 14.5px(比正文还小),而那段注释自己写的就是"三级都要调大"。
阶梯重排 h3/h4/h5/h6 = 22/20/18/17,四级两两不同号。
更值得记的是四条判据全部只盯 h4(v61 A9b、v85 B2/B2b 前身、静态断言、线上 A4),
而 h4 恰好是唯一修好的那一级 ⇒ 各加"每一级/最矮的一级"判据,并用
「只把 h6 打回 14.5px ⇒ A9b 仍绿、只有 A9b2 变红」证明新判据有独立价值。

【工具链】`_b61_falsify.py` 的 main_all 改成每个 case 起独立子进程 ——
原先同一进程连跑 10 遍探针,Playwright 的临时 profile 建/删会在同一轮里累计,
撞沙箱批量删除钩子被 SIGTERM(症状:输出戛然而止、退出码 1、一条 FAIL 都没有)。

验证:v93 正向 28/0 · v93 反向 3 处注入各自被抓 · v61 34/0 · v61 反向 9/9 ·
v85 md_heading 12/0 · v85 静态反向 8/0 · 全量流水线通过并已部署。
2026-10-25 14:52:43 +08:00

547 lines
29 KiB
JavaScript
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.
/* ============================================================================
条目详情浮层 —— 底栏按「来源」分形态
----------------------------------------------------------------------------
起源 v43(用户 2026-10-11 第 6 条):首页日程 / 首页任务 / 日程页条目点开弹浮层。
🔴🔴 v49(用户 2026-10-14)——**用户原话**:
「在首页上点击某一个日程…下面三个按钮,都放在下面的右侧…三个按钮分别是详情、编辑和删除。
详细用蓝色,编辑用黄色,删除用红色。
当点击详细的时候,**如果这是一个日程**(正常的日程里的事件、不是来自观影/旅程等其他模块)
→ 就**在当前页面让模态框变大**、显示更多信息。
当点击编辑的时候,也是**在当前的界面**弹出编辑日程的框,就地编辑;
编辑完之后下面有两个按钮:**取消和保存**。
当点击删除的时候,就直接把这个日程给删除。
如果当前这个日程**来自其他模块**(观影 / 旅程 / 其它)→ 点击详情就**进入那些模块**展示它
(观影→那一天的观影详情页;旅程→那个行程所在的旅程页)。
**这种情况下,下面就没有编辑、也没有删除,就只有一个详情。**」
🔴🔴 v93(用户 2026-10-25)——**用户原话**:
「如果点击日程,首页上点击一个日程后会有一个详情。点完之后还是一个弹出框,
这个弹出框跟原来的弹出框几乎一模一样。这样的话,这个详情就没有意义了,
所以弹出来就只显示两个按钮吧:一个『编辑』,一个『删除』。
要注意,删除的时候需要有几个选项:删除当前;删除从现在到以后;删除全部。
编辑重复日历时,也会有这三个选项。」
⇒ 底栏四态(都在同一排、**一律靠右**):
① detail(默认):`编辑`(黄) + `删除`(红)
· 只有 source='event'(自定义日程)才有这两颗;
其余来源(观影/旅程/任务/纪念日/农历/节假日)**只给「详情」一颗**
(跳去各自模块,v49 用户明确要求过)。
· 🔴 v93 起 source='event' **不再有「详情」**:正文已经是它的全部信息,
再点一次只是"换个更宽的框把同一份信息再画一遍"(用户原话"没有意义")。
· 「删除」还要 `del` 非空(= 我在这本日历上可写)。
② info(服务端 `?embed=detail`):v93 起**没有入口了**(见 `loadInfo` 的注释),
函数保留作兜底。
③ edit(点「编辑」):正文换成编辑表单;底栏 = `取消` + `保存`。
编辑后的表单自带"改当前 / 改成从现在到以后 / 改全部"三档
(由服务端 `templates/calendar/event.php` 输出,**occ 由 edit URL 带进来**)。
④ delscope(点「删除」且是**重复日程**):正文问"删哪些";底栏 = `取消` + `确认删除`。
三档措辞与详情页那张删除表单逐字一致;不重复的日程直接删除(不需范围)。
设计取舍(每条都对应一个踩过的坑):
① **事件委托挂 document**:列表会被 AJAX 换掉(新建日程后就地插入、首页任务勾选后重画),
一次性绑定永久有效(v40 的教训)。
② **点条目 = 弹浮层,不跳页**:拦掉 `<a href>` 默认行为。
⚠️ 必须按 `[data-fb-entry]` 精确匹配 —— 不能笼统拦 `.cal-ev`
(页面里还有"点某天新建日程"那套逻辑,它同样排除 `.cal-ev`)。
③ **数据全在 `data-fb-entry` 上**:浮层不发请求就能画出来,秒开。
④ **详情/编辑都就地换正文**(用户:「就在当前的页面」)——不叠第二层浮层。
⑤ **保存走原生提交**:四个来源的 update 路由本来就收普通 POST 并跳转
(自己写 AJAX 保存要为四个来源各写一套"成功后回哪",得不偿失)。
⑥ **删除走 AJAX**(v49 新增,服务端 `calendar/event_del` 认 X-Requested-With 回 JSON):
成功后就地摘掉那一条,**不刷页** —— 与编辑不同,删除不需要"回到哪一页"的语义。
⚠️ 两种例外退回整页重载:`removed_forward`(这一次及以后砍掉了整个系列尾段,
就地摘一条会漏;且这条系列在视图里可能横跨多天),以及
拿不到 JSON(老服务端 / 出错)。
⑦ **Esc / 点遮罩只关最上面那层**:antd 面板开着时先让它收(v38/v39 的坑)。
⑧ **v78 新增"面板态"**(任务专属):`data-fb-entry` 里带 `panel` 时,正文换成
`tasks/panel` 吐的那块**可操作面板**(状态 / 看板 / 子任务 / 附件 / 评论
都能就地改),底栏仍是「详情」那颗(跳任务详情页)。
面板里的动作由 `task_panel.js` 委托处理 —— **别在这里加**:
两个文件各管一段,谁也不会把谁的监听覆盖掉。
⚠️ 本脚本**全站加载**(layout.php):三处入口分散在不同页面,按需加载的判据
会在这些页面之间来回漏;它只有几 KB,不值得为省这点流量赌漏加载。
============================================================================ */
(function () {
'use strict';
var modal = null;
var detailCache = null; /* 详情态的数据(从编辑态「取消」时用它重画) */
function M() { if (!modal) { modal = document.getElementById('fbEntryModal'); } return modal; }
function panelEl(){ return document.getElementById('fbEntryPanel'); }
function bodyEl() { return document.getElementById('fbEntryBody'); }
function titleEl(){ return document.getElementById('fbEntryTitle'); }
function tipEl() { return document.getElementById('fbEntryTip'); }
function byId(id) { return document.getElementById(id); }
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function setTip(msg, err) {
var t = tipEl();
if (!t) { return; }
t.textContent = msg || '';
t.className = 'cal-modal-tip' + (err ? ' err' : '');
}
/* ---- 底栏:按状态显示哪几颗 ----------------------------------------------
四组 id 各对应一种状态。全部**先隐藏再显示**,避免残留上一态可见的按钮
(比如从"编辑态"直接点 Esc 关掉、再开另一条 —— 若不收,"保存"会留在屏幕上)。
⚠️🔴 `detail` 这一态**只列「详情」**:另外两颗(编辑/删除)要不要显,
由 `paintDetail()` **按来源**决定(派生条目只有详情)。
如果把三颗都列在这里,`footShow('detail')` 会把它们无条件点亮,
**盖掉** paintDetail 刚设好的 hidden —— 本轮实测就是这么翻车的
(观影条目本该只有「详情」,三颗却都露着)。
⇒ 调用顺序也必须配合:**先 footShow('detail')、再设编辑/删除**。 */
var GROUPS = {
detail: ['fbEntryDetail'],
edit: ['fbEntryCancel', 'fbEntrySave'],
delscope: ['fbEntryDelCancel', 'fbEntryDelGo']
};
var ALL_BTNS = ['fbEntryDetail', 'fbEntryEdit', 'fbEntryDelete',
'fbEntryCancel', 'fbEntrySave', 'fbEntryDelCancel', 'fbEntryDelGo'];
function footShow(state) {
ALL_BTNS.forEach(function (id) { var e = byId(id); if (e) { e.hidden = true; } });
(GROUPS[state] || []).forEach(function (id) {
var e = byId(id); if (e) { e.hidden = false; }
});
}
function footNone() {
ALL_BTNS.forEach(function (id) { var e = byId(id); if (e) { e.hidden = true; } });
}
/* ---- 详情态渲染 ----------------------------------------------------------- */
function rowsHtml(list) {
var html = '';
(list || []).forEach(function (r) {
if (!r || r[1] === '' || r[1] == null) { return; }
html += '<div class="kv-row"><dt>' + esc(r[0]) + '</dt><dd>' + esc(r[1]) + '</dd></div>';
});
return html || '<p class="empty-hint">这条没有更多信息。</p>';
}
/**
* 画详情态。`data` 来自 `data-fb-entry`(服务端 fb_entry_json 产出)。
*
* 底栏规则(用户第 2 条的核心):
* · 「详情」:**恒显**。source='event' 时它就是"就地展开更多信息"(下面用 info 片段);
* 其余来源它就是那个跳去模块页的链接(href = detail)。
* · 「编辑」「删除」:**只有 source='event'** 才显(且各要有地址:edit / del 非空)。
* 这就是用户说的"来自其他模块时,下面就没有编辑、也没有删除,就只有一个详情"。
*/
function paintDetail(data) {
var isEvent = (data.source === 'event');
if (titleEl()) { titleEl().textContent = data.title || '详情'; }
if (bodyEl()) { bodyEl().innerHTML = '<dl class="kv">' + rowsHtml(data.rows) + '</dl>'; }
/* 🔴 v78:任务带 `panel`(可就地操作的那块)—— 面板比只读信息宽一号,
所以这里按**有没有 panel** 决定宽度,而不是按 source。 */
if (panelEl()) { panelEl().classList.toggle('is-wide', !!data.panel); }
setTip('');
/* ⚠️ 顺序要紧:**先** footShow('detail')(它会把所有按钮收掉、只点亮「详情」),
**再**按来源点亮/收起「编辑」「删除」。反过来会被 footShow 盖掉。 */
footShow('detail');
var det = byId('fbEntryDetail');
if (det) {
/* 🔴🔴 v93(用户 2026-10-25)——**用户原话**:
「如果点击日程,首页上点击一个日程后会有一个详情。点完之后还是一个弹出框,
这个弹出框跟原来的弹出框几乎一模一样。这样的话,这个详情就没有意义了,
所以弹出来就只显示两个按钮吧:一个『编辑』,一个『删除』。」
⇒ 自定义日程(source='event')**不再给「详情」**:
浮层正文本来就已经是它的全部信息(时间 / 日历本 / 地点 / 备注),
再点一次「详情」只是把同一份信息换个更宽的框再画一遍 —— 正是用户说的"没有意义"。
⇒ 派生来源(观影 / 旅程 / 任务 / 纪念日 / 农历 / 节假日)**仍然只有「详情」**:
那些内容不归这里管,必须跳去各自模块看(v49 用户明确要求"就只有一个详情")。
⚠️ 顺序仍然要紧:先 `footShow('detail')` 统一收干净,再按来源点亮/收起 ——
反过来会被 footShow 盖掉(v49 实测翻车过一次)。 */
if (isEvent) {
det.hidden = true;
det.removeAttribute('data-fb-info');
} else {
det.hidden = false;
det.removeAttribute('data-fb-info');
if (data.detail) { det.href = data.detail; } else { det.removeAttribute('href'); }
}
}
/* 🔴 用户:「来自其他模块时,下面就没有编辑、也没有删除,就只有一个详情」——
所以这两颗**只对 source='event'** 显(且各要有地址)。 */
var ed = byId('fbEntryEdit');
if (ed) { ed.hidden = !(isEvent && data.edit); }
var de = byId('fbEntryDelete');
if (de) { de.hidden = !(isEvent && data.del); }
/* 🔴 v78:任务面板 —— 把正文换成"能直接动手"的那一块。
放在最后(上面的只读 rows 已经画好),取不到就保留那份只读信息。 */
if (data.panel) { loadPanel(data.panel, data); }
}
/**
* 取回任务面板并塞进正文(v78)。
*
* 用户原话:「网页端,首页点击一个任务后,除了展示信息,应该有相应的功能…
* 其目地,是为了让用户不离开首页,就能对任务进行简单的处理。」
*
* ⚠️ 失败时**不要清空正文**:退回 `rows` 那份只读信息(用户至少还能看清这条是什么),
* 并且把宽度收回去 —— 一块空的宽面板比"只有信息"更像坏了。
* ⚠️ 面板里的事件全部由 `task_panel.js` 委托处理(动态换 HTML 不重绑),
* 这里只负责"取回来放进去"。
*/
function loadPanel(url, data) {
if (!url || !window.fetch) { return; }
if (bodyEl()) { bodyEl().innerHTML = '<p class="empty-hint">正在载入…</p>'; }
fetch(url, { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) {
if (!r.ok) { throw new Error('panel'); }
return r.text();
})
.then(function (html) {
if (!bodyEl() || html.indexOf('data-tk-panel') < 0) { throw new Error('panel'); }
bodyEl().innerHTML = html;
runInlineScripts(bodyEl(), html);
})
.catch(function () {
if (!bodyEl()) { return; }
bodyEl().innerHTML = '<dl class="kv">' + rowsHtml(data && data.rows) + '</dl>';
if (panelEl()) { panelEl().classList.remove('is-wide'); }
setTip('这条任务的快捷面板没载入,先看基本信息。', true);
});
}
function restoreDetail() {
if (!detailCache) { return; }
paintDetail(detailCache);
}
/* ---- 打开 / 关闭 ---------------------------------------------------------- */
function setOpen(on) {
var m = M();
if (!m) { return; }
m.hidden = !on;
if (!on) {
detailCache = null;
if (bodyEl()) { bodyEl().innerHTML = ''; }
if (panelEl()) { panelEl().classList.remove('is-wide'); }
var m2 = M();
if (m2) { m2.classList.remove('is-delscope'); }
}
}
function antdOpen() {
var d = document.querySelector('.ant-picker-dropdown');
return !!(d && !d.classList.contains('ant-picker-dropdown-hidden'));
}
/* ---- 片段:取回 → 剥壳 → 塞进正文 → 补跑初始化 ---------------------------- */
/* innerHTML 不执行脚本(v42 的血泪结论)⇒ 抠出来重跑。 */
function runInlineScripts(container, html) {
var re = /<script\b[^>]*>([\s\S]*?)<\/script>/gi, m, code = '';
while ((m = re.exec(html)) !== null) {
if (!/\bsrc\s*=/i.test(m[0].split('>')[0])) { code += m[1] + '\n;\n'; }
}
if (!code) { return; }
var s = document.createElement('script');
s.textContent = code;
(container || document.body).appendChild(s);
s.parentNode.removeChild(s);
}
/**
* 片段可能是三种形态:整页(带 layout)/只带弹框壳/只有表单。
* 我们要的是**表单本体** —— 弹框壳必须剥掉,否则正文里又多一层遮罩。
*/
function pickForm(html) {
var tmp = document.createElement('div');
tmp.innerHTML = html;
var form = tmp.querySelector('#anForm, #mvForm, #nodeForm, #tripForm, #calEditForm, form');
if (form) { return { form: form, html: html }; }
return null;
}
function loadEdit(url) {
if (!url) { setTip('这条没有可编辑的表单。', true); return; }
setTip('');
footNone();
if (titleEl()) { titleEl().textContent = '载入中…'; }
if (bodyEl()) { bodyEl().innerHTML = '<p class="empty-hint">正在载入…</p>'; }
if (!window.fetch) { window.location.href = url; return; }
fetch(url, { credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) { return r.text(); })
.then(function (html) {
if (!bodyEl()) { return; }
var picked = pickForm(html);
if (!picked) { window.location.href = url; return; }
bodyEl().innerHTML = '';
bodyEl().appendChild(picked.form);
if (panelEl()) { panelEl().classList.remove('is-wide'); }
// 片段里的初始化脚本 + 宿主这侧的通用升级(日期 / antd / 选人 / 上传 / 行程表单)
runInlineScripts(bodyEl(), picked.html);
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
if (window.fbBootAntd) { window.fbBootAntd(); }
if (window.fbNodeForm) { window.fbNodeForm(bodyEl()); }
if (typeof window.fbLoadPicker === 'function') { window.fbLoadPicker(); }
if (typeof window.fbInitPickers === 'function') { window.fbInitPickers(bodyEl()); }
/* v93:编辑表单里若带「色点下拉」(`.cs[data-cs-picker]`),也要补一次 ——
与选人组件同一条理由:AJAX 换进来的 HTML 不会自己跑初始化。 */
if (typeof window.fbInitCsPicker === 'function') { window.fbInitCsPicker(bodyEl()); }
/* 🔴 v47(用户 2026-10-13):**地址联想**也要补一次。
「日程里输入地点要调高德」「行程的出发/到达地要调高德」——
这两处的编辑表单都是走这条 AJAX 换片段的路径进来的,
而 layout 的按需加载只在**首屏**扫过一次 `[data-addr]`
⇒ 片段里的地点框没人绑,联想永远不出现(正是 v42 在行程上踩过的同一个坑:
内联 script 不执行 + 没人补初始化)。 */
if (typeof window.fbLoadAddr === 'function') { window.fbLoadAddr(bodyEl()); }
// 标题取表单片段里的自我描述(各模板的弹框头)
var h = picked.form.querySelector('.cal-modal-head b')
|| (html.match(/aria-label="([^"]*编辑[^"]*)"/) || [])[1];
if (titleEl()) { titleEl().textContent = h ? String(h).trim() : '编辑'; }
footShow('edit');
var f = bodyEl().querySelector('#nf-title, #an-title, #ev-title, #title, input[name="title"]');
if (f) { f.focus(); }
})
.catch(function () { window.location.href = url; });
}
/** 编辑态「保存」= 原生提交那张表单(服务端 update 路由会跳转,刷新到新页)。 */
function submitEdit() {
var form = bodyEl() && (bodyEl().querySelector('form#anForm, form#mvForm, form#nodeForm, form#tripForm, form#calEditForm')
|| bodyEl().querySelector('form'));
if (!form) { setTip('找不到可保存的表单。', true); return; }
form.setAttribute('data-fb-entry-submit', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
}
/* ---- ③ 详情:就地展开更多信息 ----------------------------------------------
用户:「让模态框变大,然后显示出来更多的信息」——
⇒ 面板加 `is-wide`(CSS 放宽 max-width),正文换成服务端 `?embed=detail` 的只读块。
🔴 v93(用户 2026-10-25):**这条路径已经不再有入口了** ——
自定义日程的浮层只留「编辑」「删除」两颗(正文就是全部信息,不必再展开一次)。
函数保留而不删,是因为:① `paintDetail()` 里仍会清掉 `data-fb-info`
(老页面/老缓存里可能残留),点下去走到这里也不该报错;
② 将来若要"详情页"这个入口,直接接回这颗按钮即可。
⚠️ 不要因为"没入口了"就删掉服务端的 `?embed=detail` 分支:
`calendar/event` 那条仍被本函数使用,删了就是半个功能。 */
function loadInfo(url) {
if (!url) { return; }
setTip('');
if (panelEl()) { panelEl().classList.add('is-wide'); }
if (bodyEl()) { bodyEl().innerHTML = '<p class="empty-hint">正在载入…</p>'; }
footNone();
fetch(url, { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) { return r.text(); })
.then(function (html) {
var tmp = document.createElement('div');
tmp.innerHTML = html;
var block = tmp.querySelector('.ev-detail-block') || tmp.querySelector('.card-pad')
|| tmp.querySelector('.kv') || tmp;
if (!block) { window.location.href = url; return; }
if (bodyEl()) { bodyEl().innerHTML = ''; bodyEl().appendChild(block); }
footShow('detail'); // 还能继续编辑/删除
})
.catch(function () { window.location.href = url; });
}
/* ---- ④ 删除:AJAX,成功就地摘条 -------------------------------------------- */
/* 从当前 DOM 里摘掉这条:两处入口的选择器不同,都试一遍。
⚠️ 用 `data-fb-entry` 里那个 ref 找(就是日程 id),不是靠标题文本
—— 同名日程很常见,按文本会误摘。 */
function removeFromDom(evId, occDay) {
if (!evId) { return false; }
var hit = false;
var els = document.querySelectorAll('[data-fb-entry]');
Array.prototype.forEach.call(els, function (el) {
var d;
try { d = JSON.parse(el.getAttribute('data-fb-entry') || '{}'); } catch (e) { return; }
/* 只处理**同一条**自定义日程(`ref` 就是日程 id,由服务端给出)。
⚠️ 不要拿标题文本比 —— 同名日程很常见,会误摘别人的。 */
if (String(d.ref || '') !== String(evId)) { return; }
if (occDay && (d.occ || '') !== '' && (d.occ || '') !== occDay) { return; } // 只摘那一天
var box = el.closest('.ag-ev, .ag-allday-item, .cal-ev, .tg-block') || el;
if (box && box.parentNode) { box.parentNode.removeChild(box); hit = true; }
});
return hit;
}
var delCtx = null; /* { url, id, occ, repeat, ym } */
function askDelete(data, evId, occ) {
delCtx = { url: data.del, id: evId, occ: occ || '', repeat: !!data.repeat };
if (!data.repeat) {
/* 不重复:不用问范围,但**要确认一次**(删除不可逆)。
⚠️ 用原生 confirm 而不是自绘 —— 它同步返回、不引入新的 DOM 状态,
而且浮层里已经有一层遮罩了,再叠一层自绘框 Esc/焦点都难处理。 */
if (window.confirm('删除这条日程?')) { doDelete('all'); }
return;
}
/* 重复日程:正文变成"删哪些"。
🔴 v93(用户 2026-10-25):「删除的时候需要有几个选项:删除当前;
删除从现在到以后;删除全部。」⇒ 三档措辞按用户原话对齐
(服务端 `calendar/event_del` 收的就是 once / future / all 这三个值)。
⚠️ `occ` 为空时**不能**给"当前 / 从现在到以后":定位不到"这一次",
服务端会退化成整条删除 —— 那是最重的后果,不能靠一个空字符串触发。
这种情况只给「删除全部」,并说明为什么。
⚠️ 措辞必须与详情页那张删除表单(`templates/calendar/event.php`)一致,
用户学一次就够,不要出现第二套说法。 */
var hasOcc = !!occ;
if (titleEl()) { titleEl().textContent = '删除重复日程'; }
if (bodyEl()) {
bodyEl().innerHTML =
'<p class="ev-del-head">这是一条<b>重复日程</b>,删哪些?</p>' +
'<div class="ev-del-picks">' +
(hasOcc
? '<label class="chk-inline"><input type="radio" name="fbscope" value="once">' +
'<span>删除<b>当前</b>这一次(' + esc(occ) + ')</span></label>' +
'<label class="chk-inline"><input type="radio" name="fbscope" value="future">' +
'<span>删除<b>从现在到以后</b>(' + esc(occ) + ' 起,之前的保留)</span></label>'
: '') +
'<label class="chk-inline"><input type="radio" name="fbscope" value="all" checked>' +
'<span>删除<b>全部</b>(整条系列)</span></label>' +
'</div>' +
(hasOcc ? ''
: '<p class="small muted" style="margin:6px 0 0">' +
'这条是从列表打开的、不知道是哪一次,只能整条删除。</p>');
}
if (panelEl()) { panelEl().classList.remove('is-wide'); }
setTip('');
footShow('delscope');
}
function csrfToken() {
var el = document.querySelector('#fbEntryModal .fb-entry-csrf input[name="_csrf"]');
return el ? el.value : '';
}
function doDelete(scope) {
if (!delCtx || !delCtx.url) { return; }
var fd = new FormData();
fd.append('_csrf', csrfToken());
fd.append('id', String(delCtx.id));
fd.append('scope', scope || 'all');
if (delCtx.occ) { fd.append('occ', delCtx.occ); }
setTip('正在删除…');
footNone();
if (!window.fetch) { window.location.reload(); return; }
fetch(delCtx.url, { method: 'POST', body: fd, credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) { return r.json(); })
.then(function (res) {
if (!res || !res.ok) {
setTip((res && res.msg) || '删除失败。', true);
footShow('detail'); // 回到详情态,用户还能重试
return;
}
/* ⚠️ "这一次及以后"砍的是**系列尾段**,就地摘一条会漏掉后面那些
⇒ 整页重载(视图由服务端重画,最稳)。 */
if (res.removed_forward) { window.location.reload(); return; }
var n = removeFromDom(delCtx.id, (scope === 'once') ? delCtx.occ : '');
setOpen(false);
/* 摘不掉(比如首页那条是"全天"块、结构不同)→ 重载一次,别假装成功。 */
if (!n) { window.location.reload(); }
})
.catch(function () { window.location.reload(); });
}
/* ---- 打开一条 ------------------------------------------------------------- */
function openEntry(el) {
var raw = el.getAttribute('data-fb-entry') || '';
var data;
try { data = JSON.parse(raw); } catch (e) { return; } /* 数据坏了就别拦点击 */
if (!data || typeof data !== 'object') { return; }
detailCache = data;
paintDetail(data);
setOpen(true);
}
/**
* 直接用**数据对象**开详情层(不需要页面上存在对应的 DOM 元素)。
*
* 起源 v70(用户 2026-10-20 第 2 条):月视图的「+N 条」点开是"当天的清单",
* 清单里的每一条并没有自己的 `<a data-fb-entry>`(它们只是清单里的一行文字),
* 而 `openEntry(el)` 只吃元素 ⇒ 清单项点不进详情。
* ⇒ 抽出这个入口,`openEntry()` 也走它,两条路径共用同一套渲染与底栏规则。
*
* ⚠️ 数据结构必须与 `fb_entry_json()` 产出的一致(服务端保证)。
*/
function openEntryData(data) {
if (!data || typeof data !== 'object') { return; }
detailCache = data;
paintDetail(data);
setOpen(true);
}
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
if (t.closest('[data-fb-entry-close]')) { e.preventDefault(); setOpen(false); return; }
/* ④ 删除范围态:确认 / 取消(必须在「删除」之前判 —— 两颗都在同一排) */
if (t.closest('#fbEntryDelGo')) {
e.preventDefault();
var sel = document.querySelector('#fbEntryBody input[name="fbscope"]:checked');
doDelete(sel ? sel.value : 'all');
return;
}
if (t.closest('#fbEntryDelCancel')) { e.preventDefault(); restoreDetail(); return; }
/* ① 删除 */
if (t.closest('#fbEntryDelete')) {
e.preventDefault();
if (detailCache && detailCache.del) {
askDelete(detailCache, detailCache.ref, detailCache.occ || '');
}
return;
}
/* ② 编辑 */
if (t.closest('#fbEntryEdit')) {
e.preventDefault();
if (detailCache && detailCache.edit) { loadEdit(detailCache.edit); }
return;
}
/* 编辑态:取消(回详情)/ 保存 */
if (t.closest('#fbEntryCancel')) { e.preventDefault(); restoreDetail(); return; }
if (t.closest('#fbEntrySave')) { e.preventDefault(); submitEdit(); return; }
/* ③ 详情:source='event' 就地展开;其余来源是普通链接(浏览器自己跳) */
var detBtn = t.closest('#fbEntryDetail');
if (detBtn) {
if (detBtn.getAttribute('data-fb-info') === '1') {
e.preventDefault();
if (detailCache && detailCache.info) { loadInfo(detailCache.info); }
}
return; /* 派生来源:不 preventDefault,让它走 href 跳去那个模块 */
}
var item = t.closest('[data-fb-entry]');
if (item) { e.preventDefault(); openEntry(item); return; }
var m2 = M();
if (m2 && t === m2) { setOpen(false); } /* 点遮罩关;点面板内不关 */
});
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') { return; }
var m = M();
if (!m || m.hidden) { return; }
if (antdOpen()) { return; } /* 让 antd 先收它自己的面板 */
e.preventDefault();
setOpen(false);
});
/* 编辑态里表单用回车提交时,别让浮层的 Esc 逻辑挡住(默认行为即可)——
这里不再另挂 submit 监听:原生提交就是要它跳走。 */
window.fbEntryOpen = openEntry;
/* v70:按**数据对象**开详情层(月视图「+N 条」的清单项用这个入口) */
window.fbEntryOpenData = openEntryData;
window.fbEntryClose = function () { setOpen(false); };
})();