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

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 (81) hide show
  1. package/CODEBUDDY.md +0 -24
  2. package/bin/cli.js +18 -5
  3. package/knowledge-base/chat/web/index.yaml +168 -0
  4. package/knowledge-base/chat/web/path-d-signals.yaml +4 -0
  5. package/knowledge-base/docs/chat/debug/GenerateTestUserSig.js +23 -0
  6. package/knowledge-base/docs/chat/debug/lib-generate-test-usersig.min.js +1 -0
  7. package/knowledge-base/docs/chat/gen-usersig.md +129 -0
  8. package/knowledge-base/docs/chat/product.md +61 -0
  9. package/knowledge-base/docs/chat/restapi.md +227 -0
  10. package/knowledge-base/docs/chat/sdk/android/faq.md +7 -0
  11. package/knowledge-base/docs/chat/sdk/android/index.md +94 -0
  12. package/knowledge-base/docs/chat/sdk/flutter/faq.md +7 -0
  13. package/knowledge-base/docs/chat/sdk/flutter/index.md +83 -0
  14. package/knowledge-base/docs/chat/sdk/ios/faq.md +7 -0
  15. package/knowledge-base/docs/chat/sdk/ios/index.md +94 -0
  16. package/knowledge-base/docs/chat/sdk/web/faq.md +8 -0
  17. package/knowledge-base/docs/chat/sdk/web/index.md +97 -0
  18. package/knowledge-base/docs/chat/uikit/android/index.md +28 -0
  19. package/knowledge-base/docs/chat/uikit/flutter/index.md +17 -0
  20. package/knowledge-base/docs/chat/uikit/ios/index.md +28 -0
  21. package/knowledge-base/docs/chat/uikit/react/index.md +6 -0
  22. package/knowledge-base/docs/chat/uikit/uniapp/index.md +6 -0
  23. package/knowledge-base/docs/chat/uikit/vue3/index.md +6 -0
  24. package/knowledge-base/docs/chat/webhook.md +73 -0
  25. package/knowledge-base/products.yaml +2 -2
  26. package/knowledge-base/slices/chat/web/at-mention.md +417 -0
  27. package/knowledge-base/slices/chat/web/conversation-actions.md +178 -0
  28. package/knowledge-base/slices/chat/web/conversation-list.md +368 -0
  29. package/knowledge-base/slices/chat/web/detect-integration.md +36 -0
  30. package/knowledge-base/slices/chat/web/detect-style.md +88 -0
  31. package/knowledge-base/slices/chat/web/direct-chat-entry.md +210 -0
  32. package/knowledge-base/slices/chat/web/login-auth.md +244 -0
  33. package/knowledge-base/slices/chat/web/message-base-actions.md +273 -0
  34. package/knowledge-base/slices/chat/web/message-input.md +209 -0
  35. package/knowledge-base/slices/chat/web/message-list.md +532 -0
  36. package/knowledge-base/slices/chat/web/send-custom-message.md +189 -0
  37. package/knowledge-base/slices/chat/web/send-media.md +214 -0
  38. package/knowledge-base/slices/chat/web/state-api-skeleton.md +60 -0
  39. package/knowledge-base/slices/chat/web/style-guide.md +210 -0
  40. package/package.json +4 -1
  41. package/skills/trtc/SKILL.md +57 -23
  42. package/skills/trtc/hooks/topic_phase_gate.py +5 -0
  43. package/skills/trtc/tools/kb.py +130 -0
  44. package/skills/trtc/tools/reporting.py +23 -2
  45. package/skills/trtc/tools/reporting_v2.py +263 -0
  46. package/skills/trtc-ai-service/README.ja.md +199 -0
  47. package/skills/trtc-ai-service/README.md +11 -7
  48. package/skills/trtc-ai-service/README.zh-CN.md +11 -7
  49. package/skills/trtc-chat/.docs-query.yaml +8 -0
  50. package/skills/trtc-chat/SKILL.md +186 -0
  51. package/skills/trtc-chat/docs/SKILL.md +31 -0
  52. package/skills/trtc-chat/flows/maintenance.md +19 -0
  53. package/skills/trtc-chat/flows/onboarding.md +79 -0
  54. package/skills/trtc-chat/references/01-detect-project.md +136 -0
  55. package/skills/trtc-chat/references/02-path-a-questions.md +182 -0
  56. package/skills/trtc-chat/references/02-path-a-scaffold-template.md +59 -0
  57. package/skills/trtc-chat/references/02-path-a-script.md +266 -0
  58. package/skills/trtc-chat/references/02-path-a-templates.md +159 -0
  59. package/skills/trtc-chat/references/03-path-b-script.md +226 -0
  60. package/skills/trtc-chat/references/04-path-c-script.md +95 -0
  61. package/skills/trtc-chat/references/04-uikit-redirect.md +32 -0
  62. package/skills/trtc-chat/references/05-path-d-script.md +647 -0
  63. package/skills/trtc-chat/references/05-slice-loading.md +139 -0
  64. package/skills/trtc-chat/references/06-a-defensive-coding.md +139 -0
  65. package/skills/trtc-chat/references/06-hard-rules.md +190 -0
  66. package/skills/trtc-chat/references/08-state-config.md +229 -0
  67. package/skills/trtc-chat/references/09-troubleshoot.md +91 -0
  68. package/skills/trtc-chat/references/10-references-index.md +63 -0
  69. package/skills/trtc-chat/references/11-what-to-do-next-template.md +338 -0
  70. package/skills/trtc-chat/references/12-page-composition.md +73 -0
  71. package/skills/trtc-chat/references/13-reporting.md +128 -0
  72. package/skills/trtc-chat/references/14-official-docs.md +169 -0
  73. package/skills/trtc-chat/references/execution-units.yaml +27 -0
  74. package/skills/trtc-chat/references/vue3.md +104 -0
  75. package/skills/trtc-chat/tests/test_chat_bundle_contract.py +293 -0
  76. package/skills/trtc-chat/tools/__init__.py +5 -0
  77. package/skills/trtc-chat/tools/_delegate.py +32 -0
  78. package/skills/trtc-chat/tools/flow.py +8 -0
  79. package/skills/trtc-chat/tools/kb.py +8 -0
  80. package/skills/trtc-chat/tools/reporting_v2.py +8 -0
  81. package/skills/trtc-chat/tools/session.py +8 -0
