# 步骤 11：卡片与全局交互

> 实现三种弹出卡片和全局交互：服务器详情卡、频道详情卡、语音卡、Poke。
> 对应设计：`docs/UI架构设计.md` - 三、卡片详细设计 + 四、全局交互
> 依赖步骤：05（频道列表页）、06（频道切换）、08（语音通信）

---

## 一、目标

- [ ] 服务器详情卡（ServerDetailCard）— 服务器信息展示 + 断开连接 + 返回配置
- [ ] 频道详情卡（ChannelDetailCard）— 频道信息 + 密码切换 + 语音权限
- [ ] 语音卡（VoiceCard）— 当前发言人 + 传输控制 + 音量调节 + 耳机/扬声器
- [ ] Poke 发送与接收 — 成员菜单 Poke + 气泡通知 + 震动
- [ ] 成员操作菜单 — 扩展完整操作（Poke、踢出、封禁、移动等）

---

## 二、任务清单

### 11.1 服务器详情卡

**目标**：实现从频道列表页头部点击触发的服务器详情卡片，展示服务器信息并提供操作入口。

**前置条件**：
- 步骤 05 的 ChannelListHeader 已预留 `onOpenServerDetail` 回调
- TSBridge 已实现 `GetServerInfoJSON()` 方法（`docs/implementation/02_Bridge层实现.md` 2.3）

**任务**：

1. **ServerInfo 数据模型**
   ```kotlin
   // data/Models.kt

   @Serializable
   data class ServerInfo(
       val name: String = "",
       val welcomeMessage: String = "",
       val maxClients: Int = 0,
       val clientsOnline: Int = 0,
       val channelsOnline: Int = 0,
       val uptime: Long = 0,
       val version: String = "",
       val platform: String = "",
       val created: Long = 0,
       val iconID: Long = 0
   )
   ```

2. **ServerViewModel 扩展 — 服务器详情获取**
   ```kotlin
   // viewmodel/ServerViewModel.kt

   private val _serverInfo = MutableStateFlow<ServerInfo?>(null)
   val serverInfo: StateFlow<ServerInfo?> = _serverInfo

   private val _showServerDetailCard = MutableStateFlow(false)
   val showServerDetailCard: StateFlow<Boolean> = _showServerDetailCard

   /**
    * 获取服务器详情信息
    * 通过 TSBridge.GetServerInfoJSON() 从 Go 层获取
    */
   fun fetchServerInfo() {
       viewModelScope.launch {
           try {
               val json = TSBridge.getServerInfoJSON()
               if (json.isNotEmpty() && json != "{}") {
                   _serverInfo.value = Json.decodeFromString<ServerInfo>(json)
               }
           } catch (e: Exception) {
               Log.e(TAG, "Failed to fetch server info", e)
           }
       }
   }

   fun openServerDetailCard() {
       fetchServerInfo()
       _showServerDetailCard.value = true
   }

   fun closeServerDetailCard() {
       _showServerDetailCard.value = false
   }
   ```

3. **ServerDetailCard Composable**
   ```kotlin
   // ui/components/cards/ServerDetailCard.kt

   @Composable
   fun ServerDetailCard(
       serverInfo: ServerInfo?,
       serverName: String,
       serverAddress: String,
       connectionState: ConnectionState,
       onDisconnect: () -> Unit,
       onBackToConfig: () -> Unit,
       onDismiss: () -> Unit
   ) {
       Card(
           modifier = Modifier
               .fillMaxWidth()
               .padding(16.dp),
           elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
       ) {
           Column(modifier = Modifier.padding(16.dp)) {
               // 标题
               Text(
                   text = serverName,
                   style = MaterialTheme.typography.headlineSmall,
                   fontWeight = FontWeight.Bold
               )
               Text(
                   text = serverAddress,
                   style = MaterialTheme.typography.bodyMedium,
                   color = MaterialTheme.colorScheme.onSurfaceVariant
               )

               Spacer(modifier = Modifier.height(16.dp))
               HorizontalDivider()
               Spacer(modifier = Modifier.height(16.dp))

               // 服务器信息区域
               ServerInfoSection(serverInfo, connectionState)

               Spacer(modifier = Modifier.height(16.dp))
               HorizontalDivider()
               Spacer(modifier = Modifier.height(16.dp))

               // 操作按钮
               Row(
                   modifier = Modifier.fillMaxWidth(),
                   horizontalArrangement = Arrangement.spacedBy(8.dp)
               ) {
                   // 断开连接
                   OutlinedButton(
                       onClick = onDisconnect,
                       modifier = Modifier.weight(1f),
                       colors = ButtonDefaults.outlinedButtonColors(
                           contentColor = MaterialTheme.colorScheme.error
                       )
                   ) {
                       Icon(Icons.Default.LinkOff, contentDescription = null)
                       Spacer(modifier = Modifier.width(4.dp))
                       Text("断开连接")
                   }

                   // 返回配置
                   Button(
                       onClick = onBackToConfig,
                       modifier = Modifier.weight(1f)
                   ) {
                       Icon(Icons.Default.Settings, contentDescription = null)
                       Spacer(modifier = Modifier.width(4.dp))
                       Text("返回配置")
                   }
               }
           }
       }
   }

   @Composable
   private fun ServerInfoSection(
       serverInfo: ServerInfo?,
       connectionState: ConnectionState
   ) {
       Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
           // 连接状态
           InfoRow(
               label = "状态",
               value = when (connectionState) {
                   ConnectionState.Ready -> "已连接"
                   ConnectionState.Connecting -> "连接中..."
                   ConnectionState.Connected -> "已连接"
                   ConnectionState.Syncing -> "同步中..."
                   is ConnectionState.Failed -> "连接失败"
                   else -> "未连接"
               }
           )

           if (serverInfo != null) {
               InfoRow(label = "在线人数", value = "${serverInfo.clientsOnline}/${serverInfo.maxClients}")
               InfoRow(label = "频道数", value = "${serverInfo.channelsOnline}")
               InfoRow(label = "版本", value = serverInfo.version)
               InfoRow(label = "平台", value = serverInfo.platform)

               if (serverInfo.uptime > 0) {
                   InfoRow(label = "运行时长", value = formatUptime(serverInfo.uptime))
               }

               if (serverInfo.welcomeMessage.isNotEmpty()) {
                   Spacer(modifier = Modifier.height(8.dp))
                   Text(
                       text = "欢迎消息",
                       style = MaterialTheme.typography.labelMedium,
                       color = MaterialTheme.colorScheme.onSurfaceVariant
                   )
                   Text(
                       text = serverInfo.welcomeMessage,
                       style = MaterialTheme.typography.bodySmall
                   )
               }
           } else {
               Text(
                   text = "正在加载服务器信息...",
                   style = MaterialTheme.typography.bodyMedium,
                   color = MaterialTheme.colorScheme.onSurfaceVariant
               )
           }
       }
   }

   @Composable
   private fun InfoRow(label: String, value: String) {
       Row(
           modifier = Modifier.fillMaxWidth(),
           horizontalArrangement = Arrangement.SpaceBetween
       ) {
           Text(
               text = label,
               style = MaterialTheme.typography.bodyMedium,
               color = MaterialTheme.colorScheme.onSurfaceVariant
           )
           Text(
               text = value,
               style = MaterialTheme.typography.bodyMedium
           )
       }
   }

   private fun formatUptime(seconds: Long): String {
       val days = seconds / 86400
       val hours = (seconds % 86400) / 3600
       val minutes = (seconds % 3600) / 60
       return when {
           days > 0 -> "${days}天${hours}小时"
           hours > 0 -> "${hours}小时${minutes}分钟"
           else -> "${minutes}分钟"
       }
   }
   ```

