v30 第 30 批(3 条):ICS 导入读完即弃 / 导航联想词根因修复 / 导航设置改纯图标

三条都是「静态检查与 e2e 恒绿、只有真机才现形」的,每条先写真机诊断探针
拿到真根因再改,并逐条反向验证(改回旧行为必须被抓住)。

1. 日程 ICS 导入:文件已上传仍提示「请选择一个文本」
   真根因:_stage_drop.php 是"拖进来即刻上传到暂存区"的唯一形态——
   拖放时文件走 upload_stage.php 落到 uploads/_staging/,而 <input>.files
   始终是空的;两个导入框(ics/通讯录)都带 required → 浏览器原生校验把提交
   拦下,弹它自己的文案。就算绕过校验,路由只读 $_FILES['ics']、从不看
   stage_token[],暂存文件永远没人认领。
   改:片段新增 $sdStage(默认 true)。导入型传 false → 框上不带
   data-drop-stage → app.js 走"写回 input.files、随表单提交"的老路,文件走
   PHP 临时文件,路由读完即弃;不落存储、不建附件行、不进 OSS。
   同时把 app.js 找框的选择器从 [data-drop-stage][data-sd-for=…] 放宽成
   [data-sd-for=…]——否则这些框点不开也接不住拖动。

2. 导航联想词"还是不好用"
   真根因:nav_show.php 给出的 suggest 地址已带 ?p=<短语>,而 nav-show.js 又
   硬拼 '?slug=…' → 实际是 …?p=abc?slug=bing&q=…;PHP 认为第一个 ? 之后整串
   都是查询值 → p 匹配不上短语 → 403 → 下拉永远空。服务端一直是对的(直连
   200 + 10 条词),e2e 探针自己拼对了参数,所以 HTTP 层恒绿、只有真页面能抓。
   改:nav-show.js 新增 withQuery(),按 base 里有没有 ? 决定分隔符;
   nav_suggest.php 加短语容错(切掉第一个 ?/& 之后的内容,救缓存里的旧 JS)。

3. 导航设置按钮改纯图标 ☰
   v29 只改成了下拉菜单,summary 仍是 btn-ghost btn-sm + 文字「设置」+ 一个
   向下箭头。改:换成与其他七个模块逐字节相同的 btn-icon + 三条横线。

