feat:聊天页面相关功能优化,提供用户体验感

This commit is contained in:
bujinyuan
2026-04-20 15:40:16 +08:00
parent 0c1540bdee
commit ccfceefdaf
28 changed files with 1402 additions and 62 deletions

View File

@@ -20,9 +20,13 @@ alwaysApply: true
- 分支:`feature/phase2b-instant-messaging` - 分支:`feature/phase2b-instant-messaging`
- **Phase 2c群聊与已读回执**:✅ 全部完成14 个 Task + 代码审查修复 14 项 + 浏览器测试修复 21 项),设计文档 `docs/plans/2026-03-04-phase2c-design.md` - **Phase 2c群聊与已读回执**:✅ 全部完成14 个 Task + 代码审查修复 14 项 + 浏览器测试修复 21 项),设计文档 `docs/plans/2026-03-04-phase2c-design.md`
- 分支:`feature/phase2c-group-read-receipt` - 分支:`feature/phase2c-group-read-receipt`
- **Phase 2d消息类型扩展**:✅ 全部完成14 个 Task + 代码审查修复 2 项 + 富媒体 Bug 修复 4 项),设计文档 `docs/plans/2026-03-04-phase2d-design.md` - **Phase 2d消息类型扩展**:✅ 全部完成14 个 Task + 代码审查修复 2 项 + 富媒体 Bug 修复 4 项 + UX 优化 1 项),设计文档 `docs/plans/2026-03-04-phase2d-design.md`
- 分支:`feature/phase2d-message-types` - **⚠️ 分支说明**Phase 2d 的实际开发工作误用了 `feature/phase2c-group-read-receipt` 分支,`feature/phase2d-message-types` 分支未承载 2d 代码。此次偏差不做回溯修复;**Phase 2e 起必须为每个阶段新建独立分支**`feature/phase2e-xxx`)。
- **开发者运维脚本2026-04-20**:新增 `scripts/{start,stop,status}.sh` 三件套,`scripts/dev-setup.sh` 补齐 MinIO 健康检查;遵循 setup/start 职责分离Rails/Django 社区模式) - **开发者运维脚本2026-04-20**:新增 `scripts/{start,stop,status}.sh` 三件套,`scripts/dev-setup.sh` 补齐 MinIO 健康检查;遵循 setup/start 职责分离Rails/Django 社区模式)
- **聊天页 UX 优化2026-04-20**:单聊/群聊引入「新消息悬浮提示 + 按需已读」机制(贴底自动滚+已读,远离底部显示"N 条新消息"悬浮按钮),消除"视图未见但对方已读"的 UX 错位;**后续修复**watch 误将"加载更多历史消息"计入 newMsgCount → 改为对比「末尾消息标识tail id/client_msg_id」而非 `messages.length`真实双账号duanlingyun ↔ bojinyuan端到端验证通过
- **语音消息 H5 兼容2026-04-20**:修复 PC Chrome 无法按住"按住说话"的问题,链路涉及 4 层(事件 + 录音 API + 上传 + 后端校验VoiceRecorder 并行监听 touch/mouse 事件;新增 `H5Recorder` 类(基于 `MediaRecorder + getUserMedia`)作为 uni 录音接口在 H5 端的回退;`uploadVoice` 增加 blob 分支走 `fetch+FormData` 精确控制 filename后端 `allowedVoiceExts` 扩展 `.webm/.ogg`。Playwright 端到端验证录音计时正常3s→5s 跳动、Blob 上传成功、数据库 `im_messages` type=3 记录写入
- **语音未听红点2026-04-20仿微信**:语音需"听"才算真消费,仅通用已读不能准确反映 → 为「对方发来的」语音叠加独立状态。`chat store` 新增 `voicePlayedMap` + 4 API`markVoicePlayed/isVoicePlayed/loadVoicePlayedState/resetVoicePlayedState`localStorage 按 userId 隔离(`echo:voice-played:{userId}`),不同步后端(私人视图态);`MsgVoice.vue` 自主消费 store单聊/群聊同构),模板加 `.unplayed-dot`10rpx 红圆),`onTogglePlay` 点击即标记清除;`user.logout()` 补 `resetVoicePlayedState()` 防串用户。Playwright 验证duanlingyun(13) ↔ bojinyuan(7) 会话 6初始 5 个红点(对方 5 条语音) → 点击 2 条 → 红点减至 3 → 页面刷新仍为 3持久化生效
- **已读状态刷新持久化2026-04-20**:修复"刷新后所有已读标签变未读 / 群聊 N人已读消失"的体验倒退 bug根因是前端 `readStatusMap`/`groupReadCountMap` 仅由 WS 事件填充、刷新后无 API 补回。扩展 `HistoryMessageResponse` 新增 `peer_last_read_msg_id`(单聊)+ `read_count_map`(群聊仅含自己发送消息);`GetHistoryMessages` 按会话类型分支填充(`GetPeerUserID`+`GetMember` 或 `readRecorder.GetReadCountBatch`);前端 `loadHistoryMessages` 仅在首次加载时回填(避免加载更多时覆盖 WS 增量。Playwright 双端验证:单聊会话 6peer_last_read=202首屏 20 已读+1 未读、群聊 8 刷新后 13 条自己消息全部显示"N人已读"11×1人 + 2×2人不新增 API不增加请求次数向前兼容
- 范围:图片/语音/文件消息完整流程 + 管理端消息管理(列表+统计+撤回+删除)+ ECharts 仪表板 - 范围:图片/语音/文件消息完整流程 + 管理端消息管理(列表+统计+撤回+删除)+ ECharts 仪表板
- **跨模块通信模式**接口注入标准ws.FriendIDsGetter / im.FriendChecker / im.UserInfoGetter → contact.FriendshipDAOim.OfflineMessagePusher → ws.Handlercontact.OnlineChecker → ws.OnlineServiceim.GroupInfoGetter → group.GroupDAOim.MessageReadRecorder → group.MessageReadDAOgroup.UserInfoProvider → auth.UserDAOgroup.MessageWriter → im.MessageDAOadmin.MessageManageService → im.ConversationDAO + ws.PubSub - **跨模块通信模式**接口注入标准ws.FriendIDsGetter / im.FriendChecker / im.UserInfoGetter → contact.FriendshipDAOim.OfflineMessagePusher → ws.Handlercontact.OnlineChecker → ws.OnlineServiceim.GroupInfoGetter → group.GroupDAOim.MessageReadRecorder → group.MessageReadDAOgroup.UserInfoProvider → auth.UserDAOgroup.MessageWriter → im.MessageDAOadmin.MessageManageService → im.ConversationDAO + ws.PubSub

View File

@@ -0,0 +1,5 @@
- generic [active] [ref=e1]:
- generic:
- generic:
- generic:
- paragraph: 认证已过期或无效,请重新登录

View File

@@ -0,0 +1,27 @@
- generic [ref=e38]:
- generic [ref=e40]:
- img [ref=e43] [cursor=pointer]
- generic [ref=e46]: 登录
- generic [ref=e50]:
- generic [ref=e51]:
- generic [ref=e53]: E
- generic [ref=e54]: EchoChat
- generic [ref=e55]: 连接无限,沟通无界
- generic [ref=e56]:
- generic [ref=e57]: 登录
- generic [ref=e58]:
- generic [ref=e59]: 账号
- generic [ref=e62]:
- generic: 请输入用户名或邮箱
- textbox [ref=e63]
- generic [ref=e64]:
- generic [ref=e65]: 密码
- generic [ref=e66]:
- generic [ref=e68]:
- generic: 请输入密码
- textbox [ref=e69]
- generic [ref=e70]: 显示
- generic [ref=e72] [cursor=pointer]: 登录
- generic [ref=e73]:
- generic [ref=e74]: 还没有账号?
- generic [ref=e75]: 立即注册

View File

@@ -0,0 +1,11 @@
- generic [ref=e193]:
- generic [ref=e194]:
- generic [ref=e196]:
- generic [ref=e198]: 测试用户2
- generic [ref=e200]:
- generic [ref=e206]:
- generic [ref=e208]:
- generic [ref=e211]:
- generic: 输入消息...
- textbox [ref=e212]
- generic [ref=e214]:

View File

@@ -0,0 +1,16 @@
- generic [ref=e193]:
- generic [ref=e194]:
- generic [ref=e196]:
- generic [ref=e198]: 测试用户2
- generic [ref=e200]:
- generic [ref=e218]:
- generic [ref=e219]:
- generic [ref=e222]: 测试消息 1
- generic [ref=e225]:
- generic [ref=e226]: 未读
- generic [ref=e206]:
- generic [ref=e208]:
- generic [ref=e211]:
- generic: 输入消息...
- textbox [ref=e212]
- generic [ref=e228]:

View File

@@ -0,0 +1,115 @@
- generic [ref=e431]:
- generic [ref=e432]:
- generic [ref=e434]:
- generic [ref=e436]: 测试用户2
- generic [ref=e438]:
- generic [ref=e442]:
- generic [ref=e444]:
- generic [ref=e445]:
- generic [ref=e448]: 测试消息 1
- generic [ref=e451]:
- generic [ref=e452]: 未读
- generic [ref=e454]:
- generic [ref=e455]:
- generic [ref=e458]: 测试消息 2 - 填充内容
- generic [ref=e461]:
- generic [ref=e462]: 未读
- generic [ref=e464]:
- generic [ref=e465]:
- generic [ref=e468]: 测试消息 3 - 填充内容
- generic [ref=e471]:
- generic [ref=e472]: 未读
- generic [ref=e474]:
- generic [ref=e475]:
- generic [ref=e478]: 测试消息 4 - 填充内容
- generic [ref=e481]:
- generic [ref=e482]: 未读
- generic [ref=e484]:
- generic [ref=e485]:
- generic [ref=e488]: 测试消息 5 - 填充内容
- generic [ref=e491]:
- generic [ref=e492]: 未读
- generic [ref=e494]:
- generic [ref=e495]:
- generic [ref=e498]: 测试消息 6 - 填充内容
- generic [ref=e501]:
- generic [ref=e502]: 未读
- generic [ref=e504]:
- generic [ref=e505]:
- generic [ref=e508]: 测试消息 7 - 填充内容
- generic [ref=e511]:
- generic [ref=e512]: 未读
- generic [ref=e514]:
- generic [ref=e515]:
- generic [ref=e518]: 测试消息 8 - 填充内容
- generic [ref=e521]:
- generic [ref=e522]: 未读
- generic [ref=e524]:
- generic [ref=e525]:
- generic [ref=e528]: 测试消息 9 - 填充内容
- generic [ref=e531]:
- generic [ref=e532]: 未读
- generic [ref=e534]:
- generic [ref=e535]:
- generic [ref=e538]: 测试消息 10 - 填充内容
- generic [ref=e541]:
- generic [ref=e542]: 未读
- generic [ref=e544]:
- generic [ref=e545]:
- generic [ref=e548]: 测试消息 11 - 填充内容
- generic [ref=e551]:
- generic [ref=e552]: 未读
- generic [ref=e554]:
- generic [ref=e555]:
- generic [ref=e558]: 测试消息 12 - 填充内容
- generic [ref=e561]:
- generic [ref=e562]: 未读
- generic [ref=e564]:
- generic [ref=e565]:
- generic [ref=e568]: 测试消息 13 - 填充内容
- generic [ref=e571]:
- generic [ref=e572]: 未读
- generic [ref=e574]:
- generic [ref=e575]:
- generic [ref=e578]: 测试消息 14 - 填充内容
- generic [ref=e581]:
- generic [ref=e582]: 未读
- generic [ref=e584]:
- generic [ref=e585]:
- generic [ref=e588]: 测试消息 15 - 填充内容
- generic [ref=e591]:
- generic [ref=e592]: 未读
- generic [ref=e594]:
- generic [ref=e595]:
- generic [ref=e598]: 测试消息 16 - 填充内容
- generic [ref=e601]:
- generic [ref=e602]: 未读
- generic [ref=e604]:
- generic [ref=e605]:
- generic [ref=e608]: 测试消息 17 - 填充内容
- generic [ref=e611]:
- generic [ref=e612]: 未读
- generic [ref=e614]:
- generic [ref=e615]:
- generic [ref=e618]: 测试消息 18 - 填充内容
- generic [ref=e621]:
- generic [ref=e622]: 未读
- generic [ref=e624]:
- generic [ref=e625]:
- generic [ref=e628]: 测试消息 19 - 填充内容
- generic [ref=e631]:
- generic [ref=e632]: 未读
- generic [ref=e634]:
- generic [ref=e635]:
- generic [ref=e638]: 测试消息 20 - 填充内容
- generic [ref=e641]:
- generic [ref=e642]: 未读
- generic [ref=e643]:
- generic [ref=e646]:
- generic [ref=e649]: ✅ 模拟对方消息1
- generic [ref=e651]:
- generic [ref=e653]:
- generic [ref=e656]:
- generic: 输入消息...
- textbox [ref=e657]
- generic [ref=e659]:

View File

@@ -0,0 +1,121 @@
- generic [ref=e674]:
- generic [ref=e675]:
- generic [ref=e677]:
- generic [ref=e679]: 测试用户2
- generic [ref=e681]:
- generic [ref=e685]:
- generic [ref=e687]:
- generic [ref=e688]:
- generic [ref=e691]: 测试消息 1
- generic [ref=e694]:
- generic [ref=e695]: 未读
- generic [ref=e697]:
- generic [ref=e698]:
- generic [ref=e701]: 测试消息 2 - 填充内容
- generic [ref=e704]:
- generic [ref=e705]: 未读
- generic [ref=e707]:
- generic [ref=e708]:
- generic [ref=e711]: 测试消息 3 - 填充内容
- generic [ref=e714]:
- generic [ref=e715]: 未读
- generic [ref=e717]:
- generic [ref=e718]:
- generic [ref=e721]: 测试消息 4 - 填充内容
- generic [ref=e724]:
- generic [ref=e725]: 未读
- generic [ref=e727]:
- generic [ref=e728]:
- generic [ref=e731]: 测试消息 5 - 填充内容
- generic [ref=e734]:
- generic [ref=e735]: 未读
- generic [ref=e737]:
- generic [ref=e738]:
- generic [ref=e741]: 测试消息 6 - 填充内容
- generic [ref=e744]:
- generic [ref=e745]: 未读
- generic [ref=e747]:
- generic [ref=e748]:
- generic [ref=e751]: 测试消息 7 - 填充内容
- generic [ref=e754]:
- generic [ref=e755]: 未读
- generic [ref=e757]:
- generic [ref=e758]:
- generic [ref=e761]: 测试消息 8 - 填充内容
- generic [ref=e764]:
- generic [ref=e765]: 未读
- generic [ref=e767]:
- generic [ref=e768]:
- generic [ref=e771]: 测试消息 9 - 填充内容
- generic [ref=e774]:
- generic [ref=e775]: 未读
- generic [ref=e777]:
- generic [ref=e778]:
- generic [ref=e781]: 测试消息 10 - 填充内容
- generic [ref=e784]:
- generic [ref=e785]: 未读
- generic [ref=e787]:
- generic [ref=e788]:
- generic [ref=e791]: 测试消息 11 - 填充内容
- generic [ref=e794]:
- generic [ref=e795]: 未读
- generic [ref=e797]:
- generic [ref=e798]:
- generic [ref=e801]: 测试消息 12 - 填充内容
- generic [ref=e804]:
- generic [ref=e805]: 未读
- generic [ref=e807]:
- generic [ref=e808]:
- generic [ref=e811]: 测试消息 13 - 填充内容
- generic [ref=e814]:
- generic [ref=e815]: 未读
- generic [ref=e817]:
- generic [ref=e818]:
- generic [ref=e821]: 测试消息 14 - 填充内容
- generic [ref=e824]:
- generic [ref=e825]: 未读
- generic [ref=e827]:
- generic [ref=e828]:
- generic [ref=e831]: 测试消息 15 - 填充内容
- generic [ref=e834]:
- generic [ref=e835]: 未读
- generic [ref=e837]:
- generic [ref=e838]:
- generic [ref=e841]: 测试消息 16 - 填充内容
- generic [ref=e844]:
- generic [ref=e845]: 未读
- generic [ref=e847]:
- generic [ref=e848]:
- generic [ref=e851]: 测试消息 17 - 填充内容
- generic [ref=e854]:
- generic [ref=e855]: 未读
- generic [ref=e857]:
- generic [ref=e858]:
- generic [ref=e861]: 测试消息 18 - 填充内容
- generic [ref=e864]:
- generic [ref=e865]: 未读
- generic [ref=e867]:
- generic [ref=e868]:
- generic [ref=e871]: 测试消息 19 - 填充内容
- generic [ref=e874]:
- generic [ref=e875]: 未读
- generic [ref=e877]:
- generic [ref=e878]:
- generic [ref=e881]: 测试消息 20 - 填充内容
- generic [ref=e884]:
- generic [ref=e885]: 未读
- generic [ref=e886]:
- generic [ref=e889]:
- generic [ref=e892]: ✅ 模拟对方消息1
- generic [ref=e893]:
- generic [ref=e896]:
- generic [ref=e899]: ⬇️ 模拟对方新消息 A
- generic [ref=e900]:
- generic [ref=e903]:
- generic [ref=e906]: 🔴 在顶部时收到的对方新消息 B
- generic [ref=e908]:
- generic [ref=e910]:
- generic [ref=e913]:
- generic: 输入消息...
- textbox [ref=e914]
- generic [ref=e916]:

View File

@@ -0,0 +1,127 @@
- generic [ref=e674]:
- generic [ref=e675]:
- generic [ref=e677]:
- generic [ref=e679]: 测试用户2
- generic [ref=e681]:
- generic [ref=e685]:
- generic [ref=e687]:
- generic [ref=e688]:
- generic [ref=e691]: 测试消息 1
- generic [ref=e694]:
- generic [ref=e695]: 未读
- generic [ref=e697]:
- generic [ref=e698]:
- generic [ref=e701]: 测试消息 2 - 填充内容
- generic [ref=e704]:
- generic [ref=e705]: 未读
- generic [ref=e707]:
- generic [ref=e708]:
- generic [ref=e711]: 测试消息 3 - 填充内容
- generic [ref=e714]:
- generic [ref=e715]: 未读
- generic [ref=e717]:
- generic [ref=e718]:
- generic [ref=e721]: 测试消息 4 - 填充内容
- generic [ref=e724]:
- generic [ref=e725]: 未读
- generic [ref=e727]:
- generic [ref=e728]:
- generic [ref=e731]: 测试消息 5 - 填充内容
- generic [ref=e734]:
- generic [ref=e735]: 未读
- generic [ref=e737]:
- generic [ref=e738]:
- generic [ref=e741]: 测试消息 6 - 填充内容
- generic [ref=e744]:
- generic [ref=e745]: 未读
- generic [ref=e747]:
- generic [ref=e748]:
- generic [ref=e751]: 测试消息 7 - 填充内容
- generic [ref=e754]:
- generic [ref=e755]: 未读
- generic [ref=e757]:
- generic [ref=e758]:
- generic [ref=e761]: 测试消息 8 - 填充内容
- generic [ref=e764]:
- generic [ref=e765]: 未读
- generic [ref=e767]:
- generic [ref=e768]:
- generic [ref=e771]: 测试消息 9 - 填充内容
- generic [ref=e774]:
- generic [ref=e775]: 未读
- generic [ref=e777]:
- generic [ref=e778]:
- generic [ref=e781]: 测试消息 10 - 填充内容
- generic [ref=e784]:
- generic [ref=e785]: 未读
- generic [ref=e787]:
- generic [ref=e788]:
- generic [ref=e791]: 测试消息 11 - 填充内容
- generic [ref=e794]:
- generic [ref=e795]: 未读
- generic [ref=e797]:
- generic [ref=e798]:
- generic [ref=e801]: 测试消息 12 - 填充内容
- generic [ref=e804]:
- generic [ref=e805]: 未读
- generic [ref=e807]:
- generic [ref=e808]:
- generic [ref=e811]: 测试消息 13 - 填充内容
- generic [ref=e814]:
- generic [ref=e815]: 未读
- generic [ref=e817]:
- generic [ref=e818]:
- generic [ref=e821]: 测试消息 14 - 填充内容
- generic [ref=e824]:
- generic [ref=e825]: 未读
- generic [ref=e827]:
- generic [ref=e828]:
- generic [ref=e831]: 测试消息 15 - 填充内容
- generic [ref=e834]:
- generic [ref=e835]: 未读
- generic [ref=e837]:
- generic [ref=e838]:
- generic [ref=e841]: 测试消息 16 - 填充内容
- generic [ref=e844]:
- generic [ref=e845]: 未读
- generic [ref=e847]:
- generic [ref=e848]:
- generic [ref=e851]: 测试消息 17 - 填充内容
- generic [ref=e854]:
- generic [ref=e855]: 未读
- generic [ref=e857]:
- generic [ref=e858]:
- generic [ref=e861]: 测试消息 18 - 填充内容
- generic [ref=e864]:
- generic [ref=e865]: 未读
- generic [ref=e867]:
- generic [ref=e868]:
- generic [ref=e871]: 测试消息 19 - 填充内容
- generic [ref=e874]:
- generic [ref=e875]: 未读
- generic [ref=e877]:
- generic [ref=e878]:
- generic [ref=e881]: 测试消息 20 - 填充内容
- generic [ref=e884]:
- generic [ref=e885]: 未读
- generic [ref=e886]:
- generic [ref=e889]:
- generic [ref=e892]: ✅ 模拟对方消息1
- generic [ref=e893]:
- generic [ref=e896]:
- generic [ref=e899]: ⬇️ 模拟对方新消息 A
- generic [ref=e900]:
- generic [ref=e903]:
- generic [ref=e906]: 🔴 在顶部时收到的对方新消息 B
- generic [ref=e917]:
- generic [ref=e920]:
- generic [ref=e923]: "🔴 顶部时对方新消息 #1"
- generic [ref=e924]:
- generic [ref=e925]:
- generic [ref=e926]: 24 条新消息
- generic [ref=e908]:
- generic [ref=e910]:
- generic [ref=e913]:
- generic: 输入消息...
- textbox [ref=e914]
- generic [ref=e916]:

View File

@@ -0,0 +1,27 @@
- generic [ref=e4]:
- generic [ref=e6]:
- img [ref=e9] [cursor=pointer]
- generic [ref=e12]: 登录
- generic [ref=e16]:
- generic [ref=e17]:
- generic [ref=e19]: E
- generic [ref=e20]: EchoChat
- generic [ref=e21]: 连接无限,沟通无界
- generic [ref=e22]:
- generic [ref=e23]: 登录
- generic [ref=e24]:
- generic [ref=e25]: 账号
- generic [ref=e28]:
- generic: 请输入用户名或邮箱
- textbox [ref=e29]
- generic [ref=e30]:
- generic [ref=e31]: 密码
- generic [ref=e32]:
- generic [ref=e34]:
- generic: 请输入密码
- textbox [ref=e35]
- generic [ref=e36]: 显示
- generic [ref=e38] [cursor=pointer]: 登录
- generic [ref=e39]:
- generic [ref=e40]: 还没有账号?
- generic [ref=e41]: 立即注册

View File

@@ -0,0 +1,27 @@
- generic [ref=e4]:
- generic [ref=e6]:
- img [ref=e9] [cursor=pointer]
- generic [ref=e12]: 登录
- generic [ref=e16]:
- generic [ref=e17]:
- generic [ref=e19]: E
- generic [ref=e20]: EchoChat
- generic [ref=e21]: 连接无限,沟通无界
- generic [ref=e22]:
- generic [ref=e23]: 登录
- generic [ref=e24]:
- generic [ref=e25]: 账号
- generic [ref=e28]:
- generic: 请输入用户名或邮箱
- textbox [ref=e29]
- generic [ref=e30]:
- generic [ref=e31]: 密码
- generic [ref=e32]:
- generic [ref=e34]:
- generic: 请输入密码
- textbox [ref=e35]
- generic [ref=e36]: 显示
- generic [ref=e38] [cursor=pointer]: 登录
- generic [ref=e39]:
- generic [ref=e40]: 还没有账号?
- generic [ref=e41]: 立即注册

View File

@@ -0,0 +1,17 @@
- generic [active] [ref=e1]:
- generic [ref=e7]:
- generic [ref=e8]:
- generic [ref=e10]:
- generic [ref=e12]: 聊天
- generic [ref=e14]:
- generic [ref=e20]: 加载更多
- generic [ref=e22]:
- generic [ref=e24]:
- generic [ref=e27]:
- generic: 输入消息...
- textbox [ref=e28]
- generic [ref=e30]:
- generic:
- generic:
- generic:
- paragraph: 缺少认证信息

View File

@@ -0,0 +1,3 @@
- generic [ref=e6]:
- img [ref=e9] [cursor=pointer]
- generic [ref=e12]: 登录

View File

@@ -0,0 +1,32 @@
- generic [active] [ref=e1]:
- generic [ref=e4]:
- generic [ref=e6]:
- img [ref=e9] [cursor=pointer]
- generic [ref=e12]: 登录
- generic [ref=e16]:
- generic [ref=e17]:
- generic [ref=e19]: E
- generic [ref=e20]: EchoChat
- generic [ref=e21]: 连接无限,沟通无界
- generic [ref=e22]:
- generic [ref=e23]: 登录
- generic [ref=e24]:
- generic [ref=e25]: 账号
- generic [ref=e28]:
- generic: 请输入用户名或邮箱
- textbox [ref=e29]
- generic [ref=e30]:
- generic [ref=e31]: 密码
- generic [ref=e32]:
- generic [ref=e34]:
- generic: 请输入密码
- textbox [ref=e35]
- generic [ref=e36]: 显示
- generic [ref=e38] [cursor=pointer]: 登录
- generic [ref=e39]:
- generic [ref=e40]: 还没有账号?
- generic [ref=e41]: 立即注册
- generic:
- generic:
- generic:
- paragraph: 认证已失效,请重新登录

View File

@@ -0,0 +1,27 @@
- generic [ref=e4]:
- generic [ref=e6]:
- img [ref=e9] [cursor=pointer]
- generic [ref=e12]: 登录
- generic [ref=e16]:
- generic [ref=e17]:
- generic [ref=e19]: E
- generic [ref=e20]: EchoChat
- generic [ref=e21]: 连接无限,沟通无界
- generic [ref=e22]:
- generic [ref=e23]: 登录
- generic [ref=e24]:
- generic [ref=e25]: 账号
- generic [ref=e28]:
- generic: 请输入用户名或邮箱
- textbox [ref=e29]
- generic [ref=e30]:
- generic [ref=e31]: 密码
- generic [ref=e32]:
- generic [ref=e34]:
- generic: 请输入密码
- textbox [ref=e35]
- generic [ref=e36]: 显示
- generic [ref=e38] [cursor=pointer]: 登录
- generic [ref=e39]:
- generic [ref=e40]: 还没有账号?
- generic [ref=e41]: 立即注册

View File

@@ -96,9 +96,17 @@ type HistoryMessageRequest struct {
} }
// HistoryMessageResponse 历史消息响应 // HistoryMessageResponse 历史消息响应
//
// 已读状态回填设计2026-04-20
// - PeerLastReadMsgID单聊时填"对方 last_read_msg_id",用于前端在页面刷新后恢复 readStatusMap
// 避免已读标签变未读的体验退化;群聊时为 0
// - ReadCountMap群聊时批量返回"自己发送消息的 read_count",前端合并到 groupReadCountMap
// 单聊时为 nilJSON omitempty 省略,节省带宽)
type HistoryMessageResponse struct { type HistoryMessageResponse struct {
List []MessageDTO `json:"list"` // 消息列表ID 降序) List []MessageDTO `json:"list"` // 消息列表ID 降序)
HasMore bool `json:"has_more"` // 是否还有更多历史消息 HasMore bool `json:"has_more"` // 是否还有更多历史消息
PeerLastReadMsgID int64 `json:"peer_last_read_msg_id"` // 单聊:对方已读位置(群聊=0
ReadCountMap map[int64]int `json:"read_count_map,omitempty"` // 群聊:{messageID: readCount}(仅自己发的消息)
} }
// ====== 已读标记 DTO ====== // ====== 已读标记 DTO ======

