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:
@ -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' : ''
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</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,7 +996,6 @@ 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"
|
||||
@ -992,7 +1004,6 @@ function ReplyBar({
|
||||
收起
|
||||
<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: '发送',
|
||||
|
||||
@ -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} 项失败`);
|
||||
|
||||
|
||||
@ -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 && <div className="mt-1.5">{children}</div>}')
|
||||
);
|
||||
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('<ChatBubbleIcon')
|
||||
);
|
||||
check(
|
||||
'回复框展开后有收回归口,且点开时焦点落到输入框',
|
||||
mv.includes('aria-label="收起回复框"') && mv.includes('autoFocus={narrow}')
|
||||
'回复框展开后有收回归口(宽窄都有,不再只在窄屏渲染),点开时焦点落到输入框',
|
||||
mv.includes('aria-label="收起回复框"') &&
|
||||
// 焦点:原来是 autoFocus={narrow},宽屏点开悬浮球就不会聚焦
|
||||
/autoFocus(?![={])/.test(mv)
|
||||
);
|
||||
check(
|
||||
'折叠控件里没有一处只在窄屏生效(narrow && … 的折叠分支已清空)',
|
||||
!/narrow &&/.test(mv),
|
||||
'MailView 里仍有 narrow && 的折叠分支 —— 宽屏会退化成“点不动”'
|
||||
);
|
||||
check(
|
||||
'回复悬浮球锁定在**详情栏**内(两个根节点带 relative)',
|
||||
(mv.match(/className="relative flex-1 min-w-0 flex flex-col bg-/g) || []).length === 2,
|
||||
'少了 relative:宽屏下 absolute 会一路找到视口,球挂到窗口右下角而不是详情栏'
|
||||
);
|
||||
check(
|
||||
'悬浮球与列表页新建球同一套观感(w-14 h-14 rounded-full)',
|
||||
|
||||
@ -58,7 +58,7 @@ for (const must of ['配对/解析', 'allow-list', '变异验证', '剥掉注释
|
||||
|
||||
const SUITE = [
|
||||
['test/markdown-xss.test.mjs', [], 9],
|
||||
['test/narrow-layout.test.mjs', [], 52],
|
||||
['test/narrow-layout.test.mjs', [], 54],
|
||||
['test/nav-merge.test.mjs', [], 8],
|
||||
['test/theme.test.mjs', [], 30],
|
||||
['test/background.test.mjs', [], 42],
|
||||
|
||||
Reference in New Issue
Block a user