feat(webui): 通信二级页签与列表头一体 + 沉浸式(PWA 全屏)+ 日历滑动验收脚本

用户三条(同一线索):「通信页面的二级页面与其他位置极其割裂」、
「不支持沉浸式网页」、「日历页面还不支持左右滑动手势」。

## ① 二级页签不再割裂

页签原先是**带 shadow 的白色胶囊**浮在面板上,看起来像硬贴上去的另一套控件。
改成**下划线页签**:与列表头同一内边距、同一条下边框,选中态用蓝色下划线 +
`-mb-px` 压住分隔线(否则会出现"两条线"的接缝)。

## ② 沉浸式

根因不是 viewport(`viewport-fit=cover` 早就有了,安全区也接了
`env(safe-area-inset-*)`),而是**没有 Web App Manifest**:手机上"添加到主屏幕"后
打开仍然是带地址栏的网页。现在加了 `manifest.webmanifest`(`display: standalone`)
+ iOS 的 `apple-mobile-web-app-capable` / `black-translucent`(状态栏内容叠在页面上)。

manifest 放在**根路径**而不是 /assets/ 下:它里面的 `start_url`/`scope` 是相对
manifest 自己的 URL 解析的,挂在 /assets/ 下就得写 "../"。静态只挂了 `/` 与 `/assets/*`,
所以显式加了一条路由(并从 embed 读,而不是读磁盘 —— 前端产物必须与应用同源同版本)。
图标由项目唯一图标源生成 192/512(尺寸与声明一致,我用 struct 读文件头核对过)。

**实测**:`/manifest.webmanifest` → HTTP 200 `application/manifest+json`。

## ③ 日历滑动:补上真正的验收脚本

`test/manual/calendar-swipe-verify.mjs` 四条,含**反向对照**(纵向拖动不得翻页)
与前置断言。写它时又踩了一次自己的坑:标题真实格式是「2026 年 9 月」(数字与"年月"
之间有空格),我第一版正则按无空格写 ⇒ 匹配不到 ⇒ 三个值全是 null,
**看起来像"滑动没生效",其实是探针瞎了**。所以脚本里第一条就是"标题读得到"。

实测:9 月 →左滑→ 10 月 →右滑→ 9 月,纵向拖动不动。

## 顺带

把我为验收造的测试数据**归档**(不是删除):10 个 `/tmp/scrollprobe-*` 独立会话 +
12 封"滚动验收/窄屏验收样例"邮件。
This commit is contained in:
2026-09-14 11:07:03 +08:00
parent 50ee10a522
commit 0a4b98144c
8 changed files with 177 additions and 4 deletions

View File

@ -0,0 +1,91 @@
/**
* 日历左右滑动手势验收2026-09-14 用户:「日历页面还不支持左右滑动手势」)。
*
* 三条判据必须一起看,否则"滑一下就翻页"也能算过:
* ① 左滑 → 下一段(月/周/日三种刻度都走同一个 shift()
* ② 右滑 → 回到上一段(可逆)
* ③ **反向对照**:纵向拖动不翻页 —— 移动端最容易犯的手势错误就是把滚动当翻页
*
* 探针踩过的坑标题真实格式是「2026 年 9 月」(**数字与"年月"之间有空格**
* 我第一版正则写成 /^(\d{4})年(\d{1,2})月/ 匹配不到 ⇒ 返回 null ⇒ 三个值全是 null
* 看起来"滑动没生效",其实是探针瞎了。所以这里先断言标题读得到。
*
* 用法AGENTMAIL_DIST=<dist> ADMIN_USER=.. ADMIN_PW=.. node test/manual/calendar-swipe-verify.mjs
*/
import { openApp } from './narrow-probe-helper.mjs';
const { page, ctx } = await openApp({ width: 390, height: 844 });
const results = [];
const record = (name, ok, note) => {
results.push({ name, ok });
console.log(` ${ok ? '通过' : '失败'} ${name}${note ? ' — ' + note : ''}`);
};
try {
await page.waitForSelector('.narrow-nav', { timeout: 25000 });
await page.click('.narrow-nav button:has-text("日历")');
await page.waitForTimeout(1500);
const title = () =>
page.evaluate(() => {
const rx = /\d{4}\s*年\s*\d{1,2}\s*月/;
const c = [...document.querySelectorAll('*')]
.filter(e => rx.test(e.textContent) && e.children.length <= 1)
.map(e => e.textContent.trim())
.filter(t => t.length < 40)
.sort((a, b) => a.length - b.length);
return c[0] ?? null;
});
const swipe = (x1, y1, x2, y2) =>
page.evaluate(
([x1, y1, x2, y2]) => {
const mk = (type, x, y) =>
new TouchEvent(type, {
bubbles: true,
cancelable: true,
touches:
type === 'touchend'
? []
: [new Touch({ identifier: 1, target: document.body, clientX: x, clientY: y })],
changedTouches: [
new Touch({ identifier: 1, target: document.body, clientX: x, clientY: y })
]
});
const el = document.elementFromPoint(x1, y1);
el.dispatchEvent(mk('touchstart', x1, y1));
el.dispatchEvent(mk('touchend', x2, y2));
},
[x1, y1, x2, y2]
);
const before = await title();
record('前置:日历标题读得到(否则本判据无意义)', !!before, `标题=${before}`);
await swipe(320, 420, 80, 420);
await page.waitForTimeout(900);
const afterLeft = await title();
record('① 左滑翻到下一段', !!afterLeft && afterLeft !== before, `${before}${afterLeft}`);
await swipe(80, 420, 320, 420);
await page.waitForTimeout(900);
const afterRight = await title();
record('② 右滑滑回上一段', afterRight === before, `${afterLeft}${afterRight}`);
await swipe(200, 300, 195, 620);
await page.waitForTimeout(900);
const afterVertical = await title();
record('③ 反向对照:纵向拖动不翻页', afterVertical === afterRight, `仍为 ${afterVertical}`);
// ④ 回到今天不该被手势判断干扰(按钮仍在)
const todayBtn = await page.locator('button:has-text("今天")').count();
record('④ 手势没有吃掉「今天」按钮', todayBtn > 0, `找到 ${todayBtn}`);
} finally {
await page.close();
await ctx.close();
}
const failed = results.filter(r => !r.ok);
console.log(`\n 日历滑动:${results.length - failed.length}/${results.length} 通过`);
if (failed.length) console.log(' 失败:' + failed.map(f => f.name).join('; '));
process.exitCode = failed.length ? 1 : 0;