# 步骤 06：频道切换

> 实现频道切换流程：频道点击处理、密码弹窗、ClientMove 命令发送、等待 OnClientMoved 服务端确认、自身频道状态更新。
> 对应流程：`docs/流程/03_切换频道.md`
> 依赖步骤：05（频道列表页）

---

## 一、目标

- [ ] 频道点击事件处理（区分有密码/无密码频道）
- [ ] 密码输入弹窗组件
- [ ] ChannelSwitchState 状态机（idle → requesting → waitingServerEvent → idle/failed）
- [ ] ClientMove 命令发送（通过 TSBridge.MoveToChannel）
- [ ] 等待 OnClientMoved 服务端事实确认
- [ ] 自身频道状态更新（④ 自身状态同步）
- [ ] 错误处理与用户反馈

---

## 二、任务清单

### 6.1 频道点击处理

**目标**：在频道列表页中处理频道点击事件，区分有密码和无密码频道。

**前置条件**：
- 步骤 05 的 ChannelListScreen 已实现
- ChannelRow 组件已支持点击事件

**任务**：

1. **频道点击入口**
   ```kotlin
   // ChannelListScreen.kt - ChannelTreeContent 中的 onChannelClick 回调
   @Composable
   fun ChannelTreeContent(
       channelViewModel: ChannelViewModel,
       onChannelClick: (ChannelInfo) -> Unit,
       onClientClick: (ClientInfo) -> Unit,
       onNavigateToChat: () -> Unit
   ) {
       // ... 已有实现 ...
   }
   ```

2. **频道点击逻辑（ChannelViewModel）**
   ```kotlin
   // ChannelViewModel.kt
   
   // 密码弹窗状态
   private val _showPasswordDialog = MutableStateFlow(false)
   val showPasswordDialog: StateFlow<Boolean> = _showPasswordDialog
   
   // 待切换的目标频道
   private val _pendingSwitchChannel = MutableStateFlow<ChannelInfo?>(null)
   val pendingSwitchChannel: StateFlow<ChannelInfo?> = _pendingSwitchChannel
   
   /**
    * 处理频道点击事件
    * 对应 UI架构设计.md 频道树交互规则
    */
   fun onChannelClicked(channel: ChannelInfo) {
       // 如果是当前频道，忽略
       if (channel.id == repository.selfChannelId.value) {
           Log.d(TAG, "Already in channel ${channel.id}, ignoring click")
           return
       }
       
       // 检查是否正在切换中
       if (_switchState.value != ChannelSwitchState.Idle) {
           Log.w(TAG, "Channel switch already in progress, ignoring click")
           return
       }
       
       if (channel.isPassword) {
           // 有密码频道：弹出密码输入框
           _pendingSwitchChannel.value = channel
           _showPasswordDialog.value = true
       } else {
           // 无密码频道：直接发起切换
           viewModelScope.launch {
               performChannelSwitch(channel.id, "")
           }
       }
   }
   ```

### 6.2 密码弹窗组件

**目标**：实现频道密码输入弹窗，支持密码错误重试。

**任务**：

