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="alternate icon" type="image/x-icon" href="/assets/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/assets/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/assets/apple-touch-icon.png" />
|
||||||
<meta name="theme-color" content="#2563eb" />
|
<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>
|
<title>AgentMail</title>
|
||||||
<script>
|
<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 (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="comm-tabs"
|
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"
|
role="tablist"
|
||||||
aria-label="通信"
|
aria-label="通信"
|
||||||
>
|
>
|
||||||
@ -46,10 +51,10 @@ export default function CommTabs() {
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={on}
|
aria-selected={on}
|
||||||
onClick={() => setCommTab(key)}
|
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
|
on
|
||||||
? 'bg-white text-gray-900 shadow-sm'
|
? 'border-blue-600 text-gray-900'
|
||||||
: 'text-gray-500 hover:text-gray-800 hover:bg-white/60'
|
: 'border-transparent text-gray-500 hover:text-gray-800 hover:border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{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())
|
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)
|
addr := fmt.Sprintf(":%s", cfg.Port)
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
|
|||||||
@ -35,3 +35,11 @@ func Handler() http.Handler {
|
|||||||
sub, _ := fs.Sub(staticFS, "static")
|
sub, _ := fs.Sub(staticFS, "static")
|
||||||
return http.FileServer(http.FS(sub))
|
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