4. **在 ChannelListScreen 中集成服务器详情卡**
   ```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 showServerDetailCard by serverViewModel.showServerDetailCard.collectAsState()

       // ... 已有布局 ...

       // 服务器详情卡
       if (showServerDetailCard) {
           ServerDetailCard(
               serverInfo = serverViewModel.serverInfo.collectAsState().value,
               serverName = serverViewModel.serverName,
               serverAddress = serverViewModel.serverAddress,
               connectionState = serverViewModel.connectionState.collectAsState().value,
               onDisconnect = {
                   serverViewModel.closeServerDetailCard()
                   serverViewModel.disconnect()
               },
               onBackToConfig = {
                   serverViewModel.closeServerDetailCard()
                   onNavigateToServerConfig()
               },
               onDismiss = { serverViewModel.closeServerDetailCard() }
           )
       }
   }
   ```

### 11.2 频道详情卡

**目标**：实现频道详情卡片，展示频道信息并提供频道切换和语音权限控制。

**前置条件**：
- 步骤 05 的 ChannelListHeader 已预留 `onOpenChannelDetail` 回调
- TSBridge 已实现 `GetChannelInfoJSON()` 方法

**任务**：

1. **ChannelDetailInfo 数据模型**
   ```kotlin
   // data/Models.kt

   @Serializable
   data class ChannelDetailInfo(
       val id: Long = 0,
       val name: String = "",
       val topic: String = "",
       val description: String = "",
       val password: String = "",    // 是否有密码（"1"=有）
       val maxClients: Int = -1,     // -1 = 无限制
       val clients: Int = 0,
       val codec: Int = 0,
       val codecQuality: Int = 0,
       val order: Int = 0,
       val parentId: Long = 0
   )
   ```

2. **ChannelViewModel 扩展 — 频道详情获取**
   ```kotlin
   // viewmodel/ChannelViewModel.kt

   private val _channelDetailInfo = MutableStateFlow<ChannelDetailInfo?>(null)
   val channelDetailInfo: StateFlow<ChannelDetailInfo?> = _channelDetailInfo

   private val _showChannelDetailCard = MutableStateFlow(false)
   val showChannelDetailCard: StateFlow<Boolean> = _showChannelDetailCard

   /**
    * 获取频道详情信息
    */
   fun fetchChannelDetail(channelId: Long) {
       viewModelScope.launch {
           try {
               val json = TSBridge.getChannelInfoJSON(channelId.toString())
               if (json.isNotEmpty() && json != "{}") {
                   _channelDetailInfo.value = Json.decodeFromString<ChannelDetailInfo>(json)
               }
           } catch (e: Exception) {
               Log.e(TAG, "Failed to fetch channel info", e)
           }
       }
   }

   fun openChannelDetailCard(channelId: Long) {
       fetchChannelDetail(channelId)
       _showChannelDetailCard.value = true
   }

   fun closeChannelDetailCard() {
       _showChannelDetailCard.value = false
       _channelDetailInfo.value = null
   }
   ```

