# 步骤 09：断开连接

> 实现断开连接流程：主动断开、被动断开、被踢处理、会话清理。
> 对应流程：`docs/流程/07_断开连接.md`
> 依赖步骤：08（语音通信）

---

## 一、目标

- [ ] 主动断开流程（Disconnect）— 服务器详情卡中触发
- [ ] 被动断开处理（OnDisconnected）— 网络异常或服务端断开
- [ ] 被踢处理（OnKicked）— 自己被频道踢或服务器踢
- [ ] 会话状态清理 — 停止语音、清除频道/成员/Pending
- [ ] 断线重连机制 — 自动重连 + 手动重连 + 放弃
- [ ] 返回主页逻辑 — 清理后导航回服务器配置页

---

## 二、任务清单

### 9.1 主动断开

**目标**：实现用户主动断开连接的完整流程，从 UI 触发到会话清理。

**对应流程**：`docs/流程/07_断开连接.md` 时序图 — 用户主动断开分支

**对应 UI 设计**：`docs/UI架构设计.md` 3.1 服务器详情卡 — 断开服务器按钮

**对应 SDK**：`docs/sdk文档-go.md` — `Disconnect() error`

**断开时序**：

```
用户点击 "断开服务器"
  → 弹出确认对话框 "确定要断开连接吗？"
  → 确认
  → ServerViewModel.disconnect()
    → 状态改为 Disconnecting（阻止新命令/语音/移动）
    → TSBridge.disconnect()
      → Go SDK Disconnect()
        → 发送 shutdown reason 到服务器
        → 服务器关闭会话
      → Go SDK 触发 OnDisconnected(nil)
    → 清理会话状态
    → 导航回服务器配置页
```

**任务**：

1. **断开确认对话框**

   在服务器详情卡中，点击"断开服务器"按钮时弹出确认对话框：

   ```kotlin
   // ui/components/DisconnectConfirmDialog.kt

   @Composable
   fun DisconnectConfirmDialog(
       onConfirm: () -> Unit,
       onDismiss: () -> Unit
   ) {
       AlertDialog(
           onDismissRequest = onDismiss,
           title = { Text("断开连接") },
           text = { Text("确定要断开与服务器的连接吗？") },
           confirmButton = {
               TextButton(onClick = onConfirm) {
                   Text("断开", color = MaterialTheme.colorScheme.error)
               }
           },
           dismissButton = {
               TextButton(onClick = onDismiss) {
                   Text("取消")
               }
           }
       )
   }
   ```

2. **ServerViewModel 主动断开方法**

   ```kotlin
   // viewmodel/ServerViewModel.kt

   /**
    * 主动断开连接（用户触发）
    *
    * 对应 docs/流程/07_断开连接.md：
    * "用户明确要求离开服务器"
    * "立即阻止新聊天、移动和语音请求"
    */
   fun disconnect() {
       Log.i(TAG, "User requested disconnect")

       // 1. 设置断开中状态，阻止新操作
       _connectionState.value = ConnectionState.Disconnecting

       // 2. 停止语音活动
       voiceViewModel.stopVoice()

       // 3. 调用 SDK 断开
       try {
           TSBridge.disconnect()
       } catch (e: Exception) {
           Log.e(TAG, "Disconnect error", e)
       }

       // 4. 清理会话状态
       clearSession()

       // 5. 更新连接状态
       _connectionState.value = ConnectionState.Disconnected

       // 6. 导航回主页（由 UI 层观察 connectionState 变化后执行）
   }
   ```

3. **服务器详情卡集成**

   ```kotlin
   // ui/components/ServerDetailCard.kt

   @Composable
   fun ServerDetailCard(
       serverViewModel: ServerViewModel,
       onDismiss: () -> Unit
   ) {
       val serverInfo by serverViewModel.serverInfo.collectAsState()
       var showDisconnectDialog by remember { mutableStateOf(false) }

       Column(
           modifier = Modifier
               .fillMaxWidth()
               .padding(16.dp)
       ) {
           // 标题栏
           Row(
               modifier = Modifier.fillMaxWidth(),
               horizontalArrangement = Arrangement.SpaceBetween
           ) {
               Text("服务器详情", style = MaterialTheme.typography.titleMedium)
               IconButton(onClick = onDismiss) {
                   Icon(Icons.Default.Close, contentDescription = "关闭")
               }
           }

           Spacer(Modifier.height(16.dp))

           // 服务器信息
           InfoRow("服务器名", serverInfo.name)
           InfoRow("服务器地址", serverInfo.address)
           InfoRow("在线人数", "${serverInfo.clientsOnline} / ${serverInfo.maxClients}")

           Spacer(Modifier.height(24.dp))

           // 断开按钮
           Button(
               onClick = { showDisconnectDialog = true },
               colors = ButtonDefaults.buttonColors(
                   containerColor = MaterialTheme.colorScheme.error
               ),
               modifier = Modifier.fillMaxWidth()
           ) {
               Text("断开服务器")
           }
       }

       // 确认对话框
       if (showDisconnectDialog) {
           DisconnectConfirmDialog(
               onConfirm = {
                   showDisconnectDialog = false
                   serverViewModel.disconnect()
                   onDismiss()  // 关闭卡片
               },
               onDismiss = { showDisconnectDialog = false }
           )
       }
   }
   ```

