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

@ -13,6 +13,19 @@
<link rel="alternate icon" type="image/x-icon" href="/assets/favicon.ico" />
<link rel="apple-touch-icon" href="/assets/apple-touch-icon.png" />
<meta name="theme-color" content="#2563eb" />
<!--
沉浸式(用户 2026-09-14「不支持沉浸式网页」
原先没有 manifest也没有 iOS 全屏 meta ⇒ 在手机上「添加到主屏幕」后打开,
看到的仍然是**带地址栏的网页**。加上 standalone + black-translucent
(状态栏内容叠在页面上)之后才是真正的全屏;安全区由 viewport-fit=cover
与 .safe-frame / .narrow-nav 的 env(safe-area-inset-*) 负责,不会被刘海挡住。
-->
<link rel="manifest" href="./manifest.webmanifest" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="AgentMail" />
<title>AgentMail</title>
<script>
/*

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,40 @@
{
"name": "AgentMail",
"short_name": "AgentMail",
"description": "以邮件为统一交互范式的多智能体协作客户端",
"start_url": ".",
"scope": ".",
"display": "standalone",
"display_override": [
"standalone",
"minimal-ui"
],
"orientation": "any",
"background_color": "#f8fafc",
"theme_color": "#2563eb",
"icons": [
{
"src": "assets/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "assets/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "assets/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "assets/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
}
]
}

View File

@ -32,7 +32,12 @@ export default function CommTabs() {
return (
<div
data-testid="comm-tabs"
className="shrink-0 flex items-stretch gap-0.5 px-1.5 pt-1.5 border-b border-gray-200"
/*
* 与列表头同一套观感(同内边距、同下边框),页签用**下划线**而不是浮动白胶囊。
* 用户2026-09-14「通信页面的二级页面与其他位置极其割裂」——
* 之前的白胶囊带 shadow 浮在面板上,看起来是硬贴上去的另一套控件。
*/
className="shrink-0 flex items-stretch gap-1 px-3 border-b border-gray-200"
role="tablist"
aria-label="通信"
>
@ -46,10 +51,10 @@ export default function CommTabs() {
role="tab"
aria-selected={on}
onClick={() => setCommTab(key)}
className={`relative px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors ${
className={`relative -mb-px px-3 py-3 text-xs font-medium border-b-2 transition-colors ${
on
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-500 hover:text-gray-800 hover:bg-white/60'
? 'border-blue-600 text-gray-900'
: 'border-transparent text-gray-500 hover:text-gray-800 hover:border-gray-300'
}`}
>
{label}

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;