feat(webui): 打开即已读 + 回复即已读

用户:「现在邮件需要完全手动标记是否已读而不支持点进去自动已读或者回复自动已读」。

## 改法

- **打开即已读**:MailView 里加一个 effect —— 当前邮件是 unread 且**真正可见**时
  调 `markRead`。两个刻意的细节:
  1. 窄屏下 MailView 可能已经渲染但被列表覆盖层盖住(NarrowStack)⇒ 必须等到
     `narrowPane === 'detail'` 才标,否则"滑过去但没看"的邮件也会被标已读;
  2. 只对 `unread` 发请求(已读的再标一次是白跑,还会让接口日志一直响)。
- **回复即已读**:ReplyBar 发送**成功之后**才标(发送失败不该把"我处理过了"记下来)。
- 手动标记按钮保留(显式动作仍然有用)。

## 端到端验证(真浏览器 + 真库)

造一封未读 → 浏览器里展开会话分组、点开那封邮件 →

    POST /read → 200
    mail_reads 里出现 (reader=gui-lab)
    冗余列 mails.status: unread → read

即"打开即已读"确实生效,而且是记在**读者维度**上(不会像旧的邮件级已读那样
被别人一标就没了)。

## 一个过程记录

前两次验证都"没有发出 /read 请求",我一度以为代码没生效。其实是**点错了对象**:
会话分组默认折叠,`button:has-text(主题)` 匹配到的是**分组那颗**,点它只展开、
不选中邮件(所以不触发已读 —— 这恰恰是正确行为)。展开后再点具体邮件行才触发。
判据必须点"用户真正会点的那一层",这句话这次又应验了。

套件:vitest 15 文件 / 258 用例全绿。
This commit is contained in:
2026-09-14 13:12:36 +08:00
parent 8dd3b0eb17
commit 4d6e944220
2 changed files with 25 additions and 1 deletions

View File

@ -17,6 +17,7 @@ import type { Mail } from '../types';
import { MailIcon, ShieldIcon, PersonIcon, BotIcon, ForwardIcon, TreeIcon, TagIcon, GaugeIcon, ChevronRightIcon, ChatBubbleIcon } from './icons';
import { Composer, ComposerChip } from './Composer';
import { useIsNarrow } from '../hooks/useIsNarrow';
import { useUIStore } from '../stores/uiStore';
import AddressInput from './AddressInput';
import { AttachmentList, AttachmentPicker, type PendingAttachment } from './Attachments';
import ThreadView from './ThreadView';
@ -44,6 +45,24 @@ export default function MailView() {
setThreadOf(null);
}, [currentMailID]);
/*
* 打开即已读2026-09-14 用户:「现在邮件需要完全手动标记是否已读而不支持点进去
* 自动已读或者回复自动已读」)。
*
* 两个细节:
* 1. 窄屏下 MailView **可能已经渲染但被列表盖住**NarrowStack 的覆盖层),
* 那时用户还没看到它 ⇒ 必须等到真正可见narrowPane === 'detail')才标,
* 否则"滑过去但没看"的邮件也会被标已读。
* 2. 只对 unread 发请求:已读的再标一次是白跑一趟(而且会让接口日志噪音不断)。
*/
const narrowPane = useUIStore(s => s.narrowPane);
const visible = !narrow || narrowPane === 'detail';
const currentStatus = currentMail?.status;
useEffect(() => {
if (!visible || !currentMailID || currentStatus !== 'unread') return;
void markRead(currentMailID);
}, [visible, currentMailID, currentStatus, markRead]);
if (currentSession && currentSessionMails.length > 0) {
const last = currentSessionMails[currentSessionMails.length - 1];
// 会话视图的对端是**会话的属性**,不能由「最后一封是谁发的」决定:
@ -862,6 +881,8 @@ function ReplyBar({
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchInbox = useMailStore(s => s.fetchInbox);
// 回复即已读要用
const markRead = useMailStore(s => s.markRead);
const fetchSent = useMailStore(s => s.fetchSent);
const fetchSessions = useSessionStore(s => s.fetchSessions);
const fetchContacts = useContactStore(s => s.fetchContacts);
@ -911,6 +932,9 @@ function ReplyBar({
cc: cc.trim(),
attachment_ids: attachments.map(a => a.id)
});
// 回复即已读(用户要求):回了信就等于处理过这封了,不必再手动点一次。
// 放在发送成功之后:发送失败时不该把"我处理过了"记下来。
void markRead(replyTo.mail_id);
setBody('');
setCc('');
setCcOpen(false);

View File

@ -1,4 +1,4 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { render, fireEvent } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import Sidebar from '../../src/components/Sidebar';
import { useUIStore } from '../../src/stores/uiStore';