View File

@@ -29,7 +29,7 @@ var (
ErrUploadFailed = errors.New("文件上传失败") ErrUploadFailed = errors.New("文件上传失败")
ErrFileTooLarge = errors.New("文件大小超出限制") ErrFileTooLarge = errors.New("文件大小超出限制")
ErrInvalidImage = errors.New("无效的图片文件") ErrInvalidImage = errors.New("无效的图片文件")
ErrInvalidVoice = errors.New("不支持的语音格式,仅支持 mp3/wav/aac/m4a") ErrInvalidVoice = errors.New("不支持的语音格式,仅支持 mp3/wav/aac/m4a/webm/ogg")
ErrVoiceTooLong = errors.New("语音时长不能超过 60 秒") ErrVoiceTooLong = errors.New("语音时长不能超过 60 秒")
ErrThumbnailGen = errors.New("缩略图生成失败") ErrThumbnailGen = errors.New("缩略图生成失败")
) )
@@ -76,11 +76,14 @@ type VoiceUploadResult struct {
} }
// allowedVoiceExts 允许的语音文件扩展名 // allowedVoiceExts 允许的语音文件扩展名
// .webm / .ogg 用于兼容 H5 端浏览器 MediaRecorder 输出uni-app H5 端不支持 getRecorderManager
var allowedVoiceExts = map[string]bool{ var allowedVoiceExts = map[string]bool{
".mp3": true, ".mp3": true,
".wav": true, ".wav": true,
".aac": true, ".aac": true,
".m4a": true, ".m4a": true,
".webm": true,
".ogg": true,
} }
// Upload 上传文件到 MinIO返回文件访问 URL // Upload 上传文件到 MinIO返回文件访问 URL