3. **ChannelDetailCard Composable**
   ```kotlin
   // ui/components/cards/ChannelDetailCard.kt

   @Composable
   fun ChannelDetailCard(
       channelDetail: ChannelDetailInfo?,
       isCurrentChannel: Boolean,
       onSwitchChannel: () -> Unit,
       onDismiss: () -> Unit
   ) {
       Card(
           modifier = Modifier
               .fillMaxWidth()
               .padding(16.dp),
           elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
       ) {
           Column(modifier = Modifier.padding(16.dp)) {
               // 标题
               Text(
                   text = channelDetail?.name ?: "频道详情",
                   style = MaterialTheme.typography.headlineSmall,
                   fontWeight = FontWeight.Bold
               )

               Spacer(modifier = Modifier.height(16.dp))
               HorizontalDivider()
               Spacer(modifier = Modifier.height(16.dp))

               // 频道信息
               if (channelDetail != null) {
                   ChannelInfoSection(channelDetail)
               } else {
                   Text(
                       text = "正在加载频道信息...",
                       style = MaterialTheme.typography.bodyMedium,
                       color = MaterialTheme.colorScheme.onSurfaceVariant
                   )
               }

               Spacer(modifier = Modifier.height(16.dp))
               HorizontalDivider()
               Spacer(modifier = Modifier.height(16.dp))

               // 操作按钮
               Row(
                   modifier = Modifier.fillMaxWidth(),
                   horizontalArrangement = Arrangement.End
               ) {
                   TextButton(onClick = onDismiss) {
                       Text("关闭")
                   }

                   if (!isCurrentChannel) {
                       Spacer(modifier = Modifier.width(8.dp))
                       Button(onClick = onSwitchChannel) {
                           Text("切换到此频道")
                       }
                   }
               }
           }
       }
   }

   @Composable
   private fun ChannelInfoSection(channelDetail: ChannelDetailInfo) {
       Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
           if (channelDetail.topic.isNotEmpty()) {
               InfoRow(label = "主题", value = channelDetail.topic)
           }

           InfoRow(
               label = "人数",
               value = if (channelDetail.maxClients >= 0) {
                   "${channelDetail.clients}/${channelDetail.maxClients}"
               } else {
                   "${channelDetail.clients}"
               }
           )

           if (channelDetail.description.isNotEmpty()) {
               Spacer(modifier = Modifier.height(8.dp))
               Text(
                   text = "描述",
                   style = MaterialTheme.typography.labelMedium,
                   color = MaterialTheme.colorScheme.onSurfaceVariant
               )
               Text(
                   text = channelDetail.description,
                   style = MaterialTheme.typography.bodySmall
               )
           }
       }
   }
   ```

4. **在 ChannelListScreen 中集成频道详情卡**
   ```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 showChannelDetailCard by channelViewModel.showChannelDetailCard.collectAsState()
       val channelDetail by channelViewModel.channelDetailInfo.collectAsState()
       val currentChannelId by channelViewModel.currentChannelId.collectAsState()

       // ... 已有布局 ...

       // 频道详情卡
       if (showChannelDetailCard) {
           ChannelDetailCard(
               channelDetail = channelDetail,
               isCurrentChannel = channelDetail?.id == currentChannelId,
               onSwitchChannel = {
                   channelDetail?.let { channel ->
                       channelViewModel.closeChannelDetailCard()
                       channelViewModel.onChannelClicked(
                           ChannelInfo(
                               id = channel.id,
                               name = channel.name,
                               isPassword = channel.password == "1"
                           )
                       )
                   }
               },
               onDismiss = { channelViewModel.closeChannelDetailCard() }
           )
       }
   }
   ```

### 11.3 语音卡

**目标**：实现语音控制卡片，提供完整的语音交互控制。

**前置条件**：
- 步骤 05 的 VoiceControlBar 已预留 `onExpand` 回调
- 步骤 08 的 VoiceViewModel 已实现基础语音控制

**任务**：

1. **VoiceViewModel 扩展 — 语音卡状态**
   ```kotlin
   // viewmodel/VoiceViewModel.kt

   // ─── 语音卡状态 ─────────────────────────────────────────
   private val _showVoiceCard = MutableStateFlow(false)
   val showVoiceCard: StateFlow<Boolean> = _showVoiceCard

   private val _outputDevice = MutableStateFlow(VoiceOutputDevice.SPEAKER)
   val outputDevice: StateFlow<VoiceOutputDevice> = _outputDevice

   private val _inputVolume = MutableStateFlow(1.0f)
   val inputVolume: StateFlow<Float> = _inputVolume

   private val _outputVolume = MutableStateFlow(1.0f)
   val outputVolume: StateFlow<Float> = _outputVolume

   fun openVoiceCard() {
       _showVoiceCard.value = true
   }

   fun closeVoiceCard() {
       _showVoiceCard.value = false
   }

   fun switchOutputDevice(device: VoiceOutputDevice) {
       _outputDevice.value = device
       // TODO: 通知底层切换音频输出设备
   }

   fun setInputVolume(volume: Float) {
       _inputVolume.value = volume.coerceIn(0f, 1f)
       // TODO: 通知底层调整输入音量
   }

   fun setOutputVolume(volume: Float) {
       _outputVolume.value = volume.coerceIn(0f, 1f)
       // TODO: 通知底层调整输出音量
   }
   ```

2. **VoiceOutputDevice 枚举**
   ```kotlin
   // data/Models.kt

   enum class VoiceOutputDevice {
       SPEAKER,    // 扬声器
       EARPIECE    // 听筒
   }
   ```

