用户 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()` / 不删。
252 lines
12 KiB
JavaScript
252 lines
12 KiB
JavaScript
/* ============================================================================
|
||
🔴 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
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); };
|
||
})(); |