View File

@@ -537,7 +537,44 @@ func (s *IMService) GetHistoryMessages(ctx context.Context, userID int64, req *d
list = append(list, *s.toMessageDTO(&m)) list = append(list, *s.toMessageDTO(&m))
} }
return &dto.HistoryMessageResponse{List: list, HasMore: hasMore}, nil resp := &dto.HistoryMessageResponse{List: list, HasMore: hasMore}
// ========== 已读状态回填(修复页面刷新后已读标签变未读的 Bug ==========
// 获取会话类型以决定回填策略
conv, convErr := s.convDAO.GetByID(ctx, req.ConversationID)
if convErr != nil || conv == nil {
// 拉不到会话元信息时静默跳过已读回填,消息仍正常返回(向前兼容)
return resp, nil
}
switch conv.Type {
case constants.ConversationTypePrivate:
// 单聊:查询对方成员的 last_read_msg_id
peerID, pErr := s.convDAO.GetPeerUserID(ctx, req.ConversationID, userID)
if pErr == nil && peerID > 0 {
peerMember, pmErr := s.convDAO.GetMember(ctx, req.ConversationID, peerID)
if pmErr == nil && peerMember != nil {
resp.PeerLastReadMsgID = peerMember.LastReadMsgID
}
}
case constants.ConversationTypeGroup:
// 群聊:批量查询"本次返回消息中自己发送的那些"的 read_count
// 仅查自己发的,因为前端仅在 isSelf=true 时展示 "N人已读"
selfMsgIDs := make([]int64, 0, len(messages))
for i := range messages {
if messages[i].SenderID == userID && messages[i].ID > 0 {
selfMsgIDs = append(selfMsgIDs, messages[i].ID)
}
}
if len(selfMsgIDs) > 0 && s.readRecorder != nil {
countMap, rcErr := s.readRecorder.GetReadCountBatch(ctx, selfMsgIDs)
if rcErr == nil && len(countMap) > 0 {
resp.ReadCountMap = countMap
}
}
}
return resp, nil
} }
// MarkRead 标记会话已读(清零未读 + 更新 Redis 总未读数 + 推送已读 ACK 给对方) // MarkRead 标记会话已读(清零未读 + 更新 Redis 总未读数 + 推送已读 ACK 给对方)