### 9.2 被动断开处理

**目标**：处理网络异常或服务端主动断开的情况，显示重连 UI 并支持自动重连。

**对应流程**：`docs/流程/07_断开连接.md` 时序图 — 网络或服务器异常分支

**对应 UI 设计**：`docs/UI架构设计.md` 5.4 断线重连

**对应 SDK**：`docs/sdk文档-go.md` — `OnDisconnected(fn func(error))`

**TSBridge 回调**：`TSBridge.Callbacks.onDisconnected(message: String)`

**被动断开时序**：

```
网络异常 / 服务端关闭
  → Go SDK 触发 OnDisconnected(error)
  → TSBridge.onDisconnected(message)
  → ServerViewModel.handleDisconnected(message)
    → 停止语音活动
    → 设置连接状态为 DisconnectedWithError
    → 显示重连横幅
    → 启动自动重连计时器
```

**重连策略**：

| 尝试次数 | 间隔 | 说明 |
|----------|------|------|
| 第 1 次 | 2 秒 | 快速重试 |
| 第 2 次 | 4 秒 | 递增 |
| 第 3 次 | 8 秒 | 递增 |
| 第 4 次 | 16 秒 | 递增 |
| 第 5 次 | 30 秒 | 最后一次 |
| 超过 5 次 | 停止 | 显示放弃提示 |

**任务**：

1. **连接状态密封类**

   ```kotlin
   // data/Models.kt

   /**
    * 连接状态机
    *
    * 对应 docs/流程/07_断开连接.md 状态树：
    *   Disconnecting — 正在主动或被动结束会话
    *   Terminated — 当前会话资源已清理
    *
    * 状态转换：
    *   Idle → Connecting：用户点击连接
    *   Connecting → Connected：握手成功
    *   Connected → Disconnecting：主动断开 / 网络异常 / 被踢
    *   Connected → Reconnecting：网络异常（自动重连）
    *   Disconnecting → Disconnected：断开完成
    *   Reconnecting → Connected：重连成功
    *   Reconnecting → Disconnected：重连失败 / 用户放弃
    *   Disconnected → Idle：返回主页
    */
   sealed class ConnectionState {
       /** 空闲 — 未连接 */
       object Idle : ConnectionState()

       /** 连接中 — 正在握手 */
       object Connecting : ConnectionState()

       /** 已连接 — 正常会话中 */
       object Connected : ConnectionState()

       /** 断开中 — 正在主动断开 */
       object Disconnecting : ConnectionState()

       /** 重连中 — 网络异常后自动重连 */
       data class Reconnecting(
           val attempt: Int,
           val maxAttempts: Int = 5,
           val reason: String = ""
       ) : ConnectionState()

       /** 已断开 — 会话结束 */
       data class Disconnected(
           val reason: String = "",
           val wasKicked: Boolean = false
       ) : ConnectionState()
   }
   ```

