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:
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
'横屏:列表栏固定 320(lg:断点在 844 宽下不生效,否则会吃掉整宽)',
|
||||
l.listPaneW === 320,
|
||||
`列表宽=${l.listPaneW}`
|
||||
);
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.waitForTimeout(1300);
|
||||
|
||||
Reference in New Issue
Block a user