@tencent-rtc/trtc-agent-skills 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CODEBUDDY.md +0 -24
  2. package/README.md +12 -0
  3. package/README.zh.md +12 -0
  4. package/bin/cli.js +18 -5
  5. package/knowledge-base/chat/web/index.yaml +168 -0
  6. package/knowledge-base/chat/web/path-d-signals.yaml +4 -0
  7. package/knowledge-base/docs/chat/debug/GenerateTestUserSig.js +23 -0
  8. package/knowledge-base/docs/chat/debug/lib-generate-test-usersig.min.js +1 -0
  9. package/knowledge-base/docs/chat/gen-usersig.md +129 -0
  10. package/knowledge-base/docs/chat/product.md +61 -0
  11. package/knowledge-base/docs/chat/restapi.md +227 -0
  12. package/knowledge-base/docs/chat/sdk/android/faq.md +7 -0
  13. package/knowledge-base/docs/chat/sdk/android/index.md +94 -0
  14. package/knowledge-base/docs/chat/sdk/flutter/faq.md +7 -0
  15. package/knowledge-base/docs/chat/sdk/flutter/index.md +83 -0
  16. package/knowledge-base/docs/chat/sdk/ios/faq.md +7 -0
  17. package/knowledge-base/docs/chat/sdk/ios/index.md +94 -0
  18. package/knowledge-base/docs/chat/sdk/web/faq.md +8 -0
  19. package/knowledge-base/docs/chat/sdk/web/index.md +97 -0
  20. package/knowledge-base/docs/chat/uikit/android/index.md +28 -0
  21. package/knowledge-base/docs/chat/uikit/flutter/index.md +17 -0
  22. package/knowledge-base/docs/chat/uikit/ios/index.md +28 -0
  23. package/knowledge-base/docs/chat/uikit/react/index.md +6 -0
  24. package/knowledge-base/docs/chat/uikit/uniapp/index.md +6 -0
  25. package/knowledge-base/docs/chat/uikit/vue3/index.md +6 -0
  26. package/knowledge-base/docs/chat/webhook.md +73 -0
  27. package/knowledge-base/products.yaml +2 -2
  28. package/knowledge-base/slices/chat/web/at-mention.md +417 -0
  29. package/knowledge-base/slices/chat/web/conversation-actions.md +178 -0
  30. package/knowledge-base/slices/chat/web/conversation-list.md +368 -0
  31. package/knowledge-base/slices/chat/web/detect-integration.md +36 -0
  32. package/knowledge-base/slices/chat/web/detect-style.md +88 -0
  33. package/knowledge-base/slices/chat/web/direct-chat-entry.md +210 -0
  34. package/knowledge-base/slices/chat/web/login-auth.md +244 -0
  35. package/knowledge-base/slices/chat/web/message-base-actions.md +273 -0
  36. package/knowledge-base/slices/chat/web/message-input.md +209 -0
  37. package/knowledge-base/slices/chat/web/message-list.md +532 -0
  38. package/knowledge-base/slices/chat/web/send-custom-message.md +189 -0
  39. package/knowledge-base/slices/chat/web/send-media.md +214 -0
  40. package/knowledge-base/slices/chat/web/state-api-skeleton.md +60 -0
  41. package/knowledge-base/slices/chat/web/style-guide.md +210 -0
  42. package/package.json +5 -1
  43. package/skills/trtc/SKILL.md +57 -23
  44. package/skills/trtc/hooks/topic_phase_gate.py +5 -0
  45. package/skills/trtc/tests/test_reporting_v2_docs_query.py +175 -0
  46. package/skills/trtc/tools/kb.py +130 -0
  47. package/skills/trtc/tools/reporting.py +23 -2
  48. package/skills/trtc/tools/reporting_v2.py +501 -0
  49. package/skills/trtc-ai-service/README.ja.md +209 -0
  50. package/skills/trtc-ai-service/README.md +23 -9
  51. package/skills/trtc-ai-service/README.zh-CN.md +23 -9
  52. package/skills/trtc-ai-service/SKILL.md +4 -4
  53. package/skills/trtc-chat/.docs-query.yaml +9 -0
  54. package/skills/trtc-chat/SKILL.md +186 -0
  55. package/skills/trtc-chat/docs/SKILL.md +31 -0
  56. package/skills/trtc-chat/flows/maintenance.md +19 -0
  57. package/skills/trtc-chat/flows/onboarding.md +79 -0
  58. package/skills/trtc-chat/references/01-detect-project.md +136 -0
  59. package/skills/trtc-chat/references/02-path-a-questions.md +182 -0
  60. package/skills/trtc-chat/references/02-path-a-scaffold-template.md +59 -0
  61. package/skills/trtc-chat/references/02-path-a-script.md +266 -0
  62. package/skills/trtc-chat/references/02-path-a-templates.md +159 -0
  63. package/skills/trtc-chat/references/03-path-b-script.md +226 -0
  64. package/skills/trtc-chat/references/04-path-c-script.md +95 -0
  65. package/skills/trtc-chat/references/04-uikit-redirect.md +32 -0
  66. package/skills/trtc-chat/references/05-path-d-script.md +735 -0
  67. package/skills/trtc-chat/references/05-slice-loading.md +139 -0
  68. package/skills/trtc-chat/references/06-a-defensive-coding.md +139 -0
  69. package/skills/trtc-chat/references/06-hard-rules.md +190 -0
  70. package/skills/trtc-chat/references/08-state-config.md +231 -0
  71. package/skills/trtc-chat/references/09-troubleshoot.md +91 -0
  72. package/skills/trtc-chat/references/10-references-index.md +63 -0
  73. package/skills/trtc-chat/references/11-what-to-do-next-template.md +338 -0
  74. package/skills/trtc-chat/references/12-page-composition.md +73 -0
  75. package/skills/trtc-chat/references/13-reporting.md +166 -0
  76. package/skills/trtc-chat/references/14-official-docs.md +169 -0
  77. package/skills/trtc-chat/references/execution-units.yaml +27 -0
  78. package/skills/trtc-chat/references/vue3.md +104 -0
  79. package/skills/trtc-chat/tests/test_chat_bundle_contract.py +330 -0
  80. package/skills/trtc-chat/tools/__init__.py +5 -0
  81. package/skills/trtc-chat/tools/_delegate.py +32 -0
  82. package/skills/trtc-chat/tools/flow.py +8 -0
  83. package/skills/trtc-chat/tools/kb.py +8 -0
  84. package/skills/trtc-chat/tools/reporting_v2.py +8 -0
  85. package/skills/trtc-chat/tools/session.py +8 -0
  86. package/hooks/__pycache__/cursor-adapter.cpython-313.pyc +0 -0
  87. package/skills/trtc/hooks/__pycache__/report_prompt.cpython-313.pyc +0 -0
  88. package/skills/trtc/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  89. package/skills/trtc/tools/__pycache__/query_classifier.cpython-313.pyc +0 -0
  90. package/skills/trtc/tools/__pycache__/reporting.cpython-313.pyc +0 -0
  91. package/skills/trtc/tools/__pycache__/search.cpython-313.pyc +0 -0
  92. package/skills/trtc/tools/__pycache__/session.cpython-313.pyc +0 -0
  93. package/skills/trtc-conference/tests/__pycache__/test_conference_onboarding_contract.cpython-313-pytest-9.0.2.pyc +0 -0
  94. package/skills/trtc-conference/tests/__pycache__/test_conference_topic_flow_contract.cpython-313-pytest-9.0.2.pyc +0 -0
