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:
@ -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>
|
||||
/*
|
||||
|
||||
BIN
client/electron/public/assets/icon-192.png
Normal file
BIN
client/electron/public/assets/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
BIN
client/electron/public/assets/icon-512.png
Normal file
BIN
client/electron/public/assets/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
40
client/electron/public/manifest.webmanifest
Normal file
40
client/electron/public/manifest.webmanifest
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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}
|
||||
|
||||
91
client/electron/test/manual/calendar-swipe-verify.mjs
Normal file
91
client/electron/test/manual/calendar-swipe-verify.mjs
Normal 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;
|
||||
@ -289,6 +289,22 @@ func main() {
|
||||
w.Write(static.GetIndex())
|
||||
})
|
||||
|
||||
// Web App Manifest(沉浸式全屏的前提:没有它,"添加到主屏幕"打开后
|
||||
// 仍然是带地址栏的网页)。放在根路径而不是 /assets/ 下,是因为 manifest 里的
|
||||
// start_url / scope 是相对**manifest 自己的 URL** 解析的:挂在 /assets/ 下就得写
|
||||
// "../",可读性差且容易被后来者改错。这里显式给一条路由(静态只挂了 / 与 /assets/*)。
|
||||
r.Get("/manifest.webmanifest", func(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := static.ReadFile("manifest.webmanifest")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/manifest+json")
|
||||
// 不缓存:图标/名称/显示模式改动要能立刻生效(它只有几百字节)
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Write(data)
|
||||
})
|
||||
|
||||
addr := fmt.Sprintf(":%s", cfg.Port)
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
|
||||
@ -35,3 +35,11 @@ func Handler() http.Handler {
|
||||
sub, _ := fs.Sub(staticFS, "static")
|
||||
return http.FileServer(http.FS(sub))
|
||||
}
|
||||
|
||||
// ReadFile 读打包进来的静态文件(目前只给 manifest 用)。
|
||||
//
|
||||
// 用 embed 而不是读磁盘:静态根可由 --static 指定成别的目录,
|
||||
// 那时 manifest 会跟着一起"消失";它属于前端产物的一部分,必须和应用同源同版本。
|
||||
func ReadFile(name string) ([]byte, error) {
|
||||
return fs.ReadFile(staticFS, "static/"+name)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user