2. **ServerViewModel 被动断开处理**

   ```kotlin
   // viewmodel/ServerViewModel.kt

   companion object {
       private const val TAG = "ServerViewModel"
       private const val MAX_RECONNECT_ATTEMPTS = 5
       private val RECONNECT_DELAYS = longArrayOf(2000, 4000, 8000, 16000, 30000)
   }

   // ── 重连状态 ──
   private var reconnectAttempt = 0
   private var reconnectJob: Job? = null
   private var lastConnectParams: ConnectParams? = null

   /**
    * 处理被动断开（由 TSBridge 回调触发）
    *
    * 对应 docs/流程/07_断开连接.md：
    * "OnDisconnected(error) — 携带异常断开原因"
    * "停止全部依赖连接的操作"
    */
   fun handleDisconnected(message: String) {
       Log.w(TAG, "handleDisconnected: $message")

       // 如果是主动断开，不触发重连
       if (_connectionState.value is ConnectionState.Disconnecting) {
           return
       }

       // 1. 停止语音
       voiceViewModel.onDisconnected()

       // 2. 保存断开原因
       _lastDisconnectReason.value = message

       // 3. 启动自动重连
       if (lastConnectParams != null) {
           startReconnect(message)
       } else {
           // 无连接参数，直接标记断开
           _connectionState.value = ConnectionState.Disconnected(
               reason = message, wasKicked = false
           )
       }
   }

   /**
    * 启动自动重连
    *
    * 对应 docs/UI架构设计.md 5.4：
    * "自动重连：最多尝试 5 次，间隔递增（2s → 4s → 8s → 16s → 30s）"
    */
   private fun startReconnect(reason: String) {
       reconnectAttempt = 0
       _connectionState.value = ConnectionState.Reconnecting(
           attempt = 0, reason = reason
       )

       reconnectJob = viewModelScope.launch {
           while (reconnectAttempt < MAX_RECONNECT_ATTEMPTS &&
               _connectionState.value is ConnectionState.Reconnecting
           ) {
               val delayMs = RECONNECT_DELAYS[reconnectAttempt]
               Log.i(TAG, "Reconnect attempt ${reconnectAttempt + 1}/$MAX_RECONNECT_ATTEMPTS in ${delayMs}ms")
               delay(delayMs)

               reconnectAttempt++
               _connectionState.value = ConnectionState.Reconnecting(
                   attempt = reconnectAttempt, reason = reason
               )

               val params = lastConnectParams ?: break
               val error = TSBridge.connect(
                   host = params.host,
                   nickname = params.nickname,
                   password = params.password,
                   defaultChannel = params.defaultChannel,
                   defaultChannelPassword = params.defaultChannelPassword,
                   callbacks = createCallbacks()
               )

               if (error.isEmpty()) {
                   Log.i(TAG, "Reconnect succeeded on attempt $reconnectAttempt")
                   // 重连成功由 onConnected 回调处理
                   return@launch
               }

               Log.w(TAG, "Reconnect attempt $reconnectAttempt failed: $error")
           }

           // 重连失败
           Log.w(TAG, "All reconnect attempts failed")
           _connectionState.value = ConnectionState.Disconnected(
               reason = reason, wasKicked = false
           )
       }
   }

   /**
    * 手动重连（用户点击"手动重连"按钮）
    *
    * 对应 docs/UI架构设计.md 5.4：
    * "手动重连：用户主动触发立即重连"
    */
   fun manualReconnect() {
       reconnectJob?.cancel()
       reconnectAttempt = 0

       val params = lastConnectParams ?: return
       _connectionState.value = ConnectionState.Connecting

       viewModelScope.launch {
           val error = TSBridge.connect(
               host = params.host,
               nickname = params.nickname,
               password = params.password,
               defaultChannel = params.defaultChannel,
               defaultChannelPassword = params.defaultChannelPassword,
               callbacks = createCallbacks()
           )

           if (error.isNotEmpty()) {
               _connectionState.value = ConnectionState.Disconnected(
                   reason = error, wasKicked = false
               )
           }
           // 成功由 onConnected 回调处理
       }
   }

   /**
    * 放弃重连（用户点击"放弃"按钮）
    *
    * 对应 docs/UI架构设计.md 5.4：
    * "放弃：停止重连 → 显示完整断线提示 → 返回主页"
    */
   fun abandonReconnect() {
       reconnectJob?.cancel()
       reconnectJob = null
       reconnectAttempt = 0

       clearSession()
       _connectionState.value = ConnectionState.Disconnected(
           reason = _lastDisconnectReason.value, wasKicked = false
       )
   }
   ```

3. **重连横幅 UI**

   ```kotlin
   // ui/components/ReconnectBanner.kt

   @Composable
   fun ReconnectBanner(
       reconnectState: ConnectionState.Reconnecting,
       onManualReconnect: () -> Unit,
       onAbandon: () -> Unit
   ) {
       Surface(
           modifier = Modifier.fillMaxWidth(),
           color = MaterialTheme.colorScheme.errorContainer,
           tonalElevation = 4.dp
       ) {
           Column(
               modifier = Modifier.padding(12.dp)
           ) {
               Row(
                   verticalAlignment = Alignment.CenterVertically
               ) {
                   Icon(
                       imageVector = Icons.Default.Warning,
                       contentDescription = null,
                       tint = MaterialTheme.colorScheme.error,
                       modifier = Modifier.size(20.dp)
                   )
                   Spacer(Modifier.width(8.dp))
                   Text(
                       text = "连接已断开",
                       style = MaterialTheme.typography.labelLarge,
                       color = MaterialTheme.colorScheme.onErrorContainer
                   )
               }

               Spacer(Modifier.height(4.dp))

               Text(
                   text = "正在尝试重连... (${reconnectState.attempt}/${reconnectState.maxAttempts})",
                   style = MaterialTheme.typography.bodySmall,
                   color = MaterialTheme.colorScheme.onErrorContainer
               )

               if (reconnectState.reason.isNotEmpty()) {
                   Text(
                       text = reconnectState.reason,
                       style = MaterialTheme.typography.bodySmall,
                       color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f)
                   )
               }

               Spacer(Modifier.height(8.dp))

               Row(
                   modifier = Modifier.fillMaxWidth(),
                   horizontalArrangement = Arrangement.End
               ) {
                   TextButton(onClick = onManualReconnect) {
                       Text("手动重连")
                   }
                   TextButton(onClick = onAbandon) {
                       Text("放弃", color = MaterialTheme.colorScheme.error)
                   }
               }
           }
       }
   }
   ```

### 9.3 被踢处理

**目标**：处理自己被踢出频道或服务器的情况，显示全屏提示并清理会话。

**对应流程**：`docs/流程/07_断开连接.md` 时序图 — 自己被踢分支

