diff --git a/client/electron/src/components/MailView.tsx b/client/electron/src/components/MailView.tsx index cf249cc..09cff76 100644 --- a/client/electron/src/components/MailView.tsx +++ b/client/electron/src/components/MailView.tsx @@ -69,8 +69,10 @@ export default function MailView() { // 人在这里打字就是「给这次任务的对方追加一句」,而最后一封很可能是自己刚发的, // 那时取对端会取成自己 —— 信就发给了自己(生产已发生)。 const sessionTarget = sessionReplyTarget(currentSessionMails, currentSession, me); + // relative 是给收起态的回复悬浮球定位用的(见 ReplyBar 的说明): + // 宽屏下没有它,球会挂到视口右下角,而不是这块详情栏的右下角。 return ( -
+
} @@ -131,7 +133,7 @@ export default function MailView() { } return ( -
+
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 (
@@ -591,23 +602,20 @@ function CollapsibleHeader({ {lead}
{open &&
{children}
} @@ -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 ( - {narrow && ( - - )} +
{ccOpen && (
@@ -1012,7 +1023,7 @@ function ReplyBar({ onChange={setBody} placeholder="回复内容(Markdown)" density="roomy" - autoFocus={narrow} + autoFocus error={error} submit={{ label: '发送', diff --git a/client/electron/test/manual/wide-regression.mjs b/client/electron/test/manual/wide-regression.mjs index 7d3214b..f882bb0 100644 --- a/client/electron/test/manual/wide-regression.mjs +++ b/client/electron/test/manual/wide-regression.mjs @@ -63,6 +63,67 @@ const of = await page.evaluate(() => ({ })); chk('无横向溢出', of.s <= of.d, `doc=${of.d} scroll=${of.s}`); +/* + * 折叠策略在**宽屏**同样生效(2026-09-14 用户:「我发现宽屏布局也有邮件内容 + * 显示区域过小的问题,宽屏也同步窄屏的折叠策略吧」)。 + * + * 为什么必须量真实的盒子:上一版宽屏是「恒展开」,而头部 139px(17%) + 回复框 + * 246px(31%) 把正文挤到只剩 385px(48%)。那时**没有任何判据会红** —— + * narrow-layout 只判窄屏,而宽屏这边一条断言都没有。 + */ +const fold = await page.evaluate(() => { + const px = n => Math.round(n); + const exp = document.querySelector('[aria-expanded]'); + const header = exp ? exp.closest('.glass-card') : null; + let scroller = document.querySelector('.markdown'); + while (scroller && !['auto', 'scroll'].includes(getComputedStyle(scroller).overflowY)) { + scroller = scroller.parentElement; + } + const fab = document.querySelector('[data-testid="reply-fab"]'); + const pane = document.querySelector('#root > div > div:last-child'); + const R = el => (el ? el.getBoundingClientRect() : null); + const hb = R(header), sb = R(scroller), fb = R(fab), pb = R(pane); + return { + vh: px(window.innerHeight), + expanded: exp ? exp.getAttribute('aria-expanded') : null, + headerH: hb ? px(hb.height) : 0, + scrollerH: sb ? px(sb.height) : 0, + fab: fb ? { x: px(fb.x), y: px(fb.y), w: px(fb.width), h: px(fb.height) } : null, + pane: pb ? { x: px(pb.x), y: px(pb.y), w: px(pb.width), h: px(pb.height) } : null, + hasTextarea: !!document.querySelector('textarea') + }; +}); + +chk('宽屏头部也默认收起', fold.expanded === 'false', String(fold.expanded)); +chk( + '宽屏正文占视口 80% 以上', + fold.scrollerH / fold.vh >= 0.8, + `${Math.round((fold.scrollerH / fold.vh) * 100)}%(收起态实测 90%)` +); +chk('宽屏回复框也默认收起、只剩悬浮球', fold.fab !== null && !fold.hasTextarea); +chk( + '悬浮球在详情栏内(不是视口右下角)', + fold.fab && fold.pane && + fold.fab.x >= fold.pane.x && fold.fab.y >= fold.pane.y && + fold.fab.x + fold.fab.w <= fold.pane.x + fold.pane.w + 1 && + fold.fab.y + fold.fab.h <= fold.pane.y + fold.pane.h + 1, + `球=${JSON.stringify(fold.fab)} 栏=${JSON.stringify(fold.pane)}` +); + +// 点标题行要真的展开(“默认收起”不能变成“永远打不开”) +await page.locator('[aria-expanded]').click(); +await page.waitForTimeout(500); +const afterExpand = await page.evaluate(() => ({ + expanded: document.querySelector('[aria-expanded]')?.getAttribute('aria-expanded'), + hasFrom: document.body.innerText.includes('发件') +})); +chk('宽屏点标题行能展开并看到收发件人', afterExpand.expanded === 'true' && afterExpand.hasFrom); + +// 点悬浮球要能打开输入框 +await page.locator('[data-testid="reply-fab"]').click(); +await page.waitForTimeout(500); +chk('宽屏点悬浮球能展开输入框(带焦点)', await page.evaluate(() => document.activeElement?.tagName === 'TEXTAREA')); + console.log('\nissues:', issues.length ? issues : '无'); console.log(failed.length === 0 ? '\n宽屏回归:全部通过' : `\n宽屏回归:${failed.length} 项失败`); diff --git a/client/electron/test/narrow-layout.test.mjs b/client/electron/test/narrow-layout.test.mjs index b9a35c2..efda5df 100644 --- a/client/electron/test/narrow-layout.test.mjs +++ b/client/electron/test/narrow-layout.test.mjs @@ -182,20 +182,27 @@ for (const f of wide) { check(`${f} 没有裸 px-6(应为 px-4 md:px-6)`, bare.length === 0, `发现 ${bare.length} 处`); } -// 7) 窄屏阅读:顶部只留标题、底部收起为悬浮球 +// 7) 阅读态:顶部只留标题、底部收起为悬浮球(**窄屏与宽屏同一套**) // // 用户(2026-09-14):「窄屏页面阅读邮件时,顶部的邮件信息和底部的输入框等 // 占用了绝大部分页面,用户只能通过中间的一小块看邮件……上面缩为窄栏,只显示 // 邮件标题,点击展开显示完整信息,底部一个悬浮的聊天图标的球,点一下展开输入框」。 // -// 这两处坏掉的形态很隐蔽:组件照样渲染、宽屏一切正常,只是窄屏可读区被挤成一条缝。 -// 所以在结构层钉住「窄屏默认收起 + 有展开出口 + 有收回归口」。 +// ★ 同日追加:「我发现宽屏布局也有邮件内容显示区域过小的问题,宽屏也同步 +// 窄屏的折叠策略吧」。上一版是「窄屏收起 / 宽屏恒展开」—— 那条只看了**横向**, +// 而实测 1280x800 下头部 139px(17%) + 回复框 246px(31%),正文只剩 385px(48%)。 +// 所以现在两种宽度**同一套默认值**,判据也改成钉「不再按窄宽分叉」。 +// +// 这两处坏掉的形态很隐蔽:组件照样渲染、只是可读区被挤成一条缝。 +// 所以在结构层钉住「默认收起 + 宽窄同一套 + 有展开出口 + 有收回归口」。 const mv = read('../src/components/MailView.tsx'); check( - '头部窄屏默认收起(useState(!narrow) + 断点变化时重置)', - /useState\(!narrow\)/.test(mv) && /setOpen\(!narrow\)/.test(mv), - '缺少「窄屏默认收起」或跨断点重置 —— 自动隐藏只在首次生效' + '头部默认收起,且**不再按窄宽分叉**(旧写法 useState(!narrow) 已消失)', + /const \[open, setOpen\] = useState\(false\)/.test(mv) && + /useEffect\(\(\) => setOpen\(false\), \[narrow\]\)/.test(mv) && + !/useState\(!narrow\)/.test(mv), + '头部还在按窄/宽分叉默认值 —— 宽屏竖向空间一样不够' ); check( '头部收起态保留标题与状态点,展开才有完整信息', @@ -204,15 +211,28 @@ check( mv.includes('{open &&
{children}
}') ); check( - '回复框窄屏收起为悬浮球(reply-fab),点开才展开', + '回复框默认收起为悬浮球(reply-fab),框自身不带 narrow 条件', mv.includes('data-testid="reply-fab"') && - mv.includes('narrow && !open') && + /if \(!open\) \{/.test(mv) && + !/narrow && !open/.test(mv) && mv.includes('absolute bottom-4 right-4 z-20') && mv.includes('