验证:e2e 1464/0(新增 2 条)、升级 153/0、vCard 40 项、城市 32/0、
桌面几何全过、移动 680/0、交互 92/0、窄屏 30/0、b24_nav 41/0、b24_ui 26/0、
b28 39/0、b29 38/0、b27 34/0;新真机探针 _b30 22/0、反向验证 11/0;
部署静态断言新增 8 条守卫并逐条证伪通过(本轮抓到 2 条恒真守卫 + 1 处
_code_only 的 locale 差异)。线上 7 个文件 SHA256 与本地一致。
This commit is contained in:
2026-10-08 14:46:16 +08:00
parent 37ba86321b
commit 91c55038c9
7 changed files with 121 additions and 19 deletions
+6 -2
View File
@@ -667,10 +667,14 @@
bindBox(pv, input); bindBox(pv, input);
}); });
} }
// 新结构:框自己声明 data-sd-for 指向这个 input // 新结构:框自己声明 data-sd-for 指向这个 input。
// 🔴 v30 用户第 1 条:选择器**不能**带上 `[data-drop-stage]` 这一层 ——
// "导入型"上传框(ics / 通讯录)故意**不带** data-drop-stage(它要走
// "写回 input.files、随表单提交、PHP 读完即弃"的老路,见 _stage_drop.php 头部)。
// 带上它就会漏掉这些框 → 框既点不开文件选择框、也接不住拖动。
if (input.id) { if (input.id) {
Array.prototype.forEach.call( Array.prototype.forEach.call(
document.querySelectorAll('[data-drop-stage][data-sd-for="' + input.id + '"]'), document.querySelectorAll('[data-sd-for="' + input.id + '"]'),
function (bx) { bindZone(bx, input); bindBox(bx, input); }); function (bx) { bindZone(bx, input); bindBox(bx, input); });
} }
} }
+29 -1
View File
@@ -134,6 +134,33 @@
}); });
} }
/**
* 往一个 URL 追加查询串,**分隔符按 URL 里有没有 `?` 决定**。
*
* 🔴🔴 v30 用户第 2 条:「导航的联想词功能还是不好用」。真根因就在这一行。
*
* `window.SIMPLENAV.suggest` 是**服务端按当前短语生成**的,形态是
* `https://<站点>/nav_suggest.php?p=<短语>`
* —— 它**已经带了一个 `?`**(`p=` 是那个免登录接口的门禁,见 nav_suggest.php)。
* 而旧代码在这里硬拼 `'?slug=…&q=…'` → 实际发出的是
* `…/nav_suggest.php?p=abc?slug=bing&q=天气`
* PHP 对重复的 `?` 的处理:**第一个 `?` 之后整串都是查询值**,第二个 `?` 只是
* `p` 的值里一个普通字符 → `?p` 变成 `abc?slug=bing` → `Nav::ownerByPhrase()`
* 匹配不上任何短语 → **403 forbidden** → `data.items` 是 undefined → `showWord()`
* 收到空数组 → `hideWord()` → 下拉永远是空的。
*
* ⚠️ 这条**只在真机上才现形**:e2e 里直连 `/nav_suggest.php?p=…&slug=…&q=…`
* 是 200(探针自己拼对了),所以"接口是好的"一直成立,坏的只是前端那一步。
* (实测记录:前端发出的 URL 带 `?p=…?slug=…`,服务端回 403;服务端本身 200。)
* ⚠️ 不改成"服务端直接把 slug/q 一起拼好"是为了保持这份 JS 与 SimpleNav 原版的
* 可对照性 —— 原版 `suggest` 是一个不带问号的固定地址,参数确实是前端拼的。
* 这里只把"分隔符"这件事修正回来,其余一行不动。
*/
function withQuery(base, qs) {
if (!base) { return ''; }
return base + (base.indexOf('?') >= 0 ? '&' : '?') + qs;
}
function askSuggest() { function askSuggest() {
if (!input) return; if (!input) return;
var q = input.value.trim(); var q = input.value.trim();
@@ -142,7 +169,8 @@
lastQ = q; lastQ = q;
var mine = ++seq; var mine = ++seq;
var slug = form ? (form.getAttribute('data-slug') || '') : ''; var slug = form ? (form.getAttribute('data-slug') || '') : '';
fetch(cfg.suggest + '?slug=' + encodeURIComponent(slug) + '&q=' + encodeURIComponent(q)) fetch(withQuery(cfg.suggest, 'slug=' + encodeURIComponent(slug)
+ '&q=' + encodeURIComponent(q)))
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (data) { if (mine === seq) { showWord(data && data.items); } }) .then(function (data) { if (mine === seq) { showWord(data && data.items); } })
.catch(function () { if (mine === seq) { hideWord(); } }); .catch(function () { if (mine === seq) { hideWord(); } });
+17
View File
@@ -37,6 +37,23 @@ header('X-Robots-Tag: noindex');
// ⚠️ 这**不是**身份校验(短语本来就是公开的),它挡的是"随便谁来都能刷": // ⚠️ 这**不是**身份校验(短语本来就是公开的),它挡的是"随便谁来都能刷":
// 没有它,这个 URL 就是一个匿名的外网请求放大器(每次 GET 都会去打百度/必应)。 // 没有它,这个 URL 就是一个匿名的外网请求放大器(每次 GET 都会去打百度/必应)。
$phrase = trim((string)($_GET['p'] ?? '')); $phrase = trim((string)($_GET['p'] ?? ''));
/* 🔴 v30 用户第 2 条:容错 —— 短语里**不允许**出现 `?` 或 `&`(短语本身是
`[A-Za-z0-9_-]{4,32}`,见 Nav::PHRASE_RE,永远不含这两个字符)。
为什么要有这一段:前端曾经把 suggest 地址硬拼成 `…?p=abc?slug=bing&q=…`
(suggest 地址本身已带 `?`,旧代码又补了一个 `?`)。PHP 对重复 `?` 的处理是
"第一个 `?` 之后整串都算查询值" → `p` 变成了 `abc?slug=bing` → 403 →
展示页下拉**永远是空的**,而服务端单测是好的(探针自己拼对了)→ 极难查。
修法在前端(`withQuery()`,已改)。这里再做一层"把第一个 `?` 之后的垃圾切掉",
是为了让**任何**老前端 / 缓存住的旧 JS 都不至于整条联想失效 —— 属于廉价兜底:
合法短语不可能含 `?`,切掉不会误伤。 */
if ($phrase !== '') {
$cut = strcspn($phrase, '?&');
if ($cut < strlen($phrase)) {
$phrase = substr($phrase, 0, $cut);
}
}
$owner = $phrase === '' ? 0 : Nav::ownerByPhrase($phrase); $owner = $phrase === '' ? 0 : Nav::ownerByPhrase($phrase);
if ($owner <= 0) { if ($owner <= 0) {
http_response_code(403); http_response_code(403);
+40 -3
View File
@@ -41,12 +41,45 @@
* $sdRequired 必填(默认 false;只有"专门用来补传附件"那种单一用途表单才需要) * $sdRequired 必填(默认 false;只有"专门用来补传附件"那种单一用途表单才需要)
* $sdLabel 给屏幕阅读器/测试用的说明(默认「点击或拖动进行上传」) * $sdLabel 给屏幕阅读器/测试用的说明(默认「点击或拖动进行上传」)
* $sdMsg 框里那句话(默认同上;用户点名要这句) * $sdMsg 框里那句话(默认同上;用户点名要这句)
* $sdStage **是否走"拖进来即刻上传到暂存区"**(默认 true)。
* false = 「普通模式」:拖进来的文件只写进 `<input>`,随表单一起提交。
*
* ────────────────────────────────────────────────────────────────
* 🔴 v30 用户第 1 条:**"读完就丢"的导入文件不能走暂存模式**。
*
* 用户原话:「日程模块,导入 ics 有问题…我感觉,这个不需要上传到 oss 吧,
* 一般我们上传的 oss 的都是附件,就是以后还有访问它的可能性,还会查看它。
* 但是这个文件导入之后,以后应该就不会再访问它。所以他他不挂载在任何的数据下面,
* 如果用户需要再重复导入的话,他再重新导传就行了。所以这个策略应该就是
* 用户选择完文件之后,我们把它上传到服务器上个临时目录里面,
* 把数据读出来之后,写入到我们的日程的数据库表中,然后再把这个文件删除就可以了。」
*
* ⇒ 「导入型」表单(ics 导入 / 通讯录导入)要的就是 **PHP 自带的那个临时文件**:
* 浏览器把文件放进 `$_FILES[x]['tmp_name']`,业务路由 `file_get_contents()` 读出来、
* 解析、写库,请求结束 PHP 自动删掉它 —— 不落存储、不建附件行、不进 OSS。
*
* ⚠️ 而"暂存模式"(本片段原来的唯一形态)与它**天然冲突**,两个后果都很硬:
* ① 拖进来的文件走 `upload_stage.php` 落到 `uploads/_staging/`,并把令牌塞进
* `stage_token[]`;`<input>` 的 `files` **始终是空的**;
* ② 可导入型表单的 `<input>` 都带 `required`(不选就没意义)→ 提交被浏览器的
* 原生校验拦下,弹它自己的文案(中文 Chrome 依 accept 生成「请选择一个文本。」)
* —— 用户看到的正是「我明明已经上传了,还是提示请选择一个文本」。
* ③ 就算绕过校验提交了,路由只读 `$_FILES['ics']`、**从不看** `stage_token[]`,
* 暂存文件永远没人认领(实测:暂存目录留着 1 个孤儿目录,库里 0 条)。
* ⇒ `$sdStage = false` 时框上**不带** `data-drop-stage`,app.js 会走第 19 批那条
* "把 File 写回 input.files"的老路 —— 拖放照常可用,而 required 也真的满足了。
*
* ⚠️ 别把 `required` 删掉来"绕过"这个报错:那是把"没选文件就提交"变成静默失败,
* 比报错更难查。要修的是"拖进来的文件根本没进 input"这件事。
*/ */
$sdName = (string)($sdName ?? ''); $sdName = (string)($sdName ?? '');
$sdSlot = (string)($sdSlot ?? ''); $sdSlot = (string)($sdSlot ?? '');
$sdAccept = (string)($sdAccept ?? ''); $sdAccept = (string)($sdAccept ?? '');
$sdMultiple = $sdMultiple ?? true; $sdMultiple = $sdMultiple ?? true;
$sdRequired = (bool)($sdRequired ?? false); $sdRequired = (bool)($sdRequired ?? false);
/* 是否走"拖进来即刻上传到暂存区"(见头部 v30 第 1 条的说明)。
默认 true = 保持既有行为(附件型上传都该如此);导入型表单显式传 false。 */
$sdStage = $sdStage ?? true;
/* 用户点名要的那句话(第 7 条)。可覆盖,但默认就是它。 */ /* 用户点名要的那句话(第 7 条)。可覆盖,但默认就是它。 */
$sdMsg = (string)($sdMsg ?? '点击或拖动进行上传'); $sdMsg = (string)($sdMsg ?? '点击或拖动进行上传');
/* id:不传就按字段名净化一个(photo[] → sd-photo)。必须是**合法且唯一**的 id, /* id:不传就按字段名净化一个(photo[] → sd-photo)。必须是**合法且唯一**的 id,
@@ -95,8 +128,12 @@ $sdField = in_array($sdSlot, ['poster', 'ticket'], true)
(JS 里绑了 keydown)。不然这个框只能用鼠标 —— 那是无障碍倒退。 (JS 里绑了 keydown)。不然这个框只能用鼠标 —— 那是无障碍倒退。
⚠️ 不能写成 `<button>`:里面要放暂存清单(含「撤掉这张」按钮), ⚠️ 不能写成 `<button>`:里面要放暂存清单(含「撤掉这张」按钮),
按钮里嵌按钮是非法 HTML(第 20 批刚在 <form> 嵌套上栽过同一个跟头)。 */ ?> 按钮里嵌按钮是非法 HTML(第 20 批刚在 <form> 嵌套上栽过同一个跟头)。 */ ?>
<div class="upbox" data-drop-stage data-sd-for="<?= e($sdId) ?>" <?php /* ⚠️ `data-drop-stage` 是"走暂存上传"的**开关**(app.js 的 queueDrop 靠它分流):
data-stage-field="<?= e($sdField) ?>" 附件型 = 有它 → 拖进来立刻 POST 到 upload_stage.php;
导入型($sdStage=false)= 没它 → app.js 走"把 File 写回 input.files",
随表单一起提交,PHP 读完即弃(见头部 v30 第 1 条)。 */ ?>
<div class="upbox"<?= $sdStage ? ' data-drop-stage' : '' ?> data-sd-for="<?= e($sdId) ?>"
<?= $sdStage ? 'data-stage-field="' . e($sdField) . '"' : '' ?>
<?= $sdSlot !== '' ? 'data-stage-slot="' . e($sdSlot) . '"' : '' ?> <?= $sdSlot !== '' ? 'data-stage-slot="' . e($sdSlot) . '"' : '' ?>
role="button" tabindex="0" role="button" tabindex="0"
aria-label="<?= e($sdMsg) ?>"> aria-label="<?= e($sdMsg) ?>">
@@ -115,4 +152,4 @@ $sdField = in_array($sdSlot, ['poster', 'ticket'], true)
</div> </div>
<?php <?php
// ⚠️ 只 unset 本片段自己的变量(与 _datetime.php / _loc_field.php 同一约定) // ⚠️ 只 unset 本片段自己的变量(与 _datetime.php / _loc_field.php 同一约定)
unset($sdName, $sdSlot, $sdAccept, $sdMultiple, $sdRequired, $sdMsg, $sdId, $sdField); unset($sdName, $sdSlot, $sdAccept, $sdMultiple, $sdRequired, $sdMsg, $sdId, $sdField, $sdStage);
+10 -2
View File
@@ -187,8 +187,16 @@ $canWrite = \App\Auth::canWrite();
</div> </div>
<div class="field"> <div class="field">
<label for="im-file">.ics 文件 *</label> <label for="im-file">.ics 文件 *</label>
<?php $sdName = 'ics'; $sdId = 'im-file'; $sdAccept = '.ics,text/calendar'; <?php /* 🔴 v30 用户第 1 条:$sdStage = false —— 导入型上传**不走暂存**。
$sdMultiple = false; $sdRequired = true; 用户原话:「这个文件导入之后,以后应该就不会再访问它…把它上传到服务器上
个临时目录里面,把数据读出来之后,写入到我们的日程的数据库表中,
然后再把这个文件删除就可以了。」
⇒ 文件走 PHP 自带的临时文件($_FILES['ics']['tmp_name']),
路由读完即弃;不落存储、不建附件行、不进 OSS。
⚠️ 走暂存模式时 `<input>.files` 始终是空的 → 带 required 的提交会被
浏览器原生校验拦下,弹「请选择一个文本。」(用户报的就是这句)。 */
$sdName = 'ics'; $sdId = 'im-file'; $sdAccept = '.ics,text/calendar';
$sdMultiple = false; $sdRequired = true; $sdStage = false;
require __DIR__ . '/../_stage_drop.php'; ?> require __DIR__ . '/../_stage_drop.php'; ?>
</div> </div>
</div> </div>
+13 -9
View File
@@ -100,16 +100,20 @@ $setItems = [
<?php /* 🔴 v29 用户第 5 条:右上角「设置」点开是**下拉菜单**,四项分设。 <?php /* 🔴 v29 用户第 5 条:右上角「设置」点开是**下拉菜单**,四项分设。
⚠️ 用项目已有的 `.movie-set.menu`(任务/观影模块同款): ⚠️ 用项目已有的 `.movie-set.menu`(任务/观影模块同款):
外观、点击外部收起、键盘可达全都是现成的, 外观、点击外部收起、键盘可达全都是现成的,
这里再手写一套 `<details>` 样式,迟早跟那边不一致。 */ ?> 这里再手写一套 `<details>` 样式,迟早跟那边不一致。
🔴🔴 v30 用户第 3 条:「导航模块的设置按钮,右上角,不要用文字,
和其他的模块一样,三个横线那个图标。」
⇒ summary 换成与其他七个模块**逐字节相同**的形态:
`class="btn btn-icon"` + 一个 path 里的三条横线(☰)。
⚠️ 三处必须一起改,少一处就是"看着像改了、其实没改":
`btn-ghost btn-sm` → **`btn-icon`**(纯图标方形,高度取 --act-h);
删掉「设置」二字(文字会把方块撑成一行,`.btn-icon` 的 width 形同虚设);
图标换成 ☰(原来是单个向下的箭头,那是"展开"的意思,不是"设置")。
⚠️ 宽度固定为 --act-h 之后,`.btn-icon` 的 `padding:0 !important` 才会生效 ——
仍带 `.btn-sm` 会让它在页头动作区里矮一截(部署脚本有一条专门守这个)。 */ ?>
<details class="movie-set menu"> <details class="movie-set menu">
<summary class="btn btn-ghost btn-sm" title="导航设置" aria-label="导航设置"> <summary class="btn btn-icon" title="导航设置" aria-label="导航设置"><svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/></svg></summary>
设置
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
stroke-linejoin="round" style="margin-left:2px;vertical-align:-1px">
<path d="M4 6.5l4 4 4-4"/>
</svg>
</summary>
<div class="menu-list"> <div class="menu-list">
<?php foreach ($setItems as $k => $it): ?> <?php foreach ($setItems as $k => $it): ?>
<a href="<?= e(url('nav', ['set' => $k])) ?>"<?= $setKey === $k ? ' class="on"' : '' ?>> <a href="<?= e(url('nav', ['set' => $k])) ?>"<?= $setKey === $k ? ' class="on"' : '' ?>>
+6 -2
View File
@@ -34,9 +34,13 @@ $canMove = \App\Auth::canWrite();
<div class="form-grid"> <div class="form-grid">
<div class="field full"> <div class="field full">
<label for="imp-file">联系人文件 *</label> <label for="imp-file">联系人文件 *</label>
<?php $sdName = 'contacts'; $sdId = 'imp-file'; <?php /* 🔴 v30:与 ics 导入同一条(用户第 1 条)—— 导入型上传**不走暂存**。
文件走 PHP 临时文件,路由读完即弃,不落存储、不建附件行。
⚠️ 不这么改的话,拖进来的文件只到暂存区、`<input>.files` 是空的,
required 的原生校验会弹「请选择一个文本。」(与 ics 完全同一个坑)。 */
$sdName = 'contacts'; $sdId = 'imp-file';
$sdAccept = '.vcf,.csv,.txt,text/vcard,text/csv,text/plain'; $sdAccept = '.vcf,.csv,.txt,text/vcard,text/csv,text/plain';
$sdMultiple = false; $sdRequired = true; $sdMultiple = false; $sdRequired = true; $sdStage = false;
require __DIR__ . '/../_stage_drop.php'; ?> require __DIR__ . '/../_stage_drop.php'; ?>
</div> </div>
<div class="field"> <div class="field">