**对应 UI 设计**：`docs/UI架构设计.md` 5.3 被踢处理

**对应 SDK**：`docs/sdk文档-go.md` — `OnKicked(fn func(string))`，`ClientLeftViewEvent.ReasonID`

**TSBridge 回调**：`TSBridge.Callbacks.onKicked(reason: String)`

**被踢时序**：

```
服务器发送 notifyclientleftview (reasonid=4 或 5)
  → Go SDK 识别为自己被踢
  → Go SDK 触发 OnKicked(reason)
  → TSBridge.onKicked(reason)
  → ServerViewModel.handleKicked(reason)
    → 立即停止语音
    → 清理会话状态
    → 设置连接状态为 Disconnected(wasKicked=true)
    → 显示被踢全屏提示
```

**ReasonID 说明**：

| ReasonID | 含义 | 说明 |
|----------|------|------|
| 4 | 频道踢出 | 被踢出当前频道，但仍在服务器上 |
| 5 | 服务器踢出 | 被踢出整个服务器 |

> 注意：Go SDK 的 `OnKicked` 已从 `notifyclientleftview` 中识别出自己被踢，上层无需再次判断。

**任务**：

1. **ServerViewModel 被踢处理**

   ```kotlin
   // viewmodel/ServerViewModel.kt

   /**
    * 处理被踢事件（由 TSBridge 回调触发）
    *
    * 对应 docs/流程/07_断开连接.md：
    * "OnKicked(reason) — SDK 将自己被踢转换为独立事件"
    * "必须与普通成员离开区分"
    *
    * 对应 docs/UI架构设计.md 5.3：
    * "全屏覆盖提示，不可通过点击外部关闭"
    */
   fun handleKicked(reason: String) {
       Log.w(TAG, "handleKicked: $reason")

       // 1. 立即停止语音（断开或被踢必须立即停止）
       voiceViewModel.stopVoice()

       // 2. 停止重连（如果正在进行）
       reconnectJob?.cancel()
       reconnectJob = null

       // 3. 清理会话状态
       clearSession()

       // 4. 设置被踢状态
       _connectionState.value = ConnectionState.Disconnected(
           reason = reason, wasKicked = true
       )

       // 5. 记录被踢原因供 UI 显示
       _kickReason.value = reason

       Log.i(TAG, "Kicked from server, session cleared")
   }
   ```

2. **被踢全屏提示 UI**

   ```kotlin
   // ui/screens/KickedScreen.kt

   @Composable
   fun KickedScreen(
       reason: String,
       onReconnect: () -> Unit,
       onBackToHome: () -> Unit
   ) {
       // 全屏覆盖，不可通过点击外部关闭
       Box(
           modifier = Modifier
               .fillMaxSize()
               .background(MaterialTheme.colorScheme.surface)
               .systemBarsPadding(),
           contentAlignment = Alignment.Center
       ) {
           Column(
               modifier = Modifier.padding(32.dp),
               horizontalAlignment = Alignment.CenterHorizontally
           ) {
               // 图标
               Icon(
                   imageVector = Icons.Default.PersonOff,
                   contentDescription = null,
                   modifier = Modifier.size(64.dp),
                   tint = MaterialTheme.colorScheme.error
               )

               Spacer(Modifier.height(24.dp))

               // 标题
               Text(
                   text = "你已被踢出",
                   style = MaterialTheme.typography.headlineMedium,
                   color = MaterialTheme.colorScheme.onSurface
               )

               Spacer(Modifier.height(12.dp))

               // 原因
               if (reason.isNotEmpty()) {
                   Text(
                       text = "原因：$reason",
                       style = MaterialTheme.typography.bodyLarge,
                       color = MaterialTheme.colorScheme.onSurfaceVariant
                   )
               } else {
                   Text(
                       text = "你已被服务器管理员踢出",
                       style = MaterialTheme.typography.bodyLarge,
                       color = MaterialTheme.colorScheme.onSurfaceVariant
                   )
               }

               Spacer(Modifier.height(32.dp))

               // 重新连接按钮
               Button(
                   onClick = onReconnect,
                   modifier = Modifier.fillMaxWidth()
               ) {
                   Text("重新连接")
               }

               Spacer(Modifier.height(12.dp))

               // 返回主页按钮
               OutlinedButton(
                   onClick = onBackToHome,
                   modifier = Modifier.fillMaxWidth()
               ) {
                   Text("返回主页")
               }
           }
       }
   }
   ```

### 9.4 会话清理逻辑

**目标**：统一清理当前会话的所有临时状态，防止旧数据污染下次连接。

**对应流程**：`docs/流程/07_断开连接.md` 状态树 — Terminated 分支：
- 清理命令请求（取消未完成的业务操作）
- 清理语音与传输（停止语音和文件连接）
- 清理会话状态（移除频道成员等临时事实）

**关键原则**：
- 断开时统一清理当前会话资源
- 防止旧成员、频道和 Pending 污染下一次连接
- 清理顺序：先停止活跃操作 → 再清除数据