1. **密码弹窗 Composable**
   ```kotlin
   // ui/components/ChannelPasswordDialog.kt
   
   @Composable
   fun ChannelPasswordDialog(
       channelName: String,
       onConfirm: (password: String) -> Unit,
       onDismiss: () -> Unit,
       isError: Boolean = false,
       errorMessage: String = "密码错误，请重试"
   ) {
       var password by remember { mutableStateOf("") }
       var showError by remember { mutableStateOf(isError) }
       
       // 当 isError 变化时更新本地状态
       LaunchedEffect(isError) {
           showError = isError
           if (isError) {
               password = "" // 清空输入框
           }
       }
       
       AlertDialog(
           onDismissRequest = onDismiss,
           title = {
               Text(
                   text = "该频道需要密码",
                   style = MaterialTheme.typography.titleMedium
               )
           },
           text = {
               Column {
                   Text(
                       text = "频道：$channelName",
                       style = MaterialTheme.typography.bodyMedium,
                       color = MaterialTheme.colorScheme.onSurfaceVariant
                   )
                   
                   Spacer(modifier = Modifier.height(16.dp))
                   
                   OutlinedTextField(
                       value = password,
                       onValueChange = { 
                           password = it
                           showError = false
                       },
                       label = { Text("输入频道密码") },
                       singleLine = true,
                       visualTransformation = PasswordVisualTransformation(),
                       keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
                       isError = showError,
                       supportingText = if (showError) {
                           { Text(errorMessage, color = MaterialTheme.colorScheme.error) }
                       } else null,
                       modifier = Modifier.fillMaxWidth()
                   )
               }
           },
           confirmButton = {
               TextButton(
                   onClick = { onConfirm(password) },
                   enabled = password.isNotEmpty()
               ) {
                   Text("进入")
               }
           },
           dismissButton = {
               TextButton(onClick = onDismiss) {
                   Text("取消")
               }
           }
       )
   }
   ```

2. **在 ChannelListScreen 中集成密码弹窗**
   ```kotlin
   // ChannelListScreen.kt
   
   @Composable
   fun ChannelListScreen(
       channelViewModel: ChannelViewModel,
       serverViewModel: ServerViewModel,
       voiceViewModel: VoiceViewModel,
       onNavigateToChat: () -> Unit,
       onNavigateToServerConfig: () -> Unit,
       onOpenServerDetail: () -> Unit,
       onOpenChannelDetail: (channelId: Long) -> Unit,
       onOpenVoiceCard: () -> Unit
   ) {
       val showPasswordDialog by channelViewModel.showPasswordDialog.collectAsState()
       val pendingChannel by channelViewModel.pendingSwitchChannel.collectAsState()
       val switchState by channelViewModel.switchState.collectAsState()
       
       Column(modifier = Modifier.fillMaxSize()) {
           // ... 已有布局 ...
       }
       
       // 密码弹窗
       if (showPasswordDialog && pendingChannel != null) {
           ChannelPasswordDialog(
               channelName = pendingChannel!!.name,
               onConfirm = { password ->
                   channelViewModel.confirmPasswordAndSwitch(password)
               },
               onDismiss = {
                   channelViewModel.dismissPasswordDialog()
               },
               isError = switchState is ChannelSwitchState.Failed,
               errorMessage = (switchState as? ChannelSwitchState.Failed)?.error ?: "密码错误，请重试"
           )
       }
   }
   ```

3. **密码确认与取消逻辑**
   ```kotlin
   // ChannelViewModel.kt
   
   /**
    * 用户确认密码，发起切换
    */
   fun confirmPasswordAndSwitch(password: String) {
       val channel = _pendingSwitchChannel.value ?: return
       _showPasswordDialog.value = false
       
       viewModelScope.launch {
           performChannelSwitch(channel.id, password)
       }
   }
   
   /**
    * 用户取消密码输入
    */
   fun dismissPasswordDialog() {
       _showPasswordDialog.value = false
       _pendingSwitchChannel.value = null
       _switchState.value = ChannelSwitchState.Idle
   }
   ```

### 6.3 ChannelSwitchState 状态机

**目标**：实现频道切换的状态管理，确保命令响应与事件事实分离。

**状态定义**：
```kotlin
// data/Models.kt 或 ChannelViewModel.kt

/**
 * 频道切换状态机
 * 对应 docs/流程/03_切换频道.md 中的状态转换
 * 
 * 状态转换：
 *   Idle → Requesting：用户发起切换请求
 *   Requesting → WaitingServerEvent：ClientMove 命令成功
 *   Requesting → Failed：ClientMove 命令失败
 *   WaitingServerEvent → Idle：收到自己的 OnClientMoved 事件
 *   Failed → Idle：用户重试或取消
 */
sealed class ChannelSwitchState {
    /** 空闲状态，可以发起新的切换 */
    object Idle : ChannelSwitchState()
    
    /** 正在发送 ClientMove 命令 */
    object Requesting : ChannelSwitchState()
    
    /** ClientMove 命令成功，等待服务端 OnClientMoved 事件确认 */
    data class WaitingServerEvent(val targetChannelId: Long) : ChannelSwitchState()
    
    /** 切换失败（命令被拒绝或超时） */
    data class Failed(val error: String) : ChannelSwitchState()
}
```