3. **VoiceCard Composable**
   ```kotlin
   // ui/components/cards/VoiceCard.kt

   @Composable
   fun VoiceCard(
       voiceViewModel: VoiceViewModel,
       onDismiss: () -> Unit
   ) {
       val isMuted by voiceViewModel.isMuted.collectAsState()
       val isTransmitting by voiceViewModel.isTransmitting.collectAsState()
       val isSpeaking by voiceViewModel.isSpeaking.collectAsState()
       val speakingClientId by voiceViewModel.speakingClientId.collectAsState()
       val outputDevice by voiceViewModel.outputDevice.collectAsState()
       val inputVolume by voiceViewModel.inputVolume.collectAsState()
       val outputVolume by voiceViewModel.outputVolume.collectAsState()

       Card(
           modifier = Modifier
               .fillMaxWidth()
               .padding(16.dp),
           elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
       ) {
           Column(modifier = Modifier.padding(16.dp)) {
               // 标题
               Text(
                   text = "语音控制",
                   style = MaterialTheme.typography.headlineSmall,
                   fontWeight = FontWeight.Bold
               )

               Spacer(modifier = Modifier.height(16.dp))
               HorizontalDivider()
               Spacer(modifier = Modifier.height(16.dp))

               // 当前发言人指示
               if (isSpeaking) {
                   SpeakingIndicator(speakingClientId)
                   Spacer(modifier = Modifier.height(16.dp))
               }

               // 传输控制
               TransmissionControlSection(
                   isMuted = isMuted,
                   isTransmitting = isTransmitting,
                   onToggleMute = { voiceViewModel.toggleMute() },
                   onStartTransmit = { voiceViewModel.startTransmit() },
                   onStopTransmit = { voiceViewModel.stopTransmit() }
               )

               Spacer(modifier = Modifier.height(16.dp))
               HorizontalDivider()
               Spacer(modifier = Modifier.height(16.dp))

               // 输出设备切换
               OutputDeviceSection(
                   currentDevice = outputDevice,
                   onSwitch = { voiceViewModel.switchOutputDevice(it) }
               )

               Spacer(modifier = Modifier.height(16.dp))

               // 音量控制
               VolumeControlSection(
                   inputVolume = inputVolume,
                   outputVolume = outputVolume,
                   onInputChange = { voiceViewModel.setInputVolume(it) },
                   onOutputChange = { voiceViewModel.setOutputVolume(it) }
               )
           }
       }
   }

   @Composable
   private fun SpeakingIndicator(clientId: Int?) {
       Row(
           modifier = Modifier
               .fillMaxWidth()
               .background(
                   MaterialTheme.colorScheme.primaryContainer,
                   RoundedCornerShape(8.dp)
               )
               .padding(12.dp),
           verticalAlignment = Alignment.CenterVertically
       ) {
           Icon(
               imageVector = Icons.Default.VolumeUp,
               contentDescription = null,
               tint = MaterialTheme.colorScheme.primary
           )
           Spacer(modifier = Modifier.width(8.dp))
           Text(
               text = if (clientId != null) "用户 #$clientId 正在发言" else "有人正在发言",
               style = MaterialTheme.typography.bodyMedium,
               color = MaterialTheme.colorScheme.onPrimaryContainer
           )
       }
   }

   @Composable
   private fun TransmissionControlSection(
       isMuted: Boolean,
       isTransmitting: Boolean,
       onToggleMute: () -> Unit,
       onStartTransmit: () -> Unit,
       onStopTransmit: () -> Unit
   ) {
       Column {
           Text(
               text = "传输控制",
               style = MaterialTheme.typography.labelMedium,
               color = MaterialTheme.colorScheme.onSurfaceVariant
           )

           Spacer(modifier = Modifier.height(8.dp))

           Row(
               modifier = Modifier.fillMaxWidth(),
               horizontalArrangement = Arrangement.spacedBy(8.dp)
           ) {
               // 静音按钮
               IconButton(
                   onClick = onToggleMute,
                   modifier = Modifier
                       .weight(1f)
                       .height(56.dp)
                       .background(
                           if (isMuted) MaterialTheme.colorScheme.errorContainer
                           else MaterialTheme.colorScheme.surfaceVariant,
                           RoundedCornerShape(8.dp)
                       )
               ) {
                   Icon(
                       imageVector = if (isMuted) Icons.Default.MicOff else Icons.Default.Mic,
                       contentDescription = if (isMuted) "取消静音" else "静音",
                       tint = if (isMuted) MaterialTheme.colorScheme.error
                              else MaterialTheme.colorScheme.onSurfaceVariant
                   )
               }

               // PTT 按钮
               Button(
                   onClick = {},
                   modifier = Modifier
                       .weight(3f)
                       .height(56.dp)
                       .pointerInput(Unit) {
                           detectDragGestures(
                               onDragStart = { onStartTransmit() },
                               onDragEnd = { onStopTransmit() },
                               onDragCancel = { onStopTransmit() },
                               onDrag = { _, _ -> }
                           )
                       },
                   colors = ButtonDefaults.buttonColors(
                       containerColor = if (isTransmitting) Color.Green
                                        else MaterialTheme.colorScheme.primary
                   )
               ) {
                   Text(
                       text = if (isTransmitting) "正在发言..." else "PTT 按住发言",
                       fontWeight = FontWeight.Bold
                   )
               }
           }
       }
   }

   @Composable
   private fun OutputDeviceSection(
       currentDevice: VoiceOutputDevice,
       onSwitch: (VoiceOutputDevice) -> Unit
   ) {
       Column {
           Text(
               text = "输出设备",
               style = MaterialTheme.typography.labelMedium,
               color = MaterialTheme.colorScheme.onSurfaceVariant
           )

           Spacer(modifier = Modifier.height(8.dp))

           Row(
               modifier = Modifier.fillMaxWidth(),
               horizontalArrangement = Arrangement.spacedBy(8.dp)
           ) {
               FilterChip(
                   selected = currentDevice == VoiceOutputDevice.EARPIECE,
                   onClick = { onSwitch(VoiceOutputDevice.EARPIECE) },
                   label = { Text("听筒") },
                   leadingIcon = {
                       Icon(Icons.Default.Phone, contentDescription = null)
                   },
                   modifier = Modifier.weight(1f)
               )

               FilterChip(
                   selected = currentDevice == VoiceOutputDevice.SPEAKER,
                   onClick = { onSwitch(VoiceOutputDevice.SPEAKER) },
                   label = { Text("扬声器") },
                   leadingIcon = {
                       Icon(Icons.Default.Speaker, contentDescription = null)
                   },
                   modifier = Modifier.weight(1f)
               )
           }
       }
   }

   @Composable
   private fun VolumeControlSection(
       inputVolume: Float,
       outputVolume: Float,
       onInputChange: (Float) -> Unit,
       onOutputChange: (Float) -> Unit
   ) {
       Column {
           Text(
               text = "音量控制",
               style = MaterialTheme.typography.labelMedium,
               color = MaterialTheme.colorScheme.onSurfaceVariant
           )

           Spacer(modifier = Modifier.height(8.dp))

           // 输入音量
           Row(verticalAlignment = Alignment.CenterVertically) {
               Icon(
                   imageVector = Icons.Default.Mic,
                   contentDescription = null,
                   modifier = Modifier.size(20.dp)
               )
               Slider(
                   value = inputVolume,
                   onValueChange = onInputChange,
                   modifier = Modifier.weight(1f)
               )
               Text(
                   text = "${(inputVolume * 100).toInt()}%",
                   style = MaterialTheme.typography.bodySmall,
                   modifier = Modifier.width(40.dp)
               )
           }

           // 输出音量
           Row(verticalAlignment = Alignment.CenterVertically) {
               Icon(
                   imageVector = Icons.Default.VolumeUp,
                   contentDescription = null,
                   modifier = Modifier.size(20.dp)
               )
               Slider(
                   value = outputVolume,
                   onValueChange = onOutputChange,
                   modifier = Modifier.weight(1f)
               )
               Text(
                   text = "${(outputVolume * 100).toInt()}%",
                   style = MaterialTheme.typography.bodySmall,
                   modifier = Modifier.width(40.dp)
               )
           }
       }
   }
   ```