**任务**：

1. **Repository 会话清理**

   ```kotlin
   // data/Repository.kt

   /**
    * 清理当前会话的所有临时状态
    *
    * 对应 docs/流程/07_断开连接.md：
    * "清理频道、成员和 Pending — 防止旧会话事实污染下一次连接"
    *
    * 清理顺序：
    * 1. 清除频道列表和频道树
    * 2. 清除客户端列表
    * 3. 清除当前频道/客户端状态
    * 4. 清除消息历史
    * 5. 清除待处理操作
    */
   fun clearSession() {
       Log.i(TAG, "clearSession: clearing all session data")

       // 频道数据
       _channels.value = emptyList()
       _channelTree.value = emptyList()
       _currentChannelId.value = 0L
       _currentChannel.value = null

       // 客户端数据
       _clients.value = emptyList()
       _currentClientId.value = 0

       // 消息数据
       _messages.value = emptyMap()
       _unreadCounts.value = emptyMap()

       // 服务器信息
       _serverInfo.value = ServerInfo()

       // 连接状态
       _isConnected.value = false

       Log.d(TAG, "clearSession: all session data cleared")
   }
   ```

2. **ServerViewModel 清理入口**

   ```kotlin
   // viewmodel/ServerViewModel.kt

   /**
    * 清理会话（统一入口）
    *
    * 对应 docs/流程/07_断开连接.md 统一实现原则：
    * "断开时统一清理当前会话资源"
    */
   private fun clearSession() {
       // 1. 停止语音
       voiceViewModel.stopVoice()

       // 2. 清除 Repository 数据
       repository.clearSession()

       // 3. 清除聊天 ViewModel 状态
       chatViewModel.clearMessages()

       // 4. 清除频道 ViewModel 状态
       channelViewModel.clearChannels()

       // 5. 清除连接参数（可选：保留用于重连）
       // lastConnectParams 保留，供重连使用

       Log.i(TAG, "Session cleared")
   }
   ```

### 9.5 断开 UI 反馈

**目标**：整合所有断开场景的 UI 反馈，确保用户在每种情况下都有清晰的视觉提示。

**对应 UI 设计**：`docs/UI架构设计.md` 5.1 连接状态、5.3 被踢处理、5.4 断线重连

**场景汇总**：

| 场景 | 触发 | UI 行为 |
|------|------|---------|
| 主动断开 | 用户点击"断开服务器" | 确认对话框 → 断开 → 返回主页 |
| 网络异常 | OnDisconnected(error) | 顶部重连横幅 → 自动重连 |
| 被踢出 | OnKicked(reason) | 全屏被踢提示 → 重新连接/返回主页 |
| 重连成功 | onConnected（重连后） | 横幅消失 → 全量同步 → 恢复正常 |
| 重连失败 | 超过最大重试次数 | 显示放弃提示 → 返回主页 |

**任务**：

1. **主页面断开状态观察**

   在主 Activity 或 NavGraph 中观察连接状态，处理导航：

   ```kotlin
   // MainActivity.kt 或 NavGraph.kt

   @Composable
   fun AppNavigation(serverViewModel: ServerViewModel) {
       val connectionState by serverViewModel.connectionState.collectAsState()
       val navController = rememberNavController()

       // 观察连接状态变化，处理导航
       LaunchedEffect(connectionState) {
           when (connectionState) {
               is ConnectionState.Disconnected -> {
                   val state = connectionState as ConnectionState.Disconnected
                   if (state.wasKicked) {
                       // 被踢 → 导航到被踢页面
                       navController.navigate("kicked") {
                           popUpTo("channelList") { inclusive = true }
                       }
                   } else {
                       // 普通断开 → 返回主页
                       navController.navigate("serverConfig") {
                           popUpTo("channelList") { inclusive = true }
                       }
                   }
               }
               is ConnectionState.Connected -> {
                   // 连接/重连成功 → 导航到频道列表
                   navController.navigate("channelList") {
                       popUpTo("serverConfig") { inclusive = true }
                   }
               }
               else -> { /* 其他状态不处理导航 */ }
           }
       }

       NavHost(navController, startDestination = "serverConfig") {
           composable("serverConfig") {
               ServerConfigScreen(serverViewModel)
           }
           composable("channelList") {
               // 重连横幅
               val state = connectionState
               if (state is ConnectionState.Reconnecting) {
                   ReconnectBanner(
                       reconnectState = state,
                       onManualReconnect = { serverViewModel.manualReconnect() },
                       onAbandon = { serverViewModel.abandonReconnect() }
                   )
               }
               ChannelListScreen(...)
           }
           composable("kicked") {
               val state = connectionState as? ConnectionState.Disconnected
               KickedScreen(
                   reason = serverViewModel.kickReason.collectAsState().value,
                   onReconnect = {
                       serverViewModel.manualReconnect()
                   },
                   onBackToHome = {
                       serverViewModel.abandonReconnect()
                       navController.navigate("serverConfig") {
                           popUpTo("kicked") { inclusive = true }
                       }
                   }
               )
           }
       }
   }
   ```