View File

@@ -1,14 +1,165 @@
# EchoChat 项目开发进度 # EchoChat 项目开发进度
> **最后更新**2026-04-20Phase 2d 富媒体消息缺陷修复Playwright 全流程验证通过 > **最后更新**2026-04-20Phase 2d 完成 + UX 优化 + 已读状态刷新持久化
> **当前阶段**Phase 2d 全部完成 + Bug 修复 4(图片上传端口/MinIO 策略/MsgImage 布局/文件名保留) > **当前阶段**Phase 2d 全部完成 + Bug 修复 7 + UX 优化 2 项
> **当前分支**`feature/phase2d-message-types` > **当前分支**`feature/phase2c-group-read-receipt`Phase 2d 工作误用了 2c 分支,下一阶段 2e 直接新建分支开发)
> **实施计划**`docs/plans/2026-03-04-phase2d-implementation.plan.md` > **实施计划**`docs/plans/2026-03-04-phase2d-implementation.plan.md`
> **设计文档**`docs/plans/2026-03-04-phase2d-design.md` > **设计文档**`docs/plans/2026-03-04-phase2d-design.md`
> **本次修复报告**`test-report-phase2d-bugfix.md` > **本次修复报告**`test-report-phase2d-bugfix.md`
--- ---
## 🐛 2026-04-20 Bug 修复:已读状态刷新后丢失(变回"未读"
**问题**:单聊/群聊页面中大量消息显示"已读"的情况下,刷新浏览器 → 所有已读标签全部变为"未读" / "N人已读"消失,直到对方再次触发读消息才恢复 —— 严重影响真实性与信任度。
**根因(单句)**:前端 `readStatusMap`(对方已读位置)和 `groupReadCountMap`(群聊已读计数)仅由 WebSocket 事件 `im.message.read.ack / im.message.read.count` 填充,从未在页面加载时从后端拉取,刷新后 state 归零且无 API 补回 → `isRead()` 判定 `msg.id <= 0` 恒为 false。后端数据库 `im_conversation_members.last_read_msg_id``im_message_reads` 数据其实都存在,只是未暴露给前端初始化。
**修复方案(前后端联动,不新增 API仅扩展 `HistoryMessageResponse`**
| 端 | 改动 |
|---|---|
| 后端 DTO | `HistoryMessageResponse` 新增 `peer_last_read_msg_id int64`(单聊)、`read_count_map map[int64]int`(群聊 omitempty |
| 后端 Service | `GetHistoryMessages` 按会话类型分支type=1 → `GetPeerUserID` + `GetMember` 拿对方 `last_read_msg_id`type=2 → 过滤自己发送的消息 ID`readRecorder.GetReadCountBatch` |
| 前端 Store | `loadHistoryMessages` 仅在「首次加载messages.length===0」时回填避免后续"加载更多"时用历史数据覆盖 WS 增量的最新态 |
**涉及文件**
- `backend/go-service/app/dto/im_dto.go`DTO 扩展
- `backend/go-service/app/im/service/im_service.go`GetHistoryMessages 回填逻辑
- `frontend/src/store/chat.js`loadHistoryMessages 消费新字段
**Playwright 端到端验证**
1. 数据库事实:会话 6 中 `bojinyuan(7)` last_read_msg_id=202已读到 id=202
2. duanlingyun 登录 → API 返回 `peer_last_read_msg_id=202`
3. 会话页首屏21 条自己发送的消息 → 20 条"已读" + 1 条新插入的 id=203"未读" ✓(与期望完全吻合)
4. 群聊验证bojinyuan 登录 → 群 8 API 返回 `read_count_map={94:1, 95:1, ..., 139:2, 140:2}`(共 13 条自己发的消息)→ 页面正确显示 11 个"1人已读" + 2 个"2人已读" ✓
5. 视觉回归:同一截图中语音"未听红点"仍正常工作(表明两项 UX 优化无互相干扰)
**设计权衡**
- 为什么不新增独立 API避免每次进入会话多一次往返请求
- 为什么仅首次加载回填:后续 WS 事件已能增量更新,用历史数据覆盖反而可能倒退
- 为什么群聊只查"自己发的消息":前端仅在 `isSelf=true` 时展示"N人已读",避免无用 DB 查询
---
## 🎙️ 2026-04-20 UX 优化:语音消息「未听红点」(仿微信)
**需求**:通用"已读"状态基于滚动可见性,适合文字/图片(肉眼可直接阅读);但**语音必须点击播放才算"听过"**,仅"已读"不能反映用户是否真的听过。参照微信设计,为「对方发来的语音」叠加独立的"未播放"视觉提示(红点)。
**设计决策(均为推荐方案)**
| 维度 | 取舍 |
|---|---|
| 作用范围 | 仅「对方发来的」语音显示红点(自己发的无此语义) |
| 持久化 | 本地 localStorage按 userId 隔离) —— 不同步后端,属于私人视图态 |
| 触发时机 | 用户点击播放即刻清除红点(不要求播放完整) |
| 作用层级 | 仅聊天详情页;会话列表不处理 |
**核心实现**
- `chat store` 新增 `voicePlayedMap: {[msgId]: true}` + `markVoicePlayed / isVoicePlayed / loadVoicePlayedState / resetVoicePlayedState` 4 个 API
- localStorage key`echo:voice-played:{userId}`,完整 JSON 覆盖写入
- `MsgVoice.vue` 自主消费 store父级无需改动单聊/群聊同构生效)
- 模板:`<view v-if="showUnplayedDot" class="unplayed-dot" />`10rpx 红圆,紧贴气泡右侧)
- `showUnplayedDot = computed(() => !isSelf && msg.id && !chatStore.isVoicePlayed(msg.id))`
- `onTogglePlay` 首行:`if (!isSelf && msg.id) chatStore.markVoicePlayed(msg.id)`
- `user.store.logout()`:动态导入 chat store 调用 `resetVoicePlayedState()` 防止串用户
- `chat.store.initWsListeners()`:按当前 `userStore.userInfo.id` 懒加载对应缓存
**涉及文件**
- `frontend/src/store/chat.js`:新增 voicePlayed 状态 + 4 个方法
- `frontend/src/components/msg/MsgVoice.vue`:模板加红点 + 消费 store
- `frontend/src/store/user.js`logout 清理运行时 state
**Playwright 验证**
1. duanlingyun(id=13) 登录 → 会话 6内含自己 3 条语音 + 对方 5 条语音)
2. 初始8 条语音 → 5 个红点(全部对方发) + 0 个红点(自己 3 条 `voice-self`)✓
3. 点击任意两条对方语音 → 红点数 5→4→3 ✓
4. localStorage 写入 `echo:voice-played:13 = {195:true, 196:true}`
5. 页面刷新 → 仍只剩 3 个红点(持久化生效)✓
---
## 💡 2026-04-20 UX 优化:聊天页「新消息悬浮提示 + 按需已读」
**问题**:用户停留在聊天页旧消息位置时,对方发来新消息 → 页面不会自动滚动到底 → 但 store 层一律自动 `markRead` → 对方看到"已读"但用户实际没看到消息,产生体验错位。
**方案(混合策略,业内通用)**
- 贴底(距底 < 150px新消息自动滚动到底部 + 标记已读保留原有体验
- 远离底部时不滚动不标记已读右下角显示「↓ N 条新消息悬浮胶囊按钮
- 点击悬浮按钮滚到底 + 清零计数 + 标记已读
- 用户手动滚回底部自动隐藏悬浮 + 标记已读
**技术实现(`maxScrollTop` 追踪策略)**
- `@scroll` 事件中维护"历史最大 scrollTop"`maxScrollTop - scrollTop < 150` 判断是否贴底
- 不依赖 `@scrolltolower` 作为唯一权威信号 `scroll-into-view` + 动态 scrollHeight 都正确响应
- Store 层移除 `_onNewMessage` 中对当前会话的自动 `markRead`把决策权交给页面
**涉及文件**
- `frontend/src/store/chat.js`移除新消息到达时的自动 `markRead`
- `frontend/src/pages/chat/conversation.vue`单聊滚动感知 + 悬浮按钮
- `frontend/src/pages/group/conversation.vue`群聊同构改造
**验证结果**Playwright 全流程通过三种场景贴底自动滚远离底部显示悬浮点击悬浮滚到底清零)。
### 🐛 UX 优化后续修复2026-04-20watch 逻辑误把"加载历史消息"计入 newMsgCount
**问题**用户在页面中段滚动时触发加载更多」→ 从数组**头部**插入 20 条历史消息 `messages.value.length` 变化 watch 误以为是"对方的新消息" `newMsgCount += 20`悬浮按钮显示"40+ 条新消息"。
**根因**watch 回调只看 `messages.length` 增量delta未区分"头部插入历史""尾部追加新消息"。读取 `messages[length-1]` 拿到的是已存在的最后一条消息若恰好是对方发的即通过 fromOther 判断并累加
**修复策略**监听末尾消息标识id / client_msg_id)」是否变化而非仅看长度
- 头部插入历史消息 tail 未变 直接返回不做任何处理
- 尾部追加一条新消息 tail 变化 才进入"fromSelf/fromOther/nearBottom"判断分支
- 自己发的消息 tail 也会变但会被 `fromSelf` 拦下
**真实双账号验证duanlingyun ↔ bojinyuan会话 id=6**
| 操作 | messages.length | scrollTop | scrollHeight | 悬浮按钮 |
|---|---|---|---|---|
| 初始贴底 | 20 | 1419 | 2066 | |
| 滚至顶部触发加载更多 ×3 | 76 | 0 | 4823 | **无** |
| 对方发 1 | 77 | 1003 | 4877 | "1 条新消息" |
| 对方再发 2 | 79 | 1003 | 4877 | "3 条新消息" |
| 再次加载更多历史 | 79+10 | 0 | 4986 | **仍为 "3 条新消息"** |
| 点击悬浮 | 79+10 | 4339 (贴底) | 4986 | 消失 |
**涉及文件**
- `frontend/src/pages/chat/conversation.vue``lastMsgCount` `lastTailKey` 改造
- `frontend/src/pages/group/conversation.vue`相同改造群聊
### 🐛 语音消息 H5 兼容修复2026-04-20
**问题链**按排查顺序
1. **事件层**PC Chrome 按住"按住说话"按钮无反应 —— 只绑定了 `@touchstart/move/end`现代浏览器鼠标不合成 touch 事件
2. **录音 API 层**修复事件绑定后报 `method 'uni.getRecorderManager' not supported` —— uni-app H5 端不支持原生 `getRecorderManager`
3. **上传层**即便录到 Blob`uni.uploadFile(blob:URL)` 推断出的 filename 无扩展名被后端白名单拦下
4. **后端校验层**`allowedVoiceExts` 只允许 `.mp3/.wav/.aac/.m4a`不接受 H5 MediaRecorder 输出的 webm/ogg
**修复链**
| | 改动 |
|---|---|
| 事件 | VoiceRecorder 同时监听 touch + mouse 事件`pressing` 重入守卫`@mouseleave` 兜底取消 |
| 录音 | 新增 `H5Recorder` 基于 `MediaRecorder` + `getUserMedia` 实现 uni 录音接口`onStart/onStop/onError/start/stop``createRecorder()` 工厂按平台自动选择 |
| 上传 | `uploadVoice` 增加 blob 参数分支H5 `fetch` + `FormData` 精确控制 filename基于 mimeType 推断扩展名 `voice-{ts}.webm`|
| 后端 | `allowedVoiceExts` 新增 `.webm``.ogg`错误消息同步更新 |
**验证结果Playwright 自动化)**
| 阶段 | class | 按钮文字 | 遮罩 | 计时 |
|---|---|---|---|---|
| 按压中 (4s) | `record-btn recording` | "松开发送" | 显示 | "3\"" |
| 按压中 (5.5s) | 同上 | 同上 | | "5\"" 跳动正常|
| 松开后 | `record-btn` | "按住 说话" | 消失 | |
数据库验证连续 3 次录音上传成功1/2/5 size 18K/35K/75K`im_messages` type=3语音记录正确写入MinIO `.webm` 文件可访问
**涉及文件**
- `frontend/src/components/chat/VoiceRecorder.vue`核心改造事件兼容 + H5Recorder
- `frontend/src/api/file.js``uploadVoice` 增加 blob 分支新增 `_doUploadBlob` 私有方法
- `frontend/src/pages/chat/conversation.vue` & `frontend/src/pages/group/conversation.vue``onVoiceRecorded` mimeType 生成 fileName 并传 blob
- `backend/go-service/app/file/service/file_service.go``allowedVoiceExts` 扩展 + 错误消息更新
---
## 🔧 2026-04-20 Bug 修复清单 ## 🔧 2026-04-20 Bug 修复清单
| # | 根因 | 修复 | 文件 | | # | 根因 | 修复 | 文件 |