4. **在 ChannelListScreen 中集成语音卡**
   ```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 showVoiceCard by voiceViewModel.showVoiceCard.collectAsState()

       // ... 已有布局 ...

       // 语音卡
       if (showVoiceCard) {
           VoiceCard(
               voiceViewModel = voiceViewModel,
               onDismiss = { voiceViewModel.closeVoiceCard() }
           )
       }
   }
   ```

### 11.4 Poke 交互

**目标**：实现 Poke 发送和接收，包括成员菜单触发、气泡通知和震动反馈。

**前置条件**：
- TSBridge 已实现 `Poke()` 方法（`docs/implementation/02_Bridge层实现.md` 2.3）
- TSBridge 已实现 `onPoked` 回调（`docs/implementation/02_Bridge层实现.md` 2.3）

**任务**：

1. **ServerViewModel 扩展 — Poke 发送**
   ```kotlin
   // viewmodel/ServerViewModel.kt

   /**
    * 向指定用户发送 Poke
    */
   fun pokeClient(clientId: Int, message: String) {
       viewModelScope.launch {
           try {
               val error = TSBridge.poke(clientId.toString(), message)
               if (error.isNotEmpty()) {
                   Log.w(TAG, "Poke failed: $error")
                   // 可选：显示错误提示
               } else {
                   Log.d(TAG, "Poke sent to client $clientId")
               }
           } catch (e: Exception) {
               Log.e(TAG, "Poke failed", e)
           }
       }
   }
   ```

2. **PokeEvent 数据模型**
   ```kotlin
   // data/Models.kt

   @Serializable
   data class PokeEvent(
       val invokerID: Int = 0,
       val invokerName: String = "",
       val invokerUID: String = "",
       val message: String = ""
   )
   ```

3. **全局 Poke 通知状态管理**
   ```kotlin
   // viewmodel/ServerViewModel.kt

   private val _pokeNotification = MutableStateFlow<PokeEvent?>(null)
   val pokeNotification: StateFlow<PokeEvent?> = _pokeNotification

   private val _showPokeNotification = MutableStateFlow(false)
   val showPokeNotification: StateFlow<Boolean> = _showPokeNotification

   /**
    * 处理收到的 Poke 事件
    */
   fun handlePoked(event: PokeEvent) {
       Log.d(TAG, "Poked by ${event.invokerName}: ${event.message}")

       _pokeNotification.value = event
       _showPokeNotification.value = true

       // 震动反馈
       triggerVibration()

       // 自动隐藏通知（5秒后）
       viewModelScope.launch {
           delay(5000)
           dismissPokeNotification()
       }
   }

   fun dismissPokeNotification() {
       _showPokeNotification.value = false
       _pokeNotification.value = null
   }

   private fun triggerVibration() {
       // 通过 Application context 获取 Vibrator
       val vibrator = application.getSystemService(Vibrator::class.java)
       if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
           vibrator?.vibrate(VibrationEffect.createOneShot(200, VibrationEffect.DEFAULT_AMPLITUDE))
       } else {
           @Suppress("DEPRECATION")
           vibrator?.vibrate(200)
       }
   }
   ```