2. **频道列表页断开状态指示**

   对应 `docs/UI架构设计.md` 2.2 头部右侧连接状态指示：

   ```kotlin
   // ui/components/ConnectionStatusIndicator.kt

   @Composable
   fun ConnectionStatusIndicator(
       connectionState: ConnectionState,
       modifier: Modifier = Modifier
   ) {
       val (color, text) = when (connectionState) {
           is ConnectionState.Connected -> Color(0xFF4CAF50) to "已连接"
           is ConnectionState.Connecting -> Color(0xFFFF9800) to "连接中"
           is ConnectionState.Disconnecting -> Color(0xFFFF9800) to "断开中"
           is ConnectionState.Reconnecting -> Color(0xFFFF9800) to "重连中"
           is ConnectionState.Disconnected -> Color(0xFFF44336) to "已断开"
           is ConnectionState.Idle -> Color.Gray to "未连接"
       }

       Row(
           modifier = modifier,
           verticalAlignment = Alignment.CenterVertically
       ) {
           Box(
               modifier = Modifier
                   .size(8.dp)
                   .background(color, CircleShape)
           )
           Spacer(Modifier.width(4.dp))
           Text(
               text = text,
               style = MaterialTheme.typography.labelSmall,
               color = MaterialTheme.colorScheme.onSurfaceVariant
           )
       }
   }
   ```

---

## 三、状态与数据流

### 3.1 连接状态机

```
                   ┌──────────────────────────────────────────────────┐
                   │                                                  │
                   ▼                                                  │
              ┌─────────┐                                            │
              │  Idle    │◄───────────────────────────────────────────┤
              └────┬────┘                                            │
                   │ 点击连接                                         │
                   ▼                                                  │
            ┌─────────────┐                                          │
            │  Connecting  │                                          │
            └──┬────────┬─┘                                          │
               │        │                                            │
          成功 │        │ 失败                                        │
               ▼        ▼                                            │
     ┌──────────────┐ ┌──────────────┐                               │
     │  Connected    │ │  Disconnected│                               │
     └──────┬───────┘ └──────────────┘                               │
            │                                                        │
            │ 主动断开 / 网络异常 / 被踢                               │
            ▼                                                        │
     ┌──────────────────────────────────┐                            │
     │         Disconnecting            │                            │
     │  (主动断开时直接进入此状态)         │                            │
     └──────┬───────────────────────────┘                            │
            │                                                        │
            │ 网络异常且有重连参数                                     │
            ▼                                                        │
     ┌──────────────┐                                                │
     │ Reconnecting  │──── 重连成功 ───→ Connected                    │
     │ (attempt/N)   │                                               │
     └──────┬───────┘                                                │
            │                                                        │
            │ 超过最大次数 / 用户放弃                                   │
            ▼                                                        │
     ┌──────────────┐                                                │
     │ Disconnected  │──── 返回主页 ───→ Idle                         │
     │ (wasKicked?)  │                                               │
     └──────────────┘                                                │
            │                                                        │
            │ 被踢 → 用户点击重新连接                                   │
            └──────────── Connecting ─────────────────────────────────┘
```

### 3.2 主动断开数据流

```
UI (服务器详情卡)    ServerViewModel      TSBridge         Go SDK         服务器
  │                    │                    │                │              │
  │ 点击"断开服务器"    │                    │                │              │
  ├───────────────────→│                    │                │              │
  │                    │ 确认对话框          │                │              │
  │◄───────────────────┤                    │                │              │
  │ 确认               │                    │                │              │
  ├───────────────────→│                    │                │              │
  │                    │ disconnect()       │                │              │
  │                    ├───────────────────→│                │              │
  │                    │                    │ disconnect()   │              │
  │                    │                    ├───────────────→│              │
  │                    │                    │                │ shutdown     │
  │                    │                    │                ├─────────────→│
  │                    │                    │                │              │
  │                    │                    │                │ 连接关闭      │
  │                    │                    │                │◄─────────────┤
  │                    │                    │ onDisconnected │              │
  │                    │                    │◄───────────────┤              │
  │                    │ clearSession()     │                │              │
  │                    ├───────────────────→│ (Repository)   │              │
  │                    │ 状态 → Disconnected│                │              │
  │ 导航回主页          │                    │                │              │
  │◄───────────────────┤                    │                │              │
```

### 3.3 被动断开与重连数据流