**状态机实现**：
```kotlin
// ChannelViewModel.kt

// 切换状态
private val _switchState = MutableStateFlow<ChannelSwitchState>(ChannelSwitchState.Idle)
val switchState: StateFlow<ChannelSwitchState> = _switchState

// 等待服务端确认的超时 Job
private var switchTimeoutJob: Job? = null

/**
 * 执行频道切换
 * 对应 docs/流程/03_切换频道.md 时序图
 */
private suspend fun performChannelSwitch(targetChannelId: Long, password: String) {
    Log.d(TAG, "Requesting channel switch to $targetChannelId")
    
    // 状态改为 requesting
    _switchState.value = ChannelSwitchState.Requesting
    
    try {
        // 发送 ClientMove 命令
        // TSBridge.MoveToChannel 返回空串表示成功，否则返回错误信息
        val error = TSBridge.moveSelfToChannel(targetChannelId.toString(), password)
        
        if (error.isEmpty()) {
            // 命令成功，等待服务端事件确认
            _switchState.value = ChannelSwitchState.WaitingServerEvent(targetChannelId)
            
            // 启动超时检测（10秒）
            switchTimeoutJob?.cancel()
            switchTimeoutJob = viewModelScope.launch {
                delay(10_000)
                // 超时：如果还在等待状态，视为失败
                if (_switchState.value is ChannelSwitchState.WaitingServerEvent) {
                    Log.w(TAG, "Channel switch timeout waiting for server event")
                    _switchState.value = ChannelSwitchState.Failed("等待服务端确认超时")
                }
            }
            
            Log.d(TAG, "ClientMove command accepted, waiting for server event")
        } else {
            // 命令被拒绝
            Log.w(TAG, "ClientMove command rejected: $error")
            _switchState.value = ChannelSwitchState.Failed(mapMoveError(error))
        }
    } catch (e: Exception) {
        Log.e(TAG, "ClientMove command failed", e)
        _switchState.value = ChannelSwitchState.Failed("切换失败：${e.message}")
    }
}

/**
 * 映射 MoveToChannel 错误信息为用户友好的提示
 */
private fun mapMoveError(error: String): String {
    return when {
        error.contains("password", ignoreCase = true) -> "密码错误"
        error.contains("permission", ignoreCase = true) -> "权限不足"
        error.contains("full", ignoreCase = true) -> "频道已满"
        error.contains("banned", ignoreCase = true) -> "你已被该频道封禁"
        else -> "切换失败：$error"
    }
}
```

### 6.4 自身状态同步（④）

**目标**：处理自己的 OnClientMoved 事件，确认频道切换完成。

**关键原则**：
- 命令响应（ClientMove nil）不等于状态已提交
- 必须等待服务端推送的 OnClientMoved 事件才能更新本地频道事实
- 通过比对 ClientID == selfID 识别自己的移动事件

**任务**：