4. **PokeNotification 气泡通知 Composable**
   ```kotlin
   // ui/components/PokeNotification.kt

   @Composable
   fun PokeNotification(
       pokeEvent: PokeEvent?,
       isVisible: Boolean,
       onDismiss: () -> Unit
   ) {
       AnimatedVisibility(
           visible = isVisible && pokeEvent != null,
           enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
           exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut()
       ) {
           pokeEvent?.let { event ->
               Card(
                   modifier = Modifier
                       .fillMaxWidth()
                       .padding(horizontal = 16.dp, vertical = 8.dp)
                       .clickable { onDismiss() },
                   colors = CardDefaults.cardColors(
                       containerColor = MaterialTheme.colorScheme.primaryContainer
                   ),
                   elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
               ) {
                   Row(
                       modifier = Modifier
                           .fillMaxWidth()
                           .padding(12.dp),
                       verticalAlignment = Alignment.CenterVertically
                   ) {
                       Icon(
                           imageVector = Icons.Default.TouchApp,
                           contentDescription = null,
                           tint = MaterialTheme.colorScheme.primary
                       )
                       Spacer(modifier = Modifier.width(12.dp))
                       Column(modifier = Modifier.weight(1f)) {
                           Text(
                               text = "${event.invokerName} 戳了你一下",
                               style = MaterialTheme.typography.titleSmall,
                               fontWeight = FontWeight.Bold
                           )
                           if (event.message.isNotEmpty()) {
                               Text(
                                   text = event.message,
                                   style = MaterialTheme.typography.bodySmall,
                                   color = MaterialTheme.colorScheme.onPrimaryContainer
                               )
                           }
                       }
                       IconButton(onClick = onDismiss) {
                           Icon(
                               imageVector = Icons.Default.Close,
                               contentDescription = "关闭"
                           )
                       }
                   }
               }
           }
       }
   }
   ```

5. **在 MainActivity 中集成全局 Poke 通知**
   ```kotlin
   // MainActivity.kt

   @Composable
   fun MainApp() {
       val showPokeNotification by serverViewModel.showPokeNotification.collectAsState()
       val pokeNotification by serverViewModel.pokeNotification.collectAsState()

       Box(modifier = Modifier.fillMaxSize()) {
           // 主内容
           NavGraph(/* ... */)

           // 全局 Poke 通知（显示在顶部）
           PokeNotification(
               pokeEvent = pokeNotification,
               isVisible = showPokeNotification,
               onDismiss = { serverViewModel.dismissPokeNotification() }
           )
       }
   }
   ```

6. **事件处理器注册**
   ```kotlin
   // viewmodel/ServerViewModel.kt - 在 registerEventHandlers 中添加

   fun registerEventHandlers() {
       TSBridge.setCallbacks(object : TSBridge.Callbacks {
           // ... 已有回调 ...

           override fun onPoked(
               invokerID: Int,
               invokerName: String,
               invokerUID: String,
               message: String
           ) {
               val event = PokeEvent(
                   invokerID = invokerID,
                   invokerName = invokerName,
                   invokerUID = invokerUID,
                   message = message
               )
               handlePoked(event)
           }

           // ... 其他回调 ...
       })
   }
   ```

### 11.5 成员操作菜单

**目标**：扩展成员操作菜单，提供完整的成员操作功能。

**前置条件**：
- 步骤 05 已实现基础 `ClientActionMenu`

**任务**：

1. **ClientActionMenu 扩展**
   ```kotlin
   // ui/components/ClientActionMenu.kt

   @Composable
   fun ClientActionMenu(
       client: ClientInfo,
       isSelf: Boolean,
       onPoke: () -> Unit,
       onCopyNickname: () -> Unit,
       onCopyUID: () -> Unit,
       onKickFromServer: () -> Unit,
       onKickFromChannel: () -> Unit,
       onBan: () -> Unit,
       onMoveToChannel: () -> Unit,
       onDismiss: () -> Unit
   ) {
       AlertDialog(
           onDismissRequest = onDismiss,
           title = {
               Column {
                   Text(
                       text = client.nickname,
                       style = MaterialTheme.typography.titleMedium
                   )
                   Text(
                       text = "UID: ${client.uid}",
                       style = MaterialTheme.typography.bodySmall,
                       color = MaterialTheme.colorScheme.onSurfaceVariant
                   )
               }
           },
           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()
                       }
                   )

                   ListItem(
                       headlineContent = { Text("复制 UID") },
                       leadingContent = {
                           Icon(Icons.Default.Fingerprint, null)
                       },
                       modifier = Modifier.clickable {
                           onCopyUID()
                           onDismiss()
                       }
                   )

                   // 管理员操作（需要权限检查）
                   if (!isSelf && hasAdminPermission()) {
                       HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))

                       ListItem(
                           headlineContent = { Text("踢出频道") },
                           leadingContent = {
                               Icon(
                                   Icons.Default.RemoveCircle,
                                   null,
                                   tint = MaterialTheme.colorScheme.error
                               )
                           },
                           modifier = Modifier.clickable {
                               onKickFromChannel()
                               onDismiss()
                           }
                       )

                       ListItem(
                           headlineContent = { Text("踢出服务器") },
                           leadingContent = {
                               Icon(
                                   Icons.Default.Block,
                                   null,
                                   tint = MaterialTheme.colorScheme.error
                               )
                           },
                           modifier = Modifier.clickable {
                               onKickFromServer()
                               onDismiss()
                           }
                       )

                       ListItem(
                           headlineContent = { Text("封禁") },
                           leadingContent = {
                               Icon(
                                   Icons.Default.Gavel,
                                   null,
                                   tint = MaterialTheme.colorScheme.error
                               )
                           },
                           modifier = Modifier.clickable {
                               onBan()
                               onDismiss()
                           }
                       )

                       ListItem(
                           headlineContent = { Text("移动到频道") },
                           leadingContent = {
                               Icon(Icons.Default.MoveUp, null)
                           },
                           modifier = Modifier.clickable {
                               onMoveToChannel()
                               onDismiss()
                           }
                       )
                   }
               }
           }
       )
   }

   private fun hasAdminPermission(): Boolean {
       // TODO: 检查当前用户是否有管理员权限
       return false
   }
   ```

