fix(webui): 宽屏也同步折叠策略 —— 头部与回复框默认收起(正文 48% → 90%)

用户(2026-09-14):「我发现宽屏布局也有邮件内容显示区域过小的问题,
宽屏也同步窄屏的折叠策略吧」。

上一版是「窄屏默认收起 / 宽屏恒展开」,理由是「宽屏横向空间够、不该引入多余
点击」。那条理由只看了**横向**:实测 1280x800 下详情栏里头部 139px(17%)、
回复框 246px(31%),留给正文的只剩 385px(48%)—— 竖向一样不够用。

改法:两种宽度用同一套默认值(收起),点标题行 / 悬浮球展开。
- CollapsibleHeader:useState(false),toggle 不再以 narrow 为条件,展开箭头常显;
- ReplyBar:收起态恒为悬浮球(原来只有窄屏才收),「收起」按钮宽窄都有,
  autoFocus 不再以 narrow 为条件;
- MailView 两个根节点补 relative —— 否则宽屏下 `absolute` 会一路找到视口,
  悬浮球会挂到窗口右下角而不是这块详情栏里。

实测(真浏览器量盒子,1280x800,同一封正文撑满的长信):
  头部 139 → 48px,回复框 246 → 0(收起为球),正文 385 → 722px(占视口 48% → 90%);
  点标题展开 / 点球开输入框(带焦点)/「收起」收回,三条都通。
窄屏 390x844 同项通过,横向溢出 0。

判据:narrow-layout 里三条旧判据钉的是「窄屏专属」,已改成钉「宽窄同一套」,
并新增两条(折叠分支里不得再有 `narrow &&`;悬浮球必须锚在详情栏内)。
wide-regression 补 6 条宽屏实测 —— 此前宽屏的折叠行为**一条判据都没有**,
这正是它坏掉而没人发现的原因。

已知遗留(另行报告,本次未改):窄屏上回复框展开后,第一次点它上面的按钮
(抄送 / 回复全部 / 收起)会被吞掉 —— 焦点离开 textarea 会让 .form-editing
失效、底部导航重新占位 71px,按钮在 mousedown 与 mouseup 之间从指针下移走,
click 目标退化成公共祖先。HEAD 上已存在,与本次改动无关。
This commit is contained in:
2026-09-14 15:38:49 +08:00
parent ccd0a39e76
commit 7ef3ea7306
4 changed files with 142 additions and 50 deletions

View File

