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

1081 lines
38 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.
# 步骤 05:频道列表页
> 实现频道列表页,包括频道树渲染、成员显示、未读指示、当前频道栏和 ChannelViewModel。
---
## 一、目标
- [ ] ChannelListScreen 页面布局(头部 + 频道树 + 当前频道栏 + 底部语音控制)
- [ ] 频道树组件(展开/折叠、层级缩进、状态图标)
- [ ] 成员列表显示(按频道分组、在线状态)
- [ ] 当前频道栏(显示当前频道名、人数、成员预览)
- [ ] 底部语音控制区(基础:静音按钮 + PTT + 展开入口)
- [ ] 未读消息指示(红点、@提及 badge
- [ ] ChannelViewModel 实现(状态管理、增量同步、补偿同步)
---
## 二、任务清单
### 5.1 页面布局
**目标**:实现频道列表页的整体三段式布局。
**布局结构**
```
┌──────────────────────────────────────┐
│ 头部(三段) │
│ ┌────┐ ┌──────────────────┐ ┌─────┐ │
│ │ ☰ │ │ MyServer │ │ 🟢 │ │
│ │服务│ │ 192.168.1.1:9987 │ │连接 │ │ ← 连接状态指示
│ │器卡│ │ 42人在线 │ │状态 │ │
│ └────┘ └──────────────────┘ └─────┘ │
├──────────────────────────────────────┤
│ 中部:频道树 + 成员 │
│ │
│ ▼ 📁 默认频道 │ ← 展开/折叠
│ ▼ 📁 大厅 │
│ 👤 Alice │ ← 点击弹出成员操作菜单
│ 👤 Bob 🔇 │
│ 👤 Charlie 🎤 │
│ ▶ 📁 游戏区 🔒 │ ← 🔒 = 有密码
│ (3人) │ ← 折叠时显示人数
│ ▶ 📁 音乐区 │
│ (1人) │
│ ▼ 📁 VIP 频道 🔒 │
│ 👤 Admin │
│ │
│ (频道列表支持滚动) │
├──────────────────────────────────────┤
│ 中部-底部:当前频道栏 │
│ ┌──────────────────────────────────┐ │
│ │ 💬 大厅 (5人) Alice🎤 Bob │ │ ← 点击跳转到聊天页
│ └──────────────────────────────────┘ │
├──────────────────────────────────────┤
│ 底部:语音控制 │
│ ┌──────┐ ┌────────────────┐ ┌─────┐ │
│ │ 🎤 │ │ PTT 按住发言 │ │ ⬆ │ │
│ │静音 │ │ │ │语音 │ │ ← 展开语音卡
│ └──────┘ └────────────────┘ └─────┘ │
└──────────────────────────────────────┘
```
**任务**
1. **创建 ChannelListScreen.kt**
```kotlin
// ui/screens/ChannelListScreen.kt
@Composable
fun ChannelListScreen(
channelViewModel: ChannelViewModel,
serverViewModel: ServerViewModel,
voiceViewModel: VoiceViewModel,
onNavigateToChat: () -> Unit,
onNavigateToServerConfig: () -> Unit,
onOpenServerDetail: () -> Unit,
onOpenChannelDetail: (channelId: Long) -> Unit,
onOpenVoiceCard: () -> Unit
) {
val connectionState by serverViewModel.connectionState.collectAsState()
val syncState by channelViewModel.syncState.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
// 头部
ChannelListHeader(
serverName = serverViewModel.serverName,
serverAddress = serverViewModel.serverAddress,
onlineCount = serverViewModel.onlineCount,
connectionState = connectionState,
onOpenServerDetail = onOpenServerDetail,
onOpenChannelDetail = { onOpenChannelDetail(channelViewModel.currentChannelId) }
)
// 中部:频道树或同步加载态
Box(modifier = Modifier.weight(1f)) {
when (syncState) {
SyncState.Syncing -> SyncLoadingIndicator()
SyncState.SyncFailed -> SyncFailedView(onRetry = { channelViewModel.retrySync() })
else -> ChannelTreeContent(
channelViewModel = channelViewModel,
onChannelClick = { /* 切换频道逻辑 */ },
onClientClick = { /* 成员操作菜单 */ },
onNavigateToChat = onNavigateToChat
)
}
}
// 中部-底部:当前频道栏
CurrentChannelBar(
channelViewModel = channelViewModel,
onClick = onNavigateToChat
)
// 底部:语音控制
VoiceControlBar(
voiceViewModel = voiceViewModel,
onExpand = onOpenVoiceCard
)
}
}
```
2. **头部组件**
```kotlin
@Composable
fun ChannelListHeader(
serverName: String,
serverAddress: String,
onlineCount: Int,
connectionState: ConnectionState,
onOpenServerDetail: () -> Unit,
onOpenChannelDetail: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 左侧:服务器卡按钮
IconButton(onClick = onOpenServerDetail) {
Icon(Icons.Default.Menu, contentDescription = "服务器详情")
}
// 中部:服务器信息
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
Text(serverName, style = MaterialTheme.typography.titleMedium)
Text(serverAddress, style = MaterialTheme.typography.bodySmall)
Text("${onlineCount}人在线", style = MaterialTheme.typography.bodySmall)
}
// 右侧:连接状态指示
ConnectionStatusDot(connectionState)
}
}
@Composable
fun ConnectionStatusDot(state: ConnectionState) {
val color = when (state) {
ConnectionState.Ready -> Color.Green
ConnectionState.Connecting, ConnectionState.Connected, ConnectionState.Syncing -> Color.Yellow
is ConnectionState.Failed -> Color.Red
else -> Color.Gray
}
Box(
modifier = Modifier
.size(12.dp)
.background(color, CircleShape)
)
}
```
3. **同步加载态组件**
```kotlin
@Composable
fun SyncLoadingIndicator() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text("正在同步服务器数据...")
}
}
}
@Composable
fun SyncFailedView(onRetry: () -> Unit) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("同步失败", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onRetry) {
Text("重试")
}
}
}
}
```
### 5.2 频道树组件
**目标**:实现可展开/折叠的频道树,支持层级缩进和状态图标。
**任务**
1. **频道树数据模型**
```kotlin
// 频道树节点,包含子频道和成员
data class ChannelTreeNode(
val channel: ChannelInfo,
val children: List<ChannelTreeNode>,
val clients: List<ClientInfo>,
val isExpanded: Boolean = false,
val unreadCount: Int = 0,
val hasMention: Boolean = false
)
```
2. **频道树构建逻辑**
```kotlin
// ChannelViewModel.kt
fun buildChannelTree(
channels: List<ChannelInfo>,
clients: List<ClientInfo>,
expandedIds: Set<Long>
): List<ChannelTreeNode> {
// 按 ParentID 分组
val childrenMap = channels.groupBy { it.parentId }
// 递归构建树
fun buildNode(channel: ChannelInfo): ChannelTreeNode {
val children = (childrenMap[channel.id] ?: emptyList())
.sortedBy { it.order }
.map { buildNode(it) }
val channelClients = clients.filter { it.channelId == channel.id }
return ChannelTreeNode(
channel = channel,
children = children,
clients = channelClients,
isExpanded = expandedIds.contains(channel.id)
)
}
// 顶级频道(parentId == 0
return (childrenMap[0L] ?: emptyList())
.sortedBy { it.order }
.map { buildNode(it) }
}
```
3. **频道树 Composable**
```kotlin
@Composable
fun ChannelTreeContent(
channelViewModel: ChannelViewModel,
onChannelClick: (ChannelInfo) -> Unit,
onClientClick: (ClientInfo) -> Unit,
onNavigateToChat: () -> Unit
) {
val tree by channelViewModel.channelTree.collectAsState()
val selfClientId by channelViewModel.selfClientId.collectAsState()
LazyColumn(modifier = Modifier.fillMaxSize()) {
tree.forEach { node ->
channelTreeNodeItems(
node = node,
depth = 0,
selfClientId = selfClientId,
onChannelClick = onChannelClick,
onClientClick = onClientClick,
onToggleExpand = { channelViewModel.toggleExpand(it) }
)
}
}
}
// 递归添加 LazyColumn 项
fun LazyListScope.channelTreeNodeItems(
node: ChannelTreeNode,
depth: Int,
selfClientId: Int?,
onChannelClick: (ChannelInfo) -> Unit,
onClientClick: (ClientInfo) -> Unit,
onToggleExpand: (Long) -> Unit
) {
item(key = "channel_${node.channel.id}") {
ChannelRow(
node = node,
depth = depth,
onClick = {
if (node.children.isNotEmpty()) {
onToggleExpand(node.channel.id)
} else {
onChannelClick(node.channel)
}
},
onLongClick = { /* 弹出频道操作菜单 */ }
)
}
if (node.isExpanded) {
// 显示成员
node.clients.forEach { client ->
item(key = "client_${client.id}") {
ClientRow(
client = client,
depth = depth + 1,
isSelf = client.id == selfClientId,
onClick = { onClientClick(client) }
)
}
}
// 递归显示子频道
node.children.forEach { child ->
channelTreeNodeItems(
node = child,
depth = depth + 1,
selfClientId = selfClientId,
onChannelClick = onChannelClick,
onClientClick = onClientClick,
onToggleExpand = onToggleExpand
)
}
}
}
```
4. **频道行组件**
```kotlin
@Composable
fun ChannelRow(
node: ChannelTreeNode,
depth: Int,
onClick: () -> Unit,
onLongClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = (depth * 24).dp)
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 展开/折叠图标
if (node.children.isNotEmpty()) {
Icon(
imageVector = if (node.isExpanded) Icons.Default.ExpandMore else Icons.Default.ChevronRight,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
} else {
Spacer(modifier = Modifier.size(20.dp))
}
Spacer(modifier = Modifier.width(8.dp))
// 频道图标
Icon(
imageVector = Icons.Default.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
// 频道名
Text(
text = node.channel.name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
// 状态图标
if (node.channel.isPassword) {
Icon(Icons.Default.Lock, contentDescription = "有密码", modifier = Modifier.size(16.dp))
}
// 未读指示
if (node.unreadCount > 0) {
Badge { Text("${node.unreadCount}") }
} else if (node.hasMention) {
Box(
modifier = Modifier
.size(8.dp)
.background(Color.Red, CircleShape)
)
}
// 折叠时显示人数
if (!node.isExpanded && node.clients.isNotEmpty()) {
Text(
text = "(${node.clients.size}人)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
```
5. **成员行组件**
```kotlin
@Composable
fun ClientRow(
client: ClientInfo,
depth: Int,
isSelf: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = (depth * 24).dp)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width(28.dp)) // 对齐频道行的文字
// 用户图标
Icon(
imageVector = Icons.Default.Person,
contentDescription = null,
tint = if (isSelf) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(8.dp))
// 昵称
Text(
text = client.nickname,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal,
modifier = Modifier.weight(1f)
)
// 服务器组图标(可选)
if (client.serverGroups.isNotEmpty()) {
Icon(
imageVector = Icons.Default.Shield,
contentDescription = "管理员",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.tertiary
)
}
}
}
```
### 5.3 成员显示
**目标**:按频道分组显示在线成员,支持增量更新。
**任务**
1. **成员分组索引**
```kotlin
// ChannelViewModel.kt
// 从 Repository 获取按频道分组的成员列表
val channelClients: StateFlow<Map<Long, List<ClientInfo>>> = repository.channelClients
```
2. **成员操作菜单**
```kotlin
@Composable
fun ClientActionMenu(
client: ClientInfo,
isSelf: Boolean,
onPoke: () -> Unit,
onCopyNickname: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Column {
Text(client.nickname, style = MaterialTheme.typography.titleMedium)
Text("频道: ${getChannelName(client.channelId)}",
style = MaterialTheme.typography.bodySmall)
}
},
confirmButton = {},
dismissButton = {},
text = {
Column {
if (!isSelf) {
ListItem(
headlineContent = { Text("Poke") },
leadingContent = { Icon(Icons.Default.TouchApp, null) },
modifier = Modifier.clickable { onPoke(); onDismiss() }
)
}
ListItem(
headlineContent = { Text("复制昵称") },
leadingContent = { Icon(Icons.Default.ContentCopy, null) },
modifier = Modifier.clickable { onCopyNickname(); onDismiss() }
)
}
}
)
}
```
### 5.4 未读指示逻辑
**目标**:实现频道未读消息和 @提及的指示。
**任务**
1. **未读状态数据模型**
```kotlin
data class UnreadState(
val channelId: Long,
val unreadCount: Int = 0,
val hasMention: Boolean = false
)
```
2. **未读状态管理**
```kotlin
// ChannelViewModel.kt
private val _unreadStates = MutableStateFlow<Map<Long, UnreadState>>(emptyMap())
val unreadStates: StateFlow<Map<Long, UnreadState>> = _unreadStates
fun onTextMessage(msg: TextMessage) {
// 只处理频道消息(TargetMode == 2
if (msg.TargetMode != 2) return
val channelId = msg.Target.toLong()
// 如果不在该频道,更新未读状态
if (channelId != repository.selfChannelId.value) {
val current = _unreadStates.value[channelId] ?: UnreadState(channelId)
val hasMention = msg.Message.contains("@${repository.selfNickname.value}")
_unreadStates.value = _unreadStates.value.toMutableMap().apply {
put(channelId, current.copy(
unreadCount = current.unreadCount + 1,
hasMention = current.hasMention || hasMention
))
}
}
}
fun clearUnread(channelId: Long) {
_unreadStates.value = _unreadStates.value.toMutableMap().apply {
remove(channelId)
}
}
```
3. **整合到频道树**
```kotlin
// buildChannelTree 时整合未读状态
fun buildChannelTree(
channels: List<ChannelInfo>,
clients: List<ClientInfo>,
expandedIds: Set<Long>,
unreadStates: Map<Long, UnreadState>
): List<ChannelTreeNode> {
// ... 构建树逻辑 ...
return ChannelTreeNode(
channel = channel,
children = children,
clients = channelClients,
isExpanded = expandedIds.contains(channel.id),
unreadCount = unreadStates[channel.id]?.unreadCount ?: 0,
hasMention = unreadStates[channel.id]?.hasMention ?: false
)
}
```
### 5.5 当前频道栏
**目标**:显示当前所在频道信息,点击跳转聊天页。
**任务**
```kotlin
@Composable
fun CurrentChannelBar(
channelViewModel: ChannelViewModel,
onClick: () -> Unit
) {
val currentChannelId by channelViewModel.currentChannelId.collectAsState()
val channels by channelViewModel.channels.collectAsState()
val channelClients by channelViewModel.channelClients.collectAsState()
val currentChannel = channels.find { it.id == currentChannelId }
val clients = channelClients[currentChannelId] ?: emptyList()
if (currentChannel != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 频道图标和名称
Icon(Icons.Default.Chat, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "${currentChannel.name} (${clients.size}人)",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.weight(1f))
// 成员预览(最多显示 3 个名字)
val previewNames = clients.take(3).joinToString(" ") { it.nickname }
Text(
text = previewNames,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
```
### 5.6 底部语音控制区(基础)
**目标**:实现基础语音控制栏,包含静音按钮、PTT 和展开入口。
**任务**
```kotlin
@Composable
fun VoiceControlBar(
voiceViewModel: VoiceViewModel,
onExpand: () -> Unit
) {
val isMuted by voiceViewModel.isMuted.collectAsState()
val isTransmitting by voiceViewModel.isTransmitting.collectAsState()
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
// 静音按钮
IconButton(onClick = { voiceViewModel.toggleMute() }) {
Icon(
imageVector = if (isMuted) Icons.Default.MicOff else Icons.Default.Mic,
contentDescription = if (isMuted) "取消静音" else "静音",
tint = if (isMuted) Color.Red else MaterialTheme.colorScheme.onSurface
)
}
// PTT 按钮
Button(
onClick = {},
modifier = Modifier
.weight(1f)
.height(48.dp)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { voiceViewModel.startTransmit() },
onDragEnd = { voiceViewModel.stopTransmit() },
onDragCancel = { voiceViewModel.stopTransmit() },
onDrag = { _, _ -> }
)
},
colors = ButtonDefaults.buttonColors(
containerColor = if (isTransmitting) Color.Green
else MaterialTheme.colorScheme.primaryContainer
)
) {
Text(if (isTransmitting) "正在发言..." else "PTT 按住发言")
}
// 展开语音卡按钮
IconButton(onClick = onExpand) {
Icon(Icons.Default.ExpandLess, contentDescription = "展开语音卡")
}
}
}
```
### 5.7 ChannelViewModel 实现
**目标**:实现频道列表页的核心状态管理,包含增量同步和补偿同步。
**任务**
1. **ChannelViewModel 定义**
```kotlin
// viewmodel/ChannelViewModel.kt
class ChannelViewModel(
private val repository: ChannelRepository,
private val application: Application
) : ViewModel() {
companion object {
private const val TAG = "ChannelViewModel"
}
// ─── 同步状态 ─────────────────────────────────────────
private val _syncState = MutableStateFlow<SyncState>(SyncState.Unsynced)
val syncState: StateFlow<SyncState> = _syncState
// ─── 频道数据 ─────────────────────────────────────────
val channels: StateFlow<List<ChannelInfo>> = repository.channels
val clients: StateFlow<List<ClientInfo>> = repository.clients
val selfClientId: StateFlow<Int?> = repository.selfClientId
val channelClients: StateFlow<Map<Long, List<ClientInfo>>> = repository.channelClients
// ─── UI 状态 ─────────────────────────────────────────
private val _expandedChannelIds = MutableStateFlow<Set<Long>>(emptySet())
val expandedChannelIds: StateFlow<Set<Long>> = _expandedChannelIds
private val _unreadStates = MutableStateFlow<Map<Long, UnreadState>>(emptyMap())
val unreadStates: StateFlow<Map<Long, UnreadState>> = _unreadStates
// ─── 频道树 ─────────────────────────────────────────
val channelTree: StateFlow<List<ChannelTreeNode>> = combine(
channels, clients, expandedChannelIds, unreadStates
) { chs, cls, expanded, unread ->
buildChannelTree(chs, cls, expanded, unread)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
// ─── 当前频道 ────────────────────────────────────────
val currentChannelId: StateFlow<Long> = repository.selfChannelId
}
```
2. **首次同步方法**
```kotlin
// ChannelViewModel.kt
/**
* 执行首次同步,由 ServerViewModel 在 onConnected 后调用
*/
suspend fun performInitialSync() {
_syncState.value = SyncState.Syncing
try {
// 并行请求三个数据源(TsClient 直接返回 Kotlin 类型)
val channels = TSBridge.getChannelList()
val clients = TSBridge.getClientList()
val selfId = TSBridge.getClientId()
// 原子提交到仓库
repository.updateBaseline(
channels = channels,
clients = clients,
selfClientId = selfId.toInt()
)
// 自动展开有成员的频道
autoExpandChannelsWithClients()
_syncState.value = SyncState.Synchronized
Log.d(TAG, "Initial sync completed: ${channels.size} channels, ${clients.size} clients")
} catch (e: Exception) {
Log.e(TAG, "Initial sync failed", e)
_syncState.value = SyncState.SyncFailed(e)
}
}
/**
* 带重试的首次同步
*/
suspend fun performInitialSyncWithRetry(maxRetries: Int = 3) {
var retryCount = 0
while (retryCount < maxRetries) {
try {
performInitialSync()
if (_syncState.value == SyncState.Synchronized) return
} catch (e: Exception) {
Log.w(TAG, "Sync attempt ${retryCount + 1} failed", e)
}
retryCount++
if (retryCount < maxRetries) {
delay(1000L * retryCount) // 递增延迟
}
}
// 所有重试失败
if (_syncState.value != SyncState.Synchronized) {
_syncState.value = SyncState.SyncFailed(Exception("同步失败,已重试 $maxRetries 次"))
}
}
fun retrySync() {
viewModelScope.launch {
performInitialSyncWithRetry()
}
}
```
3. **增量同步 — OnClientEnter**
```kotlin
// ChannelViewModel.kt
/**
* 处理客户端进入事件(增量同步)
* 对应 08 状态同步 ②
*/
fun handleClientEnter(clientInfo: ClientInfo) {
Log.d(TAG, "Client enter: ${clientInfo.id} (${clientInfo.nickname}) -> channel ${clientInfo.channelId}")
// 按 ID 覆盖,幂等操作
repository.addOrUpdateClient(clientInfo)
}
```
4. **增量同步 — OnClientMoved**
```kotlin
// ChannelViewModel.kt
/**
* 处理客户端移动事件(增量同步 + 补偿同步)
* 对应 08 状态同步 ② + ③
*/
fun handleClientMoved(clientId: Int, targetChannelId: Long) {
Log.d(TAG, "Client moved: $clientId -> channel $targetChannelId")
val existingClient = repository.getClientById(clientId)
if (existingClient != null) {
// 成员存在:更新频道位置
repository.updateClientChannel(clientId, targetChannelId)
// 如果是自己,更新当前频道
if (clientId == repository.selfClientId.value) {
repository.updateSelfChannel(targetChannelId)
clearUnread(targetChannelId)
}
} else {
// 成员不存在:触发补偿同步(08 状态同步 ③)
Log.w(TAG, "Unknown client $clientId, triggering compensation sync")
viewModelScope.launch { compensateClientList() }
}
}
```
5. **增量同步 — OnClientLeave**
```kotlin
// ChannelViewModel.kt
/**
* 处理客户端离开事件(增量同步)
* 对应 08 状态同步 ②
*/
fun handleClientLeave(clientId: Int, reasonMsg: String) {
Log.d(TAG, "Client leave: $clientId, reason: $reasonMsg")
// 幂等删除,重复删除安全
repository.removeClient(clientId)
}
```
6. **补偿同步**
```kotlin
// ChannelViewModel.kt
/**
* 补偿同步:重新获取完整成员列表
* 对应 08 状态同步 ③
*/
private suspend fun compensateClientList() {
try {
val clients = TSBridge.getClientList()
repository.replaceAllClients(clients)
Log.d(TAG, "Compensation sync completed: ${clients.size} clients")
} catch (e: Exception) {
Log.e(TAG, "Compensation sync failed", e)
}
}
/**
* 补偿同步:重新获取完整频道列表
* 当事件引用未知频道时触发
*/
private suspend fun compensateChannelList() {
try {
val channels = TSBridge.getChannelList()
repository.replaceAllChannels(channels)
Log.d(TAG, "Channel compensation sync completed: ${channels.size} channels")
} catch (e: Exception) {
Log.e(TAG, "Channel compensation sync failed", e)
}
}
```
7. **频道展开/折叠**
```kotlin
// ChannelViewModel.kt
fun toggleExpand(channelId: Long) {
_expandedChannelIds.value = _expandedChannelIds.value.toMutableSet().apply {
if (contains(channelId)) remove(channelId) else add(channelId)
}
}
private fun autoExpandChannelsWithClients() {
val clientsByChannel = repository.channelClients.value
val toExpand = clientsByChannel.filter { it.value.isNotEmpty() }.keys
_expandedChannelIds.value = toExpand
}
```
8. **事件处理器注册(供 ServerViewModel 调用)**
```kotlin
// ChannelViewModel.kt
/**
* 注册事件处理器,由 ServerViewModel 在 Connect 前调用
* 事件处理器在 Connect 前必须完成注册,避免早期事件丢失
*/
fun registerEventHandlers(callbacks: TSBridge.Callbacks) {
// 通过 ServerViewModel 的事件回调链式处理
// 具体实现在 ServerViewModel.registerEventHandlers() 中
// ChannelViewModel 的方法被 ServerViewModel 调用
}
```
---
## 三、状态与数据流
### 3.1 数据流向
```
ServerViewModel ChannelViewModel UI
│ │ │
│ OnConnected │ │
├─────────────────────────────→│ performInitialSync() │
│ │ │
│ OnClientEnter(info) │ │
├─────────────────────────────→│ handleClientEnter() │
│ │ │
│ OnClientMoved(id, channelId) │ │
├─────────────────────────────→│ handleClientMoved() │
│ │ │
│ OnClientLeave(id, reason) │ │
├─────────────────────────────→│ handleClientLeave() │
│ │ │
│ OnTextMessage(msg) │ │
├─────────────────────────────→│ onTextMessage() │
│ │ │
│ │ channelTree (StateFlow) │
│ ├─────────────────────────→│
│ │ │
│ │ syncState (StateFlow) │
│ ├─────────────────────────→│
```
### 3.2 同步状态机
```kotlin
sealed class SyncState {
object Unsynced : SyncState() // 已连接但尚无完整数据
object Syncing : SyncState() // 调用 ListChannels 和 ListClients
object Synchronized : SyncState() // 列表基线可供 UI 使用
data class SyncFailed(val error: Throwable) : SyncState() // 同步失败
}
```
状态转换:
- `Unsynced → Syncing`:收到 OnConnected 后开始首次同步
- `Syncing → Synchronized`:三个数据源全部成功
- `Syncing → SyncFailed`:任一数据源失败
- `SyncFailed → Syncing`:用户点击重试
### 3.3 成员实体状态树
对应 `docs/流程/02_浏览频道.md` 成员实体状态树:
```
成员实体状态树
├── 基线分支(首次同步)
│ ├── ListClients 获取完整成员列表
│ ├── 按 ClientInfo.ID 建表去重
│ └── 按 ChannelID 建索引
├── 增量分支(事件驱动)
│ ├── OnClientEnter → set(ID, info)
│ ├── OnClientMoved → 覆盖 ChannelID
│ └── OnClientLeave → delete(ID)
├── 当前用户分支
│ ├── ClientID 本地调用
│ ├── 事件 ClientID 比对
│ └── 更新自身频道事实
└── 修复分支(补偿同步)
├── 检测未知 ClientID → ListClients
└── 检测未知 ChannelID → ListChannels
```
---
## 四、验收标准
### 功能验收
- [ ] **频道树渲染**
- 频道按层级正确缩进显示
- 展开/折叠交互正常
- 折叠时显示频道内人数
- 有密码频道显示 🔒 图标
- [ ] **成员显示**
- 成员按所属频道正确分组
- 自己的昵称高亮显示
- 点击成员弹出操作菜单
- [ ] **未读指示**
- 非当前频道收到消息时显示红点
- 消息包含 @昵称 时显示数字 badge
- 进入频道后清除未读标记
- [ ] **当前频道栏**
- 正确显示当前频道名和人数
- 显示成员预览(最多 3 个名字)
- 点击跳转到聊天页
- [ ] **同步功能**
- 首次同步完成后频道树正确显示
- 同步中显示加载指示器
- 同步失败显示重试按钮
- 新成员进入时增量更新
- 成员离开时增量移除
- 成员移动时增量更新位置
- 未知成员触发补偿同步
### 性能验收
- [ ] 频道树渲染流畅(100 频道、500 用户无卡顿)
- [ ] 增量更新无闪烁(DiffUtil 或 Compose recompose 优化)
- [ ] 首次同步在 3 秒内完成
### 代码质量验收
- [ ] 状态管理清晰,单向数据流
- [ ] 事件处理幂等(重复事件安全处理)
- [ ] 补偿同步有日志记录
- [ ] 无内存泄漏(viewModelScope 正确使用)
### 测试用例
| 场景 | 操作 | 预期结果 |
|------|------|----------|
| 首次加载 | 连接成功后进入频道列表 | 显示加载态 → 频道树正确显示 |
| 展开频道 | 点击折叠的频道 | 显示子频道和成员 |
| 折叠频道 | 点击展开的频道 | 隐藏子频道和成员,显示人数 |
| 新成员进入 | 其他用户加入服务器 | 频道树增量更新,无需刷新 |
| 成员离开 | 其他用户退出 | 频道树增量更新,人数减少 |
| 成员移动 | 其他用户切换频道 | 两个频道的人数同步更新 |
| 未读消息 | 其他频道收到消息 | 频道名旁显示红点 |
| @提及 | 收到包含 @昵称 的消息 | 显示数字 badge |
| 进入频道 | 点击有未读的频道 | 未读标记清除 |
| 网络断开 | 断网后 | 显示断开状态,数据冻结 |
| 补偿同步 | 收到未知成员的移动事件 | 自动重新获取完整列表 |
---
## 五、参考文档
- `docs/UI架构设计.md` - 2.2 频道列表页
- `docs/流程/02_浏览频道.md` - 状态树、时序图
- `docs/流程/08_状态同步.md` - ② 增量同步、③ 补偿同步
- `docs/sdk文档-go.md` - ListChannels、ListClients、事件处理器