# 步骤 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 = _showPasswordDialog // 待切换的目标频道 private val _pendingSwitchChannel = MutableStateFlow(null) val pendingSwitchChannel: StateFlow = _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.Idle) val switchState: StateFlow = _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