@@ -0,0 +1,417 @@
1
+ ---
2
+ id: chat/at-mention
3
+ name: 群聊 @ 提及
4
+ product: chat
5
+ platform: web
6
+ description: 群聊 @提及成员(成员选择器 + atUserList option + tag 展示)
7
+ applies-to: [tuikit-atomicx-vue3]
8
+ sdk-version: "tuikit-atomicx-vue3 >=6.0.0"
9
+ depends-on-stores: [MessageInputStore, GroupMemberStore]
10
+ trigger-keywords: [at, @, 提及, mention, @群成员, @所有人, atUserList, 群成员选择]
11
+ prerequisites: [login-auth, message-list, message-input]
12
+ tags: ['MessageInputStore', 'GroupMemberStore']
13
+ ---
14
+
15
+ ## 1. 这个 slice 处理什么
16
+
17
+ 在群聊场景的 MessageInput 中,监听用户输入 `@` 字符唤起成员选择器,选中后将 `@name` 插入输入框,发送时将 userID 列表传入 `sendMessage(payload, { atUserList })`。
18
+
19
+ > 仅群聊有效(`conversationID.startsWith('GROUP')`),C2C 会话不启用 @ 功能。
20
+
21
+ ## 2. AI 思考清单
22
+
23
+ - 如何判断当前是群聊?(`conversationID` 前缀判定)
24
+ - 成员选择器定位方式?(输入框上方 Popover / 绝对定位到光标附近)
25
+
26
+ ## 3. SDK API 必读
27
+
28
+ ### 3.1 发送侧(sendMessage option)
29
+
30
+ ```ts
31
+ import { useMessageInputStore } from 'tuikit-atomicx-vue3/chat'
32
+
33
+ const { sendMessage } = useMessageInputStore(props.conversationID)
34
+
35
+ // 发送带 @ 的文本消息
36
+ await sendMessage(
37
+ { type: 'textMessage', text: '大家注意一下 @张三 @李四' },
38
+ { atUserList: ['zhangsan_001', 'lisi_002'] }
39
+ )
40
+ ```
41
+
42
+ `SendMessageInputOption.atUserList`:
43
+ - 类型:`string[]`
44
+ - 含义:被 @ 的 userID 列表
45
+ - 群聊 @ 列表;C2C 会话传入时被 SDK 忽略
46
+
47
+ ### 3.2 成员列表(useGroupMemberStore)
48
+
49
+ ```ts
50
+ import { useGroupMemberStore, GroupAtType } from 'tuikit-atomicx-vue3/chat'
51
+ import type { GroupMember } from 'tuikit-atomicx-vue3/chat'
52
+
53
+ // groupID = conversationID.slice(5)(去掉 'GROUP' 前缀)
54
+ const {
55
+ memberList, // ComputedRef<GroupMember[]>
56
+ hasMoreMembers, // ComputedRef<boolean>
57
+ loadMembers, // (roleList?) => Promise
58
+ loadMoreMembers, // () => Promise
59
+ } = useGroupMemberStore(groupID)
60
+ ```
61
+
62
+ `GroupMember` 关键字段:
63
+
64
+ | 字段 | 类型 | 用途 |
65
+ |---|---|---|
66
+ | `userID` | `string` | 唯一标识,传入 `atUserList` |
67
+ | `nickname` | `string \| undefined` | 昵称 |
68
+ | `nameCard` | `string \| undefined` | 群名片(优先于 nickname 展示) |
69
+ | `avatarURL` | `string \| undefined` | 头像 |
70
+ | `role` | `string \| undefined` | 角色(Owner / Admin / Member) |
71
+
72
+ ### 3.3 展示名兜底链
73
+
74
+ ```ts
75
+ function memberDisplayName(m: GroupMember): string {
76
+ return m.nameCard || m.nickname || m.userID
77
+ }
78
+ ```
79
+
80
+ ## 4. Hard rules
81
+
82
+ ### 4.1 群聊判定 + 触发方式
83
+
84
+ - ❗ 通过 `props.conversationID.startsWith('GROUP')` 判定是否群聊
85
+ - ❗ 非群聊时 @ 功能完全不启用(不监听 `@` 输入、不渲染选择器)
86
+ - ❗ `groupID` 通过 `conversationID.slice(5)` 取得(去掉 `GROUP` 前缀)
87
+ - ❌ `conversationID.replace('GROUP', '')` — 如果 ID 内含 GROUP 字样会误删
88
+ - ❗ 触发方式:用户在输入框中键入 `@` 字符时自动唤起成员选择器
89
+ - 输入框中 `@` 后继续输入的文字不参与成员过滤(搜索仅在选择器内搜索框进行)
90
+ - ❌ 用 toolbar 按钮触发(不需要额外按钮)
91
+ - ❗ 关闭时机(仅以下 3 种):
92
+ - 确认选择:用户点击"确定"按钮
93
+ - 按 Esc:键盘快捷关闭
94
+ - 输入框内容完全清空:如全选删除,`text.value.length === 0` 时自动关闭
95
+ - ❗ 实现方式:用 `watch(text, ...)` 统一拦截,不准只在单个事件处理函数里判断(否则无法覆盖全选删除、Ctrl+A+Delete、剪切等所有清空路径)
96
+ - ❗ 已移除的关闭方式(不准实现):
97
+ - ❌ 点击外部关闭 — 输入框聚焦会误触发关闭,体验不好
98
+ - ❌ 删除单个 `@` 触发字符时关闭 — 用户诉求是只有输入框完全清空才关闭,删除单个 `@` 不应关闭
99
+
100
+ ### 4.2 成员加载
101
+
102
+ - ❗ `useGroupMemberStore(groupID)` 必须传 groupID
103
+ - ❌ 不传参(运行时报错)
104
+ - ❗ 组件 mount 时调 `loadMembers()` 预加载(`try/catch`)
105
+ - ❗ 渲染直接用 `memberList` ComputedRef,不赋给本地 ref
106
+ - ❌ `const list = ref(memberList.value)`
107
+ - ❗ 成员列表必须过滤掉当前登录用户自己(`useLoginStore().loginUserInfo.value?.userID`)
108
+ - ❌ 自己出现在选择器列表里,@ 自己无意义
109
+ - ✅ `const filteredMembers = computed(() => memberList.value.filter(m => m.userID !== loginUserInfo.value?.userID))`
110
+ - ❗ 列表较长时支持 `loadMoreMembers()`(`hasMoreMembers` 控制)
111
+ - ❗ 不准 `chat.getGroupMemberList(...)` 绕过 store
112
+ - ❌ 直接调 SDK 底层 API
113
+
114
+ ### 4.3 发送集成
115
+
116
+ - ❗ `atUserList` 传入 `sendMessage` 的第二参 option 中
117
+ ```ts
118
+ await sendMessage({ type: 'textMessage', text }, { atUserList: selectedUserIDs })
119
+ ```
120
+ - ❗ `atUserList` 为空数组时不传 option(或传 `undefined`),不传空数组
121
+ - ❌ `{ atUserList: [] }` — 无意义开销
122
+ - ❗ 发送成功后清空已选成员列表
123
+ - ❗ `atUserList` 中的值是 `userID` 字符串,不是 nickname / nameCard
124
+ - ❌ `atUserList: ['张三', '李四']` — 传了显示名
125
+
126
+ ### 4.4 成员选择器 UI
127
+
128
+ - ❗ 支持多选:用户可勾选多个成员,点击"确定"后一次性把所有 `@name ` 插入光标位置并关闭选择器
129
+ - ❌ 单选即关闭(每次只能 @ 一人,多人需反复操作)
130
+
131
+ - ❗ **选择器整体布局(从上到下)**:
132
+
133
+ ```
134
+ ┌─────────────────────────────────┐
135
+ │ 选择成员 取消 确定 │ ← 顶部操作栏
136
+ ├─────────────────────────────────┤
137
+ │ 🔍 搜索成员 │ ← 搜索输入框
138
+ ├─────────────────────────────────┤
139
+ │ ☑ 👥 @所有人 │ ← 固定项
140
+ ├─────────────────────────────────┤
141
+ │ ☐ 🧑 张三 │ ← 成员列表(可滚动)
142
+ │ ☐ 🧑 李四 │
143
+ │ ... │
144
+ └─────────────────────────────────┘
145
+ ```
146
+
147
+ - ❗ **顶部操作栏**:左侧标题"选择成员" + 右侧"取消"和"确定"两个按钮
148
+ - "取消":关闭面板,不插入任何内容
149
+ - "确定":插入已选成员并关闭
150
+ - 按钮文案固定为"取消"和"确定",不准用其他文案
151
+ - ❌ 只有"确定"没有"取消"
152
+ - ❌ 按钮放在底部 / 列表内部
153
+ - ❌ 文案用"确认"/"完成"/"OK"等替代
154
+
155
+ - ❗ **搜索输入框**:位于顶部操作栏下方,列表上方
156
+ - placeholder 文案"搜索成员..."
157
+ - 输入时实时过滤下方成员列表(匹配 `nameCard` / `nickname` / `userID`,不区分大小写)
158
+ - 过滤结果为空时显示"无匹配成员"提示
159
+ - ❌ 不放搜索框(只靠输入框 `@` 后的文字过滤 — 体验不够明显)
160
+ - ❌ 搜索框放在底部或列表内部
161
+
162
+ - ❗ **@所有人固定项**:位于搜索框下方、成员列表上方,不随搜索过滤消失
163
+ - 选中后 `atUserList` 传 `['__kImSDK_MesssageAtALL__']`
164
+ - 插入输入框的文案为 `@所有人 `
165
+ - 所有群成员均可使用
166
+ - ❌ @所有人被搜索过滤掉
167
+
168
+ - ❗ **成员列表区域**:可滚动,每项展示头像(兜底字母头像)+ 展示名(nameCard > nickname > userID),不显示成员角色
169
+
170
+ - ❗ 选择器宽度限制:`max-width: 360px`,不准撑满输入框宽度
171
+ - ❌ 选择器宽度等于输入框宽度(视觉过宽,尤其桌面端)
172
+
173
+ ### 4.5 @ 文案落入输入框
174
+
175
+ - ❗ 确认选择时,先**删除触发的 `@` 字符及其后的搜索文字**(从 `atTriggerPos` 到当前光标位置),再插入 `@展示名 `
176
+ - 即:`text = text[0..atTriggerPos] + '@name ' + text[cursorPos..]`
177
+ - ❌ 不删除触发字符直接插入,导致出现 `@@name`(双 @ bug)
178
+ - ❗ 选中成员后,`@展示名 ` 直接插入到输入框文本的**触发位置**(末尾带一个空格分隔)
179
+ - ❌ 在输入框外部用独立 tag/chip 展示已选成员
180
+ - ❌ 发送时才拼接 @ 文案(用户看不到最终发出的文本)
181
+ - ❗ 插入后光标移到 `@name ` 之后,用户可继续输入正文
182
+ - ❗ 展示名用 memberDisplayName 兜底链取值
183
+ - ❗ 内部维护 `atUsers: Array<{ userID, name }>` 用于发送时提取 atUserList
184
+ - ❌ 不需要存 `startIndex` / `endIndex`(不依赖预存位置索引)
185
+
186
+ ### 4.6 原子删除(基于文本内容动态查找)
187
+
188
+ - ❗ 每次 Backspace 时,通过 `text.value.indexOf(token)` 实时定位 `@name ` 在文本中的位置,判断光标是否在该 token 范围内
189
+ ```ts
190
+ const hitIndex = atUsers.value.findIndex(u => {
191
+ const token = `@${u.name} `
192
+ const idx = text.value.indexOf(token)
193
+ if (idx === -1) return false
194
+ return pos > idx && pos <= idx + token.length
195
+ })
196
+ ```
197
+ - ❗ 光标在 `@name ` 范围内任意位置(含紧贴末尾空格后)按 Backspace → 一次性删除整个 token,同时从 atUsers 数组中移除该条目
198
+ - ❗ 删除后光标回到 token 起始位置(`idx`)
199
+ - ❗ 删除后若输入框为空(`text.value.length === 0`),触发关闭(同 § 4.1 关闭时机第 3 条)
200
+ - ❗ `name` 字段是查找 token 的唯一 key(通过 `` `@${name} ` `` 匹配文本)
201
+ - ❌ 依赖预存的 startIndex/endIndex 判定(文本编辑后索引会漂移,不可靠)
202
+ - ❌ 逐字符删除,留下残缺的 `@张` 或 `@`
203
+ - ❌ 需要先选中再删除(应单次 Backspace 即可)
204
+
205
+ ### 4.7 会话列表 @ 标识
206
+
207
+ - ❗ 命中本 slice 后,ConversationList 的列表项必须同步增加 @ 标识渲染
208
+ - ❗ 数据来源:`ConversationInfo.groupAtInfoList`(`GroupAtInfo[]`)
209
+ - ❗ 标识位置:消息预览文案**最前方**,红色高亮
210
+ - ❗ 清除时机:用户进入该会话 → `clearConversationUnreadCount(conversationID)` → SDK 自动清除 `groupAtInfoList`,无需前端手动处理
211
+ - ❗ `atType` 必须用 `GroupAtType` 枚举,禁止硬编码数字
212
+ - ❌ `item.atType === 1`
213
+ - ❗ `groupAtInfoList` 为空时不渲染任何占位元素
214
+ - ❌ 空数组时仍渲染空 `<span>`
215
+
216
+ **累积规则**(多条 `GroupAtInfo` 合并):
217
+
218
+ ```ts
219
+ import { GroupAtType } from 'tuikit-atomicx-vue3/chat'
220
+ import type { ConversationInfo } from 'tuikit-atomicx-vue3/chat'
221
+
222
+ function getAtLabel(c: ConversationInfo): string {
223
+ if (!c.groupAtInfoList || c.groupAtInfoList.length === 0) return ''
224
+ let text = ''
225
+ c.groupAtInfoList.forEach((item) => {
226
+ if (item.atType === GroupAtType.AtMe) {
227
+ text = text.indexOf('[@所有人]') !== -1 ? '[有人@我][@所有人]' : '[有人@我]'
228
+ }
229
+ if (item.atType === GroupAtType.AtAll) {
230
+ text = text.indexOf('[有人@我]') !== -1 ? '[有人@我][@所有人]' : '[@所有人]'
231
+ }
232
+ if (item.atType === GroupAtType.AtAllAtMe) {
233
+ text = '[有人@我][@所有人]'
234
+ }
235
+ })
236
+ return text
237
+ }
238
+ ```
239
+
240
+ 模板中使用:
241
+
242
+ ```vue
243
+ <span v-if="getAtLabel(conv)" class="at-label">{{ getAtLabel(conv) }}</span>
244
+ <span class="conv-preview">{{ getLastMessagePreview(conv) }}</span>
245
+ ```
246
+
247
+ ## 5. 反例库
248
+
249
+ > § 4 已内联主要反例。本节补充跨规则复合错误:
250
+
251
+ - ❌ 在 C2C 场景渲染 @ 按钮 + 打开空成员列表 — 判定缺失
252
+ - ❌ `useGroupMemberStore` 写在 MessageInput 外部(ChatPage),通过 props 传 memberList — 违反封装
253
+ - ❌ 选中成员后在输入框外部用 tag/chip 展示,发送时才拼接 — 用户看不到最终文本
254
+ - ❌ Backspace 时逐字符删除 `@name`,留下残缺的 `@张` — 应原子删除
255
+
256
+ ## 6. UI 自由度
257
+
258
+ > 样式由 AI 自由发挥,遵循项目已有 CSS 方案(见 `_base/detect-style.md`)。
259
+
260
+ ✅ 完全自由:选择器容器形态(Popover / floating panel)/ 成员列表排序 / 勾选样式 / 头像形状 / 搜索框样式
261
+ ⚠️ 必须遵循项目现状:UI 库组件 / CSS 方案
262
+ ⚠️ § 4.4 布局结构 + § 4.5/4.6 交互行为不可放宽
263
+
264
+ ## 7. 参考实现
265
+
266
+ ```vue
267
+ <script setup lang="ts">
268
+ import { ref, computed, onMounted, useTemplateRef } from 'vue'
269
+ import { useMessageInputStore, useGroupMemberStore } from 'tuikit-atomicx-vue3/chat'
270
+ import type { GroupMember } from 'tuikit-atomicx-vue3/chat'
271
+
272
+ const props = defineProps<{ conversationID: string }>()
273
+ const { sendMessage } = useMessageInputStore(props.conversationID)
274
+
275
+ const isGroup = computed(() => props.conversationID.startsWith('GROUP'))
276
+ const groupID = computed(() => isGroup.value ? props.conversationID.slice(5) : '')
277
+
278
+ const memberStore = isGroup.value ? useGroupMemberStore(groupID.value) : null
279
+
280
+ onMounted(() => {
281
+ if (memberStore) {
282
+ memberStore.loadMembers().catch(() => {})
283
+ }
284
+ })
285
+
286
+ // ── @ 状态 ──
287
+ interface AtUser { userID: string; name: string }
288
+ const atUsers = ref<AtUser[]>([])
289
+ const pickerOpen = ref(false)
290
+ const text = ref('')
291
+ const textareaEl = useTemplateRef<HTMLTextAreaElement>('textareaEl')
292
+
293
+ function memberDisplayName(m: GroupMember): string {
294
+ return m.nameCard || m.nickname || m.userID
295
+ }
296
+
297
+ // 选中成员后插入输入框(替换触发的 @ + 搜索文字)
298
+ function handleConfirm(selected: GroupMember[]) {
299
+ const el = textareaEl.value
300
+ const cursorPos = el?.selectionStart ?? text.value.length
301
+
302
+ const insertions = selected.map(m => `@${memberDisplayName(m)} `)
303
+ const insertText = insertions.join('')
304
+
305
+ // 删除触发的 @ 及搜索文字,再插入 @name
306
+ const start = atTriggerPos.value >= 0 ? atTriggerPos.value : cursorPos
307
+ text.value = text.value.slice(0, start) + insertText + text.value.slice(cursorPos)
308
+
309
+ // 记录成员(仅 userID + name,不存位置索引)
310
+ for (const m of selected) {
311
+ atUsers.value.push({ userID: m.userID, name: memberDisplayName(m) })
312
+ }
313
+
314
+ pickerOpen.value = false
315
+ atTriggerPos.value = -1
316
+
317
+ // 光标移到插入内容之后
318
+ const newPos = start + insertText.length
319
+ requestAnimationFrame(() => {
320
+ el?.setSelectionRange(newPos, newPos)
321
+ el?.focus()
322
+ })
323
+ }
324
+
325
+ // 监听输入 @ 字符唤起选择器
326
+ const atTriggerPos = ref(-1)
327
+
328
+ function handleInput(e: Event) {
329
+ if (!isGroup.value) return
330
+ const el = textareaEl.value
331
+ const pos = el?.selectionStart ?? 0
332
+ const inputEvent = e as InputEvent
333
+ if (inputEvent.data === '@') {
334
+ pickerOpen.value = true
335
+ atTriggerPos.value = pos - 1 // @ 字符的位置
336
+ }
337
+ }
338
+
339
+ // Backspace 原子删除:基于文本内容动态查找
340
+ function handleKeydown(e: KeyboardEvent) {
341
+ if (e.key === 'Backspace') {
342
+ const el = textareaEl.value
343
+ const pos = el?.selectionStart ?? 0
344
+ const hitIndex = atUsers.value.findIndex(u => {
345
+ const token = `@${u.name} `
346
+ const idx = text.value.indexOf(token)
347
+ if (idx === -1) return false
348
+ return pos > idx && pos <= idx + token.length
349
+ })
350
+ if (hitIndex !== -1) {
351
+ e.preventDefault()
352
+ const hit = atUsers.value[hitIndex]
353
+ const token = `@${hit.name} `
354
+ const idx = text.value.indexOf(token)
355
+ text.value = text.value.slice(0, idx) + text.value.slice(idx + token.length)
356
+ atUsers.value.splice(hitIndex, 1)
357
+ // 光标回到 token 起始位置
358
+ requestAnimationFrame(() => el?.setSelectionRange(idx, idx))
359
+ // 删除后若输入框为空,关闭成员面板
360
+ if (text.value.length === 0) {
361
+ pickerOpen.value = false
362
+ }
363
+ }
364
+ }
365
+ }
366
+
367
+ // 发送
368
+ async function handleSend() {
369
+ const trimmed = text.value.trim()
370
+ if (!trimmed) return
371
+ const atUserList = atUsers.value.map(u => u.userID)
372
+ const option = atUserList.length > 0 ? { atUserList } : undefined
373
+ await sendMessage({ type: 'textMessage', text: trimmed }, option)
374
+ text.value = ''
375
+ atUsers.value = []
376
+ }
377
+ </script>
378
+
379
+ <template>
380
+ <textarea
381
+ ref="textareaEl"
382
+ v-model="text"
383
+ @keydown="handleKeydown"
384
+ @input="handleInput"
385
+ />
386
+
387
+ <!-- 成员选择器(Popover / Dropdown,定位到 @ 光标位置) -->
388
+ <!-- 显示条件:pickerOpen && isGroup -->
389
+ <!-- 列表数据:memberStore.memberList(按 searchQuery 过滤) -->
390
+ <!-- 确认回调:handleConfirm -->
391
+ </template>
392
+ ```
393
+
394
+ > ⚠️ 实际实现时,@ 逻辑与 MessageInput 组件合并(共享 `sending` / `text` / `sendMessage`)。上述代码仅展示核心逻辑骨架。
395
+
396
+ ### 可改
397
+
398
+ - 选择器容器形态(Popover / floating panel / 绝对定位面板)
399
+ - 成员列表排序方式
400
+ - 勾选框 / 头像 / 列表项样式
401
+ - 搜索框 UI 细节(图标位置、清除按钮)
402
+
403
+ ### 不可改
404
+
405
+ - `useGroupMemberStore(groupID)` 取成员列表(不绕过 store)
406
+ - `atUserList` 传 userID 数组到 `sendMessage` option
407
+ - 群聊判定用 `conversationID.startsWith('GROUP')`
408
+ - groupID 用 `conversationID.slice(5)`
409
+ - 非群聊不启用 @ 功能
410
+ - 触发方式:输入 `@` 字符唤起选择器(不用 toolbar 按钮)
411
+ - 选择器布局:顶部操作栏 → 搜索框 → @所有人固定项 → 成员列表
412
+ - 搜索框必须存在于选择器内部,实时过滤成员列表
413
+ - @所有人固定置顶,所有群成员可用
414
+ - 选中后 `@name ` 直接插入输入框光标位置(不用外部 tag)
415
+ - Backspace 原子删除整个 `@name`(基于 indexOf 动态查找)
416
+ - 关闭时机仅 3 种(确定 / Esc / 输入框清空)
417
+ - 发送成功后清空 atUsers
@@ -0,0 +1,178 @@
1
+ ---
2
+ id: chat/conversation-actions
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: [ConversationListStore]
10
+ trigger-keywords: [标星, 收藏, star, 标未读, 标已读, mark unread, mark read, 草稿, draft, 清空消息, clear messages]
11
+ prerequisites: [conversation-list]
12
+ tags: ['ConversationListStore']
13
+ ---
14
+
15
+ ## 1. 这个 slice 处理什么
16
+
17
+ 在 starter `conversation-list`(已覆盖加载 / 渲染 + 置顶 / 删除 / 免打扰)之上,叠加 `useConversationListStore()` 的**其余 4 个会话级操作**:
18
+
19
+ 1. **标星 / 收藏** — `markConversation` + `ConversationMarkType.Star`
20
+ 2. **标已读 / 标未读** — `clearConversationUnreadCount` + `markConversation` + `ConversationMarkType.Unread`
21
+ 3. **草稿** — `setConversationDraft`
22
+ 4. **清空消息** — `clearConversationMessages`
23
+
24
+ > 不重复 starter 已讲过的「加载 / 渲染 / 头像兜底 / 置顶 / 删除 / 免打扰」。
25
+
26
+ ## 2. AI 思考清单(写代码前必须想清楚)
27
+
28
+ - 操作放哪里:右键菜单 / 长按 / 侧滑?(建议复用 starter 已搭好的菜单入口)
29
+ - 是否需要在列表项上视觉化这些状态(星标 / 红点 / 草稿前缀)
30
+ - "清空消息"是否需要二次确认(强烈建议与"删除会话"一致)
31
+ - 草稿何时同步:失焦?还是输入停顿 500ms 去抖?
32
+
33
+ ## 3. SDK API 必读(绝对真理)
34
+
35
+ ### 3.1 引入
36
+
37
+ ```ts
38
+ import { useConversationListStore } from 'tuikit-atomicx-vue3/chat'
39
+ import { ConversationMarkType } from 'tuikit-atomicx-vue3/chat'
40
+ import type { ConversationInfo } from 'tuikit-atomicx-vue3/chat'
41
+
42
+ const {
43
+ markConversation, // (idList: string[], markType, enable: boolean) => Promise
44
+ clearConversationUnreadCount, // (id) => Promise
45
+ setConversationDraft, // (id, draft: string) => Promise
46
+ clearConversationMessages, // (id) => Promise
47
+ } = useConversationListStore()
48
+ ```
49
+
50
+ ### 3.2 状态判定(统一工具函数)
51
+
52
+ ```ts
53
+ const isStarred = (c: ConversationInfo) => c.conversationMarkList.includes(ConversationMarkType.Star)
54
+ const isMarkedUnread = (c: ConversationInfo) => c.conversationMarkList.includes(ConversationMarkType.Unread)
55
+ ```
56
+
57
+ ### 3.3 操作语义对照表
58
+
59
+ | 场景 | 调用 |
60
+ |---|---|
61
+ | 收藏 | `markConversation([id], ConversationMarkType.Star, true)` |
62
+ | 取消收藏 | `markConversation([id], ConversationMarkType.Star, false)` |
63
+ | 标已读 | `clearConversationUnreadCount(id)` + 条件性 `markConversation([id], Unread, false)` |
64
+ | 标未读 | `markConversation([id], ConversationMarkType.Unread, true)` |
65
+ | 设草稿 | `setConversationDraft(id, text)` — 空串 `''` 清除草稿 |
66
+ | 清空消息 | `clearConversationMessages(id)` — 不删会话,仅清消息 |
67
+
68
+ ### 3.4 未读数 vs 标未读
69
+
70
+ - `unreadCount: number` — 真实未读消息数,`clearConversationUnreadCount` 后归 0
71
+ - `ConversationMarkType.Unread` — 用户手动标的"未读旗",与 `unreadCount` 互不相干
72
+
73
+ UI 渲染:`unreadCount > 0` → 数字徽标;`unreadCount === 0 && isMarkedUnread` → 纯色小红点。
74
+
75
+ ## 4. Hard rules(AI 必须遵守)
76
+
77
+ > ❗ 所有 SDK 异步调用必须遵循 `references/06-a-defensive-coding.md` 防御编程规范(try/catch/finally、formatError、错误反馈形式、状态锁)。本节规则是该规范的专属补充,不替代它。
78
+
79
+ - ❗ `markConversation` 第一参必须是 `string[]`(即便单条也要数组)
80
+ - ❌ `markConversation(id, ConversationMarkType.Star, true)` — 漏 `[]`
81
+ - ❗ "标已读"必须**两步**:`clearConversationUnreadCount` + 条件性 `markConversation([id], Unread, false)`
82
+ - ❌ 只调 `clearConversationUnreadCount` — 手动标的红点擦不掉
83
+ - ❌ 只调 `markConversation([id], Unread, false)` — `unreadCount` 数字还在
84
+ - ❗ "清空消息"是不可逆操作,UI 必须二次确认
85
+ - ❌ 不弹确认框
86
+ - ❗ 清空消息 ≠ 删除会话,不准用 `deleteConversation` 替代
87
+ - ❗ 草稿同步不准对每个 keystroke 调用(频控风险),推荐失焦 / 500ms 去抖
88
+ - ❌ `@input="setConversationDraft(id, $event.target.value)"`
89
+ - ❗ 状态判定用 § 3.2 工具函数(导入枚举),不准模板内硬编码魔法数字
90
+ - ❌ `conv.conversationMarkList.includes(0)`
91
+ - ❗ 草稿在列表预览的 `[Draft]` 前缀由 starter `getLastMessagePreview` 处理,feature 层不重复实现
92
+ - ❗ 操作后不准手动 `conversationList.value = [...]` 强制刷新(store 响应式自动同步)
93
+ - ❗ 操作必须有视觉反馈(状态图标 / 菜单文案切换)
94
+
95
+ ## 5. 反例库
96
+
97
+ > § 4 各条已内联主要反例。本节补充跨规则复合错误:
98
+
99
+ - ❌ 切换免打扰时顺手清了 `unreadCount`(免打扰只是 UI 降级,不改数据)—— 跨越 starter § 4.4 + 本 slice § 3.4
100
+ - ❌ feature 层重写 `getLastMessagePreview` 加 `[Draft]` 前缀 —— 与 starter 不一致
101
+
102
+ ## 6. UI 自由度
103
+
104
+ > 样式由 AI 自由发挥,遵循项目已有 CSS 方案(见 `_base/detect-style.md`)。
105
+
106
+ ✅ 完全自由:操作菜单触发方式 / 菜单图标选型 / 状态指示样式 / 草稿去抖时机 / 清空确认框样式
107
+ ⚠️ 必须遵循项目现状:UI 库 / 现有 ContextMenu / Dialog 组件
108
+ ⚠️ § 4 强约束不可放宽
109
+
110
+ ## 7. 参考实现(明确哪些可改)
111
+
112
+ ```vue
113
+ <script setup lang="ts">
114
+ import { useConversationListStore } from 'tuikit-atomicx-vue3/chat'
115
+ import { ConversationMarkType } from 'tuikit-atomicx-vue3/chat'
116
+ import type { ConversationInfo } from 'tuikit-atomicx-vue3/chat'
117
+
118
+ const {
119
+ markConversation,
120
+ clearConversationUnreadCount,
121
+ setConversationDraft,
122
+ clearConversationMessages,
123
+ } = useConversationListStore()
124
+
125
+ const isStarred = (c: ConversationInfo) => c.conversationMarkList.includes(ConversationMarkType.Star)
126
+ const isMarkedUnread = (c: ConversationInfo) => c.conversationMarkList.includes(ConversationMarkType.Unread)
127
+
128
+ async function handleStar(conv: ConversationInfo) {
129
+ await markConversation([conv.conversationID], ConversationMarkType.Star, !isStarred(conv))
130
+ }
131
+
132
+ async function handleMarkRead(conv: ConversationInfo) {
133
+ await clearConversationUnreadCount(conv.conversationID)
134
+ if (isMarkedUnread(conv)) {
135
+ await markConversation([conv.conversationID], ConversationMarkType.Unread, false)
136
+ }
137
+ }
138
+
139
+ async function handleMarkUnread(conv: ConversationInfo) {
140
+ await markConversation([conv.conversationID], ConversationMarkType.Unread, true)
141
+ }
142
+
143
+ async function handleClear(conv: ConversationInfo) {
144
+ if (!confirm(`Clear all messages in "${conv.conversationID}"?`)) return // TODO: 替换为 UI 库 Dialog 或自建最小 Dialog
145
+ await clearConversationMessages(conv.conversationID)
146
+ }
147
+
148
+ // 草稿去抖(500ms)
149
+ let draftTimer: ReturnType<typeof setTimeout> | null = null
150
+ function syncDraft(conv: ConversationInfo, text: string) {
151
+ if (draftTimer) clearTimeout(draftTimer)
152
+ draftTimer = setTimeout(() => setConversationDraft(conv.conversationID, text), 500)
153
+ }
154
+ </script>
155
+ ```
156
+
157
+ 列表项视觉提示(推荐):
158
+
159
+ ```vue
160
+ <StarIcon v-if="isStarred(conv)" />
161
+ <span v-if="conv.unreadCount > 0" class="badge">{{ conv.unreadCount > 99 ? '99+' : conv.unreadCount }}</span>
162
+ <span v-else-if="isMarkedUnread(conv)" class="dot" />
163
+ ```
164
+
165
+ ### 可改
166
+
167
+ - 触发入口(右键 / 长按 / 侧滑 / 悬浮按钮)
168
+ - 确认框组件(项目 Dialog)
169
+ - 草稿去抖时机(500ms / 失焦)
170
+ - 图标库选型
171
+
172
+ ### 不可改
173
+
174
+ - `markConversation` 第一参 `[]` 数组形态
175
+ - "标已读"两步操作
176
+ - 清空消息必须二次确认
177
+ - 状态判定用枚举工具函数,禁止模板硬编码
178
+ - 不手动刷新 `conversationList`