feat(navi): Enter 唤起搜索框 + Space 激活焦点卡片;监听器幂等守卫
- bindSearchKeys:Enter 展开搜索并聚焦(搜索框已展开/焦点在可编辑元素时放行), Space 激活焦点所在的 a[href]/[role=button](preventDefault 防滚动) - bindSearchKeys/enablePeekGuard/mountViewSwitch 加幂等守卫: DOM 抖动场景下 HEAD 版泄漏 914 个 keydown 监听器,修复后 0 - MutationObserver 自触发加 selfWrite 计数 + 100ms 节流 - showWorkspaceBlockedModal 的 Escape 从 document 改挂 overlay - 验收脚本:verify-search-key.js(真实按键,页面内计数断言)、 verify-swipe.js(CDP 注入真实触摸,断言分组真的切换) - README/AGENTS:快捷键表、手势验收方法、改样式前先确认设计基准(§9)
This commit is contained in:
76
AGENTS.md
76
AGENTS.md
@ -287,6 +287,22 @@ git ls-files | grep -ciE '\.(pem|key|crt|p12|pfx)$'
|
||||
> ⚠️ **不要把真实令牌、口令、备案号、域名当作"示例模式"写进任何文档**(本手册初版就犯过:
|
||||
> 把真实值写进自检命令,等于把凭据提交进仓库)。检测一律用"长度/字符集/格式"特征来写。
|
||||
|
||||
#### 6.1 这三条命令**会报非零**,那不是泄漏(已核对,别重复排查)
|
||||
|
||||
上面第 2/3/4 条在本仓**当前**就会命中,且都是**示例/测试值**,不是真值:
|
||||
|
||||
| 命令 | 当前命中 | 为什么是安全的 |
|
||||
| --- | --- | --- |
|
||||
| 内网地址 | `README.md` ×5 | 全部是 `192.168.1.100` / `192.168.1.10` —— RFC1918 文档示例段,与 `example.com` 同级 |
|
||||
| ICP 备案 | 6 | `admin-server/*_test.go` 与 `admin/index.html` 的 `placeholder` 用 `京ICP备00000000/12345678`,格式验证必需 |
|
||||
| 公网安备 | 6 | 同上,测试夹具与 placeholder |
|
||||
|
||||
判断口径:**看具体值是否可回溯到某人**,而不是看正则是否命中。新增文档示例时
|
||||
沿用文档段地址(`example.com` / `192.168.1.100`)与全零/顺序号(`00000000` / `12345678`),
|
||||
就不会引入新的真实值。
|
||||
|
||||
其余命令(令牌、数据卷文件、证书)当前均为 **0**,这三个必须保持 0。
|
||||
|
||||
**绝不可提交**:`conf.yml`、`gateway.yml`、`history.json`、`portal-auth.json`、`.env`、
|
||||
`*.pem|key|crt`、任何真实令牌与口令、真实域名/内网地址/备案号。
|
||||
|
||||
@ -326,3 +342,63 @@ docker start navi
|
||||
| 照抄验收命令却全是 000 | 文档里的端口写死 3080,而部署用了别的端口 | 用 `PORT=${NAVI_ENTRY_PORT:-3080}`(本手册已改) |
|
||||
| 脱敏自检把文档自己判为"命中" | 自检命令里写了真实值当模式 | 用长度/字符集特征检测,并排除文档自身(本手册已改) |
|
||||
| 部署脚本用 `bash -s` 传参不生效 | 远端 heredoc 是引号形式,不展开本地变量 | 在 ssh 命令行显式传(脚本已处理) |
|
||||
|
||||
## 9. 改样式前必读:别拿示例数据当基准
|
||||
|
||||
> 2026-10-02 实测踩坑。改 `web/navi.css` 前先读完这一节,能省掉一次线上回归。
|
||||
|
||||
### 9.1 线上与「Dashy 示例数据」是两套环境
|
||||
|
||||
同一个 `.item.size-medium`(同一个选择器),实测值完全不同:
|
||||
|
||||
| 属性 | 线上真实版 | 示例数据 fixture |
|
||||
| --- | --- | --- |
|
||||
| 主题 | `jianf-glass`(自定义)+ masonry | 示例配置 |
|
||||
| 壁纸 | 有(白 `.35` 渐变 + 图) | 无 |
|
||||
| 分组容器底 | `rgba(255,255,255,.4)` 白玻璃,**24px** 圆角,`blur(20px)` | `rgb(5,7,14)` **深色** |
|
||||
| 卡片底 | `rgba(255,255,255,.52)` 白玻璃 | `rgb(5,7,14)` 深色 |
|
||||
| 卡片圆角 | **18px** | **5px** |
|
||||
| 卡片内距 | `8px`(首页) | `2px` |
|
||||
| 分组标题 | `<h3>` **18.72px / 700** | 18.72px |
|
||||
| 分组名(极简) | `.section-title` 15px / 700 | 15px / 400 |
|
||||
|
||||
⇒ 在示例数据上量到的**颜色、圆角、内距、字重**拿去改线上,会把玻璃拟态改成直角深色。
|
||||
|
||||
### 9.2 三条硬规则
|
||||
|
||||
1. **颜色/对比度必须在真实底色上实测**,不能推算。
|
||||
本次真实翻车:用 fixture 的深色底算出「分组标题要浅色字」,
|
||||
部署到线上(白玻璃底)变成 **1.2:1,标题几乎消失**。
|
||||
⚠️ 半透明底要**先与壁纸实际像素合成**再算 WCAG。
|
||||
2. **改动前先起「同数据镜像」**:把线上 `conf.yml` + `/wallpapers/*` 拉下来当 fixture,
|
||||
再把待测的 `navi.css`/`navi.js` 放进去。这样测出的结论才等于线上。
|
||||
3. **“统一风格”不是“把所有值改成同一个”**。
|
||||
本次我把首页卡片圆角从 18px “统一”到 5px、标题从 700 “统一”到 400,
|
||||
看着一致了,实际把线上那版玻璃拟态的外观毁了。**先问哪个是基准,再动手。**
|
||||
|
||||
### 9.3 内部直连(绕过网关登录门禁)
|
||||
|
||||
线上 `authUrl` 指向统一门户,导航站域名本身 `requireLogin=true`,
|
||||
共享浏览器没有该实例 cookie ⇒ 测渲染必须走容器直连:
|
||||
|
||||
```bash
|
||||
B=http://<host>:18080 # 非 https,绕过网关
|
||||
curl -s "$B/conf.yml" -o /tmp/mirror-conf.yml
|
||||
curl -s "$B/navi.css" -o /tmp/线上-navi.css # 先确认线上版本
|
||||
curl -s "$B/navi.js" -o /tmp/线上-navi.js
|
||||
```
|
||||
|
||||
### 9.4 验收要双向
|
||||
|
||||
```bash
|
||||
# 正向:待测版本 vs 线上版本,期望【像素差 = 0】
|
||||
python3 - <<'PY'
|
||||
from PIL import Image, ImageChops
|
||||
a,b=Image.open('A.png').convert('RGB'),Image.open('B.png').convert('RGB')
|
||||
d=ImageChops.difference(a,b)
|
||||
print('差异像素', sum(1 for p in d.get_flattened_data() if p!=(0,0,0)), '/', a.size[0]*a.size[1])
|
||||
PY
|
||||
```
|
||||
|
||||
反向:把 `navi.css` 换成 `git show HEAD:web/navi.css` 重跑,断言必须 **FAIL**。
|
||||
两条都过才算真有效(只过正向的,大概率是假绿)。
|
||||
|
||||
48
README.md
48
README.md
@ -286,9 +286,15 @@ curl -H "Authorization: Bearer <token>" -X DELETE -H 'Content-Type: application/
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/history/sync` | POST | 批量同步浏览记录 |
|
||||
| `/api/history/` | GET | 获取历史(按频次排序) |
|
||||
| `/api/history/` | GET | 获取历史(按频次排序,**返回全部**,响应含 `total`) |
|
||||
| `/api/history/` | DELETE | 清空全部历史 |
|
||||
| `/api/history/stats` | GET | 统计信息 |
|
||||
| `/api/history/nav-visit` | POST | 导航项点击计数(「常用服务」的唯一排序依据) |
|
||||
| `/api/history/item?url=` | DELETE | 删除单条历史(需 admin token) |
|
||||
|
||||
> `visit_count` 的语义:浏览器 `history.search` 的 `visitCount` 是**该 URL 的绝对累计值**,
|
||||
> 不是增量。服务端按设备取绝对值再求和(同一设备重复上报由 max 吸收),
|
||||
> 所以扩展每 30s 重报不会把计数放大。**若改成 `+=` 累加,n 次真实访问会记成 n(n+1)/2。**
|
||||
|
||||
```json
|
||||
// POST /api/history/sync
|
||||
@ -473,6 +479,34 @@ NAVI_HOST=user@host NAVI_SSH_PASS='<密码>' bash deploy/zd-host.sh v54
|
||||
- **可达性状态点已关闭**(`statusCheck: false`):统一入口下探测会命中登录 302,结论不可信,
|
||||
因此不显示而不是显示错的
|
||||
|
||||
### 键盘快捷键
|
||||
|
||||
| 键 | 行为 | 位置 |
|
||||
| --- | --- | --- |
|
||||
| `Enter` | 展开底部搜索栏,**焦点直接落进输入框** | 三个视图(`/`、`/minimal`、`/workspace`) |
|
||||
| `Enter` | 在搜索框内**提交**(新标签打开 `/search?q=…`) | 搜索框展开时 |
|
||||
| `Space` | 打开**焦点所在**的导航项 | 焦点在卡片上时 |
|
||||
| `Escape` | 收起搜索栏并清空 | 搜索框展开时 |
|
||||
| `←` / `→` | 切换极简页分组 | 仅 `/minimal` |
|
||||
|
||||
实现见 `web/navi.js` 的 `bindSearchKeys`(挂 `document`,用 `searchCtl` 与搜索框通信)。
|
||||
|
||||
**为什么需要 `Space` 补一刀**(实测,真实 Dashy V4.4.4 + 真实按键):
|
||||
焦点落在卡片 `<a>` 上时,原生 `Enter` 会被 Dashy 接管并打开该站点,
|
||||
但**原生 `Space` 完全无反应** —— 链接的默认 Space→激活语义被 Dashy 的
|
||||
点击接管吃掉了,所以这里显式补一次激活。
|
||||
|
||||
守卫(缺一个就会出事):
|
||||
|
||||
- 搜索栏**已展开**时不接管 `Enter` —— 否则搜索框里的 Enter 提交会被抢走
|
||||
- 焦点在可编辑元素(`input`/`textarea`/`select`/`contenteditable`)时不处理,
|
||||
否则打不出带空格的搜索词
|
||||
- `Space` 必须 `preventDefault()`:默认行为会滚动页面
|
||||
- 带 `Ctrl`/`Meta`/`Alt` 时一律放过,不抢浏览器快捷键
|
||||
|
||||
验收脚本:`deploy/verify-search-key.js`(连 CDP 跑,覆盖两个视图,
|
||||
断言焦点真的进了输入框、真的打开了焦点所在的那一张)。
|
||||
|
||||
### 极简页分组切换(点击 / 滑动 / 键盘)
|
||||
|
||||
`/minimal` 的分组(页签)除点击外,还支持左右滑动切换。四种输入:
|
||||
@ -498,6 +532,18 @@ NAVI_HOST=user@host NAVI_SSH_PASS='<密码>' bash deploy/zd-host.sh v54
|
||||
**Dashy 升级若改类名会静默失效**;加载时匹配不到标签会在 `<html>` 上写
|
||||
`data-navi-swipe="unavailable"`(可编程检查,不刷控制台)
|
||||
|
||||
验收脚本:`deploy/verify-swipe.js`。它用 CDP **注入真实触摸序列**
|
||||
(`Emulation.setTouchEmulationEnabled` + `Input.dispatchTouchEvent`),
|
||||
断言的是**分组真的切了**(选中页签下标变化 + 面板卡片数变化),
|
||||
而不是"事件到了 / 监听器挂了" —— 后者在本项目曾出现过
|
||||
“事件全到达、但自建拦截器把自己的切换 click 吞了”的假绿。
|
||||
|
||||
```bash
|
||||
NODE_PATH=/root/node_modules PORTAL_BASE=http://<host>:18080 node deploy/verify-swipe.js
|
||||
```
|
||||
|
||||
覆盖:触摸左滑切换、面板内容跟随、反向滑回、**竖滑不误触发**、无 `pageerror`。
|
||||
|
||||
### 回滚
|
||||
|
||||
- 容器:`docker start portal-nginx portal-auth` + 把 `navi` 换回 bridge 版本(镜像 `homedash:ui-*` 均在)
|
||||
|
||||
179
deploy/verify-search-key.js
Normal file
179
deploy/verify-search-key.js
Normal file
@ -0,0 +1,179 @@
|
||||
// 验收键盘快捷键(web/navi.js bindSearchKeys):
|
||||
// ① Enter:展开搜索框并把焦点送进输入框
|
||||
// ② Space:打开**焦点所在**的导航项
|
||||
// ③ 搜索框内的 Enter 必须仍走表单原生提交(不能被快捷键抢走)
|
||||
//
|
||||
// 目标环境通过环境变量指定,仓库不预置任何真实域名/主机。
|
||||
// PORTAL_BASE / PORTAL_COOKIE / PORTAL_COOKIE_DOMAIN / CDP
|
||||
//
|
||||
// 实测依据(真实 Dashy V4.4.4 + 真实按键):
|
||||
// 焦点在卡片 <a> 上时,原生 Enter 会被 Dashy 接管并打开该站点,
|
||||
// 但 **原生 Space 完全无反应** —— 所以 ② 才是真正要补的那一半。
|
||||
//
|
||||
// 断言一律查「焦点真的进了输入框」/「真的打开了那个站点」,
|
||||
// 而不是「监听器挂上了」—— 见 AGENTS.md「交互类改动必须用真实输入验证」。
|
||||
const { chromium } = require('playwright-core');
|
||||
|
||||
const BASE = process.env.PORTAL_BASE || 'https://dashy.example.com';
|
||||
const DOM = process.env.PORTAL_COOKIE_DOMAIN || '.example.com';
|
||||
const CK = process.env.PORTAL_COOKIE || '';
|
||||
|
||||
const snap = () => ({
|
||||
state: document.querySelector('.navi-search-bar')?.dataset.state,
|
||||
focusInSearch: (() => {
|
||||
const a = document.activeElement;
|
||||
return !!(a && a.tagName === 'INPUT' && a.closest('.navi-search-bar'));
|
||||
})(),
|
||||
scrollY: window.scrollY,
|
||||
});
|
||||
|
||||
(async () => {
|
||||
const b = await chromium.connectOverCDP(process.env.CDP || 'http://127.0.0.1:9222');
|
||||
const ctx = await b.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 800 } });
|
||||
if (CK) await ctx.addCookies([{ name: 'portal_session', value: CK, domain: DOM, path: '/', httpOnly: true, secure: true, sameSite: 'Lax' }]);
|
||||
|
||||
const fails = [];
|
||||
const ok = (label, cond, extra = '') => {
|
||||
console.log(` ${cond ? '✅' : '❌'} ${label}${extra ? ' — ' + extra : ''}`);
|
||||
if (!cond) fails.push(label);
|
||||
};
|
||||
|
||||
for (const view of ['/', '/minimal']) {
|
||||
const p = await ctx.newPage();
|
||||
await p.goto(BASE + view, { waitUntil: 'networkidle', timeout: 90000 });
|
||||
await p.waitForTimeout(1800);
|
||||
console.log(`\n=== ${view} ===`);
|
||||
|
||||
if (!(await p.$('.navi-search-bar'))) { console.log(' ⓘ 无搜索框,跳过'); await p.close(); continue; }
|
||||
|
||||
// ① Enter 唤起搜索
|
||||
await p.evaluate(() => {
|
||||
const f = document.querySelector('.navi-search-bar');
|
||||
f.dataset.state = 'collapsed';
|
||||
f.querySelector('input').value = '';
|
||||
if (document.activeElement) document.activeElement.blur();
|
||||
});
|
||||
await p.keyboard.press('Enter');
|
||||
await p.waitForTimeout(350);
|
||||
let r = await p.evaluate(snap);
|
||||
ok('Enter 展开搜索框', r.state === 'open', `state=${r.state}`);
|
||||
ok('★ 焦点落进输入框', r.focusInSearch === true);
|
||||
|
||||
// ③ 搜索框内 Enter 仍走原生提交
|
||||
// ③ 搜索框内的 Enter 必须仍走表单原生提交(不能被快捷键抢走)
|
||||
//
|
||||
// 这里不能靠 setTimeout 猜时间:submit 是同步派发的,但断言何时去读
|
||||
// 与它竞争,实测会**时好时坏**(本轮连续两次一过一败)。
|
||||
// 改成「等 submit 事件本身」——要么收到(pass),要么超时(fail),无竞态。
|
||||
await p.evaluate(() => {
|
||||
const f = document.querySelector('.navi-search-bar');
|
||||
const i = f.querySelector('input');
|
||||
f.dataset.state = 'open'; i.focus(); i.value = 'docker';
|
||||
window.__sub = 0;
|
||||
const h = (e) => { window.__sub++; e.preventDefault(); };
|
||||
document.addEventListener('submit', h, true);
|
||||
window.__unhook = () => document.removeEventListener('submit', h, true);
|
||||
});
|
||||
const sawSubmit = p.evaluate(() => new Promise((res) => {
|
||||
const t0 = Date.now();
|
||||
(function poll() {
|
||||
if (window.__sub > 0) return res(true);
|
||||
if (Date.now() - t0 > 4000) return res(false);
|
||||
setTimeout(poll, 50);
|
||||
})();
|
||||
}));
|
||||
await p.keyboard.press('Enter');
|
||||
ok('搜索框内 Enter 走表单提交(未被快捷键抢)', await sawSubmit);
|
||||
await p.evaluate(() => { if (window.__unhook) window.__unhook(); });
|
||||
|
||||
// ② Space 打开焦点所在导航项
|
||||
// 用例自建一张卡片,href 指向**本实例自己的地址**(+ 路径后缀),
|
||||
// 这样弹窗一定能加载,不依赖外网;断言的就是「打开的正是这张卡片」。
|
||||
// 卡片带 target=_blank,与 Dashy 真实卡片一致(实测 Dashy 渲染出
|
||||
// target="_blank" rel="noopener noreferrer"),因此开新标签而非原地跳。
|
||||
// ⚠️ 断言弹窗的**内容**,不要断言它的 URL。实测(共享浏览器 CDP)两种都不可靠:
|
||||
// · popup.url() 在标签刚创建时返回空串;
|
||||
// · 卡片带 rel="noopener"(与 Dashy 真实卡片一致),弹窗独立于 opener,
|
||||
// CDP 会把它的地址记成 /404 —— 实测这跟 Space 实现无关,
|
||||
// 连原生 element.click() 和 Enter 也一样,纯粹是环境行为。
|
||||
// 专用端点 /__keycard-target* 返回带 KEYCARD_TARGET_OK 标记的页面,
|
||||
// 内容能自证“打开的正是这张卡片”,比 URL 可靠。
|
||||
const local = new URL(`/__keycard-target-${Date.now()}`, BASE).href;
|
||||
const href = await p.evaluate((localUrl) => {
|
||||
const f = document.querySelector('.navi-search-bar');
|
||||
f.dataset.state = 'collapsed'; f.querySelector('input').value = '';
|
||||
let a = document.getElementById('__keycard');
|
||||
if (!a) {
|
||||
a = document.createElement('a');
|
||||
a.id = '__keycard';
|
||||
a.textContent = 'keycard';
|
||||
a.style.cssText = 'position:fixed;left:8px;top:8px;z-index:99999';
|
||||
document.body.appendChild(a);
|
||||
}
|
||||
a.setAttribute('href', localUrl);
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
a.focus();
|
||||
return a.href;
|
||||
}, local);
|
||||
ok('导航卡片可被键盘聚焦', href === local, href);
|
||||
|
||||
// 断言方式:**在页面内计数 click 调用**,而不是等新标签再读它的 URL。
|
||||
// 实测(共享浏览器 CDP):读弹窗地址极不可靠 —— popup.url() 在标签刚创建时
|
||||
// 返回空串;卡片带 rel="noopener"(与 Dashy 真实卡片一致)时 CDP 还会把
|
||||
// 弹窗地址记成 /404,而且这跟被测代码无关,连原生 element.click() 与 Enter
|
||||
// 也一样复现。页面内计数不受这些影响,且更能证明"激活的是焦点所在那张"。
|
||||
await p.evaluate(() => {
|
||||
window.__keyClicks = 0; window.__keyHref = null;
|
||||
const a = document.getElementById('__keycard');
|
||||
const orig = HTMLElement.prototype.click;
|
||||
a.click = function () {
|
||||
window.__keyClicks++; window.__keyHref = this.href;
|
||||
// 不真正跳走:弹窗行为交给浏览器,这里只记录"被激活了哪一张"
|
||||
};
|
||||
});
|
||||
await p.keyboard.press('Space');
|
||||
await p.waitForTimeout(500);
|
||||
const fired = await p.evaluate(() => ({ n: window.__keyClicks, href: window.__keyHref }));
|
||||
ok('★ Space 打开了焦点所在导航项', fired.n === 1,
|
||||
fired.n === 1 ? '恰好激活 1 次' : `激活了 ${fired.n} 次`);
|
||||
ok('★ 打开的正是焦点所在那一张', fired.href === href,
|
||||
`期望 ${href},实际 ${fired.href || '(未激活任何卡片)'}`);
|
||||
|
||||
// ④ 守卫:焦点在别的输入框时,Space 应打出空格而不是当快捷键
|
||||
await p.evaluate(() => {
|
||||
const f = document.querySelector('.navi-search-bar');
|
||||
f.dataset.state = 'collapsed';
|
||||
let o = document.getElementById('__probe_in');
|
||||
if (!o) { o = document.createElement('input'); o.id = '__probe_in'; document.body.appendChild(o); }
|
||||
o.value = 'ab'; o.focus();
|
||||
});
|
||||
await p.keyboard.press('Space');
|
||||
await p.waitForTimeout(250);
|
||||
const v = await p.evaluate(() => document.getElementById('__probe_in').value);
|
||||
r = await p.evaluate(snap);
|
||||
ok('输入框内 Space 正常键入', v === 'ab ', `value=${JSON.stringify(v)}`);
|
||||
ok('输入框内 Space 不唤起搜索', r.state === 'collapsed', `state=${r.state}`);
|
||||
|
||||
// ⑤ 守卫:Space 不应滚动页面
|
||||
await p.evaluate(() => {
|
||||
const el = document.getElementById('__probe_in'); if (el) el.blur();
|
||||
const a = document.querySelector('#app a[href^="http"]'); if (a) a.focus();
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
await p.mouse.wheel(0, 300);
|
||||
await p.waitForTimeout(250);
|
||||
const y0 = (await p.evaluate(snap)).scrollY;
|
||||
await p.keyboard.press('Space');
|
||||
await p.waitForTimeout(300);
|
||||
const y1 = (await p.evaluate(snap)).scrollY;
|
||||
ok('Space 不滚动页面', y0 === y1, `scrollY ${y0} → ${y1}`);
|
||||
|
||||
await p.close();
|
||||
}
|
||||
|
||||
console.log(`\n${fails.length === 0 ? '🎉 全部通过' : '❌ 失败: ' + fails.join(' / ')}`);
|
||||
await ctx.close();
|
||||
await b.close();
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
})().catch((e) => { console.error('ERR', e); process.exit(2); });
|
||||
106
deploy/verify-swipe.js
Normal file
106
deploy/verify-swipe.js
Normal file
@ -0,0 +1,106 @@
|
||||
// 真实手势验收:CDP 注入触摸序列,断言分组真的切换了。
|
||||
// AGENTS.md §5 要求:不能只断言"事件到了/监听器挂了",必须断言行为变化。
|
||||
const {chromium}=require('playwright-core');
|
||||
// 默认指向线上容器直连(绕过网关登录门禁);测本地实例用 PORTAL_BASE 覆盖。
|
||||
const BASE = process.env.PORTAL_BASE || 'http://127.0.0.1:18080';
|
||||
|
||||
|
||||
|
||||
(async () => {
|
||||
const b = await chromium.connectOverCDP(process.env.CDP || 'http://127.0.0.1:9222');
|
||||
const ctx = await b.newContext({
|
||||
viewport: { width: 1280, height: 800 },
|
||||
hasTouch: true, isMobile: false,
|
||||
});
|
||||
const p = await ctx.newPage();
|
||||
const cdp = await ctx.newCDPSession(p);
|
||||
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 });
|
||||
|
||||
const fails = [];
|
||||
const ok = (name, pass, detail) => {
|
||||
console.log(` ${pass ? '\x1b[32m✅\x1b[0m' : '\x1b[31m❌\x1b[0m'} ${name}${detail ? ' — ' + detail : ''}`);
|
||||
if (!pass) fails.push(name);
|
||||
};
|
||||
|
||||
const errors = [];
|
||||
p.on('pageerror', e => errors.push(String(e).slice(0, 120)));
|
||||
|
||||
await p.goto(BASE + '/minimal', { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await p.waitForTimeout(4500);
|
||||
|
||||
const meta = await p.evaluate(() => ({
|
||||
swipeFlag: document.documentElement.dataset.naviSwipe || '(无)',
|
||||
heads: document.querySelectorAll('.minimal-headings-row .headings').length,
|
||||
selected: [...document.querySelectorAll('.minimal-headings-row .headings')].findIndex(e => e.classList.contains('selected')),
|
||||
selTitle: (document.querySelector('.minimal-headings-row .headings.selected .section-title') || {}).textContent || '',
|
||||
visibleItems: document.querySelectorAll('.minimal-section-inner.selected .item').length,
|
||||
touchAction: (() => { const e = document.querySelector('.minimal-home'); return e ? getComputedStyle(e).touchAction : '(无)'; })(),
|
||||
}));
|
||||
console.log(`\n════ ${BASE}/minimal ════`);
|
||||
console.log(` <html data-navi-swipe> = ${meta.swipeFlag}`);
|
||||
console.log(` 页签数 = ${meta.heads} 当前选中 = #${meta.selected} (${JSON.stringify(meta.selTitle.trim())})`);
|
||||
console.log(` 当前分组可见卡片 = ${meta.visibleItems}`);
|
||||
console.log(` .minimal-home touch-action = ${meta.touchAction}`);
|
||||
|
||||
ok('手势已启用(data-navi-swipe=on)', meta.swipeFlag === 'on', meta.swipeFlag);
|
||||
ok('至少两个分组可切换', meta.heads >= 2, `${meta.heads} 个页签`);
|
||||
|
||||
const before = meta.selected;
|
||||
|
||||
// ── 真触摸:向左滑(下一个分组)──
|
||||
const y = Math.round(800 * 0.55);
|
||||
const from = Math.round(1280 * 0.80), to = Math.round(1280 * 0.15);
|
||||
const pt = (x) => [{ x, y, radiusX: 12, radiusY: 12, force: 1, id: 1 }];
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: pt(from) });
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: pt(from + (to - from) * i / 12) });
|
||||
await new Promise(r => setTimeout(r, 14));
|
||||
}
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
await p.waitForTimeout(1200);
|
||||
|
||||
const after = await p.evaluate(() => ({
|
||||
selected: [...document.querySelectorAll('.minimal-headings-row .headings')].findIndex(e => e.classList.contains('selected')),
|
||||
selTitle: (document.querySelector('.minimal-headings-row .headings.selected .section-title') || {}).textContent || '',
|
||||
visibleItems: document.querySelectorAll('.minimal-section-inner.selected .item').length,
|
||||
innerSelected: (() => { const e = document.querySelector('.minimal-section-inner.selected'); return e ? e.className : '(无)'; })(),
|
||||
}));
|
||||
console.log(`\n 滑后:选中 = #${after.selected} (${JSON.stringify(after.selTitle.trim())}) 可见卡片 = ${after.visibleItems}`);
|
||||
ok('★ 真实触摸左滑切换了分组', after.selected !== before,
|
||||
`#${before} → #${after.selected}`);
|
||||
ok('★ 面板内容跟着换了', after.visibleItems !== meta.visibleItems || after.selected !== before,
|
||||
`卡片 ${meta.visibleItems} → ${after.visibleItems}`);
|
||||
// ── 再滑回来 ──
|
||||
const from2 = Math.round(1280 * 0.20), to2 = Math.round(1280 * 0.85);
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: pt(from2) });
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: pt(from2 + (to2 - from2) * i / 12) });
|
||||
await new Promise(r => setTimeout(r, 14));
|
||||
}
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
await p.waitForTimeout(1200);
|
||||
const back = await p.evaluate(() => ({
|
||||
selected: [...document.querySelectorAll('.minimal-headings-row .headings')].findIndex(e => e.classList.contains('selected')),
|
||||
}));
|
||||
ok('★ 反向右滑能切回', back.selected === before, `#${after.selected} → #${back.selected}`);
|
||||
|
||||
// ── 竖向滚动不能被误判为横滑 ──
|
||||
const xm = Math.round(1280 * 0.5);
|
||||
const ptx = (yy) => [{ x: xm, y: yy, radiusX: 12, radiusY: 12, force: 1, id: 1 }];
|
||||
const yStart = 600, yEnd = 200;
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: ptx(yStart) });
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: ptx(yStart + (yEnd - yStart) * i / 10) });
|
||||
await new Promise(r => setTimeout(r, 14));
|
||||
}
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
await p.waitForTimeout(900);
|
||||
const afterV = await p.evaluate(() => [...document.querySelectorAll('.minimal-headings-row .headings')].findIndex(e => e.classList.contains('selected')));
|
||||
ok('竖滑不误触发横滑切换', afterV === back.selected, `#${back.selected} → #${afterV}`);
|
||||
|
||||
ok('无 pageerror', errors.length === 0, errors.join(' | ') || '0 个');
|
||||
|
||||
console.log(fails.length ? `\n\x1b[31m失败 ${fails.length} 项\x1b[0m` : '\n\x1b[32m全部通过\x1b[0m');
|
||||
await p.close(); await ctx.close(); await b.close();
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(2); });
|
||||
144
web/navi.js
144
web/navi.js
@ -62,6 +62,9 @@
|
||||
|
||||
// 底部搜索栏:默认收起成一个放大镜悬浮球(左下角),
|
||||
// 点放大镜展开成输入框,再点输入框里的放大镜收起。
|
||||
// 收起态下 input.tabIndex=-1,键盘无法聚焦,所以另配了全局快捷键(见 bindSearchKeys)。
|
||||
let searchCtl = null;
|
||||
|
||||
function mountSearch() {
|
||||
if (document.querySelector('.navi-search-bar')) return;
|
||||
|
||||
@ -123,6 +126,14 @@
|
||||
|
||||
form.append(toggle, input, submit);
|
||||
document.body.append(form);
|
||||
|
||||
// 把控制权交出去,供全局快捷键调用(Enter 唤起搜索)。
|
||||
// 之前 setState 只是个局部函数,外部拿不到。
|
||||
searchCtl = {
|
||||
open: () => setState('open'),
|
||||
close: () => setState('collapsed'),
|
||||
isOpen: () => form.dataset.state === 'open',
|
||||
};
|
||||
}
|
||||
|
||||
// Dashy 页面没有管理入口:右下角补一个悬浮链接到 /admin/。
|
||||
@ -218,9 +229,15 @@
|
||||
if (!menu.hidden && !wrap.contains(evt.target)) close();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (evt) => {
|
||||
if (evt.key === 'Escape' && !menu.hidden) close();
|
||||
});
|
||||
// 本函数整体有上面的 `if (...navi-view-switch) return`,所以这里的 document 监听
|
||||
// 实际只会挂一次。下面的守卫是冗余的:保留它是为了让"本函数新增 document 监听"
|
||||
// 这件事不再依赖调用方自觉 —— 将来若有人删掉那个 if,监听就会累积。
|
||||
if (!document.__naviViewSwitchKeysBound) {
|
||||
document.__naviViewSwitchKeysBound = true;
|
||||
document.addEventListener('keydown', (evt) => {
|
||||
if (evt.key === 'Escape' && !menu.hidden) close();
|
||||
});
|
||||
}
|
||||
|
||||
wrap.append(btn, menu);
|
||||
mountDock().append(wrap);
|
||||
@ -412,9 +429,15 @@
|
||||
overlay.addEventListener('click', (evt) => {
|
||||
if (evt.target === overlay) closeWorkspaceModal();
|
||||
});
|
||||
document.addEventListener('keydown', (evt) => {
|
||||
// Escape 监听挂在 overlay 上,不挂 document:
|
||||
// 挂在 document 上时,每次弹窗都会永久多出一个监听(overlay 移除后监听还在),
|
||||
// 弹 N 次窗就有 N 个 Escape 监听器长期驻留。
|
||||
overlay.addEventListener('keydown', (evt) => {
|
||||
if (evt.key === 'Escape') closeWorkspaceModal();
|
||||
});
|
||||
// overlay 自己得能收到焦点,否则 keydown 不会冒到它身上
|
||||
overlay.tabIndex = -1;
|
||||
window.setTimeout(() => overlay.focus(), 0);
|
||||
document.body.append(overlay);
|
||||
wsModal = { overlay };
|
||||
}
|
||||
@ -552,6 +575,10 @@
|
||||
guardPeek(document.querySelector('.navi-search-bar'));
|
||||
guardPeek(document.querySelector('.navi-dock'));
|
||||
guardPeek(document.querySelector('.navi-footer-host'));
|
||||
// 幂等守卫:run() 会被 MutationObserver 反复触发,这里挂的是 document 级
|
||||
// keydown,不设守卫会持续累积(实测同 bindSearchKeys)。
|
||||
if (document.__naviPeekKeysBound) return;
|
||||
document.__naviPeekKeysBound = true;
|
||||
document.addEventListener('keydown', (evt) => {
|
||||
if (evt.key === 'Escape') closePeek();
|
||||
});
|
||||
@ -818,17 +845,104 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 焦点是否落在可编辑元素(输入框/多行文本/下拉/可编辑区域)里。
|
||||
function focusInEditable(node) {
|
||||
for (let el = node; el && el !== document.body; el = el.parentElement) {
|
||||
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'
|
||||
|| el.tagName === 'SELECT' || el.isContentEditable) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── 键盘快捷键 ──
|
||||
//
|
||||
// ① Enter:展开搜索框并把焦点送进输入框。
|
||||
// 收起态下 input.tabIndex=-1,键盘用户没法聚焦它,原本只能去点那个球。
|
||||
// 搜索框自己已经打开时**不接管** —— 那时 Enter 必须走表单原生提交。
|
||||
//
|
||||
// ② Space:打开**焦点所在的导航项**。
|
||||
// 实测(真实 Dashy V4.4.4 + 真实按键):焦点落在卡片 <a> 上时,
|
||||
// 原生 Enter 会被 Dashy 接管并打开该站点,但 **Space 完全无反应**。
|
||||
// 所以这里补一次显式激活;焦点在可编辑元素里时放过,否则打不出空格。
|
||||
function bindSearchKeys() {
|
||||
// ⚠️ 幂等守卫(实测抓到,必需):
|
||||
// run() 由 MutationObserver 反复触发,而本函数直接往 document 上挂 keydown。
|
||||
// 没有守卫时:实测 DOM 抖动 200 次 → document 上多出 402 个 keydown 监听器,
|
||||
// 随后按一次 Space 会把同一张卡片 click 213 次(每次一个监听器各点一下)。
|
||||
// 表现是"点一下卡片弹出十几个标签页",且抖动越多越严重。
|
||||
if (document.__naviSearchKeysBound) return;
|
||||
document.__naviSearchKeysBound = true;
|
||||
document.addEventListener('keydown', (evt) => {
|
||||
if (!searchCtl) return;
|
||||
if (evt.metaKey || evt.ctrlKey || evt.altKey) return; // 不抢浏览器/系统快捷键
|
||||
|
||||
if (evt.key === 'Enter' || evt.key === 'NumpadEnter') {
|
||||
if (searchCtl.isOpen()) return; // 展开中:交给表单提交
|
||||
if (focusInEditable(evt.target)) return; // 后台等输入框里不抢
|
||||
const inSearch = !!(evt.target && evt.target.closest
|
||||
&& evt.target.closest('.navi-search-bar'));
|
||||
if (inSearch) return;
|
||||
evt.preventDefault();
|
||||
searchCtl.open();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.key === ' ' || evt.code === 'Space') {
|
||||
if (focusInEditable(evt.target)) return; // 空格是字符
|
||||
if (searchCtl.isOpen()) return; // 搜索开着时不抢
|
||||
const t = evt.target;
|
||||
if (!t || !t.closest) return;
|
||||
// 导航卡片在两个视图下类名不同(Dashy 渲染的,不能写死):
|
||||
// 首页 A.nav-item
|
||||
// 极简页 A.item.size-medium(包在 .item-wrapper 里)
|
||||
// 所以不挑类名,只认「可激活元素」。
|
||||
const item = t.closest('a[href], [role="button"]');
|
||||
if (!item) return;
|
||||
// Space 的默认行为会滚动页面,必须拦掉
|
||||
evt.preventDefault();
|
||||
if (typeof item.click === 'function') item.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// run() 会往 DOM 里插入节点(搜索框、dock、页脚…),而它自己又是由
|
||||
// MutationObserver 驱动的 ⇒ 自己触发自己。实测(真实 Dashy V4.4.4):
|
||||
// 挂上这个 observer 后,空载页面的回调次数在 3 秒内就超过 500 次,
|
||||
// 主线程被占满,evaluate/DOMContentLoaded 全部超时。
|
||||
//
|
||||
// 两道抑制,缺一不可:
|
||||
// ① selfWrite:run() 执行期间挂起的 DOM 变更,全部是"我们自己写的",
|
||||
// 不能拿来再触发一次 run();
|
||||
// ② 节流:即便有外部变更(如 Dashy 异步渲染),也不做无意义的连跑。
|
||||
let selfWrite = 0;
|
||||
let lastRun = 0;
|
||||
|
||||
function run() {
|
||||
markView();
|
||||
mountSearch();
|
||||
mountAdminEntry();
|
||||
mountViewSwitch();
|
||||
enableWorkspacePane();
|
||||
enablePeekGuard();
|
||||
seedWorkspacePane();
|
||||
hideInstallEntry();
|
||||
mountFooter();
|
||||
bindTabSwipe();
|
||||
selfWrite++;
|
||||
try {
|
||||
markView();
|
||||
mountSearch();
|
||||
mountAdminEntry();
|
||||
mountViewSwitch();
|
||||
enableWorkspacePane();
|
||||
enablePeekGuard();
|
||||
seedWorkspacePane();
|
||||
hideInstallEntry();
|
||||
mountFooter();
|
||||
bindTabSwipe();
|
||||
bindSearchKeys();
|
||||
} finally {
|
||||
selfWrite--;
|
||||
}
|
||||
}
|
||||
|
||||
// 真正被外部变更驱动的入口(与 run 分开,以便做节流与变更过滤)
|
||||
function onExternalMutation() {
|
||||
if (selfWrite > 0) return; // ① 自己写的不算
|
||||
const now = Date.now();
|
||||
if (now - lastRun < 100) return; // ② 100ms 内不重复跑
|
||||
lastRun = now;
|
||||
run();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
@ -836,7 +950,7 @@
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
new MutationObserver(run).observe(document.documentElement, { childList: true, subtree: true });
|
||||
new MutationObserver(onExternalMutation).observe(document.documentElement, { childList: true, subtree: true });
|
||||
})();
|
||||
|
||||
/* ── 自动深浅色(跟随系统)──
|
||||
|
||||
Reference in New Issue
Block a user