```
网络/服务器        Go SDK           TSBridge       ServerViewModel        UI
  │                  │                │                │                  │
  │ 网络中断          │                │                │                  │
  │ ──────── X ──────│                │                │                  │
  │                  │ onDisconnected │                │                  │
  │                  ├───────────────→│                │                  │
  │                  │                │ onDisconnected │                  │
  │                  │                ├───────────────→│                  │
  │                  │                │                │ stopVoice()      │
  │                  │                │                │ startReconnect() │
  │                  │                │                │ 状态→Reconnecting│
  │                  │                │                ├─────────────────→│
  │                  │                │                │                  │ 显示横幅
  │                  │                │                │                  │
  │                  │                │                │ delay(2s)        │
  │                  │                │                │ connect()        │
  │                  │                ├────────────────┤                  │
  │                  │◄───────────────┤                │                  │
  │                  │ Connect        │                │                  │
  │◄─────────────────┤                │                │                  │
  │                  │                │                │                  │
  │ 连接成功          │                │                │                  │
  │─────────────────→│                │                │                  │
  │                  │ onConnected    │                │                  │
  │                  ├───────────────→│                │                  │
  │                  │                │ onConnected    │                  │
  │                  │                ├───────────────→│                  │
  │                  │                │                │ 全量同步          │
  │                  │                │                │ 状态→Connected   │
  │                  │                │                ├─────────────────→│
  │                  │                │                │                  │ 横幅消失
```

### 3.4 被踢数据流

```
服务器             Go SDK           TSBridge       ServerViewModel        UI
  │                  │                │                │                  │
  │ notifyclient     │                │                │                  │
  │ leftview         │                │                │                  │
  │ (reasonid=5)     │                │                │                  │
  ├─────────────────→│                │                │                  │
  │                  │ 识别为自己被踢   │                │                  │
  │                  │ onKicked       │                │                  │
  │                  ├───────────────→│                │                  │
  │                  │                │ onKicked       │                  │
  │                  │                ├───────────────→│                  │
  │                  │                │                │ stopVoice()      │
  │                  │                │                │ clearSession()   │
  │                  │                │                │ 状态→Disconnected│
  │                  │                │                │ (wasKicked=true) │
  │                  │                │                ├─────────────────→│
  │                  │                │                │                  │ 全屏提示
  │                  │                │                │                  │
  │                  │                │                │ 用户选择：         │
  │                  │                │                │  重新连接/返回主页 │
```

### 3.5 断开前置依赖矩阵

对应 `docs/流程/07_断开连接.md` 事件依赖：

| 操作或事件 | 必须依赖 | 建议依赖 | 依赖失败时的处理 |
| --- | --- | --- | --- |
| `Disconnect`（主动） | 已连接 | 无 | 未连接时忽略 |
| `OnDisconnected`（被动） | 已注册处理器 | 保存断开原因 | 停止全部依赖连接的操作 |
| `OnKicked`（被踢） | 已注册处理器 | 区分 reasonid 4 和 5 | 清理会话且不伪装为普通成员离开 |
| 会话清理 | 断开事件触发 | 按顺序清理 | 部分清理失败不影响其他清理 |

---

## 四、与其他步骤的集成

### 4.1 与语音通信集成（步骤 08）

- 主动断开时调用 `voiceViewModel.stopVoice()`
- 被动断开时调用 `voiceViewModel.onDisconnected()`
- 被踢时调用 `voiceViewModel.stopVoice()`
- 重连成功后调用 `voiceViewModel.onReconnected()`

对应 `docs/流程/05_语音通信.md`：
- "断开或被踢必须立即停止"
- "OnDisconnected(error) → StopCapture → 状态改为 blocked"
- "重连并同步前禁止恢复发送" → 重连成功后恢复 Idle

### 4.2 与频道列表页集成（步骤 05）

- 断开后频道树清空
- 断开后当前频道栏隐藏
- 重连成功后重新加载频道树

### 4.3 与聊天页集成（步骤 07）

- 断开后消息列表清空
- 断开后消息输入框禁用
- 重连成功后重新加载消息历史

### 4.4 与状态同步集成（步骤 10）

- 重连成功后执行全量同步（`docs/流程/08_状态同步.md` ⑥）
- 全量同步完成后恢复正常使用

### 4.5 与服务器配置页集成（步骤 03）

- 断开后导航回服务器配置页
- 保留上次连接参数（地址、昵称、密码）用于重连
- 被踢后"重新连接"使用相同参数

---

## 五、TSBridge 回调注册

**目标**：在 ServerViewModel 中注册 OnDisconnected 和 OnKicked 回调。

**对应实现**：`android/app/src/main/java/com/tsmobile/app/TSBridge.kt` — Callbacks 接口

**已有回调定义**：

```kotlin
// TSBridge.kt 中已有：
interface Callbacks {
    fun onConnected()
    fun onDisconnected(message: String)  // ← 被动断开
    fun onTextMessage(msg: TextMsg)
    fun onClientEnter(client: Client)
    fun onClientLeave(id: Int, reasonMsg: String)
    fun onClientMoved(id: Int, targetChannelID: String)
    fun onKicked(reason: String)          // ← 被踢
    fun onVoiceData(clientID: Int, data: ByteArray, codec: Int)
}
```

**任务**：

