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

26 KiB
Raw Permalink Blame History

步骤 06:频道切换

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


一、目标

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

二、任务清单

6.1 频道点击处理

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

前置条件

  • 步骤 05 的 ChannelListScreen 已实现
  • ChannelRow 组件已支持点击事件

任务

  1. 频道点击入口

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

    // 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

    // 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 中集成密码弹窗

    // 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. 密码确认与取消逻辑

    // 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 状态机

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

状态定义

// data/Models.kt 或 ChannelViewModel.kt

/**
 * 频道切换状态机
 * 对应 docs/流程/03_切换频道.md 中的状态转换
 * 
 * 状态转换:
 *   Idle → Requesting:用户发起切换请求
 *   Requesting → WaitingServerEventClientMove 命令成功
 *   Requesting → FailedClientMove 命令失败
 *   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()
}

状态机实现

// 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 事件(区分自己和他人)

    // 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. 切换超时处理

    // 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. 切换中指示器

    // 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 中集成

    // 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 正确调用。

任务

// 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秒后显示超时提示,可重试
被管理员移动 静默更新当前频道

性能验收

  • 切换响应时间 < 100msUI 反馈)
  • 服务端确认时间 < 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