1. **处理 OnClientMoved 事件（区分自己和他人）**
   ```kotlin
   // ChannelViewModel.kt
   
   /**
    * 处理客户端移动事件
    * 对应 docs/流程/08_状态同步.md ② 增量同步 + ④ 自身状态同步
    * 
    * @param clientId 移动的客户端 ID
    * @param targetChannelId 目标频道 ID（字符串形式）
    */
   fun handleClientMoved(clientId: Int, targetChannelId: Long) {
       Log.d(TAG, "Client moved: $clientId -> channel $targetChannelId")
       
       val selfId = repository.selfClientId.value
       
       if (clientId == selfId) {
           // ④ 自身状态同步：这是自己的移动事件
           handleSelfMoved(targetChannelId)
       } else {
           // ② 增量同步：这是其他用户的移动事件
           handleOtherClientMoved(clientId, targetChannelId)
       }
   }
   
   /**
    * 处理自己的移动事件
    * 对应 docs/流程/03_切换频道.md 中等待 OnClientMoved 确认的分支
    */
   private fun handleSelfMoved(targetChannelId: Long) {
       val currentState = _switchState.value
       
       // 更新自身频道事实
       repository.updateSelfChannel(targetChannelId)
       
       // 清除目标频道的未读标记
       clearUnread(targetChannelId)
       
       when (currentState) {
           is ChannelSwitchState.WaitingServerEvent -> {
               // 正常流程：确认切换完成
               Log.d(TAG, "Channel switch confirmed by server: target=$targetChannelId")
               switchTimeoutJob?.cancel()
               _switchState.value = ChannelSwitchState.Idle
               _pendingSwitchChannel.value = null
           }
           is ChannelSwitchState.Requesting -> {
               // 罕见情况：事件先于命令响应到达
               Log.d(TAG, "Server event arrived before command response")
               switchTimeoutJob?.cancel()
               _switchState.value = ChannelSwitchState.Idle
               _pendingSwitchChannel.value = null
           }
           else -> {
               // 非切换流程中的移动（例如被管理员移动）
               Log.d(TAG, "Self moved by external action to channel $targetChannelId")
               _switchState.value = ChannelSwitchState.Idle
           }
       }
   }
   
   /**
    * 处理其他用户的移动事件（增量同步）
    */
   private fun handleOtherClientMoved(clientId: Int, targetChannelId: Long) {
       val existingClient = repository.getClientById(clientId)
       
       if (existingClient != null) {
           // 成员存在：更新频道位置
           repository.updateClientChannel(clientId, targetChannelId)
       } else {
           // 成员不存在：触发补偿同步
           Log.w(TAG, "Unknown client $clientId, triggering compensation sync")
           viewModelScope.launch { compensateClientList() }
       }
   }
   ```

2. **切换超时处理**
   ```kotlin
   // ChannelViewModel.kt
   
   /**
    * 重试频道切换（失败后）
    */
   fun retryChannelSwitch() {
       val channel = _pendingSwitchChannel.value ?: return
       _switchState.value = ChannelSwitchState.Idle
       
       viewModelScope.launch {
           performChannelSwitch(channel.id, "")
       }
   }
   
   /**
    * 取消频道切换
    */
   fun cancelChannelSwitch() {
       switchTimeoutJob?.cancel()
       _switchState.value = ChannelSwitchState.Idle
       _pendingSwitchChannel.value = null
       _showPasswordDialog.value = false
   }
   ```

### 6.5 切换状态 UI 反馈

**目标**：在频道列表页显示切换状态，提供用户反馈。

**任务**：

1. **切换中指示器**
   ```kotlin
   // ui/components/SwitchingIndicator.kt
   
   @Composable
   fun ChannelSwitchingIndicator(
       state: ChannelSwitchState,
       onRetry: () -> Unit,
       onCancel: () -> Unit
   ) {
       when (state) {
           is ChannelSwitchState.Requesting -> {
               LinearProgressIndicator(
                   modifier = Modifier.fillMaxWidth()
               )
           }
           is ChannelSwitchState.WaitingServerEvent -> {
               LinearProgressIndicator(
                   modifier = Modifier.fillMaxWidth()
               )
           }
           is ChannelSwitchState.Failed -> {
               Snackbar(
                   action = {
                       TextButton(onClick = onRetry) {
                           Text("重试")
                       }
                       TextButton(onClick = onCancel) {
                           Text("取消")
                       }
                   }
               ) {
                   Text(state.error)
               }
           }
           else -> { /* Idle: 不显示任何指示 */ }
       }
   }
   ```

