diff --git a/docs/PHASE7-REMAINING.md b/docs/PHASE7-REMAINING.md index 510d6f2..ef0930b 100644 --- a/docs/PHASE7-REMAINING.md +++ b/docs/PHASE7-REMAINING.md @@ -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 — 深色主题 **现状**:只有浅色主题,深夜使用刺眼。 diff --git a/docs/PLAN.md b/docs/PLAN.md index c35bc65..efa4652 100644 --- a/docs/PLAN.md +++ b/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 diff --git a/web/src/components/AccountPage.tsx b/web/src/components/AccountPage.tsx index 6dc9358..170d3d4 100644 --- a/web/src/components/AccountPage.tsx +++ b/web/src/components/AccountPage.tsx @@ -86,139 +86,146 @@ export default function AccountPage() { return (
-
+

账号信息

-
- {/* 基本信息 */} -
-

基本资料

-
- - - - - - -
-
- - {/* 权限边界 */} - {user.role !== 'admin' && ( + {/* 滚动容器。 + 缺了它的后果:这个页的内容(资料 + 权限 + 改密码 + 密钥 + 退出) + 比视口高,而父级是 overflow-hidden 的 flex 列 —— 超出那段直接被裁掉, + 没有任何办法滚到。实测 390px 下内容需 860px、容器只有 795px; + 1280x800 的桌面上同样看不到最后的「退出登录」。 */} +
+
+ {/* 基本信息 */}
-

权限范围

+

基本资料

- - + + + + + +
- )} - {/* 修改密码 */} -
-

- - 修改密码 -

-
-
- - 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" - /> -
-
- - 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" - /> -
-
- - 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 &&

两次密码不一致

} -
+ {/* 权限边界 */} + {user.role !== 'admin' && ( +
+

权限范围

+
+ + +
+
+ )} - {error && ( -

- {error} -

- )} - {msg && ( -

- {msg} -

- )} + {/* 修改密码 */} +
+

+ + 修改密码 +

+ +
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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 &&

两次密码不一致

} +
+ {error && ( +

+ {error} +

+ )} + {msg && ( +

+ {msg} +

+ )} + + + +
+ + {/* 客户端连接密钥 */} +
+ setNewToken(null)} + /> +
+ + {/* 退出登录。 + 放在这里而不是导航里:它是一个低频且不可逆的动作, + 与密码、密钥同属「账号自身」。窄屏下这也是唯一的退出口: + 抽屉式侧栏已删(它的其余入口与底部导航完全重复)。 */} +
+

登录状态

- -
- - {/* 客户端连接密钥 */} -
- setNewToken(null)} - /> -
- - {/* 退出登录。 - 放在这里而不是导航里:它是一个低频且不可逆的动作, - 与密码、密钥同属「账号自身」。窄屏下这也是唯一的退出口: - 抽屉式侧栏已删(它的其余入口与底部导航完全重复)。 */} -
-

登录状态

- -
+
+
); diff --git a/web/src/components/LoginPage.tsx b/web/src/components/LoginPage.tsx index 2bbb91a..94e8d09 100644 --- a/web/src/components/LoginPage.tsx +++ b/web/src/components/LoginPage.tsx @@ -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 ( -
-
+
+
diff --git a/web/src/components/SetupPage.tsx b/web/src/components/SetupPage.tsx index 053e363..f2dc063 100644 --- a/web/src/components/SetupPage.tsx +++ b/web/src/components/SetupPage.tsx @@ -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 ( -
-
+
+
diff --git a/web/test/manual/narrow-probe-helper.mjs b/web/test/manual/narrow-probe-helper.mjs index 0cb5933..ffd1394 100644 --- a/web/test/manual/narrow-probe-helper.mjs +++ b/web/test/manual/narrow-probe-helper.mjs @@ -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) }; + }); +} diff --git a/web/test/manual/narrow-verify.mjs b/web/test/manual/narrow-verify.mjs index b744d9d..bf75d89 100644 --- a/web/test/manual/narrow-verify.mjs +++ b/web/test/manual/narrow-verify.mjs @@ -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); diff --git a/web/test/narrow-layout.test.mjs b/web/test/narrow-layout.test.mjs index 3e4834f..da7d4fc 100644 --- a/web/test/narrow-layout.test.mjs +++ b/web/test/narrow-layout.test.mjs @@ -134,6 +134,31 @@ const thread = read('../src/components/ThreadView.tsx'); check('对话树缩进随屏宽自适应', thread.includes('useIsNarrow') && /narrow \? 10 : 20/.test(thread)); check('对话树窄屏有返回出口', thread.includes('