test(webui): 横竖屏判据跟上新契约(横屏=侧边导航、列表固定 320)

`rotate-verify.mjs` 10/10。途中判据自己错了两次,都记在这里:

1. `content` 选择器取 `.narrow-shell > *:not(.narrow-nav)` —— 横屏改用侧边导航后,
   第一个子元素变成了 60px 侧栏 ⇒ 把侧栏当内容区来判断排布,报出一条假失败。
   改成同时排除 `.bg-chrome-900`。
2. 原先只断言"排布换了",没断言"导航在哪边" ⇒ 用户后续追问的侧边栏
   根本没被判据覆盖。现在补:横屏侧栏必须 60px 且在左侧、底部导航必须不存在;
   竖屏反过来(底部导航存在、无侧栏)。
This commit is contained in:
2026-09-14 11:36:50 +08:00
parent a456d2127e
commit 2b6eb97bc9
3 changed files with 181 additions and 28 deletions

View File

@ -14,7 +14,8 @@ import {
} from '../lib/replyTarget';
import * as api from '../api/client';
import type { Mail } from '../types';
import { MailIcon, ShieldIcon, PersonIcon, BotIcon, CheckIcon, CloseIcon, ForwardIcon, TreeIcon, TagIcon, GaugeIcon } from './icons';
import { MailIcon, ShieldIcon, PersonIcon, BotIcon, CheckIcon, CloseIcon, ForwardIcon, TreeIcon, TagIcon, GaugeIcon, ChevronRightIcon, ChatBubbleIcon } from './icons';
import { useIsNarrow } from '../hooks/useIsNarrow';
import AddressInput from './AddressInput';
import { AttachmentList, AttachmentPicker, type PendingAttachment } from './Attachments';
import ThreadView from './ThreadView';
@ -34,6 +35,7 @@ export default function MailView() {
const [forwarding, setForwarding] = useState<Mail | null>(null);
// 正在看哪封邮件的对话树null = 看正常的邮件视图
const [threadOf, setThreadOf] = useState<string | null>(null);
const narrow = useIsNarrow();
// 切换邮件时关掉树视图:树是针对某封邮件的,留着会显示上一封的线索
const currentMailID = currentMail?.mail_id;
@ -49,24 +51,33 @@ export default function MailView() {
const sessionTarget = sessionReplyTarget(currentSessionMails, currentSession, me);
return (
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
<div className="px-4 md:px-6 py-3 border-b border-gray-200 bg-white">
<CollapsibleHeader
narrow={narrow}
lead={<BackButton label="会话" />}
title={
currentSession.subject ||
(currentSession.session_alias ? `.${currentSession.session_alias}` : '(未命名会话)')
}
meta={
<>
<StatusBadge status={currentSession.status} />
<span className="text-xs text-gray-400 shrink-0">
{currentSessionMails.length}
</span>
</>
}
>
<div className="flex items-center gap-2 flex-wrap">
<BackButton label="会话" />
<span className="text-sm font-semibold text-gray-900 font-mono">
<span className="text-xs font-semibold text-gray-900 font-mono">
{currentSession.session_alias
? `.${currentSession.session_alias}`
: '(未命名会话)'}
</span>
<StatusBadge status={currentSession.status} />
<span className="text-xs text-gray-400">
{currentSessionMails.length}
</span>
<div className="flex-1" />
<PermissionEditor />
<BudgetEditor />
</div>
<p className="text-xs text-gray-500 mt-0.5">{currentSession.subject}</p>
</div>
</CollapsibleHeader>
<RenameProposalBar />
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4 space-y-3">
{currentSessionMails.map(m => (
@ -463,22 +474,27 @@ function Header({
);
return (
<div className="px-4 md:px-6 py-3 md:py-4 border-b border-gray-200">
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<BackButton />
<h2 className="text-sm font-semibold text-gray-900 min-w-0 break-words">{mail.subject}</h2>
<CollapsibleHeader
narrow={narrow}
lead={<BackButton />}
title={mail.subject}
meta={
<>
{mail.status === 'unread' && (
<span className="px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 text-3xs font-medium">
<span className="shrink-0 px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 text-3xs font-medium">
</span>
)}
{mail.mail_type === 'permission_request' && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 text-3xs font-medium">
<span className="shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 text-3xs font-medium">
<ShieldIcon className="w-3 h-3" />
</span>
)}
<div className="flex-1" />
</>
}
>
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
{mail.status === 'unread' && (
<button onClick={onRead} className="tap text-xs text-blue-600 hover:underline">
@ -511,6 +527,69 @@ function Header({
)}
<Row label="时间">{time}</Row>
</dl>
</CollapsibleHeader>
);
}
/**
* 窄屏可折叠头部(邮件详情 / 会话)。
*
* 用户2026-09-14「窄屏页面阅读邮件时顶部的邮件信息和底部的输入框等
* 占用了绝大部分页面,用户只能通过中间的一小块看邮件,体验十分不好,
* 这些位置应当有一个自动隐藏的逻辑,比如上面缩为窄栏,只显示邮件标题,
* 点击展开显示完整信息」。
*
* 做法:窄屏**默认收起**,只留一行标题(+ 未读/权限这类必须常驻的状态点),
* 点标题行展开完整信息与操作。宽屏没有这个矛盾(横向空间够),所以宽屏恒展开、
* 不引入多余点击 —— 同一套头部代码,两种默认值。
*
* 跨过断点时要重置(宽 → 窄应自动收起),否则「自动隐藏」只在首次生效。
*/
function CollapsibleHeader({
narrow,
lead,
title,
meta,
children
}: {
narrow: boolean;
/** 标题行最左侧的固定元素(返回键);它不能套在 toggle 按钮里button 嵌 button */
lead?: React.ReactNode;
title: React.ReactNode;
/** 收起态也要常驻的状态点(未读 / 权限请求 / 会话状态) */
meta?: React.ReactNode;
/** 展开后才显示的内容(收发件人、时间、操作按钮…) */
children: React.ReactNode;
}) {
const [open, setOpen] = useState(!narrow);
useEffect(() => setOpen(!narrow), [narrow]);
return (
<div className="shrink-0 px-4 md:px-6 py-2 md:py-3.5 border-b border-gray-200 bg-white">
<div className="flex items-center gap-2">
{lead}
<button
type="button"
// 宽屏没有可展开的东西(已经全展开了),此时点它不该有任何副作用
onClick={() => narrow && 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'}`}
>
<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>}
</div>
);
}
@ -802,9 +881,33 @@ function ReplyBar({
const budget = useSessionStore(s => s.budget);
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]);
if (!replyTo) return null;
// 窄屏收起态:一个悬浮球,点开才出现输入区。
//
// 窄屏上「顶部邮件信息 + 底部输入框」同时常驻会把可读区压成一条缝(用户实测反馈)。
// 输入框不是阅读时每刻都要用的东西,就该按需展开;球放右下、悬浮于底部导航之上,
// 与列表页的 ComposeFab 同一套观感,不新增一种视觉语言。
if (narrow && !open) {
return (
<button
type="button"
data-testid="reply-fab"
onClick={() => setOpen(true)}
title={`回复 ${target}`}
aria-label={`回复 ${target}`}
className="absolute bottom-4 right-4 z-20 w-14 h-14 rounded-full bg-blue-600 text-white shadow-lg flex items-center justify-center hover:bg-blue-700 active:bg-blue-800 transition-colors"
>
<ChatBubbleIcon className="w-6 h-6" />
</button>
);
}
// 判据是「当前登录用户名」而不是字面量 'human':后者是单用户时代的遗留,
// 多用户下登录名可能是 jianf判据恒为假 → 对端取成自己 → 信发给自己。
const peer = mailCounterpart(replyTo, me);
@ -868,6 +971,16 @@ 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>
)}
</div>
{ccOpen && (
<div className="mb-2">
@ -883,6 +996,8 @@ function ReplyBar({
value={body}
onChange={e => setBody(e.target.value)}
placeholder="回复内容Markdown"
// 窄屏是「点球展开」进来的,焦点直接落到输入框,少一次点击
autoFocus={narrow}
className="w-full h-20 text-sm font-mono border border-gray-300 rounded-md p-3 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
<div className="mt-2">

View File

@ -417,3 +417,18 @@ export function MonitorIcon({ className = 'w-4 h-4' }: P) {
</Svg>
);
}
/**
* 聊天气泡:窄屏「展开回复输入框」的悬浮球图标。
*
* 为什么复用不了一个现成的MailIcon 是信封(已读/收件语义、ComposeIcon 是
* 铅笔加号(新建),而这里要表达的是「跟他聊一句」——就是回复这件事本身,
* 用一个气泡最不容易被误读成别的操作。
*/
export function ChatBubbleIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M21 11.5a8.4 8.4 0 0 1-9 8.4 9.4 9.4 0 0 1-3.3-.6L3 21l1.7-5a8.4 8.4 0 0 1-.7-3.4 8.4 8.4 0 0 1 8.4-8.4h.6a8.4 8.4 0 0 1 8 8z" />
</Svg>
);
}

View File

@ -19,7 +19,11 @@ const record = (n, ok, note) => {
const snap = () =>
page.evaluate(() => {
const de = document.documentElement;
const content = document.querySelector('.narrow-shell > *:not(.narrow-nav)');
// 排除底部导航**与侧边栏**:紧凑横屏下第一个子元素是 60px 侧栏(不是内容区),
// 不排除就会把侧栏当成内容区来判断排布(判据自己的错,实测踩到)
const content = document.querySelector(
'.narrow-shell > *:not(.narrow-nav):not(.bg-chrome-900)'
);
const nav = document.querySelector('.narrow-nav')?.getBoundingClientRect();
return {
vw: innerWidth,
@ -28,7 +32,15 @@ const snap = () =>
overflowX: de.scrollWidth - de.clientWidth,
contentCls: content ? content.className.toString().slice(0, 40) : '',
navVisible: nav ? nav.bottom <= innerHeight + 1 && nav.top > 0 : false,
navInset: nav ? Math.round(nav.left) : null
navInset: nav ? Math.round(nav.left) : null,
hasBottomNav: !!document.querySelector('.narrow-nav'),
sideRail: (() => {
const r = document.querySelector('.narrow-shell > .bg-chrome-900');
if (!r) return null;
const b = r.getBoundingClientRect();
return { w: Math.round(b.width), h: Math.round(b.height), x: Math.round(b.x) };
})(),
listPaneW: Math.round((document.querySelector('.comm-pane')?.getBoundingClientRect().width) ?? 0)
};
});
@ -37,6 +49,7 @@ try {
await page.waitForTimeout(1500);
const p1 = await snap();
record('竖屏覆盖式排布NarrowStack', /relative overflow-hidden/.test(p1.contentCls), p1.contentCls);
record('竖屏:导航在**底部**(不是侧边)', p1.hasBottomNav === true && p1.sideRail === null, `底部=${p1.hasBottomNav} 侧栏=${!!p1.sideRail}`);
record('竖屏:--app-height 跟随视口', p1.appHeight === `${p1.vh}px`, `${p1.appHeight} vs ${p1.vh}`);
await page.setViewportSize({ width: 844, height: 390 });
@ -45,7 +58,17 @@ try {
record('横屏:真的换了排布(并排,而不是原样)', /flex-1 min-h-0 flex$/.test(l.contentCls), l.contentCls);
record('横屏:--app-height 重算(旋屏后不是旧值)', l.appHeight === '390px', l.appHeight);
record('横屏:无横向溢出', l.overflowX <= 0, `溢出 ${l.overflowX}px`);
record('横屏:底部导航仍完整可见且浮着', l.navVisible && l.navInset >= 6, `left=${l.navInset}`);
// 用户后续追问:「导航栏在窄屏横屏情况下不也应当是在侧边吗?」⇒ 横屏用侧边栏
record(
'横屏:导航在**侧边**60px 图标栏),底部导航不存在',
!!l.sideRail && l.sideRail.w === 60 && l.sideRail.x <= 12 && l.hasBottomNav === false,
`侧栏=${l.sideRail?.w}px x=${l.sideRail?.x} 底部导航=${l.hasBottomNav}`
);
record(
'横屏:列表栏固定 320lg:断点在 844 宽下不生效,否则会吃掉整宽)',
l.listPaneW === 320,
`列表宽=${l.listPaneW}`
);
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(1300);