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:
@ -144,8 +144,9 @@ agentmail/
|
|||||||
│ ├── opencode-mail-bridge/ # opencode
|
│ ├── opencode-mail-bridge/ # opencode
|
||||||
│ └── dsh-mail-bridge/ # DeepSeek Harness(Cordis)
|
│ └── dsh-mail-bridge/ # DeepSeek Harness(Cordis)
|
||||||
├── web/ # 前端(React + Vite + Tailwind)
|
├── web/ # 前端(React + Vite + Tailwind)
|
||||||
|
│ └── test/manual/ # 浏览器实测脚本(量真实盒子与命中区,不进 npm test)
|
||||||
└── deploy/ # systemd 单元 + 安装脚本
|
└── deploy/ # systemd 单元 + 安装脚本
|
||||||
└── remote-agent-demo.py # 最小跟主机 Agent(纯标准库,验证协议层能力)
|
└── remote-agent-demo.py # 最小跨主机 Agent(纯标准库,验证协议层能力)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|||||||
@ -105,8 +105,65 @@ SSE 连接的 handler 在首次连接时从缓冲区头部开始(客户端传
|
|||||||
**修法**:同上,sessionrate.go 重写为调用 RateLimitCheckAndRecord。
|
**修法**:同上,sessionrate.go 重写为调用 RateLimitCheckAndRecord。
|
||||||
|
|
||||||
### P2 — 组件级测试
|
### P2 — 组件级测试
|
||||||
**现状**:前端无任何组件测试,前端回归只靠 lint 与构建。
|
**现状**:有结构性回归(`web/test/narrow-layout.test.mjs`,28 条断言,
|
||||||
|
读源码验形态)与一套 playwright 手工脚本,但**没有渲染组件跑断言的测试**。
|
||||||
**范围**:关键组件(AddressInput 补全、PermissionPanel 决策、WorkCard 预算渲染)。
|
**范围**:关键组件(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 — 深色主题
|
### P2 — 深色主题
|
||||||
**现状**:只有浅色主题,深夜使用刺眼。
|
**现状**:只有浅色主题,深夜使用刺眼。
|
||||||
|
|||||||
56
docs/PLAN.md
56
docs/PLAN.md
@ -1326,13 +1326,13 @@ MVP 计划(Phase 1-6)已全部落地并在 systemd 部署态实测通过。
|
|||||||
transition 根本不触发(单层 rAF 在 Safari 上偶尔仍被合帧)
|
transition 根本不触发(单层 rAF 在 Safari 上偶尔仍被合帧)
|
||||||
- [x] `motion-reduce:transition-none` 尊重 `prefers-reduced-motion`
|
- [x] `motion-reduce:transition-none` 尊重 `prefers-reduced-motion`
|
||||||
- [x] 打开覆盖层时底层 `aria-hidden`,否则屏幕阅读器会读到两层内容
|
- [x] 打开覆盖层时底层 `aria-hidden`,否则屏幕阅读器会读到两层内容
|
||||||
- [x] 导航:竖条在窄屏退化为抽屉(60px 在手机上白占一成宽度),
|
- [x] 导航:竖条在窄屏让位给底部 `NarrowNav`(60px 在手机上白占一成宽度,
|
||||||
日常切换交给底部 `NarrowNav`(拇指够得到);
|
而底部横排拇指够得到)。**曾经还有一个抽屉式侧栏,后来删掉了 —— 见 7.10.1**
|
||||||
抽屉带遮罩,点空白处收起
|
|
||||||
- [x] `env(safe-area-inset-bottom)`:iPhone 手势条会盖住最后一排
|
- [x] `env(safe-area-inset-bottom)`:iPhone 手势条会盖住最后一排
|
||||||
- [x] **窄屏专属控件用条件渲染而非 `md:hidden`**:后者只是视觉隐藏,
|
- [x] **窄屏专属控件用条件渲染而非 `md:hidden`**:后者只是视觉隐藏,
|
||||||
元素仍在 DOM 与 tab 序列里,宽屏用户按 Tab 会聚焦到看不见的返回按钮上。
|
元素仍在 DOM 与 tab 序列里,宽屏用户按 Tab 会聚焦到看不见的返回按钮上。
|
||||||
为此抽了 `NarrowOnly` / `BackButton` / `NavToggle` 三个组件
|
为此抽了 `NarrowOnly` / `BackButton` 两个组件
|
||||||
|
(原先还有 `NavToggle`,随抽屉一起删除)
|
||||||
- [x] 列表栏 `w-full md:w-[320px]`;各页横向内边距 `px-4 md:px-6`
|
- [x] 列表栏 `w-full md:w-[320px]`;各页横向内边距 `px-4 md:px-6`
|
||||||
(px-6 在 375px 屏上白吃 48px)
|
(px-6 在 375px 屏上白吃 48px)
|
||||||
- [x] 管理页的 3/4 列 grid 改响应式;列表行 `flex-wrap`
|
- [x] 管理页的 3/4 列 grid 改响应式;列表行 `flex-wrap`
|
||||||
@ -1342,22 +1342,58 @@ MVP 计划(Phase 1-6)已全部落地并在 systemd 部署态实测通过。
|
|||||||
只滑走覆盖层的话下次进列表又会弹回来
|
只滑走覆盖层的话下次进列表又会弹回来
|
||||||
- [x] `narrowPane` 在宽屏下**也维护**:否则从窄屏拖宽再拖回来,
|
- [x] `narrowPane` 在宽屏下**也维护**:否则从窄屏拖宽再拖回来,
|
||||||
用户会发现自己回到了列表,刚打开的邮件不见了
|
用户会发现自己回到了列表,刚打开的邮件不见了
|
||||||
- [x] `web/test/narrow-layout.test.mjs`:16 条结构性断言,
|
- [x] `web/test/narrow-layout.test.mjs`:结构性断言(现 28 条),
|
||||||
钉住「覆盖而非分栏」「延迟卸载」「双层 rAF」「条件渲染而非 md:hidden」
|
钉住「覆盖而非分栏」「延迟卸载」「双层 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 表),多实例部署不再各自计数
|
- 登录限速与新建会话限速已改为 DB 事务(rate_limits 表),多实例部署不再各自计数
|
||||||
- SQLite 抄送查询走 `json_each` 全表展开,无索引;单机量级下够用,
|
- SQLite 抄送查询走 `json_each` 全表展开,无索引;单机量级下够用,
|
||||||
百万级邮件时需要加物化列或换回 PostgreSQL
|
百万级邮件时需要加物化列或换回 PostgreSQL
|
||||||
- 登录限速是进程内内存计数,多实例部署时失效(MVP 单实例,暂不需要)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,9 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit",
|
"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": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
@ -27,8 +27,6 @@ export default function App() {
|
|||||||
const composing = useUIStore(s => s.composing);
|
const composing = useUIStore(s => s.composing);
|
||||||
const resetUI = useUIStore(s => s.reset);
|
const resetUI = useUIStore(s => s.reset);
|
||||||
const narrowPane = useUIStore(s => s.narrowPane);
|
const narrowPane = useUIStore(s => s.narrowPane);
|
||||||
const navOpen = useUIStore(s => s.navOpen);
|
|
||||||
const closeNav = useUIStore(s => s.closeNav);
|
|
||||||
const narrow = useIsNarrow();
|
const narrow = useIsNarrow();
|
||||||
|
|
||||||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||||
@ -141,13 +139,13 @@ export default function App() {
|
|||||||
// 它们本来就是单页,套一层滑动只会让「进入账号页」也带动画,很怪
|
// 它们本来就是单页,套一层滑动只会让「进入账号页」也带动画,很怪
|
||||||
if (!hasList) {
|
if (!hasList) {
|
||||||
return (
|
return (
|
||||||
<NarrowShell navOpen={navOpen} onCloseNav={closeNav}>
|
<NarrowShell>
|
||||||
<div className="flex-1 min-h-0 flex">{main}</div>
|
<div className="flex-1 min-h-0 flex">{main}</div>
|
||||||
</NarrowShell>
|
</NarrowShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<NarrowShell navOpen={navOpen} onCloseNav={closeNav}>
|
<NarrowShell>
|
||||||
<NarrowStack base={list} overlay={main} open={narrowPane === 'detail'} />
|
<NarrowStack base={list} overlay={main} open={narrowPane === 'detail'} />
|
||||||
</NarrowShell>
|
</NarrowShell>
|
||||||
);
|
);
|
||||||
@ -164,33 +162,18 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 窄屏外壳:内容区 + 底部导航 + 抽屉式侧栏。
|
* 窄屏外壳:内容区 + 底部导航。
|
||||||
*
|
*
|
||||||
* 侧栏在窄屏下是抽屉而不是常驻:60px 竖条在手机上白占一成宽度,
|
* 没有抽屉式侧栏。它曾经存在,装的是六个与底部导航完全重复的入口,
|
||||||
* 而底部导航已经覆盖了日常切换,抽屉只留给不常用的入口。
|
* 唯一独有的是退出登录(已移到「我的」页)。为一个按钮维护一套
|
||||||
|
* fixed 层级 + 遮罩的代价是:抽屉 `z-50` 铺满视口高度,把底部导航
|
||||||
|
* 最左那一项盖住点不到(实测 elementFromPoint 命中抽屉里的 SVG)。
|
||||||
*/
|
*/
|
||||||
function NarrowShell({
|
function NarrowShell({ children }: { children: React.ReactNode }) {
|
||||||
children,
|
|
||||||
navOpen,
|
|
||||||
onCloseNav
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
navOpen: boolean;
|
|
||||||
onCloseNav: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-gray-50 overflow-hidden">
|
<div className="h-full flex flex-col bg-gray-50 overflow-hidden">
|
||||||
{children}
|
{children}
|
||||||
<NarrowNav />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import * as api from '../api/client';
|
import * as api from '../api/client';
|
||||||
import NavToggle from './NavToggle';
|
import { LockIcon, LogoutIcon } from './icons';
|
||||||
import { LockIcon } from './icons';
|
|
||||||
import KeyPanel from './KeyPanel';
|
import KeyPanel from './KeyPanel';
|
||||||
|
|
||||||
/** 当前用户个人中心:查看资料、修改密码、管理客户端连接密钥 */
|
/** 当前用户个人中心:查看资料、修改密码、管理客户端连接密钥 */
|
||||||
export default function AccountPage() {
|
export default function AccountPage() {
|
||||||
const user = useAuthStore(s => s.user);
|
const user = useAuthStore(s => s.user);
|
||||||
|
const logout = useAuthStore(s => s.logout);
|
||||||
const [oldPw, setOldPw] = useState('');
|
const [oldPw, setOldPw] = useState('');
|
||||||
const [newPw, setNewPw] = useState('');
|
const [newPw, setNewPw] = useState('');
|
||||||
const [confirmPw, setConfirmPw] = useState('');
|
const [confirmPw, setConfirmPw] = useState('');
|
||||||
@ -87,7 +87,6 @@ export default function AccountPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex-1 min-w-0 flex flex-col bg-white">
|
<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">
|
<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>
|
<h2 className="text-sm font-semibold text-gray-900">账号信息</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -205,6 +204,21 @@ export default function AccountPage() {
|
|||||||
onDismissToken={() => setNewToken(null)}
|
onDismissToken={() => setNewToken(null)}
|
||||||
/>
|
/>
|
||||||
</section>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import * as api from '../api/client';
|
import * as api from '../api/client';
|
||||||
import NavToggle from './NavToggle';
|
|
||||||
import type { AdminScopes, User } from '../types';
|
import type { AdminScopes, User } from '../types';
|
||||||
import { CheckIcon, LockIcon, UsersIcon, ChevronRightIcon, KeyIcon, BotIcon, CpuIcon } from './icons';
|
import { CheckIcon, LockIcon, UsersIcon, ChevronRightIcon, KeyIcon, BotIcon, CpuIcon } from './icons';
|
||||||
import KeyPanel from './KeyPanel';
|
import KeyPanel from './KeyPanel';
|
||||||
@ -98,7 +97,6 @@ export default function AdminUsersPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex-1 min-w-0 flex flex-col bg-white">
|
<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">
|
<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')}>
|
<TabButton active={tab === 'users'} onClick={() => setTab('users')}>
|
||||||
<UsersIcon className="w-4 h-4" />
|
<UsersIcon className="w-4 h-4" />
|
||||||
用户管理
|
用户管理
|
||||||
@ -119,7 +117,7 @@ export default function AdminUsersPage() {
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{notice && <span className="text-xs text-green-600">{notice}</span>}
|
{notice && <span className="text-xs text-green-600">{notice}</span>}
|
||||||
{tab === 'users' && (
|
{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 ? '收起' : '新建用户'}
|
{creating ? '收起' : '新建用户'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@ -174,7 +172,7 @@ function TabButton({ active, onClick, children }: {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
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'
|
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 (
|
return (
|
||||||
<div className="rounded-lg border border-gray-200">
|
<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">
|
<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' : ''}`} />
|
<ChevronRightIcon className={`w-3 h-3 transition-transform ${expanded ? 'rotate-90' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
<span className="font-mono text-sm text-gray-900 min-w-[100px]">{user.username}</span>
|
<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'}`}>
|
<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' ? '管理员' : '用户'}
|
{user.role === 'admin' ? '管理员' : '用户'}
|
||||||
</span>
|
</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">
|
<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 ? '保存中' : '保存更改'}
|
{busy ? '保存中' : '保存更改'}
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-1.5 ml-auto">
|
<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 位)"
|
<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" />
|
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}
|
<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" /> 重置
|
<CheckIcon className="w-3 h-3" /> 重置
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -390,7 +388,7 @@ function CreateUserForm({ scopes, onDone, onError }: {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<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 ? '创建中' : '创建'}
|
{busy ? '创建中' : '创建'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -118,7 +118,7 @@ export function AttachmentPicker({
|
|||||||
<button
|
<button
|
||||||
onClick={pick}
|
onClick={pick}
|
||||||
disabled={disabled || uploading !== null}
|
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" />
|
<PaperclipIcon className="w-3.5 h-3.5" />
|
||||||
添加附件
|
添加附件
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export default function BackButton({ label = '返回' }: { label?: string }) {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={showList}
|
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}
|
aria-label={label}
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon className="w-4 h-4" />
|
<ChevronLeftIcon className="w-4 h-4" />
|
||||||
|
|||||||
@ -139,7 +139,7 @@ export default function ComposePage() {
|
|||||||
<NarrowOnly>
|
<NarrowOnly>
|
||||||
<button
|
<button
|
||||||
onClick={cancelCompose}
|
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="返回"
|
aria-label="返回"
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon className="w-4 h-4" />
|
<ChevronLeftIcon className="w-4 h-4" />
|
||||||
@ -161,7 +161,7 @@ export default function ComposePage() {
|
|||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
className="text-xs text-gray-400 hover:text-gray-600"
|
className="tap text-xs text-gray-400 hover:text-gray-600"
|
||||||
>
|
>
|
||||||
清空
|
清空
|
||||||
</button>
|
</button>
|
||||||
@ -309,7 +309,7 @@ function Toggle({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
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'
|
active ? 'bg-gray-900 text-white' : 'text-gray-500 hover:text-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import {
|
|||||||
ListViewIcon,
|
ListViewIcon,
|
||||||
CardViewIcon
|
CardViewIcon
|
||||||
} from './icons';
|
} from './icons';
|
||||||
import NavToggle from './NavToggle';
|
|
||||||
import { WorkCard } from './WorkCard';
|
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">
|
<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">
|
<h2 className="text-sm font-semibold text-gray-800">
|
||||||
{view === 'card' ? '工作列表' : '联系人'}
|
{view === 'card' ? '工作列表' : '联系人'}
|
||||||
</h2>
|
</h2>
|
||||||
@ -73,7 +71,7 @@ export default function ContactPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setView(view === 'list' ? 'card' : 'list')}
|
onClick={() => setView(view === 'list' ? 'card' : 'list')}
|
||||||
title={view === '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' ? (
|
{view === 'list' ? (
|
||||||
<CardViewIcon className="w-3.5 h-3.5" />
|
<CardViewIcon className="w-3.5 h-3.5" />
|
||||||
@ -83,7 +81,7 @@ export default function ContactPanel() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={toggleArchivedView}
|
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'
|
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">
|
<div className="flex gap-2 mt-2">
|
||||||
<button
|
<button
|
||||||
onClick={onConfirm}
|
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" />
|
<CheckIcon className="w-3 h-3" />
|
||||||
确认归档
|
确认归档
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
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" />
|
<CloseIcon className="w-3 h-3" />
|
||||||
取消
|
取消
|
||||||
@ -255,11 +253,11 @@ function ContactRow({
|
|||||||
</p>
|
</p>
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={onCompose}
|
onClick={onCompose}
|
||||||
title="写信给该地址"
|
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" />
|
<ComposeIcon className="w-3 h-3" />
|
||||||
写信
|
写信
|
||||||
@ -267,7 +265,7 @@ function ContactRow({
|
|||||||
<button
|
<button
|
||||||
onClick={onRequestArchive}
|
onClick={onRequestArchive}
|
||||||
title="归档该 name@path.session"
|
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" />
|
<ArchiveIcon className="w-3 h-3" />
|
||||||
归档
|
归档
|
||||||
|
|||||||
@ -53,12 +53,12 @@ function NewKeyBanner({ token, onDismiss }: { token: string; onDismiss: () => vo
|
|||||||
</code>
|
</code>
|
||||||
<button
|
<button
|
||||||
onClick={copy}
|
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 ? <CheckIcon className="w-3.5 h-3.5" /> : <CopyIcon className="w-3.5 h-3.5" />}
|
||||||
{copied ? '已复制' : '复制'}
|
{copied ? '已复制' : '复制'}
|
||||||
</button>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -172,7 +172,7 @@ function CreateForm({ variant, busy, onSubmit }: CreateFormProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={busy}
|
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>
|
</button>
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { useSessionStore } from '../stores/sessionStore';
|
|||||||
import { useUIStore } from '../stores/uiStore';
|
import { useUIStore } from '../stores/uiStore';
|
||||||
import type { Mail } from '../types';
|
import type { Mail } from '../types';
|
||||||
import { ShieldIcon, PaperclipIcon } from './icons';
|
import { ShieldIcon, PaperclipIcon } from './icons';
|
||||||
import NavToggle from './NavToggle';
|
|
||||||
|
|
||||||
export default function MailList() {
|
export default function MailList() {
|
||||||
const viewMode = useUIStore(s => s.viewMode);
|
const viewMode = useUIStore(s => s.viewMode);
|
||||||
@ -40,7 +39,6 @@ export default function MailList() {
|
|||||||
return (
|
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="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">
|
<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>
|
<h2 className="text-sm font-semibold text-gray-800">{isSent ? '发件箱' : '收件箱'}</h2>
|
||||||
<span className="ml-2 text-xs text-gray-400">{list.length}</span>
|
<span className="ml-2 text-xs text-gray-400">{list.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -298,7 +298,7 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button
|
<button
|
||||||
onClick={() => setCcOpen(o => !o)}
|
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 ? '收起抄送' : '抄送'}
|
{ccOpen ? '收起抄送' : '抄送'}
|
||||||
</button>
|
</button>
|
||||||
@ -325,13 +325,13 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{error && <span className="text-xs text-red-600">{error}</span>}
|
{error && <span className="text-xs text-red-600">{error}</span>}
|
||||||
<div className="flex-1" />
|
<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>
|
||||||
<button
|
<button
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={busy || !to.trim()}
|
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 ? '转发中' : '转发'}
|
{busy ? '转发中' : '转发'}
|
||||||
</button>
|
</button>
|
||||||
@ -375,13 +375,13 @@ function Header({
|
|||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{mail.status === 'unread' && (
|
{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>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onThread}
|
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="沿回复与转发关系展开整条线索"
|
title="沿回复与转发关系展开整条线索"
|
||||||
>
|
>
|
||||||
<TreeIcon className="w-3.5 h-3.5" />
|
<TreeIcon className="w-3.5 h-3.5" />
|
||||||
@ -389,7 +389,7 @@ function Header({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onForward}
|
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" />
|
<ForwardIcon className="w-3.5 h-3.5" />
|
||||||
转发
|
转发
|
||||||
@ -617,14 +617,14 @@ function ReplyBar({ replyTo }: { replyTo?: Mail }) {
|
|||||||
{(replyTo.cc_list?.length ?? 0) > 0 && (
|
{(replyTo.cc_list?.length ?? 0) > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={replyAll}
|
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>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setCcOpen(o => !o)}
|
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 ? '收起抄送' : '抄送'}
|
{ccOpen ? '收起抄送' : '抄送'}
|
||||||
</button>
|
</button>
|
||||||
@ -656,14 +656,14 @@ function ReplyBar({ replyTo }: { replyTo?: Mail }) {
|
|||||||
setBody('');
|
setBody('');
|
||||||
setError(null);
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={send}
|
onClick={send}
|
||||||
disabled={busy || !body.trim()}
|
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 ? '发送中' : '发送'}
|
{busy ? '发送中' : '发送'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -213,7 +213,7 @@ function AgentModelRow({
|
|||||||
onClick={() => move(key, -1)}
|
onClick={() => move(key, -1)}
|
||||||
disabled={i === 0}
|
disabled={i === 0}
|
||||||
title="上移"
|
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>
|
||||||
@ -221,14 +221,14 @@ function AgentModelRow({
|
|||||||
onClick={() => move(key, 1)}
|
onClick={() => move(key, 1)}
|
||||||
disabled={i === picks.length - 1}
|
disabled={i === picks.length - 1}
|
||||||
title="下移"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => toggle(key)}
|
onClick={() => toggle(key)}
|
||||||
title="移除"
|
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>
|
</button>
|
||||||
@ -253,7 +253,7 @@ function AgentModelRow({
|
|||||||
key={key}
|
key={key}
|
||||||
onClick={() => toggle(key)}
|
onClick={() => toggle(key)}
|
||||||
title={m.display_name || 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
|
on
|
||||||
? 'bg-blue-50 border-blue-300 text-blue-700'
|
? 'bg-blue-50 border-blue-300 text-blue-700'
|
||||||
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'
|
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'
|
||||||
@ -278,7 +278,7 @@ function AgentModelRow({
|
|||||||
{dirty && (
|
{dirty && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setPicks(saved)}
|
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>
|
</button>
|
||||||
@ -286,7 +286,7 @@ function AgentModelRow({
|
|||||||
<button
|
<button
|
||||||
onClick={save}
|
onClick={save}
|
||||||
disabled={!dirty || saving}
|
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 ? (
|
{saving ? (
|
||||||
<SpinnerIcon className="w-3 h-3 animate-spin" />
|
<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'))}
|
onClick={() => apply(s.agent_name, Number(draft.trim() || '0'))}
|
||||||
disabled={!dirty || invalid || busy === s.agent_name}
|
disabled={!dirty || invalid || busy === s.agent_name}
|
||||||
title="保存默认预算"
|
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" />
|
<CheckIcon className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import * as api from '../api/client';
|
|||||||
import { useMailStore } from '../stores/mailStore';
|
import { useMailStore } from '../stores/mailStore';
|
||||||
import type { ThreadNode } from '../types';
|
import type { ThreadNode } from '../types';
|
||||||
import { CloseIcon, PaperclipIcon, PersonIcon, BotIcon, ShieldIcon, SpinnerIcon } from './icons';
|
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 (
|
return (
|
||||||
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
|
<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">
|
<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-sm font-semibold text-gray-900">对话树</span>
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-gray-400">
|
||||||
已加载 {nodes.length} 封
|
已加载 {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" />}
|
{loading && <SpinnerIcon className="w-3.5 h-3.5 animate-spin text-gray-400" />}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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" />
|
<CloseIcon className="w-3.5 h-3.5" />
|
||||||
关闭
|
关闭
|
||||||
@ -168,7 +174,7 @@ export default function ThreadView({ mailID, onClose }: { mailID: string; onClos
|
|||||||
{hasMore && (
|
{hasMore && (
|
||||||
<button
|
<button
|
||||||
onClick={loadMore}
|
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>
|
</button>
|
||||||
@ -191,14 +197,21 @@ function Node({
|
|||||||
anchorRef?: React.RefObject<HTMLDivElement>;
|
anchorRef?: React.RefObject<HTMLDivElement>;
|
||||||
}) {
|
}) {
|
||||||
const openMailByID = useMailStore(s => s.openMailByID);
|
const openMailByID = useMailStore(s => s.openMailByID);
|
||||||
|
const narrow = useIsNarrow();
|
||||||
const isPermission = node.mail_type === 'permission_request';
|
const isPermission = node.mail_type === 'permission_request';
|
||||||
const isAnchor = node.mail_id === anchorID;
|
const isAnchor = node.mail_id === anchorID;
|
||||||
const ccCount = node.cc_list?.length ?? 0;
|
const ccCount = node.cc_list?.length ?? 0;
|
||||||
// 转发是一条新线索:主题带 Fwd: 前缀,且落在别的会话里。
|
// 转发是一条新线索:主题带 Fwd: 前缀,且落在别的会话里。
|
||||||
// 树里把它标出来,否则一个分支为什么突然换了收件人无从判断。
|
// 树里把它标出来,否则一个分支为什么突然换了收件人无从判断。
|
||||||
const isForward = node.subject.startsWith('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', {
|
const time = new Date(node.created_at).toLocaleString('zh-CN', {
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
|
|||||||
@ -94,11 +94,11 @@ export function WorkCard({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={onCompose}
|
onClick={onCompose}
|
||||||
title="写信给该地址"
|
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" />
|
<ComposeIcon className="w-3 h-3" />
|
||||||
写信
|
写信
|
||||||
@ -106,7 +106,7 @@ export function WorkCard({
|
|||||||
<button
|
<button
|
||||||
onClick={onArchive}
|
onClick={onArchive}
|
||||||
title="归档该 name@path.session"
|
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" />
|
<ArchiveIcon className="w-3 h-3" />
|
||||||
归档
|
归档
|
||||||
|
|||||||
@ -11,3 +11,59 @@
|
|||||||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
"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;
|
showDetail: () => void;
|
||||||
showList: () => void;
|
showList: () => void;
|
||||||
|
|
||||||
/** 侧边导航在窄屏下是抽屉,宽屏下常驻 */
|
|
||||||
navOpen: boolean;
|
|
||||||
toggleNav: () => void;
|
|
||||||
closeNav: () => void;
|
|
||||||
|
|
||||||
/** 登出后重置回默认视图 */
|
/** 登出后重置回默认视图 */
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
@ -42,31 +37,25 @@ export const useUIStore = create<UIState>(set => ({
|
|||||||
viewMode: mode,
|
viewMode: mode,
|
||||||
composing: false,
|
composing: false,
|
||||||
composePrefill: null,
|
composePrefill: null,
|
||||||
narrowPane: 'list',
|
narrowPane: 'list'
|
||||||
navOpen: false
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
composing: false,
|
composing: false,
|
||||||
composePrefill: null,
|
composePrefill: null,
|
||||||
// 写信占满整个主区域,窄屏下等价于切到 detail 栏
|
// 写信占满整个主区域,窄屏下等价于切到 detail 栏
|
||||||
startCompose: prefill =>
|
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' }),
|
cancelCompose: () => set({ composing: false, composePrefill: null, narrowPane: 'list' }),
|
||||||
|
|
||||||
narrowPane: 'list',
|
narrowPane: 'list',
|
||||||
showDetail: () => set({ narrowPane: 'detail' }),
|
showDetail: () => set({ narrowPane: 'detail' }),
|
||||||
showList: () => set({ narrowPane: 'list' }),
|
showList: () => set({ narrowPane: 'list' }),
|
||||||
|
|
||||||
navOpen: false,
|
|
||||||
toggleNav: () => set(s => ({ navOpen: !s.navOpen })),
|
|
||||||
closeNav: () => set({ navOpen: false }),
|
|
||||||
|
|
||||||
reset: () =>
|
reset: () =>
|
||||||
set({
|
set({
|
||||||
viewMode: 'inbox',
|
viewMode: 'inbox',
|
||||||
composing: false,
|
composing: false,
|
||||||
composePrefill: null,
|
composePrefill: null,
|
||||||
narrowPane: 'list',
|
narrowPane: 'list'
|
||||||
navOpen: false
|
|
||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
|
|||||||
58
web/test/manual/README.md
Normal file
58
web/test/manual/README.md
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# 手工浏览器实测脚本
|
||||||
|
|
||||||
|
不进 `npm test` —— 它们需要一个跑着的 Chromium 与一个活的 Gateway。
|
||||||
|
日常回归靠 `../narrow-layout.test.mjs`(读源码验形态,无外部依赖)。
|
||||||
|
|
||||||
|
## 为什么两套都要
|
||||||
|
|
||||||
|
结构性断言守住「代码写成了什么形态」,量不出「按钮实际多大、点下去命中谁」。
|
||||||
|
|
||||||
|
窄屏那轮修复里最严重的一个 bug 是抽屉式侧栏(`fixed ... z-50` 铺满视口高度)
|
||||||
|
把底部导航最左那一项盖住 —— 按钮在那里、尺寸也够、`md:hidden` 之类的规则也
|
||||||
|
没写错,**只有 `elementFromPoint` 才能发现它命中的是抽屉里的 SVG**。
|
||||||
|
|
||||||
|
## 用法
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 窄屏:390px(iPhone 14 Pro)+ 320px(iPhone SE)
|
||||||
|
ADMIN_PW=<密码> npm run test:narrow
|
||||||
|
|
||||||
|
# 宽屏回归:窄屏修复不能把桌面改坏
|
||||||
|
ADMIN_PW=<密码> npm run test:wide
|
||||||
|
```
|
||||||
|
|
||||||
|
环境变量:
|
||||||
|
|
||||||
|
| 变量 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `ADMIN_PW` | 无(必填) | 管理员密码 |
|
||||||
|
| `ADMIN_USER` | `admin` | 登录用户名 |
|
||||||
|
| `AGENTMAIL_URL` | `https://mail.jianfgit.xyz` | 目标地址 |
|
||||||
|
| `CDP_URL` | `http://127.0.0.1:9222` | 浏览器 CDP 端点 |
|
||||||
|
| `PLAYWRIGHT` | `/usr/lib/node_modules/playwright/index.mjs` | playwright 入口 |
|
||||||
|
|
||||||
|
浏览器用的是本机 systemd 托管的共享 Chromium(`homeagent-browser.service`),
|
||||||
|
通过 CDP 连上去开自己的标签页,用完关掉。没有它时先
|
||||||
|
`systemctl start homeagent-browser`。
|
||||||
|
|
||||||
|
## 文件
|
||||||
|
|
||||||
|
| 文件 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `narrow-probe-helper.mjs` | 连浏览器、登录、量盒子/溢出/命中区/命中测试 |
|
||||||
|
| `narrow-verify.mjs` | 窄屏 13 项验收 |
|
||||||
|
| `wide-regression.mjs` | 宽屏 5 项回归 |
|
||||||
|
|
||||||
|
`narrow-probe-helper.mjs` 里两个函数值得单独知道:
|
||||||
|
|
||||||
|
- `tapTargets(page, labels)` —— 量 `.tap` 按钮的**真实**命中区(`::after`
|
||||||
|
伪元素的尺寸)。`.tap` 刻意不改变视觉尺寸,所以只看 `boundingBox` 会误判成偏小
|
||||||
|
- `hitTest(page, selector)` —— 每个元素点下去是否命中自己。遮挡类 bug 只能这样查
|
||||||
|
|
||||||
|
## 已知限制
|
||||||
|
|
||||||
|
headless Chromium 报告 `hover: none`,因此 `.reveal`(只在支持悬停的设备上隐藏)
|
||||||
|
在这里永远是可见的 —— 脚本只能验「触摸设备上可见」这一半,
|
||||||
|
「鼠标设备上隐藏」那一半靠 `../narrow-layout.test.mjs` 检查 CSS 规则存在。
|
||||||
|
|
||||||
|
没有像素级视觉比对:字体差异下极脆,维护成本高于收益。
|
||||||
173
web/test/manual/narrow-probe-helper.mjs
Normal file
173
web/test/manual/narrow-probe-helper.mjs
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* 窄屏实测辅助:连本机共享 Chromium(CDP 127.0.0.1:9222)量真实盒子。
|
||||||
|
*
|
||||||
|
* 这是**手工脚本**,不进 `npm test` —— 它需要一个跑着的浏览器与一个活的
|
||||||
|
* Gateway。日常回归靠 `../narrow-layout.test.mjs` 的结构性断言。
|
||||||
|
*
|
||||||
|
* 两者分工:结构性断言守住「代码写成了什么形态」,量不出「按钮实际多大、
|
||||||
|
* 点下去命中谁」。抽屉遮挡底部导航那个 bug(`elementFromPoint` 命中抽屉里的
|
||||||
|
* SVG 而不是导航按钮)只有这样才能发现。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* ADMIN_PW=<密码> node web/test/manual/narrow-verify.mjs
|
||||||
|
* ADMIN_PW=<密码> node web/test/manual/wide-regression.mjs
|
||||||
|
*
|
||||||
|
* 环境变量:
|
||||||
|
* ADMIN_PW 必填,管理员密码
|
||||||
|
* AGENTMAIL_URL 目标地址,默认 https://mail.jianfgit.xyz
|
||||||
|
* CDP_URL 浏览器 CDP 端点,默认 http://127.0.0.1:9222
|
||||||
|
* PLAYWRIGHT playwright 入口,默认 /usr/lib/node_modules/playwright/index.mjs
|
||||||
|
*/
|
||||||
|
const PLAYWRIGHT = process.env.PLAYWRIGHT || '/usr/lib/node_modules/playwright/index.mjs';
|
||||||
|
const { chromium } = await import(PLAYWRIGHT);
|
||||||
|
|
||||||
|
const CDP = process.env.CDP_URL || 'http://127.0.0.1:9222';
|
||||||
|
const APP = (process.env.AGENTMAIL_URL || 'https://mail.jianfgit.xyz').replace(/\/$/, '');
|
||||||
|
|
||||||
|
export const PHONE = { width: 390, height: 844 }; // iPhone 14 Pro
|
||||||
|
export const SMALL = { width: 320, height: 568 }; // iPhone SE 1 代
|
||||||
|
export const WIDE = { width: 1280, height: 800 };
|
||||||
|
|
||||||
|
export async function openApp(viewport = PHONE) {
|
||||||
|
const browser = await chromium.connectOverCDP(CDP);
|
||||||
|
const ctx = browser.contexts()[0] ?? (await browser.newContext());
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
|
||||||
|
const issues = [];
|
||||||
|
page.on('pageerror', e => issues.push('pageerror: ' + String(e).slice(0, 220)));
|
||||||
|
page.on('console', m => {
|
||||||
|
if (m.type() === 'error') {
|
||||||
|
const t = m.text();
|
||||||
|
// 401 是未登录时的正常探测,不算问题
|
||||||
|
if (!t.includes('401')) issues.push('console: ' + t.slice(0, 200));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 不能用 networkidle:SSE 是一条永不结束的长连接,networkidle 永远不触发
|
||||||
|
await page.goto(APP + '/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
|
||||||
|
// 需要登录时登录(同一 context 共享 cookie,通常只登一次)
|
||||||
|
if ((await page.locator('input[autocomplete=username]').count()) > 0) {
|
||||||
|
if (!process.env.ADMIN_PW) throw new Error('需要 ADMIN_PW 环境变量');
|
||||||
|
await page.fill('input[autocomplete=username]', process.env.ADMIN_USER || 'admin');
|
||||||
|
await page.fill('input[type=password]', process.env.ADMIN_PW);
|
||||||
|
await page.click('button:has-text("登录")');
|
||||||
|
await page.waitForTimeout(3500);
|
||||||
|
}
|
||||||
|
return { browser, page, issues };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 量一个元素的盒子;不存在返回 null。 */
|
||||||
|
export async function box(page, sel) {
|
||||||
|
const el = page.locator(sel).first();
|
||||||
|
if ((await el.count()) === 0) return null;
|
||||||
|
return await el.boundingBox();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 有没有横向溢出 —— 窄屏最常见的毛病。
|
||||||
|
*
|
||||||
|
* 只报 `right` 超过文档宽度的元素:溢出到左边通常是有意的负 margin。
|
||||||
|
*/
|
||||||
|
export async function overflowX(page) {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
const de = document.documentElement;
|
||||||
|
const over = [];
|
||||||
|
for (const el of document.querySelectorAll('*')) {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
if (r.width > 0 && r.right > de.clientWidth + 1) {
|
||||||
|
over.push({
|
||||||
|
tag: el.tagName.toLowerCase(),
|
||||||
|
cls: (el.className || '').toString().slice(0, 70),
|
||||||
|
right: Math.round(r.right),
|
||||||
|
text: (el.textContent || '').trim().slice(0, 40)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
docWidth: de.clientWidth,
|
||||||
|
scrollWidth: de.scrollWidth,
|
||||||
|
bodyScrollWidth: document.body.scrollWidth,
|
||||||
|
offenders: over.slice(0, 8)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点击目标够不够大。
|
||||||
|
*
|
||||||
|
* 量的是**视觉尺寸**;有 `.tap` 的按钮视觉上仍然很小,命中区在
|
||||||
|
* `::after` 伪元素上 —— 用 tapTargets() 才能看到真实命中区。
|
||||||
|
*/
|
||||||
|
export async function smallTargets(page, min = 40) {
|
||||||
|
return await page.evaluate(min => {
|
||||||
|
const bad = [];
|
||||||
|
for (const el of document.querySelectorAll(
|
||||||
|
'button, a, [role=button], input[type=checkbox]'
|
||||||
|
)) {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
if (r.width === 0 || r.height === 0) continue; // 隐藏的不算
|
||||||
|
if (r.height < min || r.width < min) {
|
||||||
|
bad.push({
|
||||||
|
tag: el.tagName.toLowerCase(),
|
||||||
|
w: Math.round(r.width),
|
||||||
|
h: Math.round(r.height),
|
||||||
|
text: (el.textContent || el.getAttribute('aria-label') || '').trim().slice(0, 28)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bad;
|
||||||
|
}, min);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 量 `.tap` 按钮的真实命中区(`::after` 伪元素的尺寸)。
|
||||||
|
*
|
||||||
|
* @param labels 只看这些文字的按钮
|
||||||
|
*/
|
||||||
|
export async function tapTargets(page, labels) {
|
||||||
|
return await page.evaluate(labels => {
|
||||||
|
const out = [];
|
||||||
|
for (const b of document.querySelectorAll('button')) {
|
||||||
|
const t = (b.textContent || '').trim();
|
||||||
|
if (labels.length && !labels.includes(t)) continue;
|
||||||
|
const bb = b.getBoundingClientRect();
|
||||||
|
if (bb.width === 0) continue;
|
||||||
|
const cs = getComputedStyle(b, '::after');
|
||||||
|
out.push({
|
||||||
|
t,
|
||||||
|
visual: `${Math.round(bb.width)}x${Math.round(bb.height)}`,
|
||||||
|
hitW: Math.round(parseFloat(cs.width) || 0),
|
||||||
|
hitH: Math.round(parseFloat(cs.height) || 0)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, labels);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每个元素点下去是否命中自己。
|
||||||
|
*
|
||||||
|
* 这是抽屉遮挡 bug 的检测手段:按钮明明在那里、尺寸也够,
|
||||||
|
* 但上面盖了一层 `fixed z-50`,`elementFromPoint` 命中的是别人。
|
||||||
|
*/
|
||||||
|
export async function hitTest(page, selector) {
|
||||||
|
return await page.evaluate(selector => {
|
||||||
|
const out = [];
|
||||||
|
for (const el of document.querySelectorAll(selector)) {
|
||||||
|
const bb = el.getBoundingClientRect();
|
||||||
|
if (bb.width === 0) continue;
|
||||||
|
const top = document.elementFromPoint(
|
||||||
|
Math.round(bb.x + bb.width / 2),
|
||||||
|
Math.round(bb.y + bb.height / 2)
|
||||||
|
);
|
||||||
|
out.push({
|
||||||
|
text: (el.textContent || '').replace(/\s+/g, '').slice(0, 8),
|
||||||
|
hit: el.contains(top) || el === top
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, selector);
|
||||||
|
}
|
||||||
149
web/test/manual/narrow-verify.mjs
Normal file
149
web/test/manual/narrow-verify.mjs
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* 窄屏实测验收:390px 与 320px 下量真实盒子、真实命中。
|
||||||
|
*
|
||||||
|
* 每一条都对应一个曾经真实存在的问题(见 docs/PLAN.md §7.10.1):
|
||||||
|
* 抽屉盖住底部导航、工具按钮只有 16px 高、看不见却按得动的「归档」、
|
||||||
|
* 对话树缩进把卡片压成竖条、对话树没有返回出口。
|
||||||
|
*
|
||||||
|
* 用法:ADMIN_PW=<密码> node web/test/manual/narrow-verify.mjs
|
||||||
|
*/
|
||||||
|
import { openApp, overflowX, tapTargets, hitTest, SMALL } from './narrow-probe-helper.mjs';
|
||||||
|
|
||||||
|
const { browser, page, issues } = await openApp();
|
||||||
|
const failed = [];
|
||||||
|
|
||||||
|
async function check(name, fn) {
|
||||||
|
try {
|
||||||
|
const r = await fn();
|
||||||
|
console.log(` ${r.ok ? '通过' : '失败'} ${name}${r.note ? ' — ' + r.note : ''}`);
|
||||||
|
if (!r.ok) failed.push(name);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` 错误 ${name} — ${e.message.slice(0, 90)}`);
|
||||||
|
failed.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开收件箱里的第一封邮件。
|
||||||
|
*
|
||||||
|
* 邮件行是 `<button class="w-full text-left ...">`,不是带 cursor-pointer 的 div
|
||||||
|
* —— 按后者找会一直等到超时。
|
||||||
|
*/
|
||||||
|
async function openFirstMail(page) {
|
||||||
|
await page.click('nav button:has-text("收件")');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
const rows = page.locator('button.w-full.text-left');
|
||||||
|
const n = await rows.count();
|
||||||
|
if (n === 0) throw new Error('收件箱是空的,没有邮件可点');
|
||||||
|
await rows.first().click();
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('窄屏实测(390px):');
|
||||||
|
|
||||||
|
// 抽屉已删。它是 fixed z-50 铺满视口高度,把底部导航最左那项盖住点不到。
|
||||||
|
await check('抽屉入口已移除', async () => {
|
||||||
|
const n = await page.locator('button[aria-label="打开导航"]').count();
|
||||||
|
return { ok: n === 0, note: n ? `仍有 ${n} 个` : '' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删抽屉时退出登录是它唯一的独有入口,必须有新去处
|
||||||
|
await check('「我的」页有退出登录', async () => {
|
||||||
|
await page.click('nav button:has-text("我的")');
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
const btn = page.locator('button:has-text("退出登录")');
|
||||||
|
const n = await btn.count();
|
||||||
|
const b = n ? await btn.first().boundingBox() : null;
|
||||||
|
return {
|
||||||
|
ok: n === 1 && b.height >= 36,
|
||||||
|
note: b ? `${Math.round(b.width)}x${Math.round(b.height)}` : '找不到'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 核心回归:底部导航每一项都要命中自己
|
||||||
|
await check('底部导航每项都命中自己', async () => {
|
||||||
|
const r = await hitTest(page, 'nav button');
|
||||||
|
const miss = r.filter(x => !x.hit);
|
||||||
|
return {
|
||||||
|
ok: r.length > 0 && miss.length === 0,
|
||||||
|
note: miss.length ? '未命中: ' + miss.map(m => m.text).join(',') : `${r.length} 项全部命中`
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 详情页工具按钮:视觉 15-16px,命中区必须补到 44
|
||||||
|
await check('详情页工具按钮命中区 >= 44px', async () => {
|
||||||
|
await openFirstMail(page);
|
||||||
|
|
||||||
|
const r = await tapTargets(page, ['标记已读', '对话树', '转发', '抄送', '发送', '清空']);
|
||||||
|
for (const x of r) console.log(' ', JSON.stringify(x));
|
||||||
|
const small = r.filter(x => x.hitH < 44 || x.hitW < 44);
|
||||||
|
return {
|
||||||
|
ok: r.length > 0 && small.length === 0,
|
||||||
|
note: small.length ? '仍偏小: ' + small.map(s => s.t).join(',') : `${r.length} 个都达标`
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 次要动作在触摸设备上必须可见(没有 hover 时曾经永远透明却接收点击)
|
||||||
|
await check('联系人页次要动作默认可见', async () => {
|
||||||
|
await page.click('nav button:has-text("联系人")');
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
const r = await page.evaluate(() =>
|
||||||
|
[...document.querySelectorAll('.reveal')].slice(0, 3).map(d => getComputedStyle(d).opacity)
|
||||||
|
);
|
||||||
|
return { ok: r.length > 0 && r.every(o => o === '1'), note: `opacity=${r.join(',')}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 对话树:窄屏要有返回出口
|
||||||
|
await check('对话树有返回出口', async () => {
|
||||||
|
await openFirstMail(page);
|
||||||
|
const t = page.locator('button:has-text("对话树")');
|
||||||
|
if ((await t.count()) === 0) return { ok: false, note: '找不到对话树入口' };
|
||||||
|
await t.first().click();
|
||||||
|
await page.waitForTimeout(1600);
|
||||||
|
const n = await page.locator('button[aria-label="返回"]').count();
|
||||||
|
return { ok: n >= 1, note: `${n} 个` };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 各页无横向溢出
|
||||||
|
for (const label of ['收件', '联系人', '管理', '我的']) {
|
||||||
|
await check(`${label}页无横向溢出`, async () => {
|
||||||
|
await page.click(`nav button:has-text("${label}")`);
|
||||||
|
await page.waitForTimeout(1100);
|
||||||
|
const of = await overflowX(page);
|
||||||
|
for (const o of of.offenders) console.log(' 超出:', JSON.stringify(o));
|
||||||
|
return { ok: of.scrollWidth <= of.docWidth, note: `doc=${of.docWidth} scroll=${of.scrollWidth}` };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n最窄(320px):');
|
||||||
|
await page.setViewportSize(SMALL);
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
|
||||||
|
await check('320px 收件箱无横向溢出', async () => {
|
||||||
|
await page.click('nav button:has-text("收件")');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
const of = await overflowX(page);
|
||||||
|
return { ok: of.scrollWidth <= of.docWidth, note: `doc=${of.docWidth} scroll=${of.scrollWidth}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('320px 模型范围排序按钮命中区达标', async () => {
|
||||||
|
await page.click('nav button:has-text("管理")');
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
await page.click('button:has-text("模型范围")');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
const first = page.locator('button:has-text("dsh")').first();
|
||||||
|
if ((await first.count()) === 0) return { ok: false, note: '没有 Agent 可展开' };
|
||||||
|
await first.click();
|
||||||
|
await page.waitForTimeout(1600);
|
||||||
|
const r = await tapTargets(page, ['↑', '↓', '×']);
|
||||||
|
if (r.length === 0) return { ok: true, note: '当前没有已选模型,跳过' };
|
||||||
|
const small = r.filter(x => x.hitH < 44 || x.hitW < 44);
|
||||||
|
return { ok: small.length === 0, note: `${r.length} 个,最小 ${Math.min(...r.map(x => x.hitH))}px 高` };
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\nissues:', issues.length ? issues : '无');
|
||||||
|
console.log(failed.length === 0 ? '\n窄屏实测:全部通过' : `\n窄屏实测:${failed.length} 项失败 — ${failed.join(', ')}`);
|
||||||
|
|
||||||
|
await page.close();
|
||||||
|
await browser.close();
|
||||||
|
process.exit(failed.length === 0 ? 0 : 1);
|
||||||
71
web/test/manual/wide-regression.mjs
Normal file
71
web/test/manual/wide-regression.mjs
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* 宽屏回归:窄屏修复不能把桌面布局改坏。
|
||||||
|
*
|
||||||
|
* 特别是两个只该在窄屏生效的东西:
|
||||||
|
* - `.tap` 的伪元素命中区(桌面密排工具栏里会互相重叠)
|
||||||
|
* - `BackButton`(宽屏列表与详情并排,返回没有意义)
|
||||||
|
*
|
||||||
|
* 用法:ADMIN_PW=<密码> node web/test/manual/wide-regression.mjs
|
||||||
|
*/
|
||||||
|
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
||||||
|
|
||||||
|
const { browser, page, issues } = await openApp(WIDE);
|
||||||
|
const failed = [];
|
||||||
|
const chk = (n, ok, note = '') => {
|
||||||
|
console.log(` ${ok ? '通过' : '失败'} ${n}${note ? ' — ' + note : ''}`);
|
||||||
|
if (!ok) failed.push(n);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('宽屏回归(1280px):');
|
||||||
|
|
||||||
|
const cols = await page.evaluate(() => {
|
||||||
|
const root = document.querySelector('#root > div');
|
||||||
|
return { n: root?.children.length, first: root?.children[0]?.className?.toString().slice(0, 30) };
|
||||||
|
});
|
||||||
|
chk('仍是三栏并排', cols.n === 3, `栏数=${cols.n} 首栏=${cols.first}`);
|
||||||
|
|
||||||
|
// 常驻侧栏是宽屏唯一的退出入口(账号页也有,两处都要在)
|
||||||
|
const side = await page.evaluate(() => {
|
||||||
|
const s = document.querySelector('#root > div > div');
|
||||||
|
const btns = s
|
||||||
|
? [...s.querySelectorAll('button')].map(b =>
|
||||||
|
(b.getAttribute('title') || b.textContent || '').trim().slice(0, 12)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
return { w: s ? Math.round(s.getBoundingClientRect().width) : 0, btns };
|
||||||
|
});
|
||||||
|
chk('常驻侧栏仍有退出登录', side.btns.some(b => b.includes('退出')), `宽=${side.w}`);
|
||||||
|
|
||||||
|
// 宽屏不该出现返回按钮
|
||||||
|
// 邮件行是 <button class="w-full text-left ...">
|
||||||
|
const rows = page.locator('button.w-full.text-left');
|
||||||
|
if ((await rows.count()) > 0) await rows.first().click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
const backN = await page.locator('button[aria-label="返回"], button[aria-label="会话"]').count();
|
||||||
|
chk('没有返回按钮', backN === 0, `${backN} 个`);
|
||||||
|
|
||||||
|
// .tap 只在 max-width:767px 生效
|
||||||
|
const tapWide = await page.evaluate(() => {
|
||||||
|
const b = [...document.querySelectorAll('.tap')].find(x => x.getBoundingClientRect().height > 0);
|
||||||
|
if (!b) return null;
|
||||||
|
const cs = getComputedStyle(b, '::after');
|
||||||
|
return { content: cs.content, w: cs.width, h: cs.height };
|
||||||
|
});
|
||||||
|
chk(
|
||||||
|
'.tap 伪元素在宽屏不生效',
|
||||||
|
!tapWide || tapWide.content === 'none' || tapWide.w === 'auto',
|
||||||
|
JSON.stringify(tapWide)
|
||||||
|
);
|
||||||
|
|
||||||
|
const of = await page.evaluate(() => ({
|
||||||
|
d: document.documentElement.clientWidth,
|
||||||
|
s: document.documentElement.scrollWidth
|
||||||
|
}));
|
||||||
|
chk('无横向溢出', of.s <= of.d, `doc=${of.d} scroll=${of.s}`);
|
||||||
|
|
||||||
|
console.log('\nissues:', issues.length ? issues : '无');
|
||||||
|
console.log(failed.length === 0 ? '\n宽屏回归:全部通过' : `\n宽屏回归:${failed.length} 项失败`);
|
||||||
|
|
||||||
|
await page.close();
|
||||||
|
await browser.close();
|
||||||
|
process.exit(failed.length === 0 ? 0 : 1);
|
||||||
@ -66,7 +66,7 @@ check('写信页有返回出口', compose.includes('cancelCompose') && compose.i
|
|||||||
// 注释里提到 md:hidden 是在解释「为什么不用它」,所以先剥掉注释再查。
|
// 注释里提到 md:hidden 是在解释「为什么不用它」,所以先剥掉注释再查。
|
||||||
const stripComments = src =>
|
const stripComments = src =>
|
||||||
src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||||
for (const f of ['BackButton', 'NavToggle', 'NarrowOnly']) {
|
for (const f of ['BackButton', 'NarrowOnly']) {
|
||||||
const src = read(`../src/components/${f}.tsx`);
|
const src = read(`../src/components/${f}.tsx`);
|
||||||
const code = stripComments(src);
|
const code = stripComments(src);
|
||||||
check(
|
check(
|
||||||
@ -79,6 +79,61 @@ for (const f of ['BackButton', 'NavToggle', 'NarrowOnly']) {
|
|||||||
const nav = read('../src/components/NarrowNav.tsx');
|
const nav = read('../src/components/NarrowNav.tsx');
|
||||||
check('底部导航留了安全区内边距', nav.includes('safe-area-inset-bottom'));
|
check('底部导航留了安全区内边距', nav.includes('safe-area-inset-bottom'));
|
||||||
|
|
||||||
|
// 5.1) 抽屉式侧栏已删。
|
||||||
|
// 它装的六项与底部导航完全重复,唯一独有的是退出登录;代价是 z-50 的
|
||||||
|
// fixed 层铺满视口高度,把底部导航最左那一项盖住点不到
|
||||||
|
// (实测 elementFromPoint 命中抽屉里的 SVG)。
|
||||||
|
const app = read('../src/App.tsx');
|
||||||
|
check(
|
||||||
|
'窄屏没有抽屉式侧栏(它曾遮挡底部导航)',
|
||||||
|
!app.includes('navOpen') && !app.includes('bg-black/40')
|
||||||
|
);
|
||||||
|
const ui = read('../src/stores/uiStore.ts');
|
||||||
|
check('uiStore 不再有抽屉状态', !ui.includes('navOpen') && !ui.includes('toggleNav'));
|
||||||
|
|
||||||
|
// 5.2) 退出登录必须还有地方可点 —— 删抽屉时它是唯一的独有入口
|
||||||
|
const account = read('../src/components/AccountPage.tsx');
|
||||||
|
check(
|
||||||
|
'退出登录已移到账号页(窄屏唯一出口)',
|
||||||
|
account.includes('logout') && account.includes('退出登录')
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5.3) 触摸命中区:44x44 是移动端下限,而这些按钮视觉高度只有 15-24px。
|
||||||
|
// .tap 用居中的透明伪元素扩大命中区,视觉尺寸不变。
|
||||||
|
const css = read('../src/index.css');
|
||||||
|
check(
|
||||||
|
'.tap 提供 44px 触摸命中区且只在窄屏生效',
|
||||||
|
/\.tap::after/.test(css) && css.includes('min-width: 44px') &&
|
||||||
|
css.includes('min-height: 44px') && /max-width:\s*767px/.test(css)
|
||||||
|
);
|
||||||
|
// 详情页那排工具按钮是实测最小的一组(「抄送」只有 20x15)
|
||||||
|
const viewSrc = read('../src/components/MailView.tsx');
|
||||||
|
for (const label of ['标记已读', '对话树', '转发']) {
|
||||||
|
const re = new RegExp('className="tap[^"]*"[^>]*>[\\s\\S]{0,120}' + label);
|
||||||
|
check(`详情页「${label}」有 .tap 命中区`, re.test(viewSrc));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5.4) 悬停才显形的次要动作在触摸设备上必须默认可见。
|
||||||
|
// `opacity-0 group-hover:opacity-100` 在没有 hover 的设备上永远透明,
|
||||||
|
// 却仍然接收点击 —— 一个看不见却按得动的「归档」比没有按钮更糟。
|
||||||
|
check(
|
||||||
|
'.reveal 只在支持悬停的设备上隐藏',
|
||||||
|
css.includes('.reveal') && /@media\s*\(hover:\s*hover\)\s*and\s*\(pointer:\s*fine\)/.test(css)
|
||||||
|
);
|
||||||
|
for (const f of ['ContactPanel', 'WorkCard']) {
|
||||||
|
const src = read(`../src/components/${f}.tsx`);
|
||||||
|
check(
|
||||||
|
`${f} 用 .reveal 而非裸 opacity-0 group-hover`,
|
||||||
|
src.includes('reveal') && !src.includes('opacity-0 group-hover:opacity-100')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5.5) 对话树:缩进随屏宽变,且窄屏要有返回出口。
|
||||||
|
// 固定「每级 20px、上限 8 级」在 320px 屏上把卡片压到 110px 可用宽度。
|
||||||
|
const thread = read('../src/components/ThreadView.tsx');
|
||||||
|
check('对话树缩进随屏宽自适应', thread.includes('useIsNarrow') && /narrow \? 10 : 20/.test(thread));
|
||||||
|
check('对话树窄屏有返回出口', thread.includes('<BackButton'));
|
||||||
|
|
||||||
// 6) 横向内边距在窄屏收窄(px-6 在 375px 屏上白吃 48px)
|
// 6) 横向内边距在窄屏收窄(px-6 在 375px 屏上白吃 48px)
|
||||||
const wide = ['MailView', 'ComposePage', 'ThreadView', 'AccountPage', 'AdminUsersPage'];
|
const wide = ['MailView', 'ComposePage', 'ThreadView', 'AccountPage', 'AdminUsersPage'];
|
||||||
for (const f of wide) {
|
for (const f of wide) {
|
||||||
|
|||||||
Reference in New Issue
Block a user