2. **Poke 输入弹窗**
   ```kotlin
   // ui/components/PokeDialog.kt

   @Composable
   fun PokeDialog(
       targetName: String,
       onConfirm: (message: String) -> Unit,
       onDismiss: () -> Unit
   ) {
       var message by remember { mutableStateOf("") }

       AlertDialog(
           onDismissRequest = onDismiss,
           title = {
               Text("Poke $targetName")
           },
           text = {
               Column {
                   Text(
                       text = "发送一个戳一戳给 $targetName",
                       style = MaterialTheme.typography.bodyMedium,
                       color = MaterialTheme.colorScheme.onSurfaceVariant
                   )

                   Spacer(modifier = Modifier.height(16.dp))

                   OutlinedTextField(
                       value = message,
                       onValueChange = { message = it },
                       label = { Text("消息（可选）") },
                       singleLine = true,
                       modifier = Modifier.fillMaxWidth()
                   )
               }
           },
           confirmButton = {
               TextButton(onClick = { onConfirm(message) }) {
                   Text("发送")
               }
           },
           dismissButton = {
               TextButton(onClick = onDismiss) {
                   Text("取消")
               }
           }
       )
   }
   ```

3. **在 ChannelListScreen 中集成成员操作菜单**
   ```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
   ) {
       // ... 已有状态 ...

       // 成员操作菜单状态
       var selectedClient by remember { mutableStateOf<ClientInfo?>(null) }
       var showClientMenu by remember { mutableStateOf(false) }
       var showPokeDialog by remember { mutableStateOf(false) }

       // ... 已有布局 ...

       // 成员操作菜单
       if (showClientMenu && selectedClient != null) {
           ClientActionMenu(
               client = selectedClient!!,
               isSelf = selectedClient!!.id == channelViewModel.selfClientId.collectAsState().value,
               onPoke = { showPokeDialog = true },
               onCopyNickname = {
                   copyToClipboard(selectedClient!!.nickname)
               },
               onCopyUID = {
                   copyToClipboard(selectedClient!!.uid)
               },
               onKickFromServer = {
                   // TODO: 实现踢出服务器
               },
               onKickFromChannel = {
                   // TODO: 实现踢出频道
               },
               onBan = {
                   // TODO: 实现封禁
               },
               onMoveToChannel = {
                   // TODO: 实现移动到频道
               },
               onDismiss = {
                   showClientMenu = false
                   selectedClient = null
               }
           )
       }

       // Poke 弹窗
       if (showPokeDialog && selectedClient != null) {
           PokeDialog(
               targetName = selectedClient!!.nickname,
               onConfirm = { message ->
                   serverViewModel.pokeClient(selectedClient!!.id, message)
                   showPokeDialog = false
                   showClientMenu = false
                   selectedClient = null
               },
               onDismiss = {
                   showPokeDialog = false
               }
           )
       }
   }

   private fun copyToClipboard(text: String) {
       val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
       val clip = ClipData.newPlainText("text", text)
       clipboard.setPrimaryClip(clip)
   }
   ```

---

## 三、状态与数据流

### 3.1 卡片状态管理

```
┌───────────────────────────────────────────────────────────────┐
│                      ChannelListScreen                        │
├───────────────────────────────────────────────────────────────┤
│  ServerViewModel              ChannelViewModel    VoiceViewModel│
│  ┌─────────────────┐         ┌──────────────┐   ┌───────────┐│
│  │ serverInfo      │         │ channelDetail│   │ voiceCard ││
│  │ showServerCard  │         │ showChannel  │   │ showCard  ││
│  │ pokeNotification│         │ DetailCard   │   │ outputDev ││
│  └────────┬────────┘         └──────┬───────┘   └─────┬─────┘│
│           │                         │                 │      │
│           ▼                         ▼                 ▼      │
│  ┌─────────────────┐         ┌──────────────┐   ┌───────────┐│
│  │ServerDetailCard │         │ChannelDetail │   │ VoiceCard ││
│  │                 │         │    Card      │   │           ││
│  └─────────────────┘         └──────────────┘   └───────────┘│
│                                                               │
│  ┌───────────────────────────────────────────────────────────┐│
│  │ PokeNotification (全局浮动)                               ││
│  └───────────────────────────────────────────────────────────┘│
└───────────────────────────────────────────────────────────────┘
```

### 3.2 数据流向

```
用户操作             ViewModel                 TSBridge/Go         服务端
    │                    │                        │                  │
    │ 点击服务器卡       │                        │                  │
    ├───────────────────→│                        │                  │
    │                    │ openServerDetailCard() │                  │
    │                    │                        │                  │
    │                    │ GetServerInfoJSON()    │                  │
    │                    ├───────────────────────→│    serverinfo    │
    │                    │                        ├─────────────────→│
    │                    │                        │    响应          │
    │                    │   JSON                 │←─────────────────┤
    │                    │←───────────────────────┤                  │
    │                    │                        │                  │
    │                    │ _serverInfo = parsed   │                  │
    │   显示详情卡       │                        │                  │
    │←───────────────────┤                        │                  │
    │                    │                        │                  │
    │ 点击断开连接       │                        │                  │
    ├───────────────────→│                        │                  │
    │                    │ disconnect()           │                  │
    │                    ├───────────────────────→│                  │
    │                    │                        │                  │
```