2. **在 ChannelListScreen 中集成**
   ```kotlin
   // ChannelListScreen.kt
   
   @Composable
   fun ChannelListScreen(
       // ... 参数 ...
   ) {
       val switchState by channelViewModel.switchState.collectAsState()
       
       Column(modifier = Modifier.fillMaxSize()) {
           // 头部
           ChannelListHeader(/* ... */)
           
           // 切换状态指示器
           if (switchState != ChannelSwitchState.Idle) {
               ChannelSwitchingIndicator(
                   state = switchState,
                   onRetry = { channelViewModel.retryChannelSwitch() },
                   onCancel = { channelViewModel.cancelChannelSwitch() }
               )
           }
           
           // 中部：频道树
           Box(modifier = Modifier.weight(1f)) {
               // ... 已有实现 ...
           }
           
           // ... 其余布局 ...
       }
   }
   ```

### 6.6 事件处理器注册

**目标**：确保 ChannelViewModel 的事件处理方法被 ServerViewModel 正确调用。

**任务**：

```kotlin
// ServerViewModel.kt - 在 registerEventHandlers 中添加

fun registerEventHandlers() {
    TSBridge.setCallbacks(object : TSBridge.Callbacks {
        // ... 已有回调 ...
        
        override fun onClientMoved(id: Int, targetChannelID: String) {
            val targetId = targetChannelID.toLongOrNull() ?: return
            channelViewModel.handleClientMoved(id, targetId)
        }
        
        // ... 其他回调 ...
    })
}
```

---

## 三、状态与数据流

### 3.1 频道切换状态机

```
                    ┌──────────────────────────────────────┐
                    │                                      │
                    ▼                                      │
               ┌─────────┐                                │
               │  Idle    │◄───────────────────────────────┤
               └────┬────┘                                │
                    │ 用户点击频道                          │
                    ▼                                      │
               ┌─────────────┐                            │
               │  Requesting │                            │
               └──────┬──────┘                            │
                      │                                   │
          ┌───────────┴───────────┐                       │
          │                       │                       │
          ▼                       ▼                       │
    ┌───────────┐          ┌──────────┐                   │
    │  Failed   │          │ Waiting  │                   │
    │           │          │ Server   │                   │
    └─────┬─────┘          │ Event    │                   │
          │                └────┬─────┘                   │
          │                     │                         │
          │   ┌─────────────────┤                         │
          │   │                 │                         │
          │   ▼                 ▼                         │
          │  超时          OnClientMoved                  │
          │   │           (selfID match)                  │
          │   │                 │                         │
          └───┴─────────────────┴─────────────────────────┘
```

### 3.2 数据流向

```
用户操作           ChannelViewModel              TSBridge/Go          服务端
    │                    │                           │                  │
    │ 点击频道           │                           │                  │
    ├───────────────────→│                           │                  │
    │                    │                           │                  │
    │                    │ 有密码？                   │                  │
    │                    ├─→ 显示密码弹窗             │                  │
    │ 输入密码           │                           │                  │
    ├───────────────────→│                           │                  │
    │                    │                           │                  │
    │                    │ MoveToChannel(id, pwd)    │                  │
    │                    ├──────────────────────────→│  clientmove      │
    │                    │                           ├─────────────────→│
    │                    │                           │                  │
    │                    │                           │  命令响应         │
    │                    │                           │←─────────────────┤
    │                    │  error == "" ?             │                  │
    │                    │←──────────────────────────┤                  │
    │                    │                           │                  │
    │                    │ 状态 → WaitingServerEvent  │                  │
    │                    │                           │                  │
    │                    │                           │  notifyclientmoved│
    │                    │                           │←─────────────────┤
    │                    │  OnClientMoved(selfID)    │                  │
    │                    │←──────────────────────────┤                  │
    │                    │                           │                  │
    │                    │ 更新自身频道               │                  │
    │                    │ 状态 → Idle               │                  │
    │                    │                           │                  │
```

