Files
FamilyBook/assets/js/entry_popup.js
T
laoyang 32e9e9ab84 v47:地址联想三处接入 / 读书页慢的真根因(会话锁)/ 笔记两处 / 日程状态下线
用户 2026-10-13 七条。拍板:影院只记地址、不存经纬度(后续用高德反查);
日程状态只删界面与展示、数据库列保留(「后续会有一个整体清理的工作」)。

①🔴 读书页慢 —— 真根因是**会话锁**,不是封面(我先判断错了方向)
  用户原话:「读书现在还是非常慢,就是进入读书这个页面,刚开始还是非常的慢。」
  线上实测(真公网、同一会话连打 3 次):
      第 1 次 TTFB 0.08s  ← 触发微信读书同步
      第 2 次 TTFB **8.73s**  ← 用户说的"刚开始非常慢"
      第 3 次 TTFB 0.07s
  插桩拿到的服务端时间线把因果钉死:
      21:18:05.483 QUEUED        ← 第 1 个请求排队
      21:18:05.501 FINISH  2ms   ← 响应确实提前发出去了(这步是好的)
      21:18:14.674 SYNC    9.2s  ← 但它仍占着这个人的会话文件锁跑完 9.2 秒
      21:18:14.682 QUEUED        ← 第 2 个请求此刻才真正开始执行
  ⇒ **`fastcgi_finish_request()` 只断开客户端连接、不释放 PHP 文件会话锁。**
    后台同步要 9.2 秒(真打微信读书接口),同一个人的下一个请求一路等到它跑完。
  修法(缺一不可):
    · `WeRead::queueBackgroundSync()` 回调里 **先 `session_write_close()`、
      再 `fastcgi_finish_request()`**(顺序不能反);
    · `index.php` 两处"渲染前排同步"的入口排完**立刻放锁**
      (shutdown 回调不一定执行:客户端中断/服务器超时才不放过);
    · 顺带修 **`Holiday::queueBackgroundFetch()` 的同型问题**(联网 12 秒)。
  为什么以前没发现:v29 引入"后台化"时只验证了"响应是不是提前发出去",
  **单次请求测量永远看不到这个 bug** —— 必须同一会话连打两次才现形。
  线上复验:修复后连打 4 次 = 0.15 / 0.15 / 0.09 / 0.15 秒(修复前第 2 次 8.73s);
  后台同步仍正常(`weread_sync_at` 已更新)。

② 封面:顺手也修了(但这不是用户说的那个问题)
  · `media.php` 读 OSS 前先放会话锁(原来从不放,35 张封面串行);
  · 新增 `Storage::cacheLocally()` 磁盘二级缓存(原来 `read()` 只读不写回);
  · 让 304 不再先拉整张图(原来先取回 body、再判 `If-None-Match`)。
  线上实测:冷取 149ms(走 OSS)→ 热取 35ms(本地缓存、字节一致)
  → 带 `If-None-Match` 返回 **304 / 19.8ms / 空体**;35 张封面 103ms → 20ms(5 倍)。

③ 地址联想:抽成全站共用一份(`assets/js/addr_suggest.js`)+ 接入三处
  · 影院管理页、观影新增页的「新建影院」弹框、日程地点、行程出发/到达;
  · `layout.php` 按需加载(`[data-addr]` 出现才拉 SDK + 我们的脚本),
    并暴露 `fbLoadAddr(scope)` 给 AJAX 片段(v42 踩过"内联 script 不执行");
  · 安全密钥在 SDK 之前设置;回填支持 `data-addr-fill` / `data-addr-for`、
    **只补空白字段**(不覆盖用户手写)、直辖市 `province==city` 去重;
  · 字段名有两套(影院 `address`/`place`,行程 `from_addr`/`to_addr` 缩写 `_addr`),
    `fieldFor()` 带同义词表 + 后缀匹配;
  · 影院**只存地址、不存经纬度**(按用户要求)。