View File

@@ -34,12 +34,45 @@ export function uploadImage(filePath, fileName) {
* @param {string} filePath 本地语音文件临时路径 * @param {string} filePath 本地语音文件临时路径
* @param {number} duration 语音时长(秒) * @param {number} duration 语音时长(秒)
* @param {string} [fileName] 文件名 * @param {string} [fileName] 文件名
* @param {Blob} [blob] 可选H5 端 MediaRecorder 产出的原始 Blob。传入时走 fetch+FormData
* 路径,能精确控制 filename/mimeType`uni.uploadFile` 对 blob: URL 推断的
* filename 通常缺扩展名,会被后端白名单拦下)
* @returns {Promise<{ url, file_name, size, duration, mime_type }>} * @returns {Promise<{ url, file_name, size, duration, mime_type }>}
*/ */
export function uploadVoice(filePath, duration, fileName) { export function uploadVoice(filePath, duration, fileName, blob) {
if (blob && typeof window !== 'undefined' && typeof FormData !== 'undefined') {
return _doUploadBlob('/api/v1/upload/voice', blob, fileName || 'voice.webm', {
duration: String(duration)
})
}
return _doUpload('/api/v1/upload/voice', filePath, fileName, { duration: String(duration) }) return _doUpload('/api/v1/upload/voice', filePath, fileName, { duration: String(duration) })
} }
/**
* H5 专用:直接用 Blob 上传(避免 uni.uploadFile 对 blob: URL 的 filename 不可控)
*/
function _doUploadBlob(apiPath, blob, fileName, extraFormData = {}) {
const userStore = useUserStore()
const token = userStore.token
const fd = new FormData()
fd.append('file', blob, fileName)
Object.entries(extraFormData).forEach(([k, v]) => fd.append(k, v))
return fetch(`${BASE_URL}${apiPath}`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: fd
}).then(async (resp) => {
let result
try {
result = await resp.json()
} catch (_) {
throw new Error(`上传失败(${resp.status}`)
}
if (result.code === 0) return result.data
throw new Error(result.message || '上传失败')
})
}
/** /**
* 内部上传方法 * 内部上传方法
*/ */

View File

