diff --git a/README.md b/README.md index a5f5d05..9f5ec1b 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,9 @@ agentmail/ │ ├── opencode-mail-bridge/ # opencode │ └── dsh-mail-bridge/ # DeepSeek Harness(Cordis) ├── web/ # 前端(React + Vite + Tailwind) +│ └── test/manual/ # 浏览器实测脚本(量真实盒子与命中区,不进 npm test) └── deploy/ # systemd 单元 + 安装脚本 - └── remote-agent-demo.py # 最小跟主机 Agent(纯标准库,验证协议层能力) + └── remote-agent-demo.py # 最小跨主机 Agent(纯标准库,验证协议层能力) ``` ## 技术栈 diff --git a/docs/PHASE7-REMAINING.md b/docs/PHASE7-REMAINING.md index 55c5492..510d6f2 100644 --- a/docs/PHASE7-REMAINING.md +++ b/docs/PHASE7-REMAINING.md @@ -105,8 +105,65 @@ SSE 连接的 handler 在首次连接时从缓冲区头部开始(客户端传 **修法**:同上,sessionrate.go 重写为调用 RateLimitCheckAndRecord。 ### P2 — 组件级测试 -**现状**:前端无任何组件测试,前端回归只靠 lint 与构建。 +**现状**:有结构性回归(`web/test/narrow-layout.test.mjs`,28 条断言, +读源码验形态)与一套 playwright 手工脚本,但**没有渲染组件跑断言的测试**。 **范围**:关键组件(AddressInput 补全、PermissionPanel 决策、WorkCard 预算渲染)。 +**已有的浏览器实测**:`web/test/manual/`(`npm run test:narrow` / +`npm run test:wide`)—— 连本机共享 Chromium 量真实盒子与命中区。 +不在 `npm test` 里,因为要一个跑着的浏览器加一个活的 Gateway。 +剩下的是把它接进 CI(需要一个 headless 环境与一个测试用 Gateway 实例)。 + +### 窄屏实测修复(2026-09-02)✅ + +用 playwright 连本机共享 Chromium,在 390px(iPhone 14 Pro)与 320px(iPhone SE) +两档实测。**一个功能性 bug 加五处可用性问题**: + +**抽屉式侧栏遮挡底部导航(真 bug,已删抽屉)** +抽屉是 `fixed left-0 top-0 bottom-0 z-50`,铺满整个视口高度;底部导航没有 +z-index。抽屉打开时点最左那一项「收件」,`elementFromPoint` 命中的是抽屉里的 +SVG,不是导航按钮。 + +修法是**删掉抽屉**而不是给导航加 z-index:抽屉里六项(收件/发件/联系/用户/ +新建/管理)与底部导航完全重复,唯一独有的是退出登录。为一个按钮维护一套 +fixed 层级 + 遮罩不划算,何况它还引入了遮挡、Esc 关不掉、底层未锁滚三个问题。 +退出登录移到「我的」页 —— 它与密码、密钥同属「账号自身」,而那里此前根本 +没有退出入口。 + +**触摸命中区(新增 `.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` 会把带触摸板的平板算进去。 + +**对话树缩进在 320px 下把卡片压成竖条** +固定「每级 20px、上限 8 级」= 最多 160px;320px 屏还要去掉 `px-4` 的 32px 与 +连接线 18px,卡片只剩 110px,发件人一行直接被 truncate 吃掉。 +窄屏改成每级 10px、上限 5 级。 + +**对话树窄屏没有返回出口** +只有「关闭」。两者语义不同:返回退出整个详情栏回到列表,关闭只收起树、 +留在这封邮件上。补了 `BackButton`。 + +#### 验证 + +playwright 端到端 13 项全通过(含「底部导航六项都命中自己」、 +「工具按钮命中区 >= 44px」、四个页面无横向溢出、320px 无横向溢出); +宽屏回归 5 项全通过(三栏并排、常驻侧栏仍有退出登录、无返回按钮、 +`.tap` 伪元素在宽屏不生效、无溢出)。 +`web/test/narrow-layout.test.mjs` 从 20 条扩到 28 条,把上述每一条都钉住。 ### P2 — 深色主题 **现状**:只有浅色主题,深夜使用刺眼。 diff --git a/docs/PLAN.md b/docs/PLAN.md index e0b5285..c35bc65 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1326,13 +1326,13 @@ MVP 计划(Phase 1-6)已全部落地并在 systemd 部署态实测通过。 transition 根本不触发(单层 rAF 在 Safari 上偶尔仍被合帧) - [x] `motion-reduce:transition-none` 尊重 `prefers-reduced-motion` - [x] 打开覆盖层时底层 `aria-hidden`,否则屏幕阅读器会读到两层内容 -- [x] 导航:竖条在窄屏退化为抽屉(60px 在手机上白占一成宽度), - 日常切换交给底部 `NarrowNav`(拇指够得到); - 抽屉带遮罩,点空白处收起 +- [x] 导航:竖条在窄屏让位给底部 `NarrowNav`(60px 在手机上白占一成宽度, + 而底部横排拇指够得到)。**曾经还有一个抽屉式侧栏,后来删掉了 —— 见 7.10.1** - [x] `env(safe-area-inset-bottom)`:iPhone 手势条会盖住最后一排 - [x] **窄屏专属控件用条件渲染而非 `md:hidden`**:后者只是视觉隐藏, 元素仍在 DOM 与 tab 序列里,宽屏用户按 Tab 会聚焦到看不见的返回按钮上。 - 为此抽了 `NarrowOnly` / `BackButton` / `NavToggle` 三个组件 + 为此抽了 `NarrowOnly` / `BackButton` 两个组件 + (原先还有 `NavToggle`,随抽屉一起删除) - [x] 列表栏 `w-full md:w-[320px]`;各页横向内边距 `px-4 md:px-6` (px-6 在 375px 屏上白吃 48px) - [x] 管理页的 3/4 列 grid 改响应式;列表行 `flex-wrap` @@ -1342,22 +1342,58 @@ MVP 计划(Phase 1-6)已全部落地并在 systemd 部署态实测通过。 只滑走覆盖层的话下次进列表又会弹回来 - [x] `narrowPane` 在宽屏下**也维护**:否则从窄屏拖宽再拖回来, 用户会发现自己回到了列表,刚打开的邮件不见了 -- [x] `web/test/narrow-layout.test.mjs`:16 条结构性断言, +- [x] `web/test/narrow-layout.test.mjs`:结构性断言(现 28 条), 钉住「覆盖而非分栏」「延迟卸载」「双层 rAF」「条件渲染而非 md:hidden」 - 「无裸 px-6」等不变量。不做视觉快照 —— 那需要 headless 浏览器, - 且像素比对在字体差异下极脆 + 「无裸 px-6」等不变量。不做像素级视觉快照 —— 字体差异下极脆 + +### 7.10.1 真机尺寸实测与修复(2026-09-02) + +上面那些都是「照着规则写对」,实际用 playwright 连本机共享 Chromium +在 390px(iPhone 14 Pro)与 320px(iPhone SE)量了一遍,**发现一个功能性 bug +加五处可用性问题**。详细记录见 `docs/PHASE7-REMAINING.md`。 + +- [x] **删掉抽屉式侧栏**。它是 `fixed ... z-50` 且铺满视口高度,把底部导航 + 最左那一项盖住点不到(`elementFromPoint` 命中抽屉里的 SVG)。 + 修法不是给导航加 z-index 而是删掉抽屉:它装的六项与底部导航完全重复, + 唯一独有的是退出登录 —— 为一个按钮维护一套 fixed 层级 + 遮罩不划算, + 而它还附带了「Esc 关不掉」「底层未锁滚」两个毛病 +- [x] 退出登录移到「我的」页:与密码、密钥同属「账号自身」, + 而那页此前**根本没有退出入口** +- [x] **`.tap` 工具类**:44x44 触摸命中区,用居中的透明伪元素实现, + 视觉尺寸一像素不动。实测详情页工具按钮只有 15-16px 高 + (「抄送」20x15),直接加 padding 会把头部撑散、320px 下换行。 + 只在 `max-width: 767px` 生效 —— 桌面精度足够,且扩大后的命中区 + 在密排工具栏里会互相重叠 +- [x] **`.reveal` 工具类**:`opacity-0 group-hover:opacity-100` 在没有 hover + 的设备上永远透明**却仍然接收点击** —— 一个看不见却按得动的「归档」 + 比没有按钮更糟。改为默认可见,只在 + `(hover: hover) and (pointer: fine)` 时隐藏(单看 hover 会把带触摸板的 + 平板算进去) +- [x] 对话树缩进随屏宽自适应:固定「每级 20px、上限 8 级」在 320px 下 + 把卡片压到 110px 可用宽度,发件人一行直接被 truncate 吃掉。 + 窄屏改为每级 10px、上限 5 级 +- [x] 对话树补返回出口:原先只有「关闭」,而两者语义不同 —— + 返回退出整个详情栏,关闭只收起树留在这封邮件上 + +验证:`web/test/manual/`(`npm run test:narrow` / `test:wide`) +窄屏 13 项 + 宽屏 5 项全通过;结构性断言从 20 条扩到 28 条, +把每一条修复都钉住。 + +那套脚本刻意留在仓库里而不是用完就删:结构性断言守不住「按钮实际多大、 +点下去命中谁」,而这次最严重的 bug 恰好只有 `elementFromPoint` 能发现。 +它不进 `npm test` —— 要一个跑着的浏览器加一个活的 Gateway。 --- 已知取舍,尚未处理: -- 前端只有 Markdown XSS 一个回归测试,没有组件级测试 +- 前端有 Markdown XSS 与窄屏结构性回归,但没有**渲染组件跑断言**的测试 - 深色主题未做 -- 窄屏已适配(7.10),但没有真机 / headless 浏览器的视觉回归,只有结构性断言 +- 窄屏已用 playwright 在 390/320px 实测过(7.10.1),但那套脚本是临时的、 + 没有进 CI;日常回归仍只有读源码的结构性断言 - 登录限速与新建会话限速已改为 DB 事务(rate_limits 表),多实例部署不再各自计数 - SQLite 抄送查询走 `json_each` 全表展开,无索引;单机量级下够用, 百万级邮件时需要加物化列或换回 PostgreSQL -- 登录限速是进程内内存计数,多实例部署时失效(MVP 单实例,暂不需要) --- diff --git a/web/package.json b/web/package.json index 5864411..f536f31 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,9 @@ "build": "vite build", "preview": "vite preview", "typecheck": "tsc --noEmit", - "test": "node test/markdown-xss.test.mjs && node test/narrow-layout.test.mjs" + "test": "node test/markdown-xss.test.mjs && node test/narrow-layout.test.mjs", + "test:narrow": "node test/manual/narrow-verify.mjs", + "test:wide": "node test/manual/wide-regression.mjs" }, "dependencies": { "react": "^18.3.1", diff --git a/web/src/App.tsx b/web/src/App.tsx index 78a8b38..830ea8a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 ( - +
{main}
); } return ( - + ); @@ -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 (
{children} - {navOpen && ( - <> - {/* 遮罩:点空白处收起,这是移动端的通用预期 */} - ); } diff --git a/web/src/components/AccountPage.tsx b/web/src/components/AccountPage.tsx index 8f3d6a8..6dc9358 100644 --- a/web/src/components/AccountPage.tsx +++ b/web/src/components/AccountPage.tsx @@ -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 (
-

账号信息

@@ -205,6 +204,21 @@ export default function AccountPage() { onDismissToken={() => setNewToken(null)} /> + + {/* 退出登录。 + 放在这里而不是导航里:它是一个低频且不可逆的动作, + 与密码、密钥同属「账号自身」。窄屏下这也是唯一的退出口: + 抽屉式侧栏已删(它的其余入口与底部导航完全重复)。 */} +
+

登录状态

+ +
); diff --git a/web/src/components/AdminUsersPage.tsx b/web/src/components/AdminUsersPage.tsx index fcb31a6..7aa9b01 100644 --- a/web/src/components/AdminUsersPage.tsx +++ b/web/src/components/AdminUsersPage.tsx @@ -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 (
- setTab('users')}> 用户管理 @@ -119,7 +117,7 @@ export default function AdminUsersPage() {
{notice && {notice}} {tab === 'users' && ( - )} @@ -174,7 +172,7 @@ function TabButton({ active, onClick, children }: { return ( {user.username} - {user.display_name} + {user.display_name} {user.role === 'admin' ? '管理员' : '用户'} @@ -316,7 +314,7 @@ function UserEditor({ user, scopes, onSaved, onReload, setError }: { )}
-
@@ -324,7 +322,7 @@ function UserEditor({ user, scopes, onSaved, onReload, setError }: { 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" />
@@ -390,7 +388,7 @@ function CreateUserForm({ scopes, onDone, onError }: { )}
-
diff --git a/web/src/components/Attachments.tsx b/web/src/components/Attachments.tsx index be80ad8..5a0b28f 100644 --- a/web/src/components/Attachments.tsx +++ b/web/src/components/Attachments.tsx @@ -118,7 +118,7 @@ export function AttachmentPicker({ @@ -309,7 +309,7 @@ function Toggle({ return ( -
+
-
@@ -172,7 +172,7 @@ function CreateForm({ variant, busy, onSubmit }: CreateFormProps) { diff --git a/web/src/components/MailList.tsx b/web/src/components/MailList.tsx index 3fbfe7e..84f1f2f 100644 --- a/web/src/components/MailList.tsx +++ b/web/src/components/MailList.tsx @@ -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 (
-

{isSent ? '发件箱' : '收件箱'}

{list.length}
diff --git a/web/src/components/MailView.tsx b/web/src/components/MailView.tsx index 758c9f0..5fea2d3 100644 --- a/web/src/components/MailView.tsx +++ b/web/src/components/MailView.tsx @@ -298,7 +298,7 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
@@ -325,13 +325,13 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
{error && {error}}
- @@ -375,13 +375,13 @@ function Header({ )}
{mail.status === 'unread' && ( - )} )} @@ -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" > 清空 diff --git a/web/src/components/ModelScopePanel.tsx b/web/src/components/ModelScopePanel.tsx index bebcbac..7866648 100644 --- a/web/src/components/ModelScopePanel.tsx +++ b/web/src/components/ModelScopePanel.tsx @@ -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" > ↑ @@ -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" > ↓ @@ -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 && ( @@ -286,7 +286,7 @@ function AgentModelRow({ - ); -} diff --git a/web/src/components/QuotaPanel.tsx b/web/src/components/QuotaPanel.tsx index c230cb7..917770a 100644 --- a/web/src/components/QuotaPanel.tsx +++ b/web/src/components/QuotaPanel.tsx @@ -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" > diff --git a/web/src/components/ThreadView.tsx b/web/src/components/ThreadView.tsx index 620b832..8b9b14d 100644 --- a/web/src/components/ThreadView.tsx +++ b/web/src/components/ThreadView.tsx @@ -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 (
+ {/* 窄屏下对话树是盖在列表上的一层,得有返回出口。 + 它与右侧的「关闭」语义不同:返回退出整个详情栏回到列表, + 关闭只收起树、留在这封邮件上。 */} + 对话树 已加载 {nodes.length} 封 @@ -136,7 +142,7 @@ export default function ThreadView({ mailID, onClose }: { mailID: string; onClos {loading && } @@ -191,14 +197,21 @@ function Node({ anchorRef?: React.RefObject; }) { 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', diff --git a/web/src/components/WorkCard.tsx b/web/src/components/WorkCard.tsx index 1a6b560..cee4819 100644 --- a/web/src/components/WorkCard.tsx +++ b/web/src/components/WorkCard.tsx @@ -94,11 +94,11 @@ export function WorkCard({
-
+