@@ -0,0 +1,532 @@
1
+ ---
2
+ id: chat/message-list
3
+ name: 消息列表
4
+ product: chat
5
+ platform: web
6
+ description: 消息列表(加载 / 上滑加载历史 / 渲染 + 新消息监听 + 滚动状态机)
7
+ applies-to: [tuikit-atomicx-vue3]
8
+ sdk-version: "tuikit-atomicx-vue3 >=6.0.0"
9
+ depends-on-stores: [MessageListStore]
10
+ trigger-keywords: [消息列表, message list, 聊天记录, 消息滚动, 内部滚动, 上滑加载历史, 新消息提示, scroll to bottom, MessageListStore]
11
+ prerequisites: [login-auth, conversation-list]
12
+ tags: ['MessageListStore']
13
+ ---
14
+
15
+ ## 1. 这个 slice 处理什么
16
+
17
+ 路径 A 基础功能。在 `src/components/chat/MessageList.vue` 通过 `useMessageListStore(conversationID)` 拉取并渲染**当前会话**的消息列表,订阅 `onEvent` 接收新消息,按 `MessageInfo` 渲染气泡,处理**上滑加载历史 + 贴底自动滚动 + 新消息提示徽标**这一组核心交互。
18
+
19
+ > **本 slice 只覆盖 5 个 API**:`messageList` / `hasOlderMessages` / `loadMessages` / `loadOlderMessages` / `onEvent`。其余 6 个 API 不在 starter 范围:
20
+ > - `hasNewerMessages` / `loadNewerMessages` + `MessageLoadOption.cursor/direction` → 见 `features/message-fragment-jump.md`
21
+ > - `pinnedMessageList` → 见 `features/pinned-message.md`
22
+ > - `sendMessageReadReceipts` → 见 `features/read-receipts.md`
23
+ > - `deleteMessages` / `forwardMessages` → 见 `features/recall-edit-reply.md` & `features/forward-message.md`
24
+ >
25
+ > 写代码时**不要**把这些 API 解构出来,更不要假装实现一半。
26
+
27
+ ## 2. AI 思考清单(写代码前必须想清楚)
28
+
29
+ - 当前会话 ID(`C2C${userID}` 或 `GROUP${groupID}`)从哪传入——`defineProps` / route / 全局 store?
30
+ - MessageList 的高度从哪来:父级 flex 还是固定高度?是否需要 `min-height: 0`?
31
+ - 滚动到底部的时机(首次 / 自己发送 / 收到新消息且贴底)+ 用户上翻时如何不被新消息打断
32
+ - 上滑加载历史时如何保持视线锚点(高度差补偿 `scrollTop`)
33
+ - 头像 / 昵称是否需要展示(从 `msg.from` 取,按兜底链)
34
+ - 是否需要时间分组标签(5 分钟阈值)
35
+
36
+ ## 3. SDK API 必读(绝对真理)
37
+
38
+ ### 3.1 引入与初始化
39
+
40
+ ```ts
41
+ import { useMessageListStore } from 'tuikit-atomicx-vue3/chat'
42
+ import type { MessageInfo, MessageEvent } from 'tuikit-atomicx-vue3/chat'
43
+ import { MessageType, MessageStatus } from 'tuikit-atomicx-vue3/chat'
44
+
45
+ const { messageList, hasOlderMessages, loadMessages, loadOlderMessages, onEvent }
46
+ = useMessageListStore(props.conversationID)
47
+ ```
48
+
49
+ | API | 类型 | 作用 |
50
+ |---|---|---|
51
+ | `messageList` | `ComputedRef<MessageInfo[]>` | 按时间正序(旧→新),直接 `v-for` |
52
+ | `hasOlderMessages` | `ComputedRef<boolean>` | 控制"加载更早"入口显隐 |
53
+ | `loadMessages` | `(option?) => Promise<void>` | 首次加载最新一页,**starter 不传参** |
54
+ | `loadOlderMessages` | `() => Promise<void>` | 上滑拉历史 |
55
+ | `onEvent` | `(listener) => unsubscribe` | 订阅事件,**必须存 unsubscribe 并在 onUnmounted 释放** |
56
+
57
+ ### 3.2 MessageInfo 关键字段
58
+
59
+ #### 顶层字段(`MessageInfoBase`)
60
+
61
+ | 字段 | 类型 | 用途 |
62
+ |---|---|---|
63
+ | `msgID` | `string` | 业务侧消息 ID,用作 `v-for :key` |
64
+ | `messageType` | `MessageType`(枚举数字) | `Text=1` / `Image=2` / `Video=3` / `Audio=4` / `File=5` / `Face=6` / `Tips=7` / `Custom=8` / `Merged=9` / `Stream=10` / `Unknown=0` |
65
+ | `messagePayload` | `XxxMessagePayload` | 按 `messageType` discriminated union 收窄 |
66
+ | `from` | `MessageSenderInfo`(**对象**) | 含 `userID` / `nickname` / `avatarURL` / `friendRemark` / `nameCard` |
67
+ | `to` | `string` | 接收方 |
68
+ | `isSentBySelf` | `boolean` | 决定气泡左/右 |
69
+ | `timestamp` | `Date \| undefined` | **已是 Date 对象**,不是秒/毫秒数字 |
70
+ | `status` | `MessageStatus`(枚举数字) | `InitStatus=0` / `Sending=1` / `SendSuccess=2` / `SendFail=3` / `Recalled=4` / `Deleted=5` / `LocalImported=6` / `Violation=7` |
71
+
72
+ > starter 只关心 `msgID` / `messageType` / `messagePayload` / `from` / `isSentBySelf` / `timestamp` / `status`。
73
+
74
+ #### `from: MessageSenderInfo`
75
+
76
+ | 字段 | 类型 |
77
+ |---|---|
78
+ | `userID` | `string`(必有) |
79
+ | `avatarURL` | `string \| undefined` |
80
+ | `nickname` | `string \| undefined` |
81
+ | `friendRemark` | `string \| undefined` |
82
+ | `nameCard` | `string \| undefined`(仅群聊有意义) |
83
+
84
+ ### 3.3 onEvent 事件类型
85
+
86
+ ```ts
87
+ type MessageEvent = { type: 'onReceiveNewMessage'; message: MessageInfo }
88
+ ```
89
+
90
+ `event.type` switch 必须保留 default 分支。
91
+
92
+ ## 4. Hard rules(AI 必须遵守)
93
+
94
+ > ❗ 所有 SDK 异步调用必须遵循 `references/06-a-defensive-coding.md` 防御编程规范(try/catch/finally、formatError、错误反馈形式、状态锁)。本节规则是该规范的专属补充,不替代它。
95
+ > ❗ 写 UI 代码前必须先 `read_file _base/style-guide.md`,按其规范生成样式,不准跳过。
96
+
97
+ ### 4.1 数据 / 加载
98
+
99
+ - ❗ `useMessageListStore(props.conversationID)` 的 `conversationID` **必填**
100
+ - ❌ `useMessageListStore()` 不传参
101
+ - ❗ 渲染直接用 `messageList`(`v-for="msg in messageList"`),不准 `.value` 解构后赋给本地 `ref`
102
+ - ❌ `const list = ref(messageList.value)` — 丢失响应性
103
+ - ❗ 不准 `chat.on(...)` 手动订阅 / 不准 `setInterval` 轮询(store 已托管)
104
+ - ❗ `onEvent` 必须存 `unsubscribe` + `onUnmounted` 释放;`event.type` switch 必须有 default
105
+ - ❌ `onMounted` 订阅但 `onUnmounted` 没释放(页面切换泄漏 + 旧组件还在收消息)
106
+ - ❗ 首次加载放 `onMounted`,`try/finally` 控制 `loadingInitial` 态
107
+ - ❗ "加载中" / "空列表" / "有数据" 三态显式区分
108
+ - ❗ `loadMessages()` **不传参**(传 `cursor`/`direction`/`messageListType` 属于 fragment feature)
109
+ - ❌ `loadMessages({ direction: 'both', cursor: '...' })`
110
+ - ❗ 不准解构 `loadNewerMessages` / `hasNewerMessages` / `pinnedMessageList` / `deleteMessages` / `forwardMessages` / `sendMessageReadReceipts`(属于其他 feature)
111
+
112
+ ### 4.2 滚动容器(强约束)
113
+
114
+ - ❗ **MessageList 必须是「内部滚动」容器**(`overflow-y: auto` + `overflow-x: hidden`),不准把外层页面顶出滚动条,也不准出现水平滚动条
115
+ - ❌ 只写 `overflow-y: auto` 不加 `overflow-x: hidden`,长文本消息撑宽 flex 行导致水平滚动
116
+ - ❌ 没设确定高度,消息一多把外层整体滚动
117
+ - ❗ 外层给确定高度(`height: 100%` / `flex: 1` / `calc(100vh - header - input)` 三选一)
118
+ - ❗ flex 子项必须 `min-height: 0`(否则内容撑爆 flex 容器)
119
+ - ❌ flex 布局没加 `min-height: 0`,`overflow` 失效
120
+ - ❗ 消息行(msg-row)内的气泡必须有宽度上限(如 `max-width: 70%`)**且气泡自身设 `overflow-wrap: break-word`**,防止单行长文本撑破布局
121
+ - ❗ 不准对 `body`/`html`/`#app` 做 `overflow: hidden` 兜底
122
+ - ❗ 滚到底用 `el.scrollTop = el.scrollHeight`(auto)或 `el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })`,**且要 `await nextTick()`**
123
+
124
+ ### 4.3 滚动状态机(强约束)
125
+
126
+ | 触发场景 | 是否滚到底 | 是否累加新消息计数 |
127
+ |---|---|---|
128
+ | 首次加载完成 | ✅ auto 滚(无动画) | — |
129
+ | 自己发送(`isSentBySelf`) | ✅ smooth 滚 | 否 |
130
+ | 别人发送 + 当前**贴底** | ✅ smooth 滚 | 否 |
131
+ | 别人发送 + 当前**没贴底** | ❌ 不滚 | ✅ `newMessageCount++` |
132
+
133
+ - ❗ "贴底"判定:`scrollHeight - scrollTop - clientHeight < 150px`
134
+ - ❗ 没贴底时必须显示"N 条新消息"提示徽标,点击滚到底 + 清零
135
+ - ❌ 没贴底 + 收到新消息时无任何提示
136
+ - ❗ 用户手动滚到底时也要清零计数
137
+ - ❗ 用户上翻看历史时收到新消息**不准**强制滚到底
138
+ - ❌ 收到消息无条件 `scrollTop = scrollHeight`
139
+ - ❗ `watch(messageList)` 兜底——乐观插入不触发 `onReceiveNewMessage` 时,diff 末尾 `isSentBySelf` 补滚
140
+ - ❗ `loadingOlder` 期间 `watch` 兜底必须跳过(`if (loadingOlder.value) return`)
141
+ - ❌ 上滑加载期间兜底滚动把用户拽到底
142
+
143
+ ### 4.3.1 活跃会话清未读数(强约束)
144
+
145
+ > 用户正在看当前会话时收到新消息,应自动清零未读数,但需控制调用频率。
146
+
147
+ - ❗ `clearConversationUnreadCount` 从 `useConversationListStore()` 解构获取(需在组件顶部额外解构此方法):
148
+ ```ts
149
+ import { useConversationListStore } from 'tuikit-atomicx-vue3/chat'
150
+ const { clearConversationUnreadCount } = useConversationListStore()
151
+ ```
152
+ - ❗ 仅当 `msg.conversationID === props.conversationID` 时才计入清理逻辑
153
+ - ❗ 清理策略(条件取 OR,命中任一即调用 `clearConversationUnreadCount`):
154
+ - 条件 A:本会话累积未读数 ≥ 5 → 立即执行
155
+ - 条件 B:累积未读数 < 5 且距上次清理 ≥ 10s → 执行一次
156
+ - ❗ 调用后重置计数器和计时器
157
+ - ❗ 组件卸载时如果计数器 > 0,执行一次最终清理(兜底)
158
+ - ❌ 每收到一条新消息就调一次 clearConversationUnreadCount(高频场景打爆接口)
159
+ - ❌ 只靠固定 interval 轮询(低频场景响应慢)
160
+ - ❌ 组件卸载不做兜底清理(残留未读数)
161
+ - ❌ 自行 `import { clearConversationUnreadCount } from 'tuikit-atomicx-vue3/chat'`(不存在此导出)
162
+
163
+ ### 4.4 上滑加载历史(强约束)
164
+
165
+ - ❗ 入口用 `hasOlderMessages` 控制显隐
166
+ - ❗ 调 `loadOlderMessages()`,禁止自己拼 `MessageLoadOption`
167
+ - ❗ 视线锚点补偿:拉取前记 `oldScrollHeight`,拉取后 `await nextTick()` → `el.scrollTop = el.scrollHeight - oldScrollHeight`
168
+ - ❌ 不补偿,用户视线被顶到列表顶部
169
+ - ❗ `loadingOlder` 独立态,避免重复触发;disabled 期间用 spinner
170
+ - ❌ 没有 `loadingOlder` 节流,连点 N 次
171
+
172
+ ### 4.5 头像 / 昵称兜底链(强约束)
173
+
174
+ 字段从 `msg.from`(`MessageSenderInfo` 对象)取,**不是** `msg.avatar` / `msg.nick` / `msg.from`(当 string)。
175
+
176
+ - ❗ **头像**:`msg.from.avatarURL` → 昵称首字母(字母头像)→ `msg.from.userID` 首字母
177
+ - 字母头像取首个 Unicode 码点:`[...str][0]?.toUpperCase()`,不准 `str[0]`
178
+ - 配色按 `msg.from.userID` 哈希取固定色,不准随机
179
+ - `<img>` 必须 `@error` 兜底切到字母头像
180
+ - ❌ `msg.from.avatarURL || msg.from.userID[0]` — 跳过昵称首字母档
181
+ - ❌ 字母头像每次渲染随机配色
182
+ - ❗ **昵称**:`msg.from.friendRemark` → `msg.from.nameCard`(仅群聊)→ `msg.from.nickname` → `msg.from.userID`
183
+ - 单聊 `nameCard` 跳过
184
+ - ❗ 头像与昵称**独立兜底**,不准强制绑定
185
+ - ❗ `MessageType.Tips` 系统提示不走头像/昵称
186
+
187
+ ### 4.6 渲染分支(强约束)
188
+
189
+ - ❗ 按 `msg.messageType` **枚举**判断,必须给下表每个主流 type 单独 `v-else-if` 分支
190
+ - ❌ 只写 `Text` 一个分支 + default 全员 `[Unsupported]`
191
+ - ❗ 比较用 `MessageType` 枚举常量,不准用字符串(旧 TIM 字符串全表见 § 5)
192
+ - ❌ `msg.messageType === 'TIMTextElem'` / `'text'`
193
+ - ❗ 字段名是 `messageType` / `messagePayload`,不准写成 `type` / `payload`
194
+ - ❗ 兜底文案必须带 type:`[Unsupported: {{ msg.messageType }}]`
195
+ - ❌ `[未知消息类型]` / `[Unknown]`
196
+
197
+ **分支表(写代码时逐行对照)**:
198
+
199
+ | `msg.messageType` | 取字段(`msg.messagePayload.*`) | 模板要求 |
200
+ |---|---|---|
201
+ | `MessageType.Text` (1) | `text` | 普通气泡 |
202
+ | `MessageType.Image` (2) | `thumbImageUrl` / `originalImageUrl` / `largeImageUrl` | 普通气泡 |
203
+ | `MessageType.Video` (3) | `videoUrl` / `videoSnapshotUrl` / `videoDuration` | 普通气泡 |
204
+ | `MessageType.Audio` (4) | `audioUrl` / `audioDuration` | 普通气泡 |
205
+ | `MessageType.File` (5) | `fileName` / `fileSize` / `fileUrl` | 普通气泡 |
206
+ | `MessageType.Face` (6) | `faceIndex` / `faceData` | 普通气泡 |
207
+ | `MessageType.Tips` (7) | `groupTips: GroupTipsInfo[]` | ⚠️ **居中系统提示**,不走气泡/头像/左右分列 |
208
+ | `MessageType.Custom` (8) | `customData` / `description` / `extensionInfo` | ⚠️ **卡片形态**,在 `msg-bubble` 外部独立存在 |
209
+ | `MessageType.Merged` (9) | `title` / `abstractList` | 普通气泡 |
210
+ | `MessageType.Stream` (10) | `markdown` / `isStreamEnded` | 普通气泡 |
211
+ | 其它 / `Unknown` (0) | — | `[Unsupported: {{ msg.messageType }}]` |
212
+
213
+ - ❗ **Tips 特殊处理**:居中灰色提示,默认文案 `[System Notice]`
214
+ - ❌ Tips 走普通用户气泡 / `JSON.stringify(messagePayload)` 当文案
215
+ - ❗ **撤回消息渲染**:撤回后该消息的 `status` 更新为 `MessageStatus.Recalled`(4),前端需在渲染时判断:
216
+ - `msg.status === MessageStatus.Recalled` → 居中灰色提示(不走普通气泡)
217
+ - 自己撤回(`msg.isSentBySelf`)→ `"你撤回了一条消息"`
218
+ - 对方撤回 → `"{对方昵称}撤回了一条消息"`(昵称取 msg.from 兜底链)
219
+ - ❌ 撤回后仍显示原消息内容
220
+ - ❌ 撤回提示走普通气泡(应居中展示,类似 Tips)
221
+ - ❗ **删除消息渲染**:删除后该消息的 `status` 更新为 `MessageStatus.Deleted`(5),前端渲染时需过滤掉 `status === 5` 的消息,不显示任何内容
222
+ - ❌ 删除后仍然渲染该消息
223
+ - ❌ 显示"此消息已删除"占位
224
+ - ❌ 手动 `messageList.value.splice(...)` 移除
225
+ - ❗ **Custom 特殊处理**:`v-else-if` 分支在 `msg-bubble` 外部(无气泡包裹);`customData` 若是 string 需 `try/catch JSON.parse`
226
+ - ❌ `messagePayload.businessID`(SDK 没有此字段,业务约定塞在 `customData` 内嵌 JSON)
227
+
228
+ ### 4.7 时间分组标签(可选)
229
+
230
+ - ❗ 相邻消息间隔 > 5 分钟才显示一次
231
+ - ❗ `timestamp` 是 `Date` 对象,相减用 `getTime()`,阈值 `5 * 60 * 1000`(毫秒)
232
+ - ❌ `new Date(msg.timestamp * 1000)` / 阈值 `5 * 60`(秒)
233
+ - ❗ 首条消息(`prev === undefined`)必须显示时间标签
234
+ - ❗ `timestamp` 为 `undefined` 时跳过(返回 `false`)
235
+
236
+ ## 5. 反例库
237
+
238
+ > § 4 各条已内联主要反例。本节补充**旧字段名幻觉对照表**(旧 TIM v2 → 当前 SDK)。
239
+
240
+ | ❌ 旧写法 | ✅ 正确写法 |
241
+ |---|---|
242
+ | `msg.type === 'TIMTextElem'` | `msg.messageType === MessageType.Text` |
243
+ | `msg.payload.text` | `msg.messagePayload.text` |
244
+ | `msg.from`(当 string) | `msg.from.userID` |
245
+ | `msg.nick` / `msg.avatar` | `msg.from.nickname` / `msg.from.avatarURL` |
246
+ | `new Date(msg.timestamp * 1000)` | 直接用 `msg.timestamp`(Date 对象) |
247
+ | `msg.status === 'sendSuccess'` | `msg.status === MessageStatus.SendSuccess` |
248
+ | `messagePayload.imageInfoArray[0].url` | `messagePayload.thumbImageUrl` / `originalImageUrl`(扁平字段) |
249
+ | `messagePayload.url`(音频) | `messagePayload.audioUrl` |
250
+ | `messagePayload.second`(音频) | `messagePayload.audioDuration` |
251
+ | `messagePayload.snapshotUrl`(视频) | `messagePayload.videoSnapshotUrl` |
252
+ | `messagePayload.data`(表情) | `messagePayload.faceIndex` / `faceData` |
253
+ | `messagePayload.data`(自定义) | `messagePayload.customData` / `description` / `extensionInfo` |
254
+ | `messagePayload.text`(Stream) | `messagePayload.markdown` |
255
+
256
+ ## 6. UI 自由度
257
+
258
+ > 样式由 AI 自由发挥,遵循项目已有 CSS 方案(见 `_base/detect-style.md`)。
259
+
260
+ ✅ 完全自由:气泡形状 / 头像位置形状 / 时间显示位置 / 系统消息样式 / 字母头像配色板 / 新消息徽标位置样式 / 加载触发方式(按钮 / 滚顶自动)/ 时间标签样式 / loading 与 empty 插画
261
+ ⚠️ 必须遵循项目现状:UI 库 / CSS 方案;自定义消息分支保留对应业务卡片组件
262
+ ⚠️ § 4.2–4.5 是强约束不可放宽
263
+
264
+ ## 7. 参考实现(明确哪些可改)
265
+
266
+ > ⚠️ **写代码前必须回看 § 4.6 分支表逐行核对字段名,不要凭记忆写。** Tips / Custom 的模板结构约束也在 § 4.6。
267
+
268
+ ### 最小骨架
269
+
270
+ ```vue
271
+ <script setup lang="ts">
272
+ import { onMounted, onUnmounted, ref, nextTick, watch } from 'vue'
273
+ import { useMessageListStore } from 'tuikit-atomicx-vue3/chat'
274
+ import type { MessageInfo, MessageEvent } from 'tuikit-atomicx-vue3/chat'
275
+ import { MessageType, MessageStatus } from 'tuikit-atomicx-vue3/chat'
276
+
277
+ const props = defineProps<{ conversationID: string }>()
278
+
279
+ const {
280
+ messageList,
281
+ hasOlderMessages,
282
+ loadMessages,
283
+ loadOlderMessages,
284
+ onEvent,
285
+ } = useMessageListStore(props.conversationID)
286
+
287
+ // ── 三态 ──
288
+ const loadingInitial = ref(true)
289
+ const loadingOlder = ref(false)
290
+
291
+ // ── 滚动状态机 ──
292
+ const scrollContainer = ref<HTMLElement | null>(null)
293
+ const isNearBottom = ref(true)
294
+ const newMessageCount = ref(0)
295
+ const didInitialScroll = ref(false)
296
+ const NEAR_BOTTOM_THRESHOLD = 150
297
+
298
+ async function scrollToBottom(behavior: ScrollBehavior = 'auto') {
299
+ await nextTick()
300
+ const el = scrollContainer.value
301
+ if (!el) return
302
+ if (behavior === 'smooth') {
303
+ el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
304
+ } else {
305
+ el.scrollTop = el.scrollHeight
306
+ }
307
+ }
308
+
309
+ function checkNearBottom() {
310
+ const el = scrollContainer.value
311
+ if (!el) return
312
+ isNearBottom.value =
313
+ el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_THRESHOLD
314
+ }
315
+
316
+ function handleScroll() {
317
+ checkNearBottom()
318
+ if (isNearBottom.value && newMessageCount.value > 0) {
319
+ newMessageCount.value = 0
320
+ }
321
+ }
322
+
323
+ // ── 新消息分支决策 ──
324
+ function handleNewMessage(message: MessageInfo) {
325
+ if (message.isSentBySelf) {
326
+ nextTick(() => scrollToBottom('smooth'))
327
+ return
328
+ }
329
+ if (isNearBottom.value) {
330
+ nextTick(() => scrollToBottom('smooth'))
331
+ } else {
332
+ newMessageCount.value++
333
+ }
334
+ }
335
+
336
+ // ── 兜底:乐观插入可能不触发 onEvent ──
337
+ watch(messageList, (newList, oldList) => {
338
+ if (!didInitialScroll.value) return
339
+ if (loadingOlder.value) return
340
+ if (newList.length > (oldList?.length ?? 0)) {
341
+ const last = newList[newList.length - 1]
342
+ if (last?.isSentBySelf) {
343
+ nextTick(() => scrollToBottom('smooth'))
344
+ }
345
+ }
346
+ })
347
+
348
+ // ── 生命周期 ──
349
+ let unsubscribe: (() => void) | null = null
350
+
351
+ onMounted(async () => {
352
+ try {
353
+ await loadMessages()
354
+ } finally {
355
+ loadingInitial.value = false
356
+ await nextTick()
357
+ await scrollToBottom('auto')
358
+ didInitialScroll.value = true
359
+ }
360
+
361
+ scrollContainer.value?.addEventListener('scroll', handleScroll, { passive: true })
362
+
363
+ unsubscribe = onEvent((event: MessageEvent) => {
364
+ switch (event.type) {
365
+ case 'onReceiveNewMessage':
366
+ handleNewMessage(event.message)
367
+ break
368
+ default:
369
+ console.warn('[MessageList] unknown event', event)
370
+ }
371
+ })
372
+ })
373
+
374
+ onUnmounted(() => {
375
+ scrollContainer.value?.removeEventListener('scroll', handleScroll)
376
+ unsubscribe?.()
377
+ unsubscribe = null
378
+ })
379
+
380
+ // ── 上滑加载历史 ──
381
+ async function handleLoadOlder() {
382
+ if (loadingOlder.value) return
383
+ const el = scrollContainer.value
384
+ const oldScrollHeight = el?.scrollHeight ?? 0
385
+ loadingOlder.value = true
386
+ try {
387
+ await loadOlderMessages()
388
+ await nextTick()
389
+ if (el) el.scrollTop = el.scrollHeight - oldScrollHeight
390
+ } finally {
391
+ loadingOlder.value = false
392
+ }
393
+ }
394
+
395
+ // ── 时间分组标签 ──
396
+ function shouldShowTimeLabel(curr: MessageInfo, prev: MessageInfo | undefined): boolean {
397
+ if (!prev) return true
398
+ if (!curr.timestamp || !prev.timestamp) return false
399
+ return Math.abs(curr.timestamp.getTime() - prev.timestamp.getTime()) > 5 * 60 * 1000
400
+ }
401
+
402
+ function formatTimeLabel(ts: Date): string {
403
+ const now = new Date()
404
+ const sameDay = (a: Date, b: Date) =>
405
+ a.getFullYear() === b.getFullYear() &&
406
+ a.getMonth() === b.getMonth() &&
407
+ a.getDate() === b.getDate()
408
+ const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1)
409
+ const hhmm = `${String(ts.getHours()).padStart(2, '0')}:${String(ts.getMinutes()).padStart(2, '0')}`
410
+ if (sameDay(ts, now)) return hhmm
411
+ if (sameDay(ts, yesterday)) return `Yesterday ${hhmm}`
412
+ return ts.toLocaleDateString()
413
+ }
414
+
415
+ // ── 头像 / 昵称兜底 ──
416
+ function displayName(msg: MessageInfo): string {
417
+ const f = msg.from
418
+ return f.friendRemark || f.nameCard || f.nickname || f.userID
419
+ }
420
+
421
+ function handleBadgeClick() {
422
+ newMessageCount.value = 0
423
+ scrollToBottom('smooth')
424
+ }
425
+ </script>
426
+
427
+ <template>
428
+ <div class="msg-list-wrapper">
429
+ <div ref="scrollContainer" class="msg-list">
430
+ <div v-if="loadingInitial" class="state-loading">Loading messages...</div>
431
+
432
+ <template v-else>
433
+ <div v-if="hasOlderMessages" class="load-older">
434
+ <button :disabled="loadingOlder" @click="handleLoadOlder">
435
+ {{ loadingOlder ? 'Loading…' : 'Load earlier messages' }}
436
+ </button>
437
+ </div>
438
+
439
+ <div v-if="messageList.length === 0" class="state-empty">No messages yet</div>
440
+
441
+ <template v-for="(msg, idx) in messageList" :key="msg.msgID">
442
+ <div v-if="shouldShowTimeLabel(msg, messageList[idx - 1])" class="time-label">
443
+ <template v-if="msg.timestamp">{{ formatTimeLabel(msg.timestamp) }}</template>
444
+ </div>
445
+
446
+ <!-- ═══ Tips:居中系统提示 ═══ -->
447
+ <div v-if="msg.messageType === MessageType.Tips" class="system-tip">
448
+ [System Notice]
449
+ </div>
450
+
451
+ <!-- ═══ Custom:独立卡片形态(无 msg-bubble 包裹) ═══ -->
452
+ <div
453
+ v-else-if="msg.messageType === MessageType.Custom"
454
+ class="msg-row msg-row--card"
455
+ :class="{ 'is-self': msg.isSentBySelf }"
456
+ >
457
+ <!-- Avatar + CustomMessageBubble 由项目自行实现 -->
458
+ </div>
459
+
460
+ <!-- ═══ 普通消息气泡 ═══ -->
461
+ <div v-else class="msg-row" :class="{ 'is-self': msg.isSentBySelf }">
462
+ <div class="msg-bubble">
463
+ <div v-if="msg.messageType === MessageType.Text" class="msg-text">
464
+ {{ msg.messagePayload.text }}
465
+ </div>
466
+ <img v-else-if="msg.messageType === MessageType.Image"
467
+ :src="msg.messagePayload.thumbImageUrl ?? msg.messagePayload.originalImageUrl"
468
+ />
469
+ <!-- Audio: audioUrl / audioDuration -->
470
+ <div v-else-if="msg.messageType === MessageType.Audio">
471
+ 🔊 {{ msg.messagePayload.audioDuration }}s
472
+ </div>
473
+ <!-- Video: videoUrl / videoSnapshotUrl / videoDuration -->
474
+ <video v-else-if="msg.messageType === MessageType.Video"
475
+ :src="msg.messagePayload.videoUrl"
476
+ :poster="msg.messagePayload.videoSnapshotUrl" controls
477
+ />
478
+ <!-- File: fileUrl / fileName / fileSize -->
479
+ <a v-else-if="msg.messageType === MessageType.File"
480
+ :href="msg.messagePayload.fileUrl" :download="msg.messagePayload.fileName">
481
+ {{ msg.messagePayload.fileName }}
482
+ </a>
483
+ <!-- Face: faceIndex / faceData -->
484
+ <span v-else-if="msg.messageType === MessageType.Face">
485
+ [Sticker: {{ msg.messagePayload.faceIndex }}]
486
+ </span>
487
+ <!-- Merged: title / abstractList -->
488
+ <div v-else-if="msg.messageType === MessageType.Merged">
489
+ {{ msg.messagePayload.title }}
490
+ </div>
491
+ <!-- Stream: markdown / isStreamEnded -->
492
+ <div v-else-if="msg.messageType === MessageType.Stream">
493
+ {{ msg.messagePayload.markdown }}
494
+ </div>
495
+ <div v-else>[Unsupported: {{ msg.messageType }}]</div>
496
+
497
+ <span v-if="msg.isSentBySelf && msg.status === MessageStatus.Sending">⏳</span>
498
+ <span v-if="msg.isSentBySelf && msg.status === MessageStatus.SendFail">❗</span>
499
+ </div>
500
+ </div>
501
+ </template>
502
+ </template>
503
+ </div>
504
+
505
+ <button v-if="newMessageCount > 0 && !isNearBottom" class="new-msg-badge" @click="handleBadgeClick">
506
+ {{ newMessageCount }} new messages
507
+ </button>
508
+ </div>
509
+ </template>
510
+ ```
511
+
512
+ ### 可改
513
+
514
+ - 样式 / class / Tailwind / 项目 token
515
+ - 上滑触发方式(按钮 / 滚到顶自动)
516
+ - 新消息徽标位置样式
517
+ - 时间标签格式化(i18n)
518
+ - 各 type 具体 UI(图片预览 lib / audio 播放器 / 表情映射表)
519
+ - `MessageType.Custom` 的业务卡片组件
520
+ - `MessageType.Tips` 的具体翻译文案
521
+
522
+ ### 不可改
523
+
524
+ - `useMessageListStore(props.conversationID)` 解构 5 个 API
525
+ - 不解构 `loadNewerMessages` / `pinnedMessageList` / `deleteMessages` / `forwardMessages` / `sendMessageReadReceipts`
526
+ - `loadMessages()` 不传参
527
+ - 滚动状态机分支(§ 4.3)+ 视线锚点补偿(§ 4.4)+ 头像昵称兜底链(§ 4.5)
528
+ - `onEvent` 存 `unsubscribe` + `onUnmounted` 释放 + default 分支
529
+ - 不加 `chat.on(...)` / 不 `setInterval`
530
+ - 渲染分支表所有主流 type 必须有单独分支,Tips 居中不走气泡
531
+ - 兜底文案带 type(`[Unsupported: {{ msg.messageType }}]`)
532
+ - 真实字段名:`msg.msgID` / `msg.messageType` / `msg.messagePayload` / `msg.from.avatarURL` / `msg.from.nickname` / `msg.timestamp`(Date)