@@ -1,16 +1,24 @@
<!-- <!--
语音录制组件 语音录制组件
微信风格长按录音 / 松手发送 / 上滑取消 - 微信风格长按录音 / 松手发送 / 上滑取消最长 60
最长 60 不足 1 秒提示太短 - 事件兼容同时支持 touch移动端真机 mousePC H5 调试
- 平台适配
App/小程序uni.getRecorderManager()原生实现
H5 MediaRecorder + getUserMediauni-app H5 不支持 getRecorderManager自行实现
--> -->
<template> <template>
<view class="voice-recorder"> <view class="voice-recorder">
<view <view
class="record-btn" class="record-btn"
:class="{ 'recording': isRecording, 'cancel-zone': isCancelZone }" :class="{ 'recording': isRecording, 'cancel-zone': isCancelZone }"
@touchstart.prevent="onTouchStart" @touchstart.prevent="onPressStart"
@touchmove.prevent="onTouchMove" @touchmove.prevent="onPressMove"
@touchend.prevent="onTouchEnd" @touchend.prevent="onPressEnd"
@touchcancel.prevent="onPressEnd"
@mousedown.prevent="onPressStart"
@mousemove.prevent="onPressMove"
@mouseup.prevent="onPressEnd"
@mouseleave="onMouseLeave"
> >
<text class="record-text"> <text class="record-text">
{{ isRecording ? (isCancelZone ? '松开取消' : '松开发送') : '按住 说话' }} {{ isRecording ? (isCancelZone ? '松开取消' : '松开发送') : '按住 说话' }}
@@ -31,6 +39,103 @@
<script> <script>
import { ref, onBeforeUnmount } from 'vue' import { ref, onBeforeUnmount } from 'vue'
/**
* H5 端录音器实现(基于 MediaRecorder + getUserMedia
* 与 uni.getRecorderManager() 对齐接口onStart / onStop / onError / start / stop
*/
class H5Recorder {
constructor() {
this._startCb = null
this._stopCb = null
this._errCb = null
this._mediaRecorder = null
this._stream = null
this._chunks = []
this._mimeType = ''
}
onStart(cb) { this._startCb = cb }
onStop(cb) { this._stopCb = cb }
onError(cb) { this._errCb = cb }
async start() {
try {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
this._errCb && this._errCb({ errMsg: '浏览器不支持录音(需 HTTPS 或 localhost' })
return
}
this._stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const candidates = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus'
]
this._mimeType = candidates.find(m => {
try { return typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(m) } catch { return false }
}) || ''
const opts = this._mimeType ? { mimeType: this._mimeType } : undefined
this._mediaRecorder = new MediaRecorder(this._stream, opts)
this._chunks = []
this._mediaRecorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) this._chunks.push(e.data)
}
this._mediaRecorder.onstart = () => {
this._startCb && this._startCb()
}
this._mediaRecorder.onstop = () => {
const mime = this._mediaRecorder?.mimeType || this._mimeType || 'audio/webm'
const blob = new Blob(this._chunks, { type: mime })
const tempFilePath = URL.createObjectURL(blob)
if (this._stream) {
this._stream.getTracks().forEach(t => t.stop())
this._stream = null
}
this._stopCb && this._stopCb({
tempFilePath,
fileSize: blob.size,
duration: 0,
blob,
mimeType: mime
})
}
this._mediaRecorder.onerror = (e) => {
this._errCb && this._errCb({ errMsg: e?.error?.message || '录音异常' })
}
this._mediaRecorder.start()
} catch (err) {
const msg = err && err.name === 'NotAllowedError'
? '麦克风权限被拒绝,请在浏览器地址栏允许访问麦克风'
: (err?.message || '无法启动录音')
this._errCb && this._errCb({ errMsg: msg })
}
}
stop() {
try {
if (this._mediaRecorder && this._mediaRecorder.state !== 'inactive') {
this._mediaRecorder.stop()
}
} catch (_) { /* ignore */ }
}
}
/**
* 跨端创建录音器:优先使用浏览器 MediaRecorder否则回退到 uni.getRecorderManager
*/
function createRecorder() {
// H5 优先走 MediaRecorderuni-app H5 的 getRecorderManager 是 stub
if (typeof window !== 'undefined'
&& typeof window.MediaRecorder !== 'undefined'
&& navigator && navigator.mediaDevices) {
return new H5Recorder()
}
// App / 小程序 等 uni 原生端
if (typeof uni !== 'undefined' && typeof uni.getRecorderManager === 'function') {
try {
return uni.getRecorderManager()
} catch (_) { /* fallthrough */ }
}
return null
}
export default { export default {
name: 'VoiceRecorder', name: 'VoiceRecorder',
emits: ['recorded'], emits: ['recorded'],
@@ -41,25 +146,43 @@ export default {
let recorder = null let recorder = null
let timer = null let timer = null
let startY = 0 let startY = 0
// 防止 touchstart 与 mousedown 重复触发,以及 mouseleave 误停
let pressing = false
const onTouchStart = (e) => { const getClientY = (e) => {
startY = e.touches[0].clientY if (e.touches && e.touches.length > 0) return e.touches[0].clientY
if (e.changedTouches && e.changedTouches.length > 0) return e.changedTouches[0].clientY
return typeof e.clientY === 'number' ? e.clientY : 0
}
const onPressStart = (e) => {
if (pressing) return
pressing = true
startY = getClientY(e)
isCancelZone.value = false isCancelZone.value = false
recordingTime.value = 0 recordingTime.value = 0
recorder = uni.getRecorderManager() recorder = createRecorder()
if (!recorder) {
pressing = false
uni.showToast({ title: '当前环境不支持录音', icon: 'none' })
return
}
recorder.onStart(() => { recorder.onStart(() => {
isRecording.value = true isRecording.value = true
timer = setInterval(() => { timer = setInterval(() => {
recordingTime.value++ recordingTime.value++
if (recordingTime.value >= 60) { if (recordingTime.value >= 60) {
onTouchEnd() onPressEnd()
} }
}, 1000) }, 1000)
}) })
recorder.onStop((res) => { recorder.onStop((res) => {
clearInterval(timer) clearInterval(timer)
const wasRecording = isRecording.value
isRecording.value = false isRecording.value = false
pressing = false
if (!wasRecording) return
if (isCancelZone.value) return if (isCancelZone.value) return
if (recordingTime.value < 1) { if (recordingTime.value < 1) {
uni.showToast({ title: '录音时间太短', icon: 'none' }) uni.showToast({ title: '录音时间太短', icon: 'none' })
@@ -68,35 +191,56 @@ export default {
emit('recorded', { emit('recorded', {
tempFilePath: res.tempFilePath, tempFilePath: res.tempFilePath,
duration: recordingTime.value, duration: recordingTime.value,
fileSize: res.fileSize || 0 fileSize: res.fileSize || 0,
blob: res.blob || null,
mimeType: res.mimeType || ''
}) })
}) })
recorder.onError(() => { recorder.onError((err) => {
clearInterval(timer) clearInterval(timer)
isRecording.value = false isRecording.value = false
uni.showToast({ title: '录音失败', icon: 'none' }) pressing = false
uni.showToast({ title: err?.errMsg || '录音失败', icon: 'none' })
}) })
// uni 原生 recorder.start 接收参数对象H5Recorder.start 忽略参数
try {
recorder.start({ recorder.start({
format: 'mp3', format: 'mp3',
duration: 60000, duration: 60000,
sampleRate: 44100, sampleRate: 44100,
numberOfChannels: 1 numberOfChannels: 1
}) })
} catch (e) {
pressing = false
isRecording.value = false
uni.showToast({ title: e?.message || '无法启动录音', icon: 'none' })
}
} }
const onTouchMove = (e) => { const onPressMove = (e) => {
if (!isRecording.value) return if (!isRecording.value) return
const deltaY = startY - e.touches[0].clientY const y = getClientY(e)
const deltaY = startY - y
isCancelZone.value = deltaY > 80 isCancelZone.value = deltaY > 80
} }
const onTouchEnd = () => { const onPressEnd = () => {
if (recorder && isRecording.value) { if (recorder && isRecording.value) {
recorder.stop() recorder.stop()
} else {
// 未 onStart 就抬起:重置 pressing 避免死锁
pressing = false
} }
} }
// 鼠标离开按钮PC H5 兜底):视为取消录音
const onMouseLeave = () => {
if (!isRecording.value) return
isCancelZone.value = true
onPressEnd()
}
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearInterval(timer) clearInterval(timer)
if (recorder && isRecording.value) { if (recorder && isRecording.value) {
@@ -105,7 +249,15 @@ export default {
} }
}) })
return { isRecording, isCancelZone, recordingTime, onTouchStart, onTouchMove, onTouchEnd } return {
isRecording,
isCancelZone,
recordingTime,
onPressStart,
onPressMove,
onPressEnd,
onMouseLeave
}
} }
} }
</script> </script>
@@ -122,6 +274,8 @@ export default {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: background-color 200ms; transition: background-color 200ms;
cursor: pointer;
user-select: none;
} }
.record-btn.recording { .record-btn.recording {
background-color: #DBEAFE; background-color: #DBEAFE;

View File

@@ -3,11 +3,12 @@
显示播放按钮 + 波形条 + 时长 显示播放按钮 + 波形条 + 时长
宽度按时长线性缩放最小 120rpx1最大 500rpx60 宽度按时长线性缩放最小 120rpx1最大 500rpx60
同一时间只允许一个语音播放全局单例 同一时间只允许一个语音播放全局单例
"未听"红点对方发来的语音若本地未播放过右侧显示红点点击播放即清除与微信一致
--> -->
<template> <template>
<view v-if="msg.status === 2" class="recalled-text">消息已撤回</view> <view v-if="msg.status === 2" class="recalled-text">消息已撤回</view>
<view v-else class="msg-voice-container">
<view <view
v-else
class="msg-voice-wrap" class="msg-voice-wrap"
:class="{ 'voice-self': isSelf, 'voice-playing': isPlaying }" :class="{ 'voice-self': isSelf, 'voice-playing': isPlaying }"
:style="{ width: voiceWidth + 'rpx' }" :style="{ width: voiceWidth + 'rpx' }"
@@ -21,11 +22,14 @@
</view> </view>
<text class="voice-duration" :class="{ 'duration-self': isSelf }">{{ duration }}"</text> <text class="voice-duration" :class="{ 'duration-self': isSelf }">{{ duration }}"</text>
</view> </view>
<view v-if="showUnplayedDot" class="unplayed-dot" />
</view>
</template> </template>
<script> <script>
import { ref, computed, onBeforeUnmount } from 'vue' import { ref, computed, onBeforeUnmount } from 'vue'
import { parseExtra } from '@/utils/file' import { parseExtra } from '@/utils/file'
import { useChatStore } from '@/store/chat'
let _globalAudio = null let _globalAudio = null
let _globalPlayingId = null let _globalPlayingId = null
@@ -38,6 +42,20 @@ export default {
}, },
setup(props) { setup(props) {
const isPlaying = ref(false) const isPlaying = ref(false)
const chatStore = useChatStore()
/**
* 是否显示"未播放"红点:
* - 仅对「对方发来的」语音显示isSelf=false
* - 消息未被撤回(由外层 v-if 已保证)
* - 本地存储未记录"已播放"
*/
const showUnplayedDot = computed(() => {
if (props.isSelf) return false
const id = props.msg.id
if (!id) return false
return !chatStore.isVoicePlayed(id)
})
const voiceData = computed(() => { const voiceData = computed(() => {
const extra = parseExtra(props.msg.extra) const extra = parseExtra(props.msg.extra)
@@ -61,6 +79,11 @@ export default {
const url = voiceData.value.url const url = voiceData.value.url
if (!url) return if (!url) return
// 对方发来的语音一经点击即视为"已播放",消除红点(与微信一致)
if (!props.isSelf && props.msg.id) {
chatStore.markVoicePlayed(props.msg.id)
}
const msgId = props.msg.id || props.msg.client_msg_id const msgId = props.msg.id || props.msg.client_msg_id
if (_globalPlayingId === msgId && _globalAudio) { if (_globalPlayingId === msgId && _globalAudio) {
@@ -107,12 +130,16 @@ export default {
} }
}) })
return { isPlaying, duration, voiceWidth, barHeights, onTogglePlay } return { isPlaying, duration, voiceWidth, barHeights, onTogglePlay, showUnplayedDot }
} }
} }
</script> </script>
<style scoped> <style scoped>
.msg-voice-container {
display: flex;
align-items: center;
}
.msg-voice-wrap { .msg-voice-wrap {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -120,6 +147,16 @@ export default {
padding: 4rpx 0; padding: 4rpx 0;
cursor: pointer; cursor: pointer;
} }
/* "未播放"红点紧贴气泡右侧不占据主体宽度 */
.unplayed-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background-color: #DC2626;
margin-left: 10rpx;
flex-shrink: 0;
box-shadow: 0 0 0 2rpx rgba(220, 38, 38, 0.15);
}
.voice-icon { .voice-icon {
flex-shrink: 0; flex-shrink: 0;
} }

View File

@@ -29,6 +29,9 @@
:scroll-into-view="scrollToId" :scroll-into-view="scrollToId"
:scroll-with-animation="true" :scroll-with-animation="true"
@scrolltoupper="onLoadMore" @scrolltoupper="onLoadMore"
@scrolltolower="onScrollToLower"
@scroll="onScroll"
:lower-threshold="150"
> >
<view v-if="hasMore" class="load-more" @tap="onLoadMore"> <view v-if="hasMore" class="load-more" @tap="onLoadMore">
<text class="load-more-text">{{ loadingMore ? '加载中...' : '加载更多' }}</text> <text class="load-more-text">{{ loadingMore ? '加载中...' : '加载更多' }}</text>
@@ -100,6 +103,12 @@
<view id="msg-bottom" style="height: 2rpx;" /> <view id="msg-bottom" style="height: 2rpx;" />
</scroll-view> </scroll-view>
<!-- 新消息悬浮提示仅当用户远离底部且有新消息时显示 -->
<view v-if="newMsgCount > 0" class="new-msg-hint" @tap="jumpToBottom">
<uni-icons type="bottom" size="14" color="#2563EB" />
<text class="new-msg-hint-text">{{ newMsgCount }} 条新消息</text>
</view>
<!-- 输入栏 --> <!-- 输入栏 -->
<view class="input-bar"> <view class="input-bar">
<view class="voice-toggle-btn" @tap="toggleVoiceMode"> <view class="voice-toggle-btn" @tap="toggleVoiceMode">
@@ -165,6 +174,18 @@ export default {
const scrollToId = ref('') const scrollToId = ref('')
const loadingMore = ref(false) const loadingMore = ref(false)
// 滚动位置感知:判断用户视图是否"贴近底部"
// 策略:每次 @scroll 事件都对比"历史最大 scrollTop"(等价于贴底位置),
// 差值 < 150 视为贴底。此方式不依赖 @scrolltolower 能否在程序化滚动后触发,
// 对 scroll-into-view + 动态 scrollHeight 都能正确响应。
const nearBottom = ref(true)
// 用户未看到的新消息累计数(悬浮按钮显示用)
const newMsgCount = ref(0)
// 首次渲染完成前,避免滚动事件误判
let scrollReady = false
// 已记录的最大 scrollTopscrollHeight 增大并滚到底时会同步增长)
let maxScrollTop = 0
const messages = computed(() => chatStore.currentMessages) const messages = computed(() => chatStore.currentMessages)
const hasMore = computed(() => conversationId.value > 0 && chatStore.hasMoreMap[conversationId.value] !== false) const hasMore = computed(() => conversationId.value > 0 && chatStore.hasMoreMap[conversationId.value] !== false)
const isTyping = computed(() => chatStore.typingMap[conversationId.value] || false) const isTyping = computed(() => chatStore.typingMap[conversationId.value] || false)
@@ -242,6 +263,8 @@ export default {
await chatStore.loadHistoryMessages(conversationId.value) await chatStore.loadHistoryMessages(conversationId.value)
} }
scrollToBottom() scrollToBottom()
// 初次进入会话且消息已加载:等待渲染后启用滚动感知
nextTick(() => { scrollReady = true })
} }
const scrollToBottom = () => { const scrollToBottom = () => {
@@ -251,6 +274,80 @@ export default {
}) })
} }
/**
* 滚动事件:维护 maxScrollTop并据此更新 nearBottom
* - scrollTop 持续增长(贴底滚动)→ maxScrollTop 同步增长 → 贴底
* - scrollTop 相对 maxScrollTop 回退 > 150 → 用户主动上滑,视为远离底部
*/
const onScroll = (e) => {
if (!scrollReady) return
const scrollTop = e.detail?.scrollTop
if (typeof scrollTop !== 'number') return
if (scrollTop > maxScrollTop) maxScrollTop = scrollTop
const wasNear = nearBottom.value
nearBottom.value = (maxScrollTop - scrollTop) < 150
// 由"远离"回到"贴底"的瞬间,清空未读悬浮并标记已读
if (!wasNear && nearBottom.value) {
if (newMsgCount.value > 0) newMsgCount.value = 0
if (conversationId.value) chatStore.markRead(conversationId.value)
}
}
/** 滚动到达下边界lower-threshold 内):权威地置贴底 */
const onScrollToLower = () => {
nearBottom.value = true
if (newMsgCount.value > 0) {
newMsgCount.value = 0
}
if (conversationId.value) {
chatStore.markRead(conversationId.value)
}
}
/** 点击悬浮提示:滚到底部、清零计数、标记已读 */
const jumpToBottom = () => {
scrollToBottom()
newMsgCount.value = 0
nearBottom.value = true
if (conversationId.value) {
chatStore.markRead(conversationId.value)
}
}
// 监听消息数组变化:只对「末尾新增的消息」做处理
// 关键点:必须对比末尾消息标识,而非仅看 length——加载历史时从头部插入大量消息
// length 会增加但 tail 不变,不应计入 newMsgCount
let lastTailKey = ''
const getTailKey = (msg) => msg ? String(msg.id || msg.client_msg_id || '') : ''
watch(() => messages.value.length, (newLen) => {
if (!scrollReady) return
const tail = messages.value[newLen - 1]
const tailKey = getTailKey(tail)
// 末尾消息未变(仅头部插入历史消息)→ 不处理
if (!tailKey || tailKey === lastTailKey) {
lastTailKey = tailKey || lastTailKey
return
}
const prevKey = lastTailKey
lastTailKey = tailKey
// 首次初始化(从空数组到有消息)不触发逻辑,由 loadInitialMessages 负责滚动
if (!prevKey) return
// 仅对「对方发来的新消息」做累计;自己发出的消息在 onSend/onChoose 等处已显式滚动
const myId = Number(userStore.userInfo?.id) || 0
const fromOther = tail.sender_id && tail.sender_id !== myId
if (!fromOther) return
if (nearBottom.value) {
scrollToBottom()
if (conversationId.value) {
chatStore.markRead(conversationId.value)
}
} else {
newMsgCount.value += 1
}
})
const onSend = () => { const onSend = () => {
const content = inputText.value.trim() const content = inputText.value.trim()
if (!content) return if (!content) return
@@ -433,10 +530,13 @@ export default {
// #endif // #endif
} }
const onVoiceRecorded = async ({ tempFilePath, duration }) => { const onVoiceRecorded = async ({ tempFilePath, duration, blob, mimeType }) => {
try { try {
uni.showLoading({ title: '发送中...' }) uni.showLoading({ title: '发送中...' })
const result = await uploadVoice(tempFilePath, duration) // H5 端:根据 mimeType 推断扩展名,传递 Blob 以走 fetch+FormData 精确上传
const ext = mimeTypeToExt(mimeType)
const fileName = ext ? `voice-${Date.now()}.${ext}` : undefined
const result = await uploadVoice(tempFilePath, duration, fileName, blob)
chatStore.sendVoiceMessage({ chatStore.sendVoiceMessage({
conversationId: conversationId.value || 0, conversationId: conversationId.value || 0,
targetUserId: conversationId.value ? 0 : peerId.value, targetUserId: conversationId.value ? 0 : peerId.value,
@@ -455,6 +555,18 @@ export default {
} }
} }
// 根据 MediaRecorder 的 mimeType 推断文件扩展名
const mimeTypeToExt = (mime) => {
if (!mime) return ''
const m = mime.toLowerCase()
if (m.includes('webm')) return 'webm'
if (m.includes('ogg')) return 'ogg'
if (m.includes('mp4') || m.includes('aac')) return 'm4a'
if (m.includes('mpeg') || m.includes('mp3')) return 'mp3'
if (m.includes('wav')) return 'wav'
return ''
}
const goToSettings = () => { const goToSettings = () => {
uni.navigateTo({ uni.navigateTo({
url: `/pages/chat/settings?conversationId=${conversationId.value}&peerId=${peerId.value}&peerName=${encodeURIComponent(peerName.value)}&peerAvatar=${encodeURIComponent(peerAvatar.value)}` url: `/pages/chat/settings?conversationId=${conversationId.value}&peerId=${peerId.value}&peerName=${encodeURIComponent(peerName.value)}&peerAvatar=${encodeURIComponent(peerAvatar.value)}`
@@ -466,8 +578,10 @@ export default {
inputText, scrollToId, loadingMore, convType, inputText, scrollToId, loadingMore, convType,
voiceMode, showMorePanel, voiceMode, showMorePanel,
messages, hasMore, isTyping, messages, hasMore, isTyping,
newMsgCount,
isSelf, isRead, getReadLabel, isSelf, isRead, getReadLabel,
onSend, onInputChange, onLoadMore, onSend, onInputChange, onLoadMore,
onScroll, onScrollToLower, jumpToBottom,
onMsgLongPress, onResend, goBack, goToSettings, onMsgLongPress, onResend, goBack, goToSettings,
toggleVoiceMode, toggleMorePanel, toggleVoiceMode, toggleMorePanel,
onChooseImage, onChooseFile, onVoiceRecorded onChooseImage, onChooseFile, onVoiceRecorded
@@ -661,4 +775,31 @@ export default {
padding: 12rpx !important; padding: 12rpx !important;
background-color: transparent !important; background-color: transparent !important;
} }
/* ===== 新消息悬浮提示(远离底部时展示,点击滚到底并标记已读) ===== */
.new-msg-hint {
position: fixed;
right: 32rpx;
/* 输入栏约 104rpx + 底部安全区 + 16rpx 间距 */
bottom: calc(120rpx + env(safe-area-inset-bottom, 0rpx));
display: flex;
align-items: center;
padding: 12rpx 24rpx;
background-color: #FFFFFF;
border: 1rpx solid #E2E8F0;
border-radius: 32rpx;
box-shadow: 0 4rpx 16rpx rgba(15, 23, 42, 0.12);
z-index: 10;
transition: opacity 150ms ease, transform 150ms ease;
}
.new-msg-hint:active {
opacity: 0.85;
transform: translateY(1rpx);
}
.new-msg-hint-text {
font-size: 24rpx;
color: #2563EB;
margin-left: 6rpx;
font-weight: 500;
}
</style> </style>

View File

@@ -30,6 +30,9 @@
:scroll-into-view="scrollToId" :scroll-into-view="scrollToId"
:scroll-with-animation="true" :scroll-with-animation="true"
@scrolltoupper="onLoadMore" @scrolltoupper="onLoadMore"
@scrolltolower="onScrollToLower"
@scroll="onScroll"
:lower-threshold="150"
> >
<view v-if="hasMore" class="load-more" @tap="onLoadMore"> <view v-if="hasMore" class="load-more" @tap="onLoadMore">
<text class="load-more-text">{{ loadingMore ? '加载中...' : '加载更多' }}</text> <text class="load-more-text">{{ loadingMore ? '加载中...' : '加载更多' }}</text>
@@ -124,6 +127,12 @@
<view id="msg-bottom" style="height: 2rpx;" /> <view id="msg-bottom" style="height: 2rpx;" />
</scroll-view> </scroll-view>
<!-- 新消息悬浮提示仅当用户远离底部且有新消息时显示 -->
<view v-if="newMsgCount > 0" class="new-msg-hint" @tap="jumpToBottom">
<uni-icons type="bottom" size="14" color="#2563EB" />
<text class="new-msg-hint-text">{{ newMsgCount }} 条新消息</text>
</view>
<!-- @成员选择器 --> <!-- @成员选择器 -->
<view v-if="showAtPicker" class="at-overlay" @tap="showAtPicker = false"> <view v-if="showAtPicker" class="at-overlay" @tap="showAtPicker = false">
<view class="at-panel" @tap.stop> <view class="at-panel" @tap.stop>
@@ -238,6 +247,13 @@ export default {
const voiceMode = ref(false) const voiceMode = ref(false)
const showMorePanel = ref(false) const showMorePanel = ref(false)
// 滚动位置感知(与单聊一致):远离底部时新消息转为悬浮提示,避免视图未见却已标已读
// 使用 maxScrollTop 追踪策略scrollTop 相对历史最大值回退 > 150 视为远离底部
const nearBottom = ref(true)
const newMsgCount = ref(0)
let scrollReady = false
let maxScrollTop = 0
const messages = computed(() => chatStore.currentMessages) const messages = computed(() => chatStore.currentMessages)
const hasMore = computed(() => chatStore.hasMoreMap[conversationId.value] !== false) const hasMore = computed(() => chatStore.hasMoreMap[conversationId.value] !== false)
const selfAvatar = computed(() => userStore.userInfo?.avatar || '') const selfAvatar = computed(() => userStore.userInfo?.avatar || '')
@@ -349,6 +365,7 @@ export default {
} }
scrollToBottom() scrollToBottom()
markVisibleMessagesRead() markVisibleMessagesRead()
nextTick(() => { scrollReady = true })
} }
const scrollToBottom = () => { const scrollToBottom = () => {
@@ -367,6 +384,37 @@ export default {
} }
} }
/** 滚动事件:维护 maxScrollTop 并判断是否贴底(与单聊一致) */
const onScroll = (e) => {
if (!scrollReady) return
const scrollTop = e.detail?.scrollTop
if (typeof scrollTop !== 'number') return
if (scrollTop > maxScrollTop) maxScrollTop = scrollTop
const wasNear = nearBottom.value
nearBottom.value = (maxScrollTop - scrollTop) < 150
if (!wasNear && nearBottom.value) {
if (newMsgCount.value > 0) newMsgCount.value = 0
markVisibleMessagesRead()
}
}
/** 滚动接近底部触发:将 nearBottom 置 true 并立即标记已读 */
const onScrollToLower = () => {
nearBottom.value = true
if (newMsgCount.value > 0) {
newMsgCount.value = 0
}
markVisibleMessagesRead()
}
/** 点击悬浮提示:滚到底、清零、标记已读 */
const jumpToBottom = () => {
scrollToBottom()
newMsgCount.value = 0
nearBottom.value = true
markVisibleMessagesRead()
}
// ==================== 消息发送 ==================== // ==================== 消息发送 ====================
const onSend = () => { const onSend = () => {
@@ -592,10 +640,23 @@ export default {
// #endif // #endif
} }
const onVoiceRecorded = async ({ tempFilePath, duration }) => { const mimeTypeToExt = (mime) => {
if (!mime) return ''
const m = mime.toLowerCase()
if (m.includes('webm')) return 'webm'
if (m.includes('ogg')) return 'ogg'
if (m.includes('mp4') || m.includes('aac')) return 'm4a'
if (m.includes('mpeg') || m.includes('mp3')) return 'mp3'
if (m.includes('wav')) return 'wav'
return ''
}
const onVoiceRecorded = async ({ tempFilePath, duration, blob, mimeType }) => {
try { try {
uni.showLoading({ title: '发送中...' }) uni.showLoading({ title: '发送中...' })
const result = await uploadVoice(tempFilePath, duration) const ext = mimeTypeToExt(mimeType)
const fileName = ext ? `voice-${Date.now()}.${ext}` : undefined
const result = await uploadVoice(tempFilePath, duration, fileName, blob)
chatStore.sendVoiceMessage({ chatStore.sendVoiceMessage({
conversationId: conversationId.value, conversationId: conversationId.value,
voice: { url: result.url, duration: result.duration || duration, size: result.size, file_name: result.file_name } voice: { url: result.url, duration: result.duration || duration, size: result.size, file_name: result.file_name }
@@ -608,13 +669,38 @@ export default {
} }
} }
// ==================== 监听新消息到达后标记已读 ==================== // ==================== 监听新消息到达,按滚动位置分流处理 ====================
// 关键点:对比末尾消息标识,避免"加载更多"时头部插入历史消息被误计入 newMsgCount
let lastTailKey = ''
const getTailKey = (msg) => msg ? String(msg.id || msg.client_msg_id || '') : ''
watch( watch(
() => messages.value.length, () => messages.value.length,
() => { (newLen) => {
if (!scrollReady) return
const tail = messages.value[newLen - 1]
const tailKey = getTailKey(tail)
if (!tailKey || tailKey === lastTailKey) {
lastTailKey = tailKey || lastTailKey
return
}
const prevKey = lastTailKey
lastTailKey = tailKey
if (!prevKey) return
// 系统消息type===10 或 sender_id===0与自己发的消息不计入悬浮计数
const isSystem = tail.type === 10 || tail.sender_id === 0
const fromSelf = tail.sender_id === myId.value
if (isSystem || fromSelf) {
if (nearBottom.value) scrollToBottom()
return
}
if (nearBottom.value) {
scrollToBottom() scrollToBottom()
markVisibleMessagesRead() markVisibleMessagesRead()
} else {
newMsgCount.value += 1
}
} }
) )
@@ -623,9 +709,11 @@ export default {
groupName, groupAvatar, selfAvatar, selfName, groupName, groupAvatar, selfAvatar, selfName,
inputText, scrollToId, loadingMore, memberCount, inputText, scrollToId, loadingMore, memberCount,
messages, hasMore, showAtPicker, atMemberList, isMuted, isAllMuted, messages, hasMore, showAtPicker, atMemberList, isMuted, isAllMuted,
newMsgCount,
isSelf, getMemberName, getMemberAvatar, getReadLabel, isSelf, getMemberName, getMemberAvatar, getReadLabel,
onSend, onInputChange, onSelectAtMember, onSend, onInputChange, onSelectAtMember,
onLoadMore, onMsgLongPress, onResend, onLoadMore, onMsgLongPress, onResend,
onScroll, onScrollToLower, jumpToBottom,
goBack, goToSettings, goToReadDetail, goBack, goToSettings, goToReadDetail,
voiceMode, showMorePanel, voiceMode, showMorePanel,
toggleVoiceMode, toggleMorePanel, toggleVoiceMode, toggleMorePanel,
@@ -933,4 +1021,30 @@ export default {
.bubble-media { .bubble-media {
max-width: 420rpx; max-width: 420rpx;
} }
/* ===== 新消息悬浮提示(远离底部时展示,点击滚到底并标记已读) ===== */
.new-msg-hint {
position: fixed;
right: 32rpx;
bottom: calc(120rpx + env(safe-area-inset-bottom, 0rpx));
display: flex;
align-items: center;
padding: 12rpx 24rpx;
background-color: #FFFFFF;
border: 1rpx solid #E2E8F0;
border-radius: 32rpx;
box-shadow: 0 4rpx 16rpx rgba(15, 23, 42, 0.12);
z-index: 10;
transition: opacity 150ms ease, transform 150ms ease;
}
.new-msg-hint:active {
opacity: 0.85;
transform: translateY(1rpx);
}
.new-msg-hint-text {
font-size: 24rpx;
color: #2563EB;
margin-left: 6rpx;
font-weight: 500;
}
</style> </style>

View File

@@ -46,6 +46,71 @@ export const useChatStore = defineStore('chat', () => {
/** 群聊已读计数messageId -> readCount */ /** 群聊已读计数messageId -> readCount */
const groupReadCountMap = ref({}) const groupReadCountMap = ref({})
/**
* 语音"已播放"状态messageId -> true
*
* 说明:区别于通用"已读"(基于滚动可见);此状态记录用户是否"真的点击播放过"对方发来的语音。
* - 仅对"对方发来的语音"有意义(自己发的无需标记)
* - 本地持久化(按 userId 隔离),换设备会重新显示红点(与微信行为一致)
* - 不同步到服务端:属于私人视图态,不影响对方
*/
const voicePlayedMap = ref({})
/** 已加载 voicePlayed 的 userId避免重复加载 */
let _voicePlayedLoadedUserId = null
/** 生成 voicePlayed 的存储 key按 userId 隔离) */
const _voicePlayedStorageKey = (userId) => `echo:voice-played:${userId}`
/**
* 从本地存储加载指定用户的语音已播放状态
* 幂等:同一 userId 只会加载一次;切换用户时自动重新加载
*/
const loadVoicePlayedState = (userId) => {
if (!userId) return
if (_voicePlayedLoadedUserId === userId) return
try {
const raw = uni.getStorageSync(_voicePlayedStorageKey(userId))
voicePlayedMap.value = raw && typeof raw === 'object' ? { ...raw } : {}
} catch (_) {
voicePlayedMap.value = {}
}
_voicePlayedLoadedUserId = userId
}
/**
* 标记某条语音消息为"已播放",并持久化到本地存储
* @param {number|string} messageId
*/
const markVoicePlayed = (messageId) => {
if (!messageId) return
const key = String(messageId)
if (voicePlayedMap.value[key]) return
voicePlayedMap.value[key] = true
try {
const userStore = useUserStore()
const userId = userStore.userInfo?.id
if (userId) {
uni.setStorageSync(_voicePlayedStorageKey(userId), { ...voicePlayedMap.value })
}
} catch (_) { /* ignore storage errors */ }
}
/**
* 查询某条语音是否已播放
* @param {number|string} messageId
* @returns {boolean}
*/
const isVoicePlayed = (messageId) => {
if (!messageId) return false
return !!voicePlayedMap.value[String(messageId)]
}
/** 重置语音已播放状态(登出时调用) */
const resetVoicePlayedState = () => {
voicePlayedMap.value = {}
_voicePlayedLoadedUserId = null
}
// ==================== Computed ==================== // ==================== Computed ====================
/** 当前会话的消息列表conversationId 为 null 时取 pending 队列) */ /** 当前会话的消息列表conversationId 为 null 时取 pending 队列) */
@@ -211,6 +276,9 @@ export const useChatStore = defineStore('chat', () => {
if (!conversationId) return if (!conversationId) return
const messages = messagesMap.value[conversationId] || [] const messages = messagesMap.value[conversationId] || []
const beforeId = messages.length > 0 ? messages[0].id : 0 const beforeId = messages.length > 0 ? messages[0].id : 0
// 仅在「首次加载(本地还没缓存任何消息)」时回填已读状态;
// 滚动加载更多历史消息时不覆盖,避免把已通过 WS 增量到达的最新状态冲回旧值
const isInitialLoad = messages.length === 0
const res = await imApi.getHistoryMessages(conversationId, beforeId) const res = await imApi.getHistoryMessages(conversationId, beforeId)
if (res.data) { if (res.data) {
@@ -221,6 +289,23 @@ export const useChatStore = defineStore('chat', () => {
const existing = messagesMap.value[conversationId] || [] const existing = messagesMap.value[conversationId] || []
messagesMap.value[conversationId] = [...list.reverse(), ...existing] messagesMap.value[conversationId] = [...list.reverse(), ...existing]
} }
// ========== 已读状态回填(修复页面刷新后已读标签变未读的 Bug ==========
if (isInitialLoad) {
// 单聊:对方 last_read_msg_id → 写入 readStatusMap
const peerLastRead = Number(res.data.peer_last_read_msg_id) || 0
if (peerLastRead > 0) {
readStatusMap.value[conversationId] = peerLastRead
}
// 群聊:自己发送消息的 read_count → 合并到 groupReadCountMap
const rcMap = res.data.read_count_map
if (rcMap && typeof rcMap === 'object') {
Object.keys(rcMap).forEach(mid => {
const count = Number(rcMap[mid]) || 0
if (count > 0) groupReadCountMap.value[mid] = count
})
}
}
} }
} }
@@ -266,6 +351,13 @@ export const useChatStore = defineStore('chat', () => {
/** 初始化 WebSocket 事件监听(幂等) */ /** 初始化 WebSocket 事件监听(幂等) */
const initWsListeners = () => { const initWsListeners = () => {
// 懒加载语音播放态(按当前用户隔离,每次调用都会检查 userId 是否变更)
try {
const userStore = useUserStore()
const uid = userStore.userInfo?.id
if (uid) loadVoicePlayedState(uid)
} catch (_) { /* ignore */ }
if (_wsInitialized) return if (_wsInitialized) return
_wsInitialized = true _wsInitialized = true
@@ -308,9 +400,9 @@ export const useChatStore = defineStore('chat', () => {
existingConv.unread_count = (existingConv.unread_count || 0) + 1 existingConv.unread_count = (existingConv.unread_count || 0) + 1
} }
totalUnread.value++ totalUnread.value++
} else {
markRead(data.conversation_id)
} }
// 注意:当前会话的 markRead 由聊天页面根据滚动位置决定
// (仅当用户视图贴近底部、能看到新消息时才触发),避免"视图未看到但已读"的 UX 错位
} }
/** 消息撤回推送 */ /** 消息撤回推送 */
@@ -492,6 +584,7 @@ export const useChatStore = defineStore('chat', () => {
hasMoreMap, hasMoreMap,
readStatusMap, readStatusMap,
groupReadCountMap, groupReadCountMap,
voicePlayedMap,
currentMessages, currentMessages,
sortedConversations, sortedConversations,
fetchConversations, fetchConversations,
@@ -509,6 +602,10 @@ export const useChatStore = defineStore('chat', () => {
deleteConversation, deleteConversation,
clearHistory, clearHistory,
setCurrentConversation, setCurrentConversation,
initWsListeners initWsListeners,
markVoicePlayed,
isVoicePlayed,
loadVoicePlayedState,
resetVoicePlayedState
} }
}) })

View File

@@ -120,6 +120,12 @@ export const useUserStore = defineStore('user', () => {
} catch (e) { } catch (e) {
console.warn('登出 API 调用失败,仍然清除本地状态', e) console.warn('登出 API 调用失败,仍然清除本地状态', e)
} }
// 清理 chat store 中的私人视图态(语音已播放缓存按 userId 隔离存在 storage
// 此处只重置运行时 state下次登录会按新 userId 加载对应缓存)
try {
const { useChatStore } = await import('@/store/chat')
useChatStore().resetVoicePlayedState()
} catch (_) { /* ignore */ }
token.value = '' token.value = ''
userInfo.value = null userInfo.value = null
clearAll() clearAll()