```kotlin
// viewmodel/ServerViewModel.kt

/**
 * 创建回调对象（供 TSBridge.connect 和重连使用）
 */
private fun createCallbacks(): TSBridge.Callbacks {
    return object : TSBridge.Callbacks {
        override fun onConnected() {
            Log.i(TAG, "onConnected")
            handleConnected()
        }

        override fun onDisconnected(message: String) {
            Log.w(TAG, "onDisconnected: $message")
            handleDisconnected(message)
        }

        override fun onTextMessage(msg: TextMsg) {
            chatViewModel.handleTextMessage(msg)
        }

        override fun onClientEnter(client: Client) {
            channelViewModel.handleClientEnter(client)
        }

        override fun onClientLeave(id: Int, reasonMsg: String) {
            channelViewModel.handleClientLeave(id, reasonMsg)
        }

        override fun onClientMoved(id: Int, targetChannelID: String) {
            channelViewModel.handleClientMoved(id, targetChannelID)
        }

        override fun onKicked(reason: String) {
            Log.w(TAG, "onKicked: $reason")
            handleKicked(reason)
        }

        override fun onVoiceData(clientID: Int, data: ByteArray, codec: Int) {
            voiceViewModel.handleVoiceData(clientID, data, codec)
        }
    }
}
```

---

## 六、验收标准

### 功能验收

- [ ] **主动断开**
  - 服务器详情卡中有"断开服务器"按钮
  - 点击后弹出确认对话框
  - 确认后执行断开，返回服务器配置页
  - 断开过程中语音停止、会话清理

- [ ] **被动断开**
  - 网络异常时自动检测断开
  - 顶部显示重连横幅（含断开原因）
  - 自动重连最多 5 次，间隔递增
  - 重连成功后横幅消失，恢复正常

- [ ] **被踢处理**
  - 被踢后显示全屏提示（含踢出原因）
  - 提供"重新连接"和"返回主页"两个选项
  - 被踢后语音立即停止、会话清理

- [ ] **重连机制**
  - 自动重连：2s → 4s → 8s → 16s → 30s
  - 手动重连：立即尝试连接
  - 放弃重连：停止重连，返回主页
  - 重连成功后执行全量同步

- [ ] **会话清理**
  - 断开后频道列表清空
  - 断开后客户端列表清空
  - 断开后消息历史清空
  - 断开后语音停止
  - 无旧数据污染新连接

- [ ] **状态指示**
  - 头部连接状态指示正确显示
  - 未连接时显示灰色"未连接"
  - 连接中显示橙色"连接中"
  - 已连接显示绿色"已连接"
  - 重连中显示橙色"重连中"

### 错误处理验收

| 错误场景 | 预期行为 |
|----------|----------|
| 主动断开时 SDK 报错 | 忽略错误，强制清理会话 |
| 重连时密码错误 | 停止重连，显示错误，返回主页 |
| 重连时服务器满 | 继续重试直到最大次数 |
| 重连时网络仍不可用 | 继续重试直到最大次数 |
| 被踢后重连再次被踢 | 显示被踢提示，不自动重连 |
| 清理会话时部分失败 | 继续清理其他部分，记录日志 |

### 测试用例

| 场景 | 操作 | 预期结果 |
|------|------|----------|
| 主动断开 | 服务器详情卡 → 断开服务器 → 确认 | 断开，返回主页 |
| 主动断开取消 | 服务器详情卡 → 断开服务器 → 取消 | 不断开，卡片关闭 |
| 网络异常 | 断开网络 | 显示重连横幅，自动重连 |
| 重连成功 | 网络恢复 | 重连成功，横幅消失，全量同步 |
| 重连失败 | 持续断网 5 次 | 停止重连，显示放弃提示 |
| 手动重连 | 点击"手动重连" | 立即尝试连接 |
| 放弃重连 | 点击"放弃" | 停止重连，返回主页 |
| 被频道踢 | 被管理员踢出频道 | 全屏提示，可重连或返回 |
| 被服务器踢 | 被管理员踢出服务器 | 全屏提示，可重连或返回 |
| 被踢重连 | 被踢后点击"重新连接" | 使用相同参数重新连接 |
| 被踢返回 | 被踢后点击"返回主页" | 返回服务器配置页 |
| 语音停止 | 发言中断开连接 | 语音立即停止 |
| 数据清理 | 断开后重新连接 | 新连接无旧数据 |
| 状态指示 | 观察头部状态变化 | 颜色和文字正确 |

---

## 七、参考文档

- `docs/流程/07_断开连接.md` - 时序图、状态树、事件依赖
- `docs/UI架构设计.md` - 3.1 服务器详情卡、5.3 被踢处理、5.4 断线重连
- `docs/sdk文档-go.md` - 1. 连接管理（Disconnect）、2. 事件注册（OnDisconnected、OnKicked）
- `docs/implementation/02_Bridge层实现.md` - TSBridge.disconnect、Callbacks 接口
- `docs/implementation/08_语音通信.md` - VoiceViewModel.stopVoice、onDisconnected
- `android/app/src/main/java/com/tsmobile/app/TSBridge.kt` - Bridge 层实现