@ -69,8 +69,10 @@ export default function MailView() {
// 人在这里打字就是「给这次任务的对方追加一句」,而最后一封很可能是自己刚发的,
// 那时取对端会取成自己 —— 信就发给了自己(生产已发生)。
const sessionTarget = sessionReplyTarget(currentSessionMails, currentSession, me);
// relative 是给收起态的回复悬浮球定位用的(见 ReplyBar 的说明):
// 宽屏下没有它,球会挂到视口右下角,而不是这块详情栏的右下角。
return (
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
<div className="relative flex-1 min-w-0 flex flex-col bg-gray-50">
<CollapsibleHeader
narrow={narrow}
lead={<BackButton label="会话" />}
@ -131,7 +133,7 @@ export default function MailView() {
}
return (
<div className="flex-1 min-w-0 flex flex-col bg-white">
<div className="relative flex-1 min-w-0 flex flex-col bg-white">
<Header
mail={currentMail}
onRead={() => markRead(currentMail.mail_id)}
@ -553,18 +555,27 @@ function Header({
}
/**
* 窄屏可折叠头部(邮件详情 / 会话)。
* 可折叠头部(邮件详情 / 会话)。
*
* 用户(2026-09-14):「窄屏页面阅读邮件时,顶部的邮件信息和底部的输入框等
* 占用了绝大部分页面,用户只能通过中间的一小块看邮件,体验十分不好,
* 这些位置应当有一个自动隐藏的逻辑,比如上面缩为窄栏,只显示邮件标题,
* 点击展开显示完整信息」。
*
* 做法:窄屏**默认收起**,只留一行标题(+ 未读/权限这类必须常驻的状态点),
* 点标题行展开完整信息与操作。宽屏没有这个矛盾(横向空间够),所以宽屏恒展开、
* 不引入多余点击 —— 同一套头部代码,两种默认值。
* 做法:**默认收起**,只留一行标题(+ 未读/权限这类必须常驻的状态点),
* 点标题行展开完整信息与操作。
*
* 跨过断点时要重置(宽 → 窄应自动收起),否则「自动隐藏」只在首次生效。
* ★ 默认值**不再分窄宽**(2026-09-14 用户:「我发现宽屏布局也有邮件内容
* 显示区域过小的问题,宽屏也同步窄屏的折叠策略吧」)。
*
* 上一版是「窄屏默认收起 / 宽屏恒展开」,理由是「宽屏横向空间够,不该引入
* 多余点击」。那条理由只看了**横向**:实测 1280x800 下,详情栏里头部 139px
* 占 17%、回复框 246px 占 31%,留给正文的只剩 385px(48%)—— 竖向一样不够用。
* 而邮件的用途是读,头和输入框都不是每时每刻都要用的东西,所以两种宽度用
* **同一套策略**:默认收起、点一下展开。
*
* 跨过断点回到默认(收起):宽度变了说明使用场景变了(横竖屏、分屏),
* 沿用上一个场景里手动展开的状态,会让人在更小的屏上莫名少掉正文空间。
*/
function CollapsibleHeader({
narrow,
@ -582,8 +593,8 @@ function CollapsibleHeader({
/** 展开后才显示的内容(收发件人、时间、操作按钮…) */
children: React.ReactNode;
}) {
const [open, setOpen] = useState(!narrow);
useEffect(() => setOpen(!narrow), [narrow]);
const [open, setOpen] = useState(false);
useEffect(() => setOpen(false), [narrow]);
return (
<div className="glass-card shrink-0 mx-2.5 mt-2.5 px-3.5 md:px-4 py-2.5 md:py-3">
@ -591,23 +602,20 @@ function CollapsibleHeader({
{lead}
<button
type="button"
// 宽屏没有可展开的东西(已经全展开了),此时点它不该有任何副作用
onClick={() => narrow && setOpen(o => !o)}
onClick={() => setOpen(o => !o)}
aria-expanded={open}
aria-label={narrow ? (open ? '收起邮件信息' : '展开邮件信息') : undefined}
className={`min-w-0 flex-1 flex items-center gap-2 text-left ${narrow ? 'tap' : 'cursor-default'}`}
aria-label={open ? '收起邮件信息' : '展开邮件信息'}
className="tap min-w-0 flex-1 flex items-center gap-2 text-left"
>
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-gray-900">
{title}
</span>
{meta}
{narrow && (
<ChevronRightIcon
className={`w-4 h-4 shrink-0 text-gray-400 transition-transform duration-200 motion-reduce:transition-none ${
open ? 'rotate-90' : ''
}`}
/>
)}
<ChevronRightIcon
className={`w-4 h-4 shrink-0 text-gray-400 transition-transform duration-200 motion-reduce:transition-none ${
open ? 'rotate-90' : ''
}`}
/>
</button>
</div>
{open && <div className="mt-1.5">{children}</div>}
@ -891,9 +899,10 @@ function ReplyBar({
const setBudget = useSessionStore(s => s.setBudget);
const me = useAuthStore(s => s.user?.username || '');
const narrow = useIsNarrow();
// 窄屏默认收起(只留悬浮球);宽屏恒展开。跨断点时重置,否则“自动隐藏”只在首次生效。
const [open, setOpen] = useState(!narrow);
useEffect(() => setOpen(!narrow), [narrow]);
// 默认收起(只留悬浮球),宽窄同一套 —— 理由见 CollapsibleHeader 的说明。
// 跨断点回到默认,否则“自动隐藏”只在首次生效。
const [open, setOpen] = useState(false);
useEffect(() => setOpen(false), [narrow]);
if (!replyTo) return null;
@ -902,12 +911,16 @@ function ReplyBar({
const peer = mailCounterpart(replyTo, me);
const target = overrideTarget || mailReplyTarget(replyTo, me);
// 窄屏收起态:一个悬浮球,点开才出现输入区。
// 收起态:一个悬浮球,点开才出现输入区。
//
// 窄屏上「顶部邮件信息 + 底部输入框」同时常驻会把可读区压成一条缝(用户实测反馈)。
// 输入框不是阅读时每刻都要用的东西,就该按需展开;球放右下、悬浮于底部导航之上,
// 与列表页的 ComposeFab 同一套观感,不新增一种视觉语言。
if (narrow && !open) {
// 「顶部邮件信息 + 底部输入框」同时常驻会把可读区压成一条缝(用户实测反馈),
// 窄屏与宽屏都成立(1280x800 下实测回复框 246px,占 31%)。输入框不是阅读时
// 每刻都要用的东西,就该按需展开;球放右下、悬浮于底部内容之上,与列表页的
// ComposeFab 同一套观感,不新增一种视觉语言。
//
// 球的定位靠 MailView 两个根节点上的 `relative`:宽屏没有它,
// `absolute` 会一路找到视口,球就挂到窗口右下角而不是这块详情栏的右下角。
if (!open) {
return (
<button
type="button"
@ -983,16 +996,14 @@ function ReplyBar({
>
{ccOpen ? '收起抄送' : '抄送'}
</button>
{narrow && (
<button
onClick={() => setOpen(false)}
className="tap inline-flex items-center gap-0.5 text-3xs text-gray-500 hover:text-blue-600"
aria-label="收起回复框"
>
收起
<ChevronRightIcon className="w-3 h-3 rotate-90" />
</button>
)}
<button
onClick={() => setOpen(false)}
className="tap inline-flex items-center gap-0.5 text-3xs text-gray-500 hover:text-blue-600"
aria-label="收起回复框"
>
收起
<ChevronRightIcon className="w-3 h-3 rotate-90" />
</button>
</div>
{ccOpen && (
<div className="mb-2">
@ -1012,7 +1023,7 @@ function ReplyBar({
onChange={setBody}
placeholder="回复内容(Markdown)"
density="roomy"
autoFocus={narrow}
autoFocus
error={error}
submit={{
label: '发送',