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:
2026-09-03 07:39:52 +08:00
parent 342282b92c
commit 514f443e54
8 changed files with 282 additions and 133 deletions

View File

@ -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>
);

View File

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

View File

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