fix: 窄屏实测修复 —— 删抽屉、44px 命中区、触摸设备可见的次要动作

用 playwright 连本机共享 Chromium,在 390px(iPhone 14 Pro)与 320px
(iPhone SE)量真实盒子。之前的窄屏适配是「照着规则写对」,实测发现
一个功能性 bug 加五处可用性问题。

## 抽屉式侧栏遮挡底部导航(真 bug)

抽屉是 `fixed left-0 top-0 bottom-0 z-50`,铺满整个视口高度;底部导航没有
z-index。抽屉打开时点最左那一项「收件」,elementFromPoint 命中的是抽屉里的
SVG,不是导航按钮 —— 按钮在那里、尺寸也够、CSS 规则也没写错,只有命中测试
才能发现。

**删掉抽屉而不是给导航加 z-index**:抽屉装的六项(收件/发件/联系/用户/新建/
管理)与底部导航完全重复,唯一独有的是退出登录。为一个按钮维护一套 fixed
层级加遮罩不划算,而它还附带「Esc 关不掉」「底层未锁滚」两个毛病。

退出登录移到「我的」页 —— 它与密码、密钥同属「账号自身」,而那页此前根本
没有退出入口。`NavToggle` 与 uiStore 的 navOpen/toggleNav/closeNav 一并删除。

## 触摸命中区:新增 .tap

详情页那排工具按钮视觉高度只有 15-16px(实测「标记已读」48x16、「对话树」
54x16、「转发」42x16、「抄送」20x15),移动端下限是 44x44。

直接加 padding 会把本来就挤的头部撑散、320px 下换行,因此用居中的透明伪元素
扩大命中区:**视觉一像素不动**。只在 max-width:767px 生效 —— 桌面用鼠标精度
足够,而扩大后的命中区在密排工具栏里会互相重叠,点一个可能命中隔壁那个。

覆盖 MailView / ContactPanel / WorkCard / ModelScopePanel / AdminUsersPage /
ComposePage / ThreadView / KeyPanel / QuotaPanel / Attachments / BackButton。

## 看不见却按得动的按钮:新增 .reveal

`opacity-0 group-hover:opacity-100` 在没有 hover 的设备上永远是 opacity:0,
**但仍然接收点击** —— 实测联系人列表里 elementFromPoint 命中的就是那个看不见的
「归档」。一个看不见却按得动的破坏性按钮比没有按钮更糟:人以为点的是卡片,
实际归档了一条会话。

改为默认可见,只在 `(hover: hover) and (pointer: fine)` 时隐藏。
单看 hover 会把带触摸板的平板算进去。

## 对话树

- 缩进随屏宽自适应:固定「每级 20px、上限 8 级」= 最多 160px,320px 屏还要
  去掉 px-4 的 32px 与连接线 18px,卡片只剩 110px,发件人一行直接被 truncate
  吃掉。窄屏改为每级 10px、上限 5 级
- 补返回出口:原先只有「关闭」。两者语义不同 —— 返回退出整个详情栏回到列表,
  关闭只收起树、留在这封邮件上

## 把实测脚本留进仓库

`web/test/manual/`(`npm run test:narrow` / `test:wide`),不进 npm test ——
要一个跑着的浏览器加一个活的 Gateway。

留着而不是用完即删,是因为结构性断言守不住「按钮实际多大、点下去命中谁」,
而这次最严重的 bug 恰好只有 elementFromPoint 能发现。helper 里两个函数专门
为此:tapTargets() 量 .tap 的真实命中区(伪元素尺寸,不是 boundingBox),
hitTest() 验每个元素点下去是否命中自己。

## 验证

- 窄屏 13 项 + 宽屏 5 项全通过。宽屏回归特意验了两件只该在窄屏生效的事:
  .tap 伪元素 content 为 none、没有返回按钮
- narrow-layout.test.mjs 从 20 条扩到 28 条,逐条钉住上面每个修复
- 无横向溢出:390px 与 320px 下 scrollWidth === clientWidth
- 生产已部署
This commit is contained in:
2026-09-02 23:53:59 +08:00
parent 9e5c557cdf
commit 342282b92c
26 changed files with 759 additions and 134 deletions

