# 步骤 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、事件处理器