### 3.3 命令响应与事件事实的区分

**关键原则**（对应 `docs/流程/03_切换频道.md`）：

| 概念 | 含义 | 处理方式 |
|------|------|----------|
| ClientMove 返回 error | 命令被服务器拒绝 | 立即显示错误，状态 → Failed |
| ClientMove 返回 nil | 命令被服务器接受 | 状态 → WaitingServerEvent，继续等待 |
| OnClientMoved(selfID) | 服务器确认移动完成 | 更新本地频道事实，状态 → Idle |

**为什么不能用命令响应直接更新频道？**

- 命令响应只表示服务器接受了请求
- 实际移动可能因权限、密码、容量等原因被延迟拒绝
- 只有服务端推送的 `notifyclientmoved` 事件才是最终事实

---

## 四、验收标准

### 功能验收

- [ ] **无密码频道切换**
  - 点击无密码频道 → 直接发送 ClientMove
  - 显示切换中进度指示
  - 收到 OnClientMoved 后切换完成
  - 当前频道栏更新为目标频道

- [ ] **有密码频道切换**
  - 点击有密码频道 → 弹出密码输入框
  - 输入密码后发送 ClientMove
  - 密码错误 → 显示错误提示，清空输入框，允许重试
  - 点击取消 → 关闭弹窗，不发送命令

- [ ] **切换状态管理**
  - 切换中禁止发起新的切换
  - 切换超时（10秒）显示失败提示
  - 失败后可重试或取消
  - 被管理员移动时正确更新状态

- [ ] **自身状态同步**
  - 只有匹配 selfID 的 OnClientMoved 才更新自身频道
  - 命令响应不直接提交频道事实
  - 切换完成后清除目标频道的未读标记

### 错误处理验收

| 错误场景 | 预期行为 |
|----------|----------|
| 密码错误 | 弹窗显示错误，清空输入框 |
| 频道已满 | Snackbar 提示"频道已满" |
| 权限不足 | Snackbar 提示"权限不足" |
| 网络超时 | 10秒后显示超时提示，可重试 |
| 被管理员移动 | 静默更新当前频道 |

### 性能验收

- [ ] 切换响应时间 < 100ms（UI 反馈）
- [ ] 服务端确认时间 < 3s（正常网络）
- [ ] 密码弹窗弹出/关闭动画流畅

### 测试用例

| 场景 | 操作 | 预期结果 |
|------|------|----------|
| 无密码切换 | 点击无密码频道 | 进度条 → 切换完成 → 当前频道更新 |
| 有密码切换 | 点击有密码频道 → 输入密码 → 点击进入 | 密码弹窗 → 进度条 → 切换完成 |
| 密码错误 | 输入错误密码 | 弹窗显示错误，清空输入框 |
| 取消密码 | 点击取消 | 弹窗关闭，无网络请求 |
| 切换超时 | 断网后切换 | 10秒后显示超时提示 |
| 重试切换 | 失败后点击重试 | 重新发送 ClientMove |
| 被管理员移动 | 管理员移动你到其他频道 | 当前频道静默更新 |
| 重复点击 | 快速点击多个频道 | 只处理第一次点击 |
| 切换中点击 | 切换进行中点击其他频道 | 忽略点击 |

---

## 五、参考文档

- `docs/流程/03_切换频道.md` - 时序图、状态机、事件依赖
- `docs/流程/08_状态同步.md` - ④ 自身状态同步
- `docs/UI架构设计.md` - 2.2 频道列表页交互、4.2 密码弹窗
- `docs/sdk文档-go.md` - ClientMove API、OnClientMoved 事件
- `docs/implementation/02_Bridge层实现.md` - MoveToChannel、onClientMoved 回调
- `docs/implementation/05_频道列表页.md` - ChannelViewModel、ChannelListScreen