④ 笔记两条
  · 「点保存没反应」真因:附件区是个 `<form>` **套在保存表单里** ——
    HTML 不允许 form 套 form,浏览器丢掉内层标签、保留它的 `</form>`,
    于是**保存按钮成了孤儿**(`button.form === null`),点了不提交也不报错。
    真浏览器实测:修前 `btnInsideForm:false` / 点后 POST=0;修后 true / POST=1。
    改法:附件区改 `<div id="noteAttForm">`,上传按钮用 `formaction`。
  · 详情页宽度:`.note-read { max-width: 820px }` → `none`
    (内容卡 820px vs 下方共享卡 1032px;修后两者都是 1032px)。

⑤ 日程状态下线
  · 删表单里的「状态」下拉、`Calendar::statusWord()`、浮层 JSON 的 `status_word`;
  · **保留** `events.status` 列与 `Calendar::statusOf()`(契约不动,等整体清理)。

验证(新增 5 个套件):
  · `_fb_v47_addr.py`(接线 24 条)/ `_fb_v47_addr_sim.py`(真浏览器 + 假高德,16 条)
  · `_fb_v47_readperf.py`(缓存与会话锁,23 条)
  · `_fb_v47_notes.py`(真浏览器,9 条)
  · `_b47_readperf_falsify.py`(反向验证 9 个注入)—— **9/9 全抓住**
  · 全量 e2e **1510/0**;vCard 规格+往返 40/0;TB 探针 44/0;
    v44 反向验证 17/0;全流水线 **PUSH_RC=0**(含"第47批形态"静态断言)
  · **线上终验**:读书页整页 81ms、封面 103→20ms、media 冷 149→热 35→304 19.8ms、
    修复前第 2 次请求 8.73s → 修复后 0.15s、地址联想 meta[fb-addr] 带真高德 key

顺带修掉三处**探针自身**的假红/假绿(详见 pitfalls.md):
  · `_b44_falsify.py` 的"整小时"注入在**整点**与正确实现等价 ⇒ 换成恒错值;
  · `_fb_b44_ui.py` 3b 的并发计时断言在单进程 `php -S` 下必然假红 ⇒ 改断言确定事实;
  · 多处探针的"清理删除"撞沙箱删除门禁 ⇒ 探针**中途死掉**看起来像产品坏了
    ⇒ 统一改成 `_safe_rm()` / 不删。
2026-10-14 01:20:40 +08:00

