fix: 「我的」页无法滚动 —— 缺滚动容器;顺带修矮屏登录页
用户反馈「我的页面点击进入后无法滑动」。 ## 根因:AccountPage 根本没有滚动容器 窄屏外壳是 `h-full flex flex-col overflow-hidden`,页面本身是 `flex-1 min-w-0 flex flex-col`,中间缺一层 `overflow-y-auto` —— 内容超出的部分**直接被裁**,滚不到也点不到。 实测 390px 下内容(资料 + 权限 + 改密码 + 密钥 + 退出)需 860px、容器只有 795px,「退出登录」按钮连同下面 65px 一起消失;1280x800 的桌面上同样看不到。 其余六个页面级组件(AdminUsersPage / MailView / ComposePage / ThreadView / ContactPanel / MailList)都有这一层,只有它漏了 —— 上一轮把退出登录搬进这页 之后内容变高,问题才显形。 ## 顺带:登录页与初始化页在矮屏滚不到底 卡片高约 371px,而 `h-full flex items-center` 在内容超高时让它上下**同时**溢出, 溢出到顶部那段是滚不到的(`scrollTop` 最小是 0)—— 实测 568x280(横屏手机, 或软键盘弹出后的可视高度)下「登录」按钮完全在视口外,光加 `overflow-y-auto` 也够不着。 改用卡片自己的 `my-auto` 而不是容器的 `items-center`:auto margin 在空间不足时 自动退化为 0,矮屏变成正常的顶对齐可滚布局,高屏仍然垂直居中 (390x844 与 1280x800 实测 centered=true,568x280 下滚到底能看到按钮)。 ## 两侧都加了检查 - 结构断言:七个页面级组件都必须含 `overflow-y-auto`; 登录/初始化页必须有 `my-auto` 且不用 `h-full ... items-center` - 实测脚本新增 `scrollHealth()`:每页都有滚动容器,且没有内容被 `overflow-hidden` 的父级裁掉 判据刻意是「**有**滚动容器」而不是「当前正在滚动」—— 内容暂时不够高时后者 为假,但页面是健康的;真正的 bug 是根本没有那一层。 ## 验证 窄屏实测 18 项 + 宽屏回归 5 项全通过;结构断言从 28 条扩到 37 条。 生产已部署。
This commit is contained in:
@ -157,13 +157,29 @@ ComposePage / ThreadView / KeyPanel / QuotaPanel / Attachments / BackButton。
|
||||
只有「关闭」。两者语义不同:返回退出整个详情栏回到列表,关闭只收起树、
|
||||
留在这封邮件上。补了 `BackButton`。
|
||||
|
||||
**「我的」页根本没有滚动容器(用户反馈)**
|
||||
窄屏外壳是 `h-full flex flex-col overflow-hidden`、页面是 `flex-1 flex flex-col`,
|
||||
中间缺一层 `overflow-y-auto` —— 内容超出的部分**直接被裁**,滚不到也点不到。
|
||||
实测 390px 下内容需 860px、容器 795px,「退出登录」连同下面 65px 一起消失;
|
||||
1280x800 的桌面上同样看不到。其余六个页面级组件都有这一层,只有它漏了。
|
||||
|
||||
**登录页 / 初始化页在矮屏滚不到底**
|
||||
卡片高约 371px,而 `h-full flex items-center` 在内容超高时让它上下**同时**溢出,
|
||||
溢出到顶部那段滚不到(`scrollTop` 最小是 0)—— 实测 568x280(横屏手机、
|
||||
或软键盘弹出后的可视高度)下「登录」按钮完全在视口外,光加 `overflow-y-auto`
|
||||
也够不着。改用卡片自己的 `my-auto`:auto margin 在空间不足时退化为 0,
|
||||
矮屏变成顶对齐可滚布局,高屏仍然垂直居中。
|
||||
|
||||
#### 验证
|
||||
|
||||
playwright 端到端 13 项全通过(含「底部导航六项都命中自己」、
|
||||
「工具按钮命中区 >= 44px」、四个页面无横向溢出、320px 无横向溢出);
|
||||
宽屏回归 5 项全通过(三栏并排、常驻侧栏仍有退出登录、无返回按钮、
|
||||
`.tap` 伪元素在宽屏不生效、无溢出)。
|
||||
`web/test/narrow-layout.test.mjs` 从 20 条扩到 28 条,把上述每一条都钉住。
|
||||
playwright 端到端 18 项全通过(含「底部导航六项都命中自己」、
|
||||
「工具按钮命中区 >= 44px」、五个页面各有纵向滚动容器、无横向溢出、
|
||||
320px 无横向溢出);宽屏回归 5 项全通过(三栏并排、常驻侧栏仍有退出登录、
|
||||
无返回按钮、`.tap` 伪元素在宽屏不生效、无溢出)。
|
||||
`web/test/narrow-layout.test.mjs` 从 20 条扩到 37 条,把上述每一条都钉住。
|
||||
|
||||
滚动检查的判据是「**有**滚动容器」而不是「当前正在滚动」:
|
||||
内容暂时不够高时后者为假,但页面是健康的 —— 真正的 bug 是根本没有那一层。
|
||||
|
||||
### P2 — 深色主题
|
||||
**现状**:只有浅色主题,深夜使用刺眼。
|
||||
|
||||
23
docs/PLAN.md
23
docs/PLAN.md
@ -1374,11 +1374,28 @@ MVP 计划(Phase 1-6)已全部落地并在 systemd 部署态实测通过。
|
||||
窄屏改为每级 10px、上限 5 级
|
||||
- [x] 对话树补返回出口:原先只有「关闭」,而两者语义不同 ——
|
||||
返回退出整个详情栏,关闭只收起树留在这封邮件上
|
||||
- [x] **`AccountPage` 根本没有滚动容器**(用户反馈「我的页进去后无法滑动」)。
|
||||
窄屏外壳是 `h-full flex flex-col overflow-hidden`、页面是
|
||||
`flex-1 flex flex-col`,中间没有一层 `overflow-y-auto` ——
|
||||
内容超出的部分**直接被裁**,滚不到也点不到。
|
||||
实测 390px 下内容需 860px、容器 795px,「退出登录」连同下面 65px 一起消失;
|
||||
1280x800 的桌面上同样看不到。其余六个页面级组件都有这一层,只有它漏了
|
||||
- [x] 登录页与初始化页在**矮屏**(横屏手机、软键盘弹出后)滚不到底:
|
||||
卡片高约 371px,而 `h-full flex items-center` 在内容超高时让它上下**同时**
|
||||
溢出,溢出到顶部那段滚不到(`scrollTop` 最小是 0)。实测 568x280 下
|
||||
「登录」按钮完全在视口外。改用卡片自己的 `my-auto` —— auto margin 在
|
||||
空间不足时自动退化为 0,于是矮屏变成正常的顶对齐可滚布局,
|
||||
而高屏仍然垂直居中(390x844 与 1280x800 实测 centered=true)
|
||||
|
||||
验证:`web/test/manual/`(`npm run test:narrow` / `test:wide`)
|
||||
窄屏 13 项 + 宽屏 5 项全通过;结构性断言从 20 条扩到 28 条,
|
||||
窄屏 18 项 + 宽屏 5 项全通过;结构性断言从 20 条扩到 37 条,
|
||||
把每一条修复都钉住。
|
||||
|
||||
滚动那一项两边都加了检查:结构断言查「七个页面级组件都含 overflow-y-auto」,
|
||||
实测脚本的 `scrollHealth()` 查「每页都有滚动容器且没有内容被 overflow-hidden
|
||||
的父级裁掉」。判据刻意是「**有**滚动容器」而不是「当前正在滚动」——
|
||||
内容暂时不够高时后者为假,但页面是健康的。
|
||||
|
||||
那套脚本刻意留在仓库里而不是用完就删:结构性断言守不住「按钮实际多大、
|
||||
点下去命中谁」,而这次最严重的 bug 恰好只有 `elementFromPoint` 能发现。
|
||||
它不进 `npm test` —— 要一个跑着的浏览器加一个活的 Gateway。
|
||||
@ -1389,8 +1406,8 @@ MVP 计划(Phase 1-6)已全部落地并在 systemd 部署态实测通过。
|
||||
|
||||
- 前端有 Markdown XSS 与窄屏结构性回归,但没有**渲染组件跑断言**的测试
|
||||
- 深色主题未做
|
||||
- 窄屏已用 playwright 在 390/320px 实测过(7.10.1),但那套脚本是临时的、
|
||||
没有进 CI;日常回归仍只有读源码的结构性断言
|
||||
- 窄屏实测脚本已入库(`web/test/manual/`),但没进 CI ——
|
||||
要一个 headless 环境加一个测试用 Gateway 实例
|
||||
- 登录限速与新建会话限速已改为 DB 事务(rate_limits 表),多实例部署不再各自计数
|
||||
- SQLite 抄送查询走 `json_each` 全表展开,无索引;单机量级下够用,
|
||||
百万级邮件时需要加物化列或换回 PostgreSQL
|
||||
|
||||
@ -86,139 +86,146 @@ export default function AccountPage() {
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-white">
|
||||
<div className="px-4 md:px-6 py-3 border-b border-gray-200 flex items-center gap-2">
|
||||
<div className="shrink-0 px-4 md:px-6 py-3 border-b border-gray-200 flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold text-gray-900">账号信息</h2>
|
||||
</div>
|
||||
|
||||
<div className="max-w-lg px-4 md:px-6 py-6 space-y-6">
|
||||
{/* 基本信息 */}
|
||||
<section>
|
||||
<h3 className="text-xs font-medium text-gray-500 mb-3">基本资料</h3>
|
||||
<dl className="text-sm space-y-2">
|
||||
<Row label="用户名" value={user.username} mono />
|
||||
<Row label="显示名" value={user.display_name} />
|
||||
<Row label="角色" value={user.role === 'admin' ? '管理员' : '普通用户'} />
|
||||
<Row label="状态" value={user.status === 'active' ? '启用' : '禁用'} />
|
||||
<Row label="创建时间" value={user.created_at || '-'} />
|
||||
<Row label="最后登录" value={user.last_login || '从未登录'} />
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* 权限边界 */}
|
||||
{user.role !== 'admin' && (
|
||||
{/* 滚动容器。
|
||||
缺了它的后果:这个页的内容(资料 + 权限 + 改密码 + 密钥 + 退出)
|
||||
比视口高,而父级是 overflow-hidden 的 flex 列 —— 超出那段直接被裁掉,
|
||||
没有任何办法滚到。实测 390px 下内容需 860px、容器只有 795px;
|
||||
1280x800 的桌面上同样看不到最后的「退出登录」。 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-lg px-4 md:px-6 py-6 space-y-6">
|
||||
{/* 基本信息 */}
|
||||
<section>
|
||||
<h3 className="text-xs font-medium text-gray-500 mb-3">权限范围</h3>
|
||||
<h3 className="text-xs font-medium text-gray-500 mb-3">基本资料</h3>
|
||||
<dl className="text-sm space-y-2">
|
||||
<Row
|
||||
label="可调用 Agent"
|
||||
value={
|
||||
user.allowed_agents.length === 0
|
||||
? '不限(全部可用)'
|
||||
: user.allowed_agents.join(', ')
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="可访问目录"
|
||||
value={
|
||||
user.allowed_paths.length === 0
|
||||
? '不限(全部可用)'
|
||||
: user.allowed_paths.join(', ')
|
||||
}
|
||||
/>
|
||||
<Row label="用户名" value={user.username} mono />
|
||||
<Row label="显示名" value={user.display_name} />
|
||||
<Row label="角色" value={user.role === 'admin' ? '管理员' : '普通用户'} />
|
||||
<Row label="状态" value={user.status === 'active' ? '启用' : '禁用'} />
|
||||
<Row label="创建时间" value={user.created_at || '-'} />
|
||||
<Row label="最后登录" value={user.last_login || '从未登录'} />
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 修改密码 */}
|
||||
<section>
|
||||
<h3 className="text-xs font-medium text-gray-500 mb-3 inline-flex items-center gap-1">
|
||||
<LockIcon className="w-3.5 h-3.5" />
|
||||
修改密码
|
||||
</h3>
|
||||
<form onSubmit={changePw} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">当前密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={oldPw}
|
||||
onChange={e => setOldPw(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">新密码(至少 8 位)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPw}
|
||||
onChange={e => setNewPw(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">确认新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPw}
|
||||
onChange={e => setConfirmPw(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className={`w-full text-sm border rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
|
||||
mismatch ? 'border-red-300' : 'border-gray-300 focus:border-blue-400'
|
||||
}`}
|
||||
/>
|
||||
{mismatch && <p className="mt-1 text-[10px] text-red-500">两次密码不一致</p>}
|
||||
</div>
|
||||
{/* 权限边界 */}
|
||||
{user.role !== 'admin' && (
|
||||
<section>
|
||||
<h3 className="text-xs font-medium text-gray-500 mb-3">权限范围</h3>
|
||||
<dl className="text-sm space-y-2">
|
||||
<Row
|
||||
label="可调用 Agent"
|
||||
value={
|
||||
user.allowed_agents.length === 0
|
||||
? '不限(全部可用)'
|
||||
: user.allowed_agents.join(', ')
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="可访问目录"
|
||||
value={
|
||||
user.allowed_paths.length === 0
|
||||
? '不限(全部可用)'
|
||||
: user.allowed_paths.join(', ')
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{msg && (
|
||||
<p className="text-xs text-green-600 bg-green-50 border border-green-100 rounded-md px-2.5 py-1.5">
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
{/* 修改密码 */}
|
||||
<section>
|
||||
<h3 className="text-xs font-medium text-gray-500 mb-3 inline-flex items-center gap-1">
|
||||
<LockIcon className="w-3.5 h-3.5" />
|
||||
修改密码
|
||||
</h3>
|
||||
<form onSubmit={changePw} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">当前密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={oldPw}
|
||||
onChange={e => setOldPw(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">新密码(至少 8 位)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPw}
|
||||
onChange={e => setNewPw(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">确认新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPw}
|
||||
onChange={e => setConfirmPw(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className={`w-full text-sm border rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
|
||||
mismatch ? 'border-red-300' : 'border-gray-300 focus:border-blue-400'
|
||||
}`}
|
||||
/>
|
||||
{mismatch && <p className="mt-1 text-[10px] text-red-500">两次密码不一致</p>}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{msg && (
|
||||
<p className="text-xs text-green-600 bg-green-50 border border-green-100 rounded-md px-2.5 py-1.5">
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="px-4 py-1.5 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{busy ? '保存中' : '保存'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* 客户端连接密钥 */}
|
||||
<section className="border-t border-gray-200 pt-6">
|
||||
<KeyPanel
|
||||
variant="user"
|
||||
keys={keys}
|
||||
loading={keyBusy}
|
||||
error={keyError}
|
||||
newToken={newToken}
|
||||
onCreate={createKey}
|
||||
onDelete={deleteKey}
|
||||
onDismissToken={() => setNewToken(null)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 退出登录。
|
||||
放在这里而不是导航里:它是一个低频且不可逆的动作,
|
||||
与密码、密钥同属「账号自身」。窄屏下这也是唯一的退出口:
|
||||
抽屉式侧栏已删(它的其余入口与底部导航完全重复)。 */}
|
||||
<section className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-xs font-medium text-gray-500 mb-3">登录状态</h3>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="px-4 py-1.5 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
onClick={logout}
|
||||
className="tap inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50 active:bg-gray-100 transition-colors"
|
||||
>
|
||||
{busy ? '保存中' : '保存'}
|
||||
<LogoutIcon className="w-4 h-4" />
|
||||
退出登录
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* 客户端连接密钥 */}
|
||||
<section className="border-t border-gray-200 pt-6">
|
||||
<KeyPanel
|
||||
variant="user"
|
||||
keys={keys}
|
||||
loading={keyBusy}
|
||||
error={keyError}
|
||||
newToken={newToken}
|
||||
onCreate={createKey}
|
||||
onDelete={deleteKey}
|
||||
onDismissToken={() => setNewToken(null)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 退出登录。
|
||||
放在这里而不是导航里:它是一个低频且不可逆的动作,
|
||||
与密码、密钥同属「账号自身」。窄屏下这也是唯一的退出口:
|
||||
抽屉式侧栏已删(它的其余入口与底部导航完全重复)。 */}
|
||||
<section className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-xs font-medium text-gray-500 mb-3">登录状态</h3>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50 active:bg-gray-100 transition-colors"
|
||||
>
|
||||
<LogoutIcon className="w-4 h-4" />
|
||||
退出登录
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -45,9 +45,15 @@ export default function LoginPage() {
|
||||
if (!ok) setPassword('');
|
||||
};
|
||||
|
||||
// 卡片高约 371px,比横屏手机(或软键盘弹出后)的可视高度还高。
|
||||
//
|
||||
// 居中用卡片自己的 `my-auto` 而**不是**容器的 `items-center`:后者在内容超高时
|
||||
// 会让卡片上下同时溢出,而溢出到顶部那段滚不到(scrollTop 最小是 0)——
|
||||
// 实测 568x280 下「登录」按钮完全在视口外,光加 overflow-y-auto 也够不着。
|
||||
// auto margin 在空间不足时自动退化为 0,于是矮屏变成正常的顶对齐可滚布局。
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-slate-100">
|
||||
<div className="w-[380px] max-w-[92vw] bg-white rounded-xl shadow-sm border border-gray-200 p-8">
|
||||
<div className="h-full overflow-y-auto flex justify-center bg-slate-100">
|
||||
<div className="w-[380px] max-w-[92vw] shrink-0 my-auto bg-white rounded-xl shadow-sm border border-gray-200 p-6 sm:p-8">
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center">
|
||||
<MailboxIcon className="w-6 h-6" />
|
||||
|
||||
@ -45,9 +45,15 @@ export default function SetupPage({ onDone }: { onDone: () => void }) {
|
||||
}
|
||||
};
|
||||
|
||||
// 卡片高约 371px,比横屏手机(或软键盘弹出后)的可视高度还高。
|
||||
//
|
||||
// 居中用卡片自己的 `my-auto` 而**不是**容器的 `items-center`:后者在内容超高时
|
||||
// 会让卡片上下同时溢出,而溢出到顶部那段滚不到(scrollTop 最小是 0)——
|
||||
// 实测 568x280 下「登录」按钮完全在视口外,光加 overflow-y-auto 也够不着。
|
||||
// auto margin 在空间不足时自动退化为 0,于是矮屏变成正常的顶对齐可滚布局。
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-slate-100">
|
||||
<div className="w-[420px] max-w-[92vw] bg-white rounded-xl shadow-sm border border-gray-200 p-8">
|
||||
<div className="h-full overflow-y-auto flex justify-center bg-slate-100">
|
||||
<div className="w-[420px] max-w-[92vw] shrink-0 my-auto bg-white rounded-xl shadow-sm border border-gray-200 p-6 sm:p-8">
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center">
|
||||
<MailboxIcon className="w-6 h-6" />
|
||||
|
||||
@ -171,3 +171,50 @@ export async function hitTest(page, selector) {
|
||||
return out;
|
||||
}, selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个页面是否**有**纵向滚动容器。
|
||||
*
|
||||
* 判据是「存在 overflow-y:auto|scroll 的容器」,不是「当前正在滚动」——
|
||||
* 内容暂时不够高时后者为假,但页面是健康的。真正的 bug 是**根本没有**滚动
|
||||
* 容器:内容一旦超过视口就被 `overflow-hidden` 的父级裁掉,没有任何办法看到。
|
||||
*
|
||||
* 「我的」页就是这样坏的:内容(资料+权限+改密码+密钥+退出)在 390px 下需要
|
||||
* 860px,容器只有 795px,超出那 65px 连同「退出登录」按钮一起消失。
|
||||
*
|
||||
* @returns {{ hasScroller: boolean, scrollers: object[], clipped: object[] }}
|
||||
*/
|
||||
export async function scrollHealth(page) {
|
||||
return await page.evaluate(() => {
|
||||
const root = document.querySelector('#root');
|
||||
const scrollers = [];
|
||||
for (const el of root.querySelectorAll('*')) {
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.overflowY === 'auto' || cs.overflowY === 'scroll') {
|
||||
scrollers.push({
|
||||
cls: (el.className || '').toString().slice(0, 45),
|
||||
scrollH: el.scrollHeight,
|
||||
clientH: el.clientHeight,
|
||||
needsScroll: el.scrollHeight > el.clientHeight + 4
|
||||
});
|
||||
}
|
||||
}
|
||||
// 内容超出但被 overflow:hidden 的父级裁掉 —— 这才是真正的问题
|
||||
const clipped = [];
|
||||
for (const el of root.querySelectorAll('*')) {
|
||||
const cs = getComputedStyle(el);
|
||||
if (el.scrollHeight > el.clientHeight + 20 && cs.overflowY === 'visible') {
|
||||
const p = el.parentElement;
|
||||
const pcs = p ? getComputedStyle(p) : null;
|
||||
if (pcs && (pcs.overflow === 'hidden' || pcs.overflowY === 'hidden')) {
|
||||
clipped.push({
|
||||
cls: (el.className || '').toString().slice(0, 50),
|
||||
have: el.clientHeight,
|
||||
need: el.scrollHeight
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { hasScroller: scrollers.length > 0, scrollers, clipped: clipped.slice(0, 4) };
|
||||
});
|
||||
}
|
||||
|
||||
@ -7,7 +7,14 @@
|
||||
*
|
||||
* 用法:ADMIN_PW=<密码> node web/test/manual/narrow-verify.mjs
|
||||
*/
|
||||
import { openApp, overflowX, tapTargets, hitTest, SMALL } from './narrow-probe-helper.mjs';
|
||||
import {
|
||||
openApp,
|
||||
overflowX,
|
||||
tapTargets,
|
||||
hitTest,
|
||||
scrollHealth,
|
||||
SMALL
|
||||
} from './narrow-probe-helper.mjs';
|
||||
|
||||
const { browser, page, issues } = await openApp();
|
||||
const failed = [];
|
||||
@ -115,6 +122,24 @@ for (const label of ['收件', '联系人', '管理', '我的']) {
|
||||
});
|
||||
}
|
||||
|
||||
// 每个页面都必须有纵向滚动容器 —— 否则内容一超过视口就被裁掉看不到。
|
||||
// 「我的」页曾经缺这个:390px 下内容需 860px、容器 795px,
|
||||
// 「退出登录」按钮连同下面 65px 一起消失,滚也滚不到。
|
||||
for (const label of ['收件', '发件', '联系人', '管理', '我的']) {
|
||||
await check(`${label}页有纵向滚动容器`, async () => {
|
||||
await page.click(`nav button:has-text("${label}")`);
|
||||
await page.waitForTimeout(1200);
|
||||
const h = await scrollHealth(page);
|
||||
for (const c of h.clipped) console.log(' 被裁:', JSON.stringify(c));
|
||||
return {
|
||||
ok: h.hasScroller && h.clipped.length === 0,
|
||||
note: h.hasScroller
|
||||
? `${h.scrollers.length} 个容器${h.clipped.length ? ',但有内容被裁' : ''}`
|
||||
: '没有滚动容器'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n最窄(320px):');
|
||||
await page.setViewportSize(SMALL);
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
@ -134,6 +134,31 @@ const thread = read('../src/components/ThreadView.tsx');
|
||||
check('对话树缩进随屏宽自适应', thread.includes('useIsNarrow') && /narrow \? 10 : 20/.test(thread));
|
||||
check('对话树窄屏有返回出口', thread.includes('<BackButton'));
|
||||
|
||||
// 5.6) 每个页面级组件都要有纵向滚动容器。
|
||||
// 窄屏外壳是 `h-full flex flex-col overflow-hidden`,页面本身是
|
||||
// `flex-1 min-w-0 flex flex-col` —— 内容超过视口时**没有任何办法滚到**,
|
||||
// 超出那段直接被裁。AccountPage 曾经就缺这个:390px 下内容需 860px、
|
||||
// 容器 795px,「退出登录」按钮连同下面 65px 一起消失。
|
||||
// 判据是「存在 overflow-y-auto」,不是「当前正在滚动」——
|
||||
// 内容暂时不够高时后者为假,但页面是健康的。
|
||||
for (const f of ['AccountPage', 'AdminUsersPage', 'MailView', 'ComposePage', 'ThreadView', 'ContactPanel', 'MailList']) {
|
||||
const src = read(`../src/components/${f}.tsx`);
|
||||
check(`${f} 有纵向滚动容器`, src.includes('overflow-y-auto'));
|
||||
}
|
||||
|
||||
// 5.7) 居中的单卡片页(登录 / 初始化)在矮屏必须能滚到底。
|
||||
// `items-center` 在内容超高时让卡片上下同时溢出,而溢出到顶部那段
|
||||
// 滚不到(scrollTop 最小是 0)—— 实测 568x280 下「登录」按钮完全在
|
||||
// 视口外。改用卡片自己的 my-auto:空间不足时 auto margin 退化为 0。
|
||||
for (const f of ['LoginPage', 'SetupPage']) {
|
||||
const src = read(`../src/components/${f}.tsx`);
|
||||
check(
|
||||
`${f} 矮屏可滚且不用 items-center 居中`,
|
||||
src.includes('overflow-y-auto') && src.includes('my-auto') &&
|
||||
!/h-full[^"]*items-center/.test(src)
|
||||
);
|
||||
}
|
||||
|
||||
// 6) 横向内边距在窄屏收窄(px-6 在 375px 屏上白吃 48px)
|
||||
const wide = ['MailView', 'ComposePage', 'ThreadView', 'AccountPage', 'AdminUsersPage'];
|
||||
for (const f of wide) {
|
||||
|
||||
Reference in New Issue
Block a user