### 3.3 Poke 事件流

```
用户操作             ViewModel                 TSBridge/Go         服务端
    │                    │                        │                  │
    │ 点击成员 Poke     │                        │                  │
    ├───────────────────→│                        │                  │
    │                    │ pokeClient(id, msg)    │                  │
    │                    ├───────────────────────→│    clientpoke    │
    │                    │                        ├─────────────────→│
    │                    │                        │                  │
    │                    │                        │  notifyclientpoke│
    │                    │  onPoked(event)        │←─────────────────┤
    │                    │←───────────────────────┤                  │
    │                    │                        │                  │
    │                    │ handlePoked()          │                  │
    │                    │ - 更新通知状态         │                  │
    │                    │ - 触发震动             │                  │
    │                    │ - 启动自动隐藏定时器   │                  │
    │                    │                        │                  │
    │   显示气泡通知     │                        │                  │
    │←───────────────────┤                        │                  │
    │                    │                        │                  │
    │ 点击关闭/5秒后     │                        │                  │
    ├───────────────────→│                        │                  │
    │                    │ dismissPokeNotification│                  │
    │   隐藏通知         │                        │                  │
    │←───────────────────┤                        │                  │
```

---

## 四、验收标准

### 功能验收

- [ ] **服务器详情卡**
  - 点击频道列表页头部服务器按钮弹出
  - 显示服务器名称、地址、在线人数、版本、运行时长
  - 显示服务器欢迎消息
  - 断开连接按钮正常工作
  - 返回配置按钮跳转到配置页
  - 点击外部区域关闭卡片

- [ ] **频道详情卡**
  - 点击频道列表页头部频道按钮弹出
  - 显示频道名称、主题、描述、人数
  - 当前频道不显示"切换到此频道"按钮
  - 非当前频道点击切换按钮触发频道切换流程

- [ ] **语音卡**
  - 点击语音控制栏展开按钮弹出
  - 显示当前发言人指示（有人发言时）
  - PTT 按钮按住发言、松开停止
  - 静音按钮切换静音状态
  - 听筒/扬声器切换正常工作
  - 输入/输出音量滑块调整正常

- [ ] **Poke 发送**
  - 成员菜单点击 Poke 弹出输入框
  - 可选输入消息内容
  - 点击发送后发送 Poke 命令
  - 发送成功后关闭弹窗

- [ ] **Poke 接收**
  - 收到 Poke 事件后显示气泡通知
  - 通知显示发送者昵称和消息内容
  - 通知触发震动反馈
  - 点击通知或 5 秒后自动关闭

- [ ] **成员操作菜单**
  - 点击成员弹出操作菜单
  - 基础操作：Poke、复制昵称、复制 UID
  - 管理员操作：踢出频道、踢出服务器、封禁、移动（需权限）
  - 自己不显示 Poke 选项

### 性能验收

- [ ] 卡片弹出/关闭动画流畅（< 300ms）
- [ ] 服务器信息获取 < 1 秒
- [ ] Poke 通知显示延迟 < 500ms
- [ ] 震动反馈即时（< 100ms）

### 代码质量验收

- [ ] 状态管理清晰，单向数据流
- [ ] 卡片状态由 ViewModel 管理，不使用局部状态
- [ ] Poke 事件处理幂等
- [ ] 资源正确释放（定时器、震动）

### 测试用例

| 场景 | 操作 | 预期结果 |
|------|------|----------|
| 服务器卡弹出 | 点击服务器按钮 | 卡片显示服务器信息 |
| 服务器卡断开 | 点击断开连接 | 返回配置页 |
| 频道卡弹出 | 点击频道按钮 | 卡片显示当前频道信息 |
| 频道卡切换 | 点击切换按钮 | 触发频道切换流程 |
| 语音卡展开 | 点击展开按钮 | 卡片显示语音控制 |
| 语音卡 PTT | 按住 PTT 松开 | 发言开始/停止 |
| 语音卡切换 | 点击听筒/扬声器 | 输出设备切换 |
| Poke 发送 | 选择成员 → Poke → 发送 | Poke 命令发送成功 |
| Poke 接收 | 其他用户 Poke 你 | 气泡通知 + 震动 |
| Poke 自动关闭 | 等待 5 秒 | 通知自动消失 |
| 成员菜单 | 点击成员 | 显示完整操作菜单 |
| 复制昵称 | 成员菜单 → 复制昵称 | 内容复制到剪贴板 |

---

## 五、参考文档

- `docs/UI架构设计.md` - 三、卡片详细设计（3.1 服务器详情卡、3.2 频道详情卡、3.3 语音卡）+ 四、全局交互（4.5 Poke）
- `docs/sdk文档-go.md` - GetServerInfo、GetChannelInfo、Poke、OnPoked
- `docs/implementation/02_Bridge层实现.md` - GetServerInfoJSON、GetChannelInfoJSON、Poke、onPoked
- `docs/implementation/05_频道列表页.md` - ChannelListScreen、ClientActionMenu
- `docs/implementation/06_频道切换.md` - onChannelClicked
- `docs/implementation/08_语音通信.md` - VoiceViewModel
