Files
2026-07-20 19:01:03 +08:00

1105 lines
37 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 步骤 07:聊天页
> 实现聊天页:消息列表、发送消息、消息归档。
> 对应流程:`docs/流程/04_文本消息.md`
> 依赖步骤:06(频道切换)
---
## 一、目标
- [ ] ChatScreen 页面布局(头部、消息列表、输入区、语音控制栏)
- [ ] 消息列表组件(区分自己/他人消息、自动滚动)
- [ ] 消息输入区与键盘联动(随键盘上抬、空消息禁用发送)
- [ ] SendTextMessage 流程(状态机:idle → sending → idle/failed
- [ ] OnTextMessage 消息归档(⑤ 按 TargetMode + Target 归档)
- [ ] 未读消息状态管理(弱未读红点、强未读 badge、进入频道清除)
---
## 二、任务清单
### 7.1 页面布局
**目标**:实现 ChatScreen 的三段式布局——头部、消息列表、底部(输入区 + 语音控制栏)。
**前置条件**
- 步骤 05 的 ChannelListScreen 已实现
- 步骤 06 的频道切换已完成,可通过当前频道栏跳转到聊天页
**对应 UI 设计**`docs/UI架构设计.md` 2.3 聊天页布局
**任务**
1. **ChatScreen 整体布局**
```kotlin
// ui/screens/ChatScreen.kt
@Composable
fun ChatScreen(
chatViewModel: ChatViewModel,
channelViewModel: ChannelViewModel,
voiceViewModel: VoiceViewModel,
onNavigateBack: () -> Unit,
onOpenChannelDetail: (channelId: Long) -> Unit
) {
val messages by chatViewModel.messages.collectAsState()
val currentChannel by channelViewModel.currentChannel.collectAsState()
val sendMessageState by chatViewModel.sendState.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
// 头部:返回按钮 + 频道名 + 频道详情入口
ChatHeader(
channelName = currentChannel?.name ?: "",
memberCount = currentChannel?.memberCount ?: 0,
onBack = onNavigateBack,
onChannelDetail = { currentChannel?.let { onOpenChannelDetail(it.id) } }
)
// 中部:消息列表(占满剩余空间)
Box(modifier = Modifier.weight(1f)) {
MessageList(
messages = messages,
modifier = Modifier.fillMaxSize()
)
}
// 中部-底部:消息输入区(随键盘上抬)
MessageInputBar(
sendState = sendMessageState,
onSendMessage = { text -> chatViewModel.sendMessage(text) },
modifier = Modifier.fillMaxWidth()
)
// 底部:语音控制栏(固定在底部,不受键盘影响)
VoiceControlBar(
voiceViewModel = voiceViewModel,
modifier = Modifier.fillMaxWidth()
)
}
}
```
2. **ChatHeader 头部组件**
```kotlin
// ui/components/ChatHeader.kt
@Composable
fun ChatHeader(
channelName: String,
memberCount: Int,
onBack: () -> Unit,
onChannelDetail: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 左侧:返回按钮
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "返回"
)
}
// 中部:频道名 + 人数
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = channelName,
style = MaterialTheme.typography.titleMedium
)
Text(
text = "${memberCount}人",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// 右侧:频道详情入口
IconButton(onClick = onChannelDetail) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "频道详情"
)
}
}
}
```
### 7.2 消息列表组件
**目标**:实现消息列表渲染,区分自己/他人消息,支持自动滚动和历史浏览。
**对应 UI 设计**`docs/UI架构设计.md` 2.3 消息显示格式
**任务**
1. **ChatMessage 数据模型**
```kotlin
// data/Models.kt
/**
* 聊天消息实体
* 权威来源:OnTextMessage(对应 notifytextmessage
*/
data class ChatMessage(
val id: String, // 唯一标识(用于列表 key)
val targetMode: Int, // 1=私聊, 2=频道, 3=服务器
val targetId: Long, // 目标 ID(频道 ID 或客户端 ID)
val senderId: Int, // 发送者客户端 ID
val senderName: String, // 发送者昵称
val content: String, // 消息内容
val timestamp: Long, // 消息时间戳
val isSelf: Boolean // 是否是自己发送的
)
```
2. **MessageList 消息列表**
```kotlin
// ui/components/MessageList.kt
@Composable
fun MessageList(
messages: List<ChatMessage>,
modifier: Modifier = Modifier
) {
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
// 是否在底部(用于判断是否自动滚动)
val isAtBottom by remember {
derivedStateOf {
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()
lastVisibleItem != null && lastVisibleItem.index >= messages.size - 2
}
}
// 新消息到达时自动滚动到底部
LaunchedEffect(messages.size) {
if (isAtBottom || messages.isNotEmpty()) {
listState.animateScrollToItem(messages.size - 1)
}
}
LazyColumn(
state = listState,
modifier = modifier.padding(horizontal = 8.dp),
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(
items = messages,
key = { it.id }
) { message ->
MessageItem(message = message)
}
}
// 用户查看历史消息时,新消息到达显示 "↓ 新消息" 提示条
if (!isAtBottom && messages.isNotEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
contentAlignment = Alignment.Center
) {
FilledTonalButton(
onClick = {
coroutineScope.launch {
listState.animateScrollToItem(messages.size - 1)
}
}
) {
Icon(Icons.Default.ArrowDropDown, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text("新消息")
}
}
}
}
```
3. **MessageItem 单条消息**
```kotlin
// ui/components/MessageItem.kt
@Composable
fun MessageItem(message: ChatMessage) {
val timeText = remember(message.timestamp) {
SimpleDateFormat("HH:mm", Locale.getDefault())
.format(Date(message.timestamp))
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalAlignment = if (message.isSelf) Alignment.End else Alignment.Start
) {
// 发送者名称 + 时间
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (message.isSelf) Arrangement.End else Arrangement.Start,
modifier = Modifier.fillMaxWidth()
) {
if (!message.isSelf) {
Text(
text = message.senderName,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.width(8.dp))
}
Text(
text = timeText,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (message.isSelf) {
Spacer(Modifier.width(8.dp))
Text(
text = message.senderName,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
Spacer(Modifier.height(2.dp))
// 消息气泡
Surface(
shape = RoundedCornerShape(12.dp),
color = if (message.isSelf) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
modifier = Modifier.widthIn(max = 280.dp)
) {
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
)
}
}
}
```
4. **消息长按操作菜单**
```kotlin
// ui/components/MessageContextMenu.kt
@Composable
fun MessageContextMenu(
message: ChatMessage,
onCopy: () -> Unit,
onPokeSender: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(message.senderName) },
text = {
Column {
DropdownMenuItem(
text = { Text("📋 复制") },
onClick = {
onCopy()
onDismiss()
}
)
DropdownMenuItem(
text = { Text("🫴 Poke") },
onClick = {
onPokeSender()
onDismiss()
}
)
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) { Text("取消") }
}
)
}
```
### 7.3 消息输入区
**目标**:实现消息输入框和发送按钮,支持键盘联动。
**对应 UI 设计**`docs/UI架构设计.md` 2.3 消息输入区与键盘联动
**任务**
```kotlin
// ui/components/MessageInputBar.kt
@Composable
fun MessageInputBar(
sendState: MessageSendState,
onSendMessage: (String) -> Unit,
modifier: Modifier = Modifier
) {
var inputText by remember { mutableStateOf("") }
// 发送成功后清空输入框
LaunchedEffect(sendState) {
if (sendState is MessageSendState.Idle && sendState.justSent) {
inputText = ""
}
}
Row(
modifier = modifier
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 输入框
OutlinedTextField(
value = inputText,
onValueChange = { inputText = it },
modifier = Modifier.weight(1f),
placeholder = {
Text(
"输入消息...",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
maxLines = 4,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Send
),
keyboardActions = KeyboardActions(
onSend = {
if (inputText.isNotBlank()) {
onSendMessage(inputText.trim())
}
}
)
)
Spacer(Modifier.width(8.dp))
// 发送按钮(输入框为空时置灰不可点击)
IconButton(
onClick = {
onSendMessage(inputText.trim())
},
enabled = inputText.isNotBlank() && sendState !is MessageSendState.Sending
) {
Icon(
imageVector = Icons.Default.Send,
contentDescription = "发送",
tint = if (inputText.isNotBlank()) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
}
}
```
**键盘联动要求**
- 点击输入框 → 唤起输入法 → 输入区 + 发送按钮随键盘上抬
- 语音控制栏保持固定在底部,不受键盘影响
- 使用 `WindowCompat.setDecorFitsSystemWindows(window, false)` + `imePadding()` 实现
### 7.4 发送消息流程
**目标**:实现 SendTextMessage 的完整流程,包含状态管理和错误处理。
**对应流程**`docs/流程/04_文本消息.md` 时序图
**关键原则**
- `SendTextMessage` 返回 `nil` 只表示服务器接受请求
- 服务端会向自己回推 `notifytextmessage`,用 `OnTextMessage` 归档权威消息
- 消息归档必须依据 `TargetMode` 和 `Target`,不能用当前页面频道代替真实目标
**任务**
1. **发送状态定义**
```kotlin
// data/Models.kt
/**
* 消息发送状态机
* 对应 docs/流程/04_文本消息.md 中的聊天状态
*
* 状态转换:
* Idle → Sending:用户提交发送意图
* Sending → Idle:命令成功(等待 OnTextMessage 归档)
* Sending → Failed:命令被拒绝
* Failed → Sending:用户重试
*/
sealed class MessageSendState {
/** 空闲,可以发送新消息 */
data class Idle(val justSent: Boolean = false) : MessageSendState()
/** 正在发送 SendTextMessage 等待响应 */
object Sending : MessageSendState()
/** 发送失败 */
data class Failed(val error: String) : MessageSendState()
}
```
2. **ChatViewModel 发送逻辑**
```kotlin
// viewmodel/ChatViewModel.kt
class ChatViewModel(
private val repository: Repository,
private val application: Application
) : ViewModel() {
companion object {
private const val TAG = "ChatViewModel"
}
// 当前频道的消息列表
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages
// 发送状态
private val _sendState = MutableStateFlow<MessageSendState>(MessageSendState.Idle())
val sendState: StateFlow<MessageSendState> = _sendState
// 当前查看的会话(用于筛选消息)
private var currentTargetMode: Int = 2 // 默认频道消息
private var currentTargetId: Long = 0
/**
* 切换当前查看的会话
* 进入聊天页时调用,加载对应频道的消息
*/
fun enterChat(targetMode: Int, targetId: Long) {
currentTargetMode = targetMode
currentTargetId = targetId
// 从归档中加载消息
_messages.value = repository.getMessages(targetMode, targetId)
// 清除该频道的未读标记
repository.clearUnread(targetMode, targetId)
}
/**
* 发送文本消息
* 对应 docs/流程/04_文本消息.md 时序图
*/
fun sendMessage(text: String) {
if (text.isBlank()) return
if (_sendState.value is MessageSendState.Sending) {
Log.w(TAG, "Message send already in progress")
return
}
viewModelScope.launch {
_sendState.value = MessageSendState.Sending
try {
// 通过 TSBridge 发送消息
// targetMode: 2=频道, targetId=当前频道ID
val error = TSBridge.sendTextMessage(
currentTargetMode,
currentTargetId.toString(),
text
)
if (error.isEmpty()) {
// 命令成功:服务器接受发送请求
// 服务端会回推 OnTextMessage,由 handleTextMessage 归档
Log.d(TAG, "SendTextMessage accepted by server")
_sendState.value = MessageSendState.Idle(justSent = true)
} else {
// 命令被拒绝
Log.w(TAG, "SendTextMessage rejected: $error")
_sendState.value = MessageSendState.Failed(mapSendError(error))
}
} catch (e: Exception) {
Log.e(TAG, "SendTextMessage failed", e)
_sendState.value = MessageSendState.Failed("发送失败:${e.message}")
}
}
}
/**
* 重试发送(失败后)
*/
fun retrySend(text: String) {
_sendState.value = MessageSendState.Idle()
sendMessage(text)
}
/**
* 映射发送错误为用户友好提示
*/
private fun mapSendError(error: String): String {
return when {
error.contains("permission", ignoreCase = true) -> "权限不足,无法发送消息"
error.contains("flood", ignoreCase = true) -> "发送过于频繁,请稍后再试"
error.contains("empty", ignoreCase = true) -> "消息内容不能为空"
else -> "发送失败:$error"
}
}
}
```
### 7.5 消息归档逻辑
**目标**:实现 OnTextMessage 事件处理,按 TargetMode + Target 归档消息。
**对应流程**`docs/流程/08_状态同步.md` ⑤ 消息归档同步
**关键原则**
- 消息归档必须依据 `TargetMode` 和 `Target`,不能使用当前页面频道猜测消息目标
- 非当前频道的消息仅显示弱未读提示
- 当前频道的消息直接追加到列表并自动滚动
**任务**
1. **Repository 消息归档**
```kotlin
// data/Repository.kt
class Repository {
// ... 已有实现 ...
// 消息归档:按 (TargetMode, Target) 分组存储
// key = "${targetMode}_${targetId}"
private val messageArchives = ConcurrentHashMap<String, MutableList<ChatMessage>>()
// 未读状态:key = "${targetMode}_${targetId}", value = 未读数
private val _unreadCounts = MutableStateFlow<Map<String, Int>>(emptyMap())
val unreadCounts: StateFlow<Map<String, Int>> = _unreadCounts
// 当前查看的会话(用于判断是否需要未读提示)
private var activeChatKey: String? = null
/**
* 归档消息(由 OnTextMessage 调用)
* 对应 docs/流程/08_状态同步.md ⑤ 消息归档同步
*
* 必须读取 TargetMode 和 Target,不能使用当前页面猜测目标
*/
fun archiveMessage(message: ChatMessage) {
val key = "${message.targetMode}_${message.targetId}"
// 归档到对应会话
val archive = messageArchives.getOrPut(key) { mutableListOf() }
synchronized(archive) {
archive.add(message)
// 限制每个会话最多保留 500 条消息
if (archive.size > 500) {
archive.removeAt(0)
}
}
// 如果不是当前查看的会话,增加未读计数
if (key != activeChatKey) {
val currentCounts = _unreadCounts.value.toMutableMap()
currentCounts[key] = (currentCounts[key] ?: 0) + 1
_unreadCounts.value = currentCounts
}
// 如果是当前查看的会话,更新消息列表
if (key == activeChatKey) {
// 触发 UI 更新
notifyMessagesChanged(message.targetMode, message.targetId)
}
}
/**
* 获取指定会话的消息列表
*/
fun getMessages(targetMode: Int, targetId: Long): List<ChatMessage> {
val key = "${targetMode}_${targetId}"
val archive = messageArchives[key] ?: emptyList()
return synchronized(archive) { archive.toList() }
}
/**
* 设置当前活跃会话(进入聊天页时调用)
*/
fun setActiveChat(targetMode: Int?, targetId: Long?) {
activeChatKey = if (targetMode != null && targetId != null) {
"${targetMode}_${targetId}"
} else {
null
}
}
/**
* 清除指定会话的未读标记
*/
fun clearUnread(targetMode: Int, targetId: Long) {
val key = "${targetMode}_${targetId}"
val currentCounts = _unreadCounts.value.toMutableMap()
currentCounts.remove(key)
_unreadCounts.value = currentCounts
}
}
```
2. **ChatViewModel 消息接收处理**
```kotlin
// viewmodel/ChatViewModel.kt
/**
* 处理收到的文本消息
* 由 ServerViewModel 的 OnTextMessage 回调触发
*
* 对应 docs/流程/04_文本消息.md
* "OnTextMessage 归档:按 TargetMode 与 Target 归档
* 不能使用当前页面频道代替真实目标"
*/
fun handleTextMessage(
targetMode: Int,
targetId: Long,
senderId: Int,
senderName: String,
content: String
) {
val selfId = repository.selfClientId.value
val message = ChatMessage(
id = "${System.currentTimeMillis()}_${senderId}",
targetMode = targetMode,
targetId = targetId,
senderId = senderId,
senderName = senderName,
content = content,
timestamp = System.currentTimeMillis(),
isSelf = senderId == selfId
)
// 归档消息(Repository 内部处理未读计数)
repository.archiveMessage(message)
Log.d(TAG, "Message archived: mode=$targetMode, target=$targetId, from=$senderName")
}
/**
* 离开聊天页时清理
*/
fun leaveChat() {
repository.setActiveChat(null, null)
}
```
3. **ServerViewModel 注册 OnTextMessage 回调**
```kotlin
// ServerViewModel.kt - 在 registerEventHandlers 中添加
fun registerEventHandlers() {
TSBridge.setCallbacks(object : TSBridge.Callbacks {
// ... 已有回调 ...
override fun onTextMessage(
targetMode: Int,
targetId: String,
invokerId: Int,
invokerName: String,
message: String
) {
val targetIdLong = targetId.toLongOrNull() ?: return
chatViewModel.handleTextMessage(
targetMode = targetMode,
targetId = targetIdLong,
senderId = invokerId,
senderName = invokerName,
content = message
)
}
// ... 其他回调 ...
})
}
```
### 7.6 ChatViewModel 完整实现
**目标**:整合以上各部分,实现完整的 ChatViewModel。
```kotlin
// viewmodel/ChatViewModel.kt
package com.tsmobile.app.viewmodel
import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.tsmobile.app.TSBridge
import com.tsmobile.app.data.ChatMessage
import com.tsmobile.app.data.MessageSendState
import com.tsmobile.app.data.Repository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class ChatViewModel(
private val repository: Repository,
private val application: Application
) : AndroidViewModel(application) {
companion object {
private const val TAG = "ChatViewModel"
private const val MAX_MESSAGES_PER_CHAT = 500
}
// 当前会话的消息列表
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages
// 发送状态
private val _sendState = MutableStateFlow<MessageSendState>(MessageSendState.Idle())
val sendState: StateFlow<MessageSendState> = _sendState
// 当前查看的会话参数
private var currentTargetMode: Int = 2
private var currentTargetId: Long = 0
/**
* 进入聊天页
* 加载消息并清除未读标记
*/
fun enterChat(targetMode: Int, targetId: Long) {
currentTargetMode = targetMode
currentTargetId = targetId
// 设置活跃会话(影响未读计数)
repository.setActiveChat(targetMode, targetId)
// 加载归档消息
_messages.value = repository.getMessages(targetMode, targetId)
// 清除该会话的未读标记
repository.clearUnread(targetMode, targetId)
Log.d(TAG, "Entered chat: mode=$targetMode, target=$targetId, messages=${_messages.value.size}")
}
/**
* 离开聊天页
*/
fun leaveChat() {
repository.setActiveChat(null, null)
_sendState.value = MessageSendState.Idle()
Log.d(TAG, "Left chat")
}
/**
* 发送文本消息
* 对应 docs/流程/04_文本消息.md 时序图
*/
fun sendMessage(text: String) {
if (text.isBlank()) return
if (_sendState.value is MessageSendState.Sending) {
Log.w(TAG, "Send already in progress")
return
}
viewModelScope.launch {
_sendState.value = MessageSendState.Sending
try {
val error = TSBridge.sendTextMessage(
currentTargetMode,
currentTargetId.toString(),
text
)
if (error.isEmpty()) {
Log.d(TAG, "SendTextMessage accepted")
_sendState.value = MessageSendState.Idle(justSent = true)
} else {
Log.w(TAG, "SendTextMessage rejected: $error")
_sendState.value = MessageSendState.Failed(mapSendError(error))
}
} catch (e: Exception) {
Log.e(TAG, "SendTextMessage exception", e)
_sendState.value = MessageSendState.Failed("发送失败:${e.message}")
}
}
}
/**
* 处理收到的文本消息(由 ServerViewModel 调用)
* 对应 docs/流程/08_状态同步.md ⑤ 消息归档同步
*/
fun handleTextMessage(
targetMode: Int,
targetId: Long,
senderId: Int,
senderName: String,
content: String
) {
val selfId = repository.selfClientId.value
val message = ChatMessage(
id = "${System.currentTimeMillis()}_${senderId}",
targetMode = targetMode,
targetId = targetId,
senderId = senderId,
senderName = senderName,
content = content,
timestamp = System.currentTimeMillis(),
isSelf = senderId == selfId
)
repository.archiveMessage(message)
// 如果是当前查看的会话,更新消息列表
if (targetMode == currentTargetMode && targetId == currentTargetId) {
_messages.value = repository.getMessages(targetMode, targetId)
}
}
/**
* 重试发送
*/
fun retrySend(text: String) {
_sendState.value = MessageSendState.Idle()
sendMessage(text)
}
/**
* 清除发送状态(用于 UI 重置)
*/
fun clearSendState() {
_sendState.value = MessageSendState.Idle()
}
private fun mapSendError(error: String): String {
return when {
error.contains("permission", ignoreCase = true) -> "权限不足"
error.contains("flood", ignoreCase = true) -> "发送过于频繁"
error.contains("empty", ignoreCase = true) -> "消息不能为空"
else -> "发送失败:$error"
}
}
}
```
---
## 三、状态与数据流
### 3.1 消息发送状态机
```
┌─────────────────────────────────┐
│ │
▼ │
┌─────────┐ │
│ Idle │◄──────────────────────────┤
└────┬────┘ │
│ 用户点击发送 │
▼ │
┌──────────┐ │
│ Sending │ │
└────┬─────┘ │
│ │
┌─────────┴──────────┐ │
│ │ │
▼ ▼ │
error="" error! │
│ │ │
▼ ▼ │
Idle(justSent) Failed │
│ │ │
│ 用户重试 │
│ │ │
└────────────────────┴─────────────────────┘
```
### 3.2 消息归档数据流
```
服务端 SDK Repository ChatViewModel UI
│ │ │ │ │
│ notifytextmessage │ │ │ │
├────────────────────→│ │ │ │
│ │ OnTextMessage │ │ │
│ ├───────────────────────→│ │ │
│ │ │ │ │
│ │ │ 读取 TargetMode │ │
│ │ │ + Target │ │
│ │ │ │ │
│ │ │ 归档到对应会话 │ │
│ │ │ │ │
│ │ │ 当前会话? │ │
│ │ ├─ 是 ─────────────────→│ 更新消息列表 │
│ │ │ ├─────────────────→│
│ │ │ │ │
│ │ ├─ 否 ─→ 增加未读计数 │ │
│ │ │ │ │
```
### 3.3 命令响应与事件事实的区分
**关键原则**(对应 `docs/流程/04_文本消息.md`):
| 概念 | 含义 | 处理方式 |
|------|------|----------|
| SendTextMessage 返回 error | 命令被服务器拒绝 | 立即显示错误,状态 → Failed |
| SendTextMessage 返回 nil | 命令被服务器接受 | 状态 → Idle(justSent=true) |
| OnTextMessage(self) | 服务端回推自己的消息 | 归档到对应会话,更新列表 |
**为什么发送成功后要等 OnTextMessage 归档?**
- `SendTextMessage` 返回 nil 只表示服务器接受了请求
- 服务端会向自己回推 `notifytextmessage`
- 这条回推消息包含服务器处理后的完整信息(如时间戳)
- 用 `OnTextMessage` 归档确保消息列表的一致性
---
## 四、未读消息管理
### 4.1 未读状态定义
| 状态 | 显示 | 触发条件 | 清除条件 |
|------|------|----------|----------|
| 无未读 | 正常显示 | — | — |
| 弱未读 | 频道名右侧红点 ● | 非当前频道收到普通消息 | 用户进入该频道 |
| 强未读 | 频道名右侧数字 badge | 非当前频道收到 @提及 | 用户进入该频道 |
| 当前频道 | 不显示未读 | 用户正在查看该频道 | — |
### 4.2 未读状态数据流
```kotlin
// ChannelListScreen.kt - 在频道树中显示未读状态
@Composable
fun ChannelRow(
channel: ChannelInfo,
unreadCount: Int,
isCurrentChannel: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// ... 频道名 ...
Spacer(Modifier.weight(1f))
// 未读指示(当前频道不显示)
if (!isCurrentChannel && unreadCount > 0) {
if (unreadCount > 99) {
// 强未读:显示数字
Badge { Text("99+") }
} else if (unreadCount > 1) {
// 强未读:显示数字
Badge { Text("$unreadCount") }
} else {
// 弱未读:显示红点
Box(
modifier = Modifier
.size(8.dp)
.background(
color = MaterialTheme.colorScheme.error,
shape = CircleShape
)
)
}
}
}
}
```
---
## 五、验收标准
### 功能验收
- [ ] **页面布局**
- 头部显示频道名、人数、返回按钮、频道详情入口
- 消息列表占满中间区域,支持滚动
- 输入区在底部,随键盘上抬
- 语音控制栏固定在最底部
- [ ] **消息显示**
- 自己的消息靠右对齐,使用不同背景色
- 他人的消息靠左对齐,显示发送者名称
- 每条消息显示发送时间(HH:mm 格式)
- 新消息到达时自动滚动到底部
- 查看历史消息时显示 "↓ 新消息" 提示条
- [ ] **发送消息**
- 输入框为空时发送按钮置灰
- 点击发送或键盘回车触发发送
- 发送中显示加载状态
- 发送成功清空输入框,保持键盘打开
- 发送失败显示错误提示,可重试
- [ ] **消息归档**
- 收到 OnTextMessage 按 TargetMode + Target 归档
- 当前频道消息直接追加到列表
- 非当前频道消息增加未读计数
- 进入频道时清除未读标记
- [ ] **未读指示**
- 频道列表页显示弱未读红点(1条新消息)
- 频道列表页显示强未读数字(多条新消息)
- 当前所在频道不显示未读标记
### 错误处理验收
| 错误场景 | 预期行为 |
|----------|----------|
| 权限不足 | 提示"权限不足,无法发送消息" |
| 发送过于频繁 | 提示"发送过于频繁,请稍后再试" |
| 网络断开 | 提示"发送失败",可重试 |
| 消息内容为空 | 发送按钮禁用 |
### 性能验收
- [ ] 消息列表滚动流畅(500 条消息)
- [ ] 新消息到达自动滚动无卡顿
- [ ] 输入框输入响应 < 50ms
### 测试用例
| 场景 | 操作 | 预期结果 |
|------|------|----------|
| 发送频道消息 | 输入文本 → 点击发送 | 消息出现在列表右侧 |
| 接收他人消息 | 他人发送消息 | 消息出现在列表左侧,显示发送者名 |
| 自动滚动 | 收到新消息 | 列表自动滚动到底部 |
| 查看历史 | 向上滚动查看历史 | 显示"↓ 新消息"提示条 |
| 未读指示 | 切换到其他频道收到消息 | 频道名显示红点 |
| 清除未读 | 进入有未读的频道 | 红点消失 |
| 发送失败 | 断网后发送 | 显示错误提示,可重试 |
| 空消息 | 输入框为空 | 发送按钮置灰 |
| 长按消息 | 长按消息气泡 | 弹出操作菜单(复制/Poke) |
---
## 六、参考文档
- `docs/流程/04_文本消息.md` - 时序图、状态机、事件依赖
- `docs/流程/08_状态同步.md` - ⑤ 消息归档同步
- `docs/UI架构设计.md` - 2.3 聊天页布局、5.5 未读消息指示
- `docs/sdk文档-go.md` - SendTextMessage API、OnTextMessage 事件、TextMessage 结构
- `docs/implementation/02_Bridge层实现.md` - TSBridge.sendTextMessage、onTextMessage 回调
- `docs/implementation/05_频道列表页.md` - ChannelListScreen 未读指示集成
- `docs/implementation/06_频道切换.md` - 进入聊天页的导航入口