diff --git a/client/electron/src/components/MailView.tsx b/client/electron/src/components/MailView.tsx index 0105c39..dac4bee 100644 --- a/client/electron/src/components/MailView.tsx +++ b/client/electron/src/components/MailView.tsx @@ -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(null); // 正在看哪封邮件的对话树;null = 看正常的邮件视图 const [threadOf, setThreadOf] = useState(null); + const narrow = useIsNarrow(); // 切换邮件时关掉树视图:树是针对某封邮件的,留着会显示上一封的线索 const currentMailID = currentMail?.mail_id; @@ -49,24 +51,33 @@ export default function MailView() { const sessionTarget = sessionReplyTarget(currentSessionMails, currentSession, me); return (
-
+ } + title={ + currentSession.subject || + (currentSession.session_alias ? `.${currentSession.session_alias}` : '(未命名会话)') + } + meta={ + <> + + + {currentSessionMails.length} 封 + + + } + >
- - + {currentSession.session_alias ? `.${currentSession.session_alias}` : '(未命名会话)'} - - - {currentSessionMails.length} 封 -
-

{currentSession.subject}

-
+
{currentSessionMails.map(m => ( @@ -463,22 +474,27 @@ function Header({ ); return ( -
+ } + title={mail.subject} + meta={ + <> + {mail.status === 'unread' && ( + + 未读 + + )} + {mail.mail_type === 'permission_request' && ( + + + 权限请求 + + )} + + } + >
- -

{mail.subject}

- {mail.status === 'unread' && ( - - 未读 - - )} - {mail.mail_type === 'permission_request' && ( - - - 权限请求 - - )} -
{mail.status === 'unread' && ( +
+ {open &&
{children}
}
); } @@ -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 ( + + ); + } + // 判据是「当前登录用户名」而不是字面量 'human':后者是单用户时代的遗留, // 多用户下登录名可能是 jianf,判据恒为假 → 对端取成自己 → 信发给自己。 const peer = mailCounterpart(replyTo, me); @@ -868,6 +971,16 @@ function ReplyBar({ > {ccOpen ? '收起抄送' : '抄送'} + {narrow && ( + + )}
{ccOpen && (
@@ -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" />
diff --git a/client/electron/src/components/icons.tsx b/client/electron/src/components/icons.tsx index 9a47f2f..332fee4 100644 --- a/client/electron/src/components/icons.tsx +++ b/client/electron/src/components/icons.tsx @@ -417,3 +417,18 @@ export function MonitorIcon({ className = 'w-4 h-4' }: P) { ); } + +/** + * 聊天气泡:窄屏「展开回复输入框」的悬浮球图标。 + * + * 为什么复用不了一个现成的:MailIcon 是信封(已读/收件语义)、ComposeIcon 是 + * 铅笔加号(新建),而这里要表达的是「跟他聊一句」——就是回复这件事本身, + * 用一个气泡最不容易被误读成别的操作。 + */ +export function ChatBubbleIcon({ className = 'w-4 h-4' }: P) { + return ( + + + + ); +} diff --git a/client/electron/test/manual/rotate-verify.mjs b/client/electron/test/manual/rotate-verify.mjs index e048f87..51a909d 100644 --- a/client/electron/test/manual/rotate-verify.mjs +++ b/client/electron/test/manual/rotate-verify.mjs @@ -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);