View File

@ -27,8 +27,6 @@ export default function App() {
const composing = useUIStore(s => s.composing);
const resetUI = useUIStore(s => s.reset);
const narrowPane = useUIStore(s => s.narrowPane);
const navOpen = useUIStore(s => s.navOpen);
const closeNav = useUIStore(s => s.closeNav);
const narrow = useIsNarrow();
const fetchInbox = useMailStore(s => s.fetchInbox);
@ -141,13 +139,13 @@ export default function App() {
// 它们本来就是单页,套一层滑动只会让「进入账号页」也带动画,很怪
if (!hasList) {
return (
<NarrowShell navOpen={navOpen} onCloseNav={closeNav}>
<NarrowShell>
<div className="flex-1 min-h-0 flex">{main}</div>
</NarrowShell>
);
}
return (
<NarrowShell navOpen={navOpen} onCloseNav={closeNav}>
<NarrowShell>
<NarrowStack base={list} overlay={main} open={narrowPane === 'detail'} />
</NarrowShell>
);
@ -164,33 +162,18 @@ export default function App() {
}
/**
* 窄屏外壳:内容区 + 底部导航 + 抽屉式侧栏。
* 窄屏外壳:内容区 + 底部导航。
*
* 侧栏在窄屏下是抽屉而不是常驻:60px 竖条在手机上白占一成宽度,
* 而底部导航已经覆盖了日常切换,抽屉只留给不常用的入口。
* 没有抽屉式侧栏。它曾经存在,装的是六个与底部导航完全重复的入口,
* 唯一独有的是退出登录(已移到「我的」页)。为一个按钮维护一套
* fixed 层级 + 遮罩的代价是:抽屉 `z-50` 铺满视口高度,把底部导航
* 最左那一项盖住点不到(实测 elementFromPoint 命中抽屉里的 SVG)。
*/
function NarrowShell({
children,
navOpen,
onCloseNav
}: {
children: React.ReactNode;
navOpen: boolean;
onCloseNav: () => void;
}) {
function NarrowShell({ children }: { children: React.ReactNode }) {
return (
<div className="h-full flex flex-col bg-gray-50 overflow-hidden">
{children}
<NarrowNav />
{navOpen && (
<>
{/* 遮罩:点空白处收起,这是移动端的通用预期 */}
<div className="fixed inset-0 bg-black/40 z-40" onClick={onCloseNav} aria-hidden="true" />
<div className="fixed left-0 top-0 bottom-0 z-50">
<Sidebar />
</div>
</>
)}
</div>
);
}

View File

@ -1,13 +1,13 @@
import { useCallback, useEffect, useState } from 'react';
import { useAuthStore } from '../stores/authStore';
import * as api from '../api/client';
import NavToggle from './NavToggle';
import { LockIcon } from './icons';
import { LockIcon, LogoutIcon } from './icons';
import KeyPanel from './KeyPanel';
/** 当前用户个人中心:查看资料、修改密码、管理客户端连接密钥 */
export default function AccountPage() {
const user = useAuthStore(s => s.user);
const logout = useAuthStore(s => s.logout);
const [oldPw, setOldPw] = useState('');
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
@ -87,7 +87,6 @@ export default function AccountPage() {
return (
<div className="flex-1 min-w-0 flex flex-col bg-white">
<div className="px-4 md:px-6 py-3 border-b border-gray-200 flex items-center gap-2">
<NavToggle />
<h2 className="text-sm font-semibold text-gray-900">账号信息</h2>
</div>
@ -205,6 +204,21 @@ export default function AccountPage() {
onDismissToken={() => setNewToken(null)}
/>
</section>
{/* 退出登录。
放在这里而不是导航里:它是一个低频且不可逆的动作,
与密码、密钥同属「账号自身」。窄屏下这也是唯一的退出口:
抽屉式侧栏已删(它的其余入口与底部导航完全重复)。 */}
<section className="border-t border-gray-200 pt-6">
<h3 className="text-xs font-medium text-gray-500 mb-3">登录状态</h3>
<button
onClick={logout}
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50 active:bg-gray-100 transition-colors"
>
<LogoutIcon className="w-4 h-4" />
退出登录
</button>
</section>
</div>
</div>
);

View File

@ -1,6 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import * as api from '../api/client';
import NavToggle from './NavToggle';
import type { AdminScopes, User } from '../types';
import { CheckIcon, LockIcon, UsersIcon, ChevronRightIcon, KeyIcon, BotIcon, CpuIcon } from './icons';
import KeyPanel from './KeyPanel';
@ -98,7 +97,6 @@ export default function AdminUsersPage() {
return (
<div className="flex-1 min-w-0 flex flex-col bg-white">
<div className="px-4 md:px-6 py-3 border-b border-gray-200 flex items-center gap-1 flex-wrap">
<NavToggle />
<TabButton active={tab === 'users'} onClick={() => setTab('users')}>
<UsersIcon className="w-4 h-4" />
用户管理
@ -119,7 +117,7 @@ export default function AdminUsersPage() {
<div className="flex-1" />
{notice && <span className="text-xs text-green-600">{notice}</span>}
{tab === 'users' && (
<button onClick={() => setCreating(v => !v)} className="px-3 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700">
<button onClick={() => setCreating(v => !v)} className="tap px-3 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700">
{creating ? '收起' : '新建用户'}
</button>
)}
@ -174,7 +172,7 @@ function TabButton({ active, onClick, children }: {
return (
<button
onClick={onClick}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md ${
className={`tap flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md ${
active ? 'bg-gray-900 text-white' : 'text-gray-600 hover:bg-gray-100'
}`}
>
@ -192,11 +190,11 @@ function UserCard({ user, scopes, expanded, onToggle, onSaved, onReload, setErro
return (
<div className="rounded-lg border border-gray-200">
<div className="px-4 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap">
<button onClick={onToggle} className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600">
<button onClick={onToggle} className="tap flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600">
<ChevronRightIcon className={`w-3 h-3 transition-transform ${expanded ? 'rotate-90' : ''}`} />
</button>
<span className="font-mono text-sm text-gray-900 min-w-[100px]">{user.username}</span>
<span className="text-[11px] text-gray-500">{user.display_name}</span>
<span className="tap text-[11px] text-gray-500">{user.display_name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${user.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'}`}>
{user.role === 'admin' ? '管理员' : '用户'}
</span>
@ -316,7 +314,7 @@ function UserEditor({ user, scopes, onSaved, onReload, setError }: {
)}
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap">
<button onClick={save} disabled={busy} className="px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 transition-colors">
<button onClick={save} disabled={busy} className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 transition-colors">
{busy ? '保存中' : '保存更改'}
</button>
<div className="flex items-center gap-1.5 ml-auto">
@ -324,7 +322,7 @@ function UserEditor({ user, scopes, onSaved, onReload, setError }: {
<input type="password" value={pw} onChange={e => setPw(e.target.value)} placeholder="新密码(至少 8 位)"
className="w-40 text-xs border border-gray-300 rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" />
<button onClick={resetPassword} disabled={pw.length < 8 || busy}
className="inline-flex items-center gap-1 px-2 py-1 text-[11px] rounded bg-gray-700 text-white hover:bg-gray-800 disabled:opacity-40">
className="tap inline-flex items-center gap-1 px-2 py-1 text-[11px] rounded bg-gray-700 text-white hover:bg-gray-800 disabled:opacity-40">
<CheckIcon className="w-3 h-3" /> 重置
</button>
</div>
@ -390,7 +388,7 @@ function CreateUserForm({ scopes, onDone, onError }: {
)}
<div className="flex justify-end">
<button onClick={submit} disabled={!ok} className="px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40">
<button onClick={submit} disabled={!ok} className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40">
{busy ? '创建中' : '创建'}
</button>
</div>

View File

@ -118,7 +118,7 @@ export function AttachmentPicker({
<button
onClick={pick}
disabled={disabled || uploading !== null}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-40"
className="tap inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-40"
>
<PaperclipIcon className="w-3.5 h-3.5" />
添加附件

View File

@ -20,7 +20,7 @@ export default function BackButton({ label = '返回' }: { label?: string }) {
return (
<button
onClick={showList}
className="shrink-0 -ml-1 mr-1 inline-flex items-center gap-0.5 py-1 pr-1.5 pl-0.5 rounded text-gray-500 active:bg-gray-100"
className="tap shrink-0 -ml-1 mr-1 inline-flex items-center gap-0.5 py-1 pr-1.5 pl-0.5 rounded text-gray-500 active:bg-gray-100"
aria-label={label}
>
<ChevronLeftIcon className="w-4 h-4" />

View File

@ -139,7 +139,7 @@ export default function ComposePage() {
<NarrowOnly>
<button
onClick={cancelCompose}
className="-ml-1 inline-flex items-center gap-0.5 py-1 pr-1 text-gray-500 active:bg-gray-100 rounded"
className="tap -ml-1 inline-flex items-center gap-0.5 py-1 pr-1 text-gray-500 active:bg-gray-100 rounded"
aria-label="返回"
>
<ChevronLeftIcon className="w-4 h-4" />
@ -161,7 +161,7 @@ export default function ComposePage() {
setAttachments([]);
setError(null);
}}
className="text-xs text-gray-400 hover:text-gray-600"
className="tap text-xs text-gray-400 hover:text-gray-600"
>
清空
</button>
@ -309,7 +309,7 @@ function Toggle({
return (
<button
onClick={onClick}
className={`text-[11px] px-2 py-0.5 rounded ${
className={`tap text-[11px] px-2 py-0.5 rounded ${
active ? 'bg-gray-900 text-white' : 'text-gray-500 hover:text-gray-800'
}`}
>

View File

@ -13,7 +13,6 @@ import {
ListViewIcon,
CardViewIcon
} from './icons';
import NavToggle from './NavToggle';
import { WorkCard } from './WorkCard';
/**
@ -63,7 +62,6 @@ export default function ContactPanel() {
}`}
>
<div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1">
<NavToggle />
<h2 className="text-sm font-semibold text-gray-800">
{view === 'card' ? '工作列表' : '联系人'}
</h2>
@ -73,7 +71,7 @@ export default function ContactPanel() {
<button
onClick={() => setView(view === 'list' ? 'card' : 'list')}
title={view === 'list' ? '切换到卡片视图' : '切换到列表视图'}
className="p-1 rounded text-gray-400 hover:text-gray-700 hover:bg-gray-100"
className="tap p-1 rounded text-gray-400 hover:text-gray-700 hover:bg-gray-100"
>
{view === 'list' ? (
<CardViewIcon className="w-3.5 h-3.5" />
@ -83,7 +81,7 @@ export default function ContactPanel() {
</button>
<button
onClick={toggleArchivedView}
className={`text-[11px] px-1.5 py-0.5 rounded ${
className={`tap text-[11px] px-1.5 py-0.5 rounded ${
showArchived ? 'bg-gray-900 text-white' : 'text-gray-500 hover:text-gray-800'
}`}
>
@ -189,14 +187,14 @@ function ArchiveConfirm({
<div className="flex gap-2 mt-2">
<button
onClick={onConfirm}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-red-600 text-white text-[11px] font-medium hover:bg-red-700"
className="tap inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-red-600 text-white text-[11px] font-medium hover:bg-red-700"
>
<CheckIcon className="w-3 h-3" />
确认归档
</button>
<button
onClick={onCancel}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md border border-gray-300 text-gray-600 text-[11px] hover:bg-white"
className="tap inline-flex items-center gap-1 px-2.5 py-1 rounded-md border border-gray-300 text-gray-600 text-[11px] hover:bg-white"
>
<CloseIcon className="w-3 h-3" />
取消
@ -255,11 +253,11 @@ function ContactRow({
</p>
</button>
<div className="flex gap-1 mt-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="reveal flex gap-1 mt-1.5">
<button
onClick={onCompose}
title="写信给该地址"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-white"
className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-white"
>
<ComposeIcon className="w-3 h-3" />
写信
@ -267,7 +265,7 @@ function ContactRow({
<button
onClick={onRequestArchive}
title="归档该 name@path.session"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-white hover:text-red-600 hover:border-red-300"
className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-white hover:text-red-600 hover:border-red-300"
>
<ArchiveIcon className="w-3 h-3" />
归档

View File

@ -53,12 +53,12 @@ function NewKeyBanner({ token, onDismiss }: { token: string; onDismiss: () => vo
</code>
<button
onClick={copy}
className="shrink-0 flex items-center gap-1 text-xs px-2 py-1.5 border border-amber-300 rounded hover:bg-amber-100"
className="tap shrink-0 flex items-center gap-1 text-xs px-2 py-1.5 border border-amber-300 rounded hover:bg-amber-100"
>
{copied ? <CheckIcon className="w-3.5 h-3.5" /> : <CopyIcon className="w-3.5 h-3.5" />}
{copied ? '已复制' : '复制'}
</button>
<button onClick={onDismiss} className="shrink-0 text-xs text-amber-800 hover:underline">
<button onClick={onDismiss} className="tap shrink-0 text-xs text-amber-800 hover:underline">
我已保存
</button>
</div>
@ -172,7 +172,7 @@ function CreateForm({ variant, busy, onSubmit }: CreateFormProps) {
<button
onClick={submit}
disabled={busy}
className="text-xs px-3 py-1.5 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
className="tap text-xs px-3 py-1.5 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
>
创建
</button>

View File

@ -4,7 +4,6 @@ import { useSessionStore } from '../stores/sessionStore';
import { useUIStore } from '../stores/uiStore';
import type { Mail } from '../types';
import { ShieldIcon, PaperclipIcon } from './icons';
import NavToggle from './NavToggle';
export default function MailList() {
const viewMode = useUIStore(s => s.viewMode);
@ -40,7 +39,6 @@ export default function MailList() {
return (
<div className="w-full md:w-[320px] shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0">
<div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1">
<NavToggle />
<h2 className="text-sm font-semibold text-gray-800">{isSent ? '发件箱' : '收件箱'}</h2>
<span className="ml-2 text-xs text-gray-400">{list.length}</span>
</div>

View File

@ -298,7 +298,7 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
<div className="flex-1" />
<button
onClick={() => setCcOpen(o => !o)}
className={`text-[10px] ${ccOpen ? 'text-blue-600' : 'text-gray-500 hover:text-blue-600'}`}
className={`tap text-[10px] ${ccOpen ? 'text-blue-600' : 'text-gray-500 hover:text-blue-600'}`}
>
{ccOpen ? '收起抄送' : '抄送'}
</button>
@ -325,13 +325,13 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
<div className="flex items-center gap-2">
{error && <span className="text-xs text-red-600">{error}</span>}
<div className="flex-1" />
<button onClick={onClose} className="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800">
<button onClick={onClose} className="tap px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800">
取消
</button>
<button
onClick={submit}
disabled={busy || !to.trim()}
className="px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
>
{busy ? '转发中' : '转发'}
</button>
@ -375,13 +375,13 @@ function Header({
)}
<div className="flex-1" />
{mail.status === 'unread' && (
<button onClick={onRead} className="text-xs text-blue-500 hover:underline">
<button onClick={onRead} className="tap text-xs text-blue-500 hover:underline">
标记已读
</button>
)}
<button
onClick={onThread}
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
className="tap inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
title="沿回复与转发关系展开整条线索"
>
<TreeIcon className="w-3.5 h-3.5" />
@ -389,7 +389,7 @@ function Header({
</button>
<button
onClick={onForward}
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
className="tap inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
>
<ForwardIcon className="w-3.5 h-3.5" />
转发
@ -617,14 +617,14 @@ function ReplyBar({ replyTo }: { replyTo?: Mail }) {
{(replyTo.cc_list?.length ?? 0) > 0 && (
<button
onClick={replyAll}
className="text-[10px] text-gray-500 hover:text-blue-600"
className="tap text-[10px] text-gray-500 hover:text-blue-600"
>
回复全部
</button>
)}
<button
onClick={() => setCcOpen(o => !o)}
className={`text-[10px] ${ccOpen ? 'text-blue-600' : 'text-gray-500 hover:text-blue-600'}`}
className={`tap text-[10px] ${ccOpen ? 'text-blue-600' : 'text-gray-500 hover:text-blue-600'}`}
>
{ccOpen ? '收起抄送' : '抄送'}
</button>
@ -656,14 +656,14 @@ function ReplyBar({ replyTo }: { replyTo?: Mail }) {
setBody('');
setError(null);
}}
className="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800"
className="tap px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800"
>
清空
</button>
<button
onClick={send}
disabled={busy || !body.trim()}
className="px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
>
{busy ? '发送中' : '发送'}
</button>

View File

@ -213,7 +213,7 @@ function AgentModelRow({
onClick={() => move(key, -1)}
disabled={i === 0}
title="上移"
className="shrink-0 text-gray-400 hover:text-gray-900 disabled:opacity-30 text-xs px-1"
className="tap shrink-0 text-gray-400 hover:text-gray-900 disabled:opacity-30 text-xs px-1"
>
↑
</button>
@ -221,14 +221,14 @@ function AgentModelRow({
onClick={() => move(key, 1)}
disabled={i === picks.length - 1}
title="下移"
className="shrink-0 text-gray-400 hover:text-gray-900 disabled:opacity-30 text-xs px-1"
className="tap shrink-0 text-gray-400 hover:text-gray-900 disabled:opacity-30 text-xs px-1"
>
↓
</button>
<button
onClick={() => toggle(key)}
title="移除"
className="shrink-0 text-gray-400 hover:text-red-600 text-xs px-1"
className="tap shrink-0 text-gray-400 hover:text-red-600 text-xs px-1"
>
×
</button>
@ -253,7 +253,7 @@ function AgentModelRow({
key={key}
onClick={() => toggle(key)}
title={m.display_name || key}
className={`px-2 py-1 text-[11px] font-mono rounded border transition-colors ${
className={`tap px-2 py-1 text-[11px] font-mono rounded border transition-colors ${
on
? 'bg-blue-50 border-blue-300 text-blue-700'
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'
@ -278,7 +278,7 @@ function AgentModelRow({
{dirty && (
<button
onClick={() => setPicks(saved)}
className="text-[11px] text-gray-500 hover:text-gray-900"
className="tap text-[11px] text-gray-500 hover:text-gray-900"
>
撤销
</button>
@ -286,7 +286,7 @@ function AgentModelRow({
<button
onClick={save}
disabled={!dirty || saving}
className="inline-flex items-center gap-1 px-3 py-1 text-[11px] rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
className="tap inline-flex items-center gap-1 px-3 py-1 text-[11px] rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
>
{saving ? (
<SpinnerIcon className="w-3 h-3 animate-spin" />

View File

@ -1,26 +0,0 @@
import { useIsNarrow } from '../hooks/useIsNarrow';
import { useUIStore } from '../stores/uiStore';
import { MenuIcon } from './icons';
/**
* 窄屏下打开抽屉式侧栏的按钮。
*
* 只在窄屏渲染 —— 宽屏侧栏是常驻的,放个汉堡按钮点了什么也不会发生。
* 侧栏里有底部导航没放的入口(退出登录等)。
*/
export default function NavToggle() {
const narrow = useIsNarrow();
const toggleNav = useUIStore(s => s.toggleNav);
if (!narrow) return null;
return (
<button
onClick={toggleNav}
className="-ml-1 mr-0.5 p-1 rounded text-gray-500 active:bg-gray-100"
aria-label="打开导航"
>
<MenuIcon className="w-4 h-4" />
</button>
);
}

View File

@ -103,7 +103,7 @@ export default function QuotaPanel() {
onClick={() => apply(s.agent_name, Number(draft.trim() || '0'))}
disabled={!dirty || invalid || busy === s.agent_name}
title="保存默认预算"
className="shrink-0 text-gray-400 hover:text-blue-600 disabled:opacity-30"
className="tap shrink-0 text-gray-400 hover:text-blue-600 disabled:opacity-30"
>
<CheckIcon className="w-4 h-4" />
</button>

View File

@ -3,6 +3,8 @@ import * as api from '../api/client';
import { useMailStore } from '../stores/mailStore';
import type { ThreadNode } from '../types';
import { CloseIcon, PaperclipIcon, PersonIcon, BotIcon, ShieldIcon, SpinnerIcon } from './icons';
import BackButton from './BackButton';
import { useIsNarrow } from '../hooks/useIsNarrow';
/**
* 对话树视图(从线索根整树展开,分块加载)。
@ -126,6 +128,10 @@ export default function ThreadView({ mailID, onClose }: { mailID: string; onClos
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 flex items-center gap-2">
{/* 窄屏下对话树是盖在列表上的一层,得有返回出口。
它与右侧的「关闭」语义不同:返回退出整个详情栏回到列表,
关闭只收起树、留在这封邮件上。 */}
<BackButton />
<span className="text-sm font-semibold text-gray-900">对话树</span>
<span className="text-xs text-gray-400">
已加载 {nodes.length} 封
@ -136,7 +142,7 @@ export default function ThreadView({ mailID, onClose }: { mailID: string; onClos
{loading && <SpinnerIcon className="w-3.5 h-3.5 animate-spin text-gray-400" />}
<button
onClick={onClose}
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
className="tap inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
>
<CloseIcon className="w-3.5 h-3.5" />
关闭
@ -168,7 +174,7 @@ export default function ThreadView({ mailID, onClose }: { mailID: string; onClos
{hasMore && (
<button
onClick={loadMore}
className="w-full mt-2 py-1.5 rounded border border-dashed border-gray-300 text-xs text-gray-500 hover:border-blue-300 hover:text-blue-600"
className="tap w-full mt-2 py-1.5 rounded border border-dashed border-gray-300 text-xs text-gray-500 hover:border-blue-300 hover:text-blue-600"
>
加载后续往来
</button>
@ -191,14 +197,21 @@ function Node({
anchorRef?: React.RefObject<HTMLDivElement>;
}) {
const openMailByID = useMailStore(s => s.openMailByID);
const narrow = useIsNarrow();
const isPermission = node.mail_type === 'permission_request';
const isAnchor = node.mail_id === anchorID;
const ccCount = node.cc_list?.length ?? 0;
// 转发是一条新线索:主题带 Fwd: 前缀,且落在别的会话里。
// 树里把它标出来,否则一个分支为什么突然换了收件人无从判断。
const isForward = node.subject.startsWith('Fwd: ');
// 缩进上限 8 级,再深就不缩了 —— 否则长链条会把卡片挤成竖条
const indent = Math.min(Math.max(node.depth, 0), 8) * 20;
// 缩进:每级的像素数与上限都随屏宽变。
//
// 原先固定「每级 20px、上限 8 级」= 最多 160px。在 320px 屏上容器还要去掉
// px-4 的 32px 与连接线的 18px,卡片只剩 110px —— 发件人一行就被 truncate 吃掉。
// 窄屏改成每级 10px、上限 5 级(最多 50px),层级仍然看得出来,卡片还有余地。
const step = narrow ? 10 : 20;
const maxDepth = narrow ? 5 : 8;
const indent = Math.min(Math.max(node.depth, 0), maxDepth) * step;
const time = new Date(node.created_at).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',

View File

@ -94,11 +94,11 @@ export function WorkCard({
</div>
</button>
<div className="flex gap-1 px-3 pb-2.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
<div className="reveal flex gap-1 px-3 pb-2.5">
<button
onClick={onCompose}
title="写信给该地址"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-gray-50"
className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-gray-50"
>
<ComposeIcon className="w-3 h-3" />
写信
@ -106,7 +106,7 @@ export function WorkCard({
<button
onClick={onArchive}
title="归档该 name@path.session"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-gray-50 hover:text-red-600 hover:border-red-300"
className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-gray-50 hover:text-red-600 hover:border-red-300"
>
<ArchiveIcon className="w-3 h-3" />
归档

View File

@ -11,3 +11,59 @@
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
}
@layer utilities {
/*
* .tap —— 保证 44x44 的触摸命中区,但不改变视觉尺寸。
*
* 移动端 44x44 是通行下限(Apple HIG 与 Material 都取这个数),而这些图标/
* 小字按钮视觉上只有 15-24px 高 —— 实测「标记已读」48x16、「转发」42x16、
* 「抄送」20x15。直接加 padding 会把本来就挤的头部撑散,在 320px 屏上还会换行。
*
* 改用居中的透明伪元素扩大命中区:视觉一像素不动,手指够得到。
*
* 只在窄屏生效:桌面用鼠标,精度足够;而扩大后的命中区在密排的工具栏里
* 会互相重叠,点一个可能命中隔壁那个。
*/
@media (max-width: 767px) {
.tap {
position: relative;
}
.tap::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
min-width: 44px;
min-height: 44px;
transform: translate(-50%, -50%);
}
}
/*
* .reveal —— 悬停才显形的次要动作(写信 / 归档)。
*
* 原先直接写 `opacity-0 group-hover:opacity-100`。**触摸设备没有 hover**,
* 于是这些按钮永远是透明的,却仍然接收点击 —— 实测在联系人列表上
* elementFromPoint 命中的就是那个看不见的「归档」。一个看不见却按得动的
* 破坏性按钮比没有按钮更糟:人以为自己点的是卡片,实际归档了一条会话。
*
* 因此默认可见,只在**真的支持悬停**的设备上才隐藏。判据用
* `(hover: hover) and (pointer: fine)`:单看 hover 会把带触摸板的平板算进去。
*/
.reveal {
opacity: 1;
}
@media (hover: hover) and (pointer: fine) {
.reveal {
opacity: 0;
transition: opacity 150ms;
}
.group:hover .reveal,
.reveal:focus-within {
opacity: 1;
}
}
}

View File

@ -25,11 +25,6 @@ interface UIState {
showDetail: () => void;
showList: () => void;
/** 侧边导航在窄屏下是抽屉,宽屏下常驻 */
navOpen: boolean;
toggleNav: () => void;
closeNav: () => void;
/** 登出后重置回默认视图 */
reset: () => void;
}
@ -42,31 +37,25 @@ export const useUIStore = create<UIState>(set => ({
viewMode: mode,
composing: false,
composePrefill: null,
narrowPane: 'list',
navOpen: false
narrowPane: 'list'
}),
composing: false,
composePrefill: null,
// 写信占满整个主区域,窄屏下等价于切到 detail 栏
startCompose: prefill =>
set({ composing: true, composePrefill: prefill ?? null, narrowPane: 'detail', navOpen: false }),
set({ composing: true, composePrefill: prefill ?? null, narrowPane: 'detail' }),
cancelCompose: () => set({ composing: false, composePrefill: null, narrowPane: 'list' }),
narrowPane: 'list',
showDetail: () => set({ narrowPane: 'detail' }),
showList: () => set({ narrowPane: 'list' }),
navOpen: false,
toggleNav: () => set(s => ({ navOpen: !s.navOpen })),
closeNav: () => set({ navOpen: false }),
reset: () =>
set({
viewMode: 'inbox',
composing: false,
composePrefill: null,
narrowPane: 'list',
navOpen: false
narrowPane: 'list'
})
}));