# 步骤 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, val clients: List, val isExpanded: Boolean = false, val unreadCount: Int = 0, val hasMention: Boolean = false ) ``` 2. **频道树构建逻辑** ```kotlin // ChannelViewModel.kt fun buildChannelTree( channels: List, clients: List, expandedIds: Set ): List { // 按 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>> = 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>(emptyMap()) val unreadStates: StateFlow> = _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, clients: List, expandedIds: Set, unreadStates: Map ): List { // ... 构建树逻辑 ... 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.Unsynced) val syncState: StateFlow = _syncState // ─── 频道数据 ───────────────────────────────────────── val channels: StateFlow> = repository.channels val clients: StateFlow> = repository.clients val selfClientId: StateFlow = repository.selfClientId val channelClients: StateFlow>> = repository.channelClients // ─── UI 状态 ───────────────────────────────────────── private val _expandedChannelIds = MutableStateFlow>(emptySet()) val expandedChannelIds: StateFlow> = _expandedChannelIds private val _unreadStates = MutableStateFlow>(emptyMap()) val unreadStates: StateFlow> = _unreadStates // ─── 频道树 ───────────────────────────────────────── val channelTree: StateFlow> = combine( channels, clients, expandedChannelIds, unreadStates ) { chs, cls, expanded, unread -> buildChannelTree(chs, cls, expanded, unread) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) // ─── 当前频道 ──────────────────────────────────────── val currentChannelId: StateFlow = 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、事件处理器