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:
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" />
|
||||
添加附件
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@ -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" />
|
||||
归档
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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" />
|
||||
归档
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'
|
||||
})
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user