252 lines
12 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 条)—— 条目详情浮层
----------------------------------------------------------------------------
用户原话:
「首页上的日程、首页上的近期任务,以及日程里面的日程,点击它们的时候,
都会弹出一个框显示更详细的信息。上面有不同的功能按钮,比如说详细信息,
在日程里面,如果是观影啊、旅程啊,点击详细信息就会进入到观影或者旅程的
那个相关的页面。如果是日程的话,点击详细信息就进到日程的详细页面。
当然还可以直接点击编辑,点击编辑不同的来源就进入不同的编辑页面,
就在当前的页面弹窗,把之前的窗给去掉,然后弹出编辑的那个框来就可以了。」
⇒ 一个浮层 + 事件委托,服务全站三种入口:
· 首页「今天的日程」(`.ag-ev` / `.ag-allday-item`)
· 首页「近期任务」(`.tk-row` 的标题)
· 日程页月/周/日视图的条目(`.cal-ev` / `.tg-block`)
设计取舍(每条都对应一个踩过的坑):
① **事件委托挂 document**:列表会被 AJAX 换掉(新建日程后就地插入、
首页任务勾选后重画),一次性绑定永久有效(v40 的教训)。
② **点条目 = 弹浮层,不跳页**:拦掉 `<a href>` 的默认行为。
⚠️ 必须按 `[data-fb-entry]` 精确匹配 —— 不能笼统拦 `.cal-ev`:
页面里还有"点某天新建日程"那套逻辑(它同样排除 `.cal-ev`)。
③ **数据全在 `data-fb-entry` 上**:浮层不发请求就能画出来,秒开;
服务端不参与"点开一条"这个动作。
④ **「编辑」就地换内容**:取该来源的编辑表单片段(`?embed=1`)。
用户原话「把之前的窗给去掉,然后弹出编辑的那个框来」——
所以是"正文整块换掉",不是叠第二层浮层。
⑤ **保存走原生提交**:四个来源的 update 路由本来就收普通 POST 并跳转,
不依赖任何页面自带脚本 ⇒ 浮层在任何页面都能保存成功。
(自己写 AJAX 保存要为四个来源各写一套"成功后回哪"的规则,得不偿失。
原生提交会整页刷新 —— 对"改了数据"这件事来说,刷新一次反而更清楚。)
⑥ **Esc 只关最上面那层**:antd 面板开着时先让它收(v38/v39 的坑)。
⚠️ 本脚本**全站加载**(layout.php):三处入口分散在不同页面,按需加载的判据
会在这些页面之间来回漏;它只有几 KB,不值得为省这点流量赌漏加载。
============================================================================ */
(function () {
'use strict';
var modal = null;
var detailCache = null; /* 详情态的数据(从编辑态「返回」时用它重画) */
function M() { if (!modal) { modal = document.getElementById('fbEntryModal'); } return modal; }
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' : '');
}
/* ---- 底栏两种形态 --------------------------------------------------------- */
function footDetail() {
['fbEntryDetail', 'fbEntryEdit'].forEach(function (id) {
var e = byId(id); if (e) { e.hidden = false; }
});
['fbEntryBack', 'fbEntrySave'].forEach(function (id) {
var e = byId(id); if (e) { e.hidden = true; }
});
}
function footEdit() {
['fbEntryDetail', 'fbEntryEdit'].forEach(function (id) {
var e = byId(id); if (e) { e.hidden = true; }
});
['fbEntryBack', 'fbEntrySave'].forEach(function (id) {
var e = byId(id); if (e) { e.hidden = false; }
});
}
function footNone() { /* 载入中 / 出错:全都先藏起来 */
['fbEntryDetail', 'fbEntryEdit', 'fbEntryBack', 'fbEntrySave'].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>';
}
function paintDetail(data) {
if (titleEl()) { titleEl().textContent = data.title || '详情'; }
if (bodyEl()) { bodyEl().innerHTML = '<dl class="kv">' + rowsHtml(data.rows) + '</dl>'; }
var det = byId('fbEntryDetail');
if (det) {
if (data.detail) { det.href = data.detail; det.hidden = false; }
else { det.hidden = true; }
}
var ed = byId('fbEntryEdit');
if (ed) { ed.disabled = false; ed.hidden = !data.edit; }
// 「编辑」不可用时别把它留在底栏上(否则点了没反应)
if (!data.edit) { var e2 = byId('fbEntryEdit'); if (e2) { e2.hidden = true; } }
setTip('');
footDetail();
if (!data.edit) { var e3 = byId('fbEntryEdit'); if (e3) { e3.hidden = true; } }
}
function restoreDetail() {
if (!detailCache) { return; }
var d = detailCache;
paintDetail(d);
}
/* ---- 打开 / 关闭 ---------------------------------------------------------- */
function setOpen(on) {
var m = M();
if (!m) { return; }
m.hidden = !on;
if (!on) { detailCache = null; if (bodyEl()) { bodyEl().innerHTML = ''; } }
}
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);
// 片段里的初始化脚本 + 宿主这侧的通用升级(日期 / 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()); }
/* 🔴 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() : '编辑'; }
footEdit();
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(); }
}
/* ---- 打开一条 ------------------------------------------------------------- */
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);
}
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('#fbEntryEdit')) {
e.preventDefault();
if (detailCache && detailCache.edit) { loadEdit(detailCache.edit); }
return;
}
if (t.closest('#fbEntryBack')) { e.preventDefault(); restoreDetail(); return; }
if (t.closest('#fbEntrySave')) { e.preventDefault(); submitEdit(); return; }
var item = t.closest('[data-fb-entry]');
if (item) { e.preventDefault(); openEntry(item); return; }
var m = M();
if (m && t === m) { 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;
window.fbEntryClose = function () { setOpen(false); };
})();