712 lines
26 KiB
Markdown
712 lines
26 KiB
Markdown
# 步骤 06:频道切换
|
||
|
||
> 实现频道切换流程:频道点击处理、密码弹窗、ClientMove 命令发送、等待 OnClientMoved 服务端确认、自身频道状态更新。
|
||
> 对应流程:`docs/流程/03_切换频道.md`
|
||
> 依赖步骤:05(频道列表页)
|
||
|
||
---
|
||
|
||
## 一、目标
|
||
|
||
- [ ] 频道点击事件处理(区分有密码/无密码频道)
|
||
- [ ] 密码输入弹窗组件
|
||
- [ ] ChannelSwitchState 状态机(idle → requesting → waitingServerEvent → idle/failed)
|
||
- [ ] ClientMove 命令发送(通过 TSBridge.MoveToChannel)
|
||
- [ ] 等待 OnClientMoved 服务端事实确认
|
||
- [ ] 自身频道状态更新(④ 自身状态同步)
|
||
- [ ] 错误处理与用户反馈
|
||
|
||
---
|
||
|
||
## 二、任务清单
|
||
|
||
### 6.1 频道点击处理
|
||
|
||
**目标**:在频道列表页中处理频道点击事件,区分有密码和无密码频道。
|
||
|
||
**前置条件**:
|
||
- 步骤 05 的 ChannelListScreen 已实现
|
||
- ChannelRow 组件已支持点击事件
|
||
|
||
**任务**:
|
||
|
||
1. **频道点击入口**
|
||
```kotlin
|
||
// ChannelListScreen.kt - ChannelTreeContent 中的 onChannelClick 回调
|
||
@Composable
|
||
fun ChannelTreeContent(
|
||
channelViewModel: ChannelViewModel,
|
||
onChannelClick: (ChannelInfo) -> Unit,
|
||
onClientClick: (ClientInfo) -> Unit,
|
||
onNavigateToChat: () -> Unit
|
||
) {
|
||
// ... 已有实现 ...
|
||
}
|
||
```
|
||
|
||
2. **频道点击逻辑(ChannelViewModel)**
|
||
```kotlin
|
||
// ChannelViewModel.kt
|
||
|
||
// 密码弹窗状态
|
||
private val _showPasswordDialog = MutableStateFlow(false)
|
||
val showPasswordDialog: StateFlow<Boolean> = _showPasswordDialog
|
||
|
||
// 待切换的目标频道
|
||
private val _pendingSwitchChannel = MutableStateFlow<ChannelInfo?>(null)
|
||
val pendingSwitchChannel: StateFlow<ChannelInfo?> = _pendingSwitchChannel
|
||
|
||
/**
|
||
* 处理频道点击事件
|
||
* 对应 UI架构设计.md 频道树交互规则
|
||
*/
|
||
fun onChannelClicked(channel: ChannelInfo) {
|
||
// 如果是当前频道,忽略
|
||
if (channel.id == repository.selfChannelId.value) {
|
||
Log.d(TAG, "Already in channel ${channel.id}, ignoring click")
|
||
return
|
||
}
|
||
|
||
// 检查是否正在切换中
|
||
if (_switchState.value != ChannelSwitchState.Idle) {
|
||
Log.w(TAG, "Channel switch already in progress, ignoring click")
|
||
return
|
||
}
|
||
|
||
if (channel.isPassword) {
|
||
// 有密码频道:弹出密码输入框
|
||
_pendingSwitchChannel.value = channel
|
||
_showPasswordDialog.value = true
|
||
} else {
|
||
// 无密码频道:直接发起切换
|
||
viewModelScope.launch {
|
||
performChannelSwitch(channel.id, "")
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 6.2 密码弹窗组件
|
||
|
||
**目标**:实现频道密码输入弹窗,支持密码错误重试。
|
||
|
||
**任务**:
|
||
|
||
1. **密码弹窗 Composable**
|
||
```kotlin
|
||
// ui/components/ChannelPasswordDialog.kt
|
||
|
||
@Composable
|
||
fun ChannelPasswordDialog(
|
||
channelName: String,
|
||
onConfirm: (password: String) -> Unit,
|
||
onDismiss: () -> Unit,
|
||
isError: Boolean = false,
|
||
errorMessage: String = "密码错误,请重试"
|
||
) {
|
||
var password by remember { mutableStateOf("") }
|
||
var showError by remember { mutableStateOf(isError) }
|
||
|
||
// 当 isError 变化时更新本地状态
|
||
LaunchedEffect(isError) {
|
||
showError = isError
|
||
if (isError) {
|
||
password = "" // 清空输入框
|
||
}
|
||
}
|
||
|
||
AlertDialog(
|
||
onDismissRequest = onDismiss,
|
||
title = {
|
||
Text(
|
||
text = "该频道需要密码",
|
||
style = MaterialTheme.typography.titleMedium
|
||
)
|
||
},
|
||
text = {
|
||
Column {
|
||
Text(
|
||
text = "频道:$channelName",
|
||
style = MaterialTheme.typography.bodyMedium,
|
||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||
)
|
||
|
||
Spacer(modifier = Modifier.height(16.dp))
|
||
|
||
OutlinedTextField(
|
||
value = password,
|
||
onValueChange = {
|
||
password = it
|
||
showError = false
|
||
},
|
||
label = { Text("输入频道密码") },
|
||
singleLine = true,
|
||
visualTransformation = PasswordVisualTransformation(),
|
||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||
isError = showError,
|
||
supportingText = if (showError) {
|
||
{ Text(errorMessage, color = MaterialTheme.colorScheme.error) }
|
||
} else null,
|
||
modifier = Modifier.fillMaxWidth()
|
||
)
|
||
}
|
||
},
|
||
confirmButton = {
|
||
TextButton(
|
||
onClick = { onConfirm(password) },
|
||
enabled = password.isNotEmpty()
|
||
) {
|
||
Text("进入")
|
||
}
|
||
},
|
||
dismissButton = {
|
||
TextButton(onClick = onDismiss) {
|
||
Text("取消")
|
||
}
|
||
}
|
||
)
|
||
}
|
||
```
|
||
|
||
2. **在 ChannelListScreen 中集成密码弹窗**
|
||
```kotlin
|
||
// ChannelListScreen.kt
|
||
|
||
@Composable
|
||
fun ChannelListScreen(
|
||
channelViewModel: ChannelViewModel,
|
||
serverViewModel: ServerViewModel,
|
||
voiceViewModel: VoiceViewModel,
|
||
onNavigateToChat: () -> Unit,
|
||
onNavigateToServerConfig: () -> Unit,
|
||
onOpenServerDetail: () -> Unit,
|
||
onOpenChannelDetail: (channelId: Long) -> Unit,
|
||
onOpenVoiceCard: () -> Unit
|
||
) {
|
||
val showPasswordDialog by channelViewModel.showPasswordDialog.collectAsState()
|
||
val pendingChannel by channelViewModel.pendingSwitchChannel.collectAsState()
|
||
val switchState by channelViewModel.switchState.collectAsState()
|
||
|
||
Column(modifier = Modifier.fillMaxSize()) {
|
||
// ... 已有布局 ...
|
||
}
|
||
|
||
// 密码弹窗
|
||
if (showPasswordDialog && pendingChannel != null) {
|
||
ChannelPasswordDialog(
|
||
channelName = pendingChannel!!.name,
|
||
onConfirm = { password ->
|
||
channelViewModel.confirmPasswordAndSwitch(password)
|
||
},
|
||
onDismiss = {
|
||
channelViewModel.dismissPasswordDialog()
|
||
},
|
||
isError = switchState is ChannelSwitchState.Failed,
|
||
errorMessage = (switchState as? ChannelSwitchState.Failed)?.error ?: "密码错误,请重试"
|
||
)
|
||
}
|
||
}
|
||
```
|
||
|
||
3. **密码确认与取消逻辑**
|
||
```kotlin
|
||
// ChannelViewModel.kt
|
||
|
||
/**
|
||
* 用户确认密码,发起切换
|
||
*/
|
||
fun confirmPasswordAndSwitch(password: String) {
|
||
val channel = _pendingSwitchChannel.value ?: return
|
||
_showPasswordDialog.value = false
|
||
|
||
viewModelScope.launch {
|
||
performChannelSwitch(channel.id, password)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 用户取消密码输入
|
||
*/
|
||
fun dismissPasswordDialog() {
|
||
_showPasswordDialog.value = false
|
||
_pendingSwitchChannel.value = null
|
||
_switchState.value = ChannelSwitchState.Idle
|
||
}
|
||
```
|
||
|
||
### 6.3 ChannelSwitchState 状态机
|
||
|
||
**目标**:实现频道切换的状态管理,确保命令响应与事件事实分离。
|
||
|
||
**状态定义**:
|
||
```kotlin
|
||
// data/Models.kt 或 ChannelViewModel.kt
|
||
|
||
/**
|
||
* 频道切换状态机
|
||
* 对应 docs/流程/03_切换频道.md 中的状态转换
|
||
*
|
||
* 状态转换:
|
||
* Idle → Requesting:用户发起切换请求
|
||
* Requesting → WaitingServerEvent:ClientMove 命令成功
|
||
* Requesting → Failed:ClientMove 命令失败
|
||
* WaitingServerEvent → Idle:收到自己的 OnClientMoved 事件
|
||
* Failed → Idle:用户重试或取消
|
||
*/
|
||
sealed class ChannelSwitchState {
|
||
/** 空闲状态,可以发起新的切换 */
|
||
object Idle : ChannelSwitchState()
|
||
|
||
/** 正在发送 ClientMove 命令 */
|
||
object Requesting : ChannelSwitchState()
|
||
|
||
/** ClientMove 命令成功,等待服务端 OnClientMoved 事件确认 */
|
||
data class WaitingServerEvent(val targetChannelId: Long) : ChannelSwitchState()
|
||
|
||
/** 切换失败(命令被拒绝或超时) */
|
||
data class Failed(val error: String) : ChannelSwitchState()
|
||
}
|
||
```
|
||
|
||
**状态机实现**:
|
||
```kotlin
|
||
// ChannelViewModel.kt
|
||
|
||
// 切换状态
|
||
private val _switchState = MutableStateFlow<ChannelSwitchState>(ChannelSwitchState.Idle)
|
||
val switchState: StateFlow<ChannelSwitchState> = _switchState
|
||
|
||
// 等待服务端确认的超时 Job
|
||
private var switchTimeoutJob: Job? = null
|
||
|
||
/**
|
||
* 执行频道切换
|
||
* 对应 docs/流程/03_切换频道.md 时序图
|
||
*/
|
||
private suspend fun performChannelSwitch(targetChannelId: Long, password: String) {
|
||
Log.d(TAG, "Requesting channel switch to $targetChannelId")
|
||
|
||
// 状态改为 requesting
|
||
_switchState.value = ChannelSwitchState.Requesting
|
||
|
||
try {
|
||
// 发送 ClientMove 命令
|
||
// TSBridge.MoveToChannel 返回空串表示成功,否则返回错误信息
|
||
val error = TSBridge.moveSelfToChannel(targetChannelId.toString(), password)
|
||
|
||
if (error.isEmpty()) {
|
||
// 命令成功,等待服务端事件确认
|
||
_switchState.value = ChannelSwitchState.WaitingServerEvent(targetChannelId)
|
||
|
||
// 启动超时检测(10秒)
|
||
switchTimeoutJob?.cancel()
|
||
switchTimeoutJob = viewModelScope.launch {
|
||
delay(10_000)
|
||
// 超时:如果还在等待状态,视为失败
|
||
if (_switchState.value is ChannelSwitchState.WaitingServerEvent) {
|
||
Log.w(TAG, "Channel switch timeout waiting for server event")
|
||
_switchState.value = ChannelSwitchState.Failed("等待服务端确认超时")
|
||
}
|
||
}
|
||
|
||
Log.d(TAG, "ClientMove command accepted, waiting for server event")
|
||
} else {
|
||
// 命令被拒绝
|
||
Log.w(TAG, "ClientMove command rejected: $error")
|
||
_switchState.value = ChannelSwitchState.Failed(mapMoveError(error))
|
||
}
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "ClientMove command failed", e)
|
||
_switchState.value = ChannelSwitchState.Failed("切换失败:${e.message}")
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 映射 MoveToChannel 错误信息为用户友好的提示
|
||
*/
|
||
private fun mapMoveError(error: String): String {
|
||
return when {
|
||
error.contains("password", ignoreCase = true) -> "密码错误"
|
||
error.contains("permission", ignoreCase = true) -> "权限不足"
|
||
error.contains("full", ignoreCase = true) -> "频道已满"
|
||
error.contains("banned", ignoreCase = true) -> "你已被该频道封禁"
|
||
else -> "切换失败:$error"
|
||
}
|
||
}
|
||
```
|
||
|
||
### 6.4 自身状态同步(④)
|
||
|
||
**目标**:处理自己的 OnClientMoved 事件,确认频道切换完成。
|
||
|
||
**关键原则**:
|
||
- 命令响应(ClientMove nil)不等于状态已提交
|
||
- 必须等待服务端推送的 OnClientMoved 事件才能更新本地频道事实
|
||
- 通过比对 ClientID == selfID 识别自己的移动事件
|
||
|
||
**任务**:
|
||
|
||
1. **处理 OnClientMoved 事件(区分自己和他人)**
|
||
```kotlin
|
||
// ChannelViewModel.kt
|
||
|
||
/**
|
||
* 处理客户端移动事件
|
||
* 对应 docs/流程/08_状态同步.md ② 增量同步 + ④ 自身状态同步
|
||
*
|
||
* @param clientId 移动的客户端 ID
|
||
* @param targetChannelId 目标频道 ID(字符串形式)
|
||
*/
|
||
fun handleClientMoved(clientId: Int, targetChannelId: Long) {
|
||
Log.d(TAG, "Client moved: $clientId -> channel $targetChannelId")
|
||
|
||
val selfId = repository.selfClientId.value
|
||
|
||
if (clientId == selfId) {
|
||
// ④ 自身状态同步:这是自己的移动事件
|
||
handleSelfMoved(targetChannelId)
|
||
} else {
|
||
// ② 增量同步:这是其他用户的移动事件
|
||
handleOtherClientMoved(clientId, targetChannelId)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理自己的移动事件
|
||
* 对应 docs/流程/03_切换频道.md 中等待 OnClientMoved 确认的分支
|
||
*/
|
||
private fun handleSelfMoved(targetChannelId: Long) {
|
||
val currentState = _switchState.value
|
||
|
||
// 更新自身频道事实
|
||
repository.updateSelfChannel(targetChannelId)
|
||
|
||
// 清除目标频道的未读标记
|
||
clearUnread(targetChannelId)
|
||
|
||
when (currentState) {
|
||
is ChannelSwitchState.WaitingServerEvent -> {
|
||
// 正常流程:确认切换完成
|
||
Log.d(TAG, "Channel switch confirmed by server: target=$targetChannelId")
|
||
switchTimeoutJob?.cancel()
|
||
_switchState.value = ChannelSwitchState.Idle
|
||
_pendingSwitchChannel.value = null
|
||
}
|
||
is ChannelSwitchState.Requesting -> {
|
||
// 罕见情况:事件先于命令响应到达
|
||
Log.d(TAG, "Server event arrived before command response")
|
||
switchTimeoutJob?.cancel()
|
||
_switchState.value = ChannelSwitchState.Idle
|
||
_pendingSwitchChannel.value = null
|
||
}
|
||
else -> {
|
||
// 非切换流程中的移动(例如被管理员移动)
|
||
Log.d(TAG, "Self moved by external action to channel $targetChannelId")
|
||
_switchState.value = ChannelSwitchState.Idle
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理其他用户的移动事件(增量同步)
|
||
*/
|
||
private fun handleOtherClientMoved(clientId: Int, targetChannelId: Long) {
|
||
val existingClient = repository.getClientById(clientId)
|
||
|
||
if (existingClient != null) {
|
||
// 成员存在:更新频道位置
|
||
repository.updateClientChannel(clientId, targetChannelId)
|
||
} else {
|
||
// 成员不存在:触发补偿同步
|
||
Log.w(TAG, "Unknown client $clientId, triggering compensation sync")
|
||
viewModelScope.launch { compensateClientList() }
|
||
}
|
||
}
|
||
```
|
||
|
||
2. **切换超时处理**
|
||
```kotlin
|
||
// ChannelViewModel.kt
|
||
|
||
/**
|
||
* 重试频道切换(失败后)
|
||
*/
|
||
fun retryChannelSwitch() {
|
||
val channel = _pendingSwitchChannel.value ?: return
|
||
_switchState.value = ChannelSwitchState.Idle
|
||
|
||
viewModelScope.launch {
|
||
performChannelSwitch(channel.id, "")
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 取消频道切换
|
||
*/
|
||
fun cancelChannelSwitch() {
|
||
switchTimeoutJob?.cancel()
|
||
_switchState.value = ChannelSwitchState.Idle
|
||
_pendingSwitchChannel.value = null
|
||
_showPasswordDialog.value = false
|
||
}
|
||
```
|
||
|
||
### 6.5 切换状态 UI 反馈
|
||
|
||
**目标**:在频道列表页显示切换状态,提供用户反馈。
|
||
|
||
**任务**:
|
||
|
||
1. **切换中指示器**
|
||
```kotlin
|
||
// ui/components/SwitchingIndicator.kt
|
||
|
||
@Composable
|
||
fun ChannelSwitchingIndicator(
|
||
state: ChannelSwitchState,
|
||
onRetry: () -> Unit,
|
||
onCancel: () -> Unit
|
||
) {
|
||
when (state) {
|
||
is ChannelSwitchState.Requesting -> {
|
||
LinearProgressIndicator(
|
||
modifier = Modifier.fillMaxWidth()
|
||
)
|
||
}
|
||
is ChannelSwitchState.WaitingServerEvent -> {
|
||
LinearProgressIndicator(
|
||
modifier = Modifier.fillMaxWidth()
|
||
)
|
||
}
|
||
is ChannelSwitchState.Failed -> {
|
||
Snackbar(
|
||
action = {
|
||
TextButton(onClick = onRetry) {
|
||
Text("重试")
|
||
}
|
||
TextButton(onClick = onCancel) {
|
||
Text("取消")
|
||
}
|
||
}
|
||
) {
|
||
Text(state.error)
|
||
}
|
||
}
|
||
else -> { /* Idle: 不显示任何指示 */ }
|
||
}
|
||
}
|
||
```
|
||
|
||
2. **在 ChannelListScreen 中集成**
|
||
```kotlin
|
||
// ChannelListScreen.kt
|
||
|
||
@Composable
|
||
fun ChannelListScreen(
|
||
// ... 参数 ...
|
||
) {
|
||
val switchState by channelViewModel.switchState.collectAsState()
|
||
|
||
Column(modifier = Modifier.fillMaxSize()) {
|
||
// 头部
|
||
ChannelListHeader(/* ... */)
|
||
|
||
// 切换状态指示器
|
||
if (switchState != ChannelSwitchState.Idle) {
|
||
ChannelSwitchingIndicator(
|
||
state = switchState,
|
||
onRetry = { channelViewModel.retryChannelSwitch() },
|
||
onCancel = { channelViewModel.cancelChannelSwitch() }
|
||
)
|
||
}
|
||
|
||
// 中部:频道树
|
||
Box(modifier = Modifier.weight(1f)) {
|
||
// ... 已有实现 ...
|
||
}
|
||
|
||
// ... 其余布局 ...
|
||
}
|
||
}
|
||
```
|
||
|
||
### 6.6 事件处理器注册
|
||
|
||
**目标**:确保 ChannelViewModel 的事件处理方法被 ServerViewModel 正确调用。
|
||
|
||
**任务**:
|
||
|
||
```kotlin
|
||
// ServerViewModel.kt - 在 registerEventHandlers 中添加
|
||
|
||
fun registerEventHandlers() {
|
||
TSBridge.setCallbacks(object : TSBridge.Callbacks {
|
||
// ... 已有回调 ...
|
||
|
||
override fun onClientMoved(id: Int, targetChannelID: String) {
|
||
val targetId = targetChannelID.toLongOrNull() ?: return
|
||
channelViewModel.handleClientMoved(id, targetId)
|
||
}
|
||
|
||
// ... 其他回调 ...
|
||
})
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 三、状态与数据流
|
||
|
||
### 3.1 频道切换状态机
|
||
|
||
```
|
||
┌──────────────────────────────────────┐
|
||
│ │
|
||
▼ │
|
||
┌─────────┐ │
|
||
│ Idle │◄───────────────────────────────┤
|
||
└────┬────┘ │
|
||
│ 用户点击频道 │
|
||
▼ │
|
||
┌─────────────┐ │
|
||
│ Requesting │ │
|
||
└──────┬──────┘ │
|
||
│ │
|
||
┌───────────┴───────────┐ │
|
||
│ │ │
|
||
▼ ▼ │
|
||
┌───────────┐ ┌──────────┐ │
|
||
│ Failed │ │ Waiting │ │
|
||
│ │ │ Server │ │
|
||
└─────┬─────┘ │ Event │ │
|
||
│ └────┬─────┘ │
|
||
│ │ │
|
||
│ ┌─────────────────┤ │
|
||
│ │ │ │
|
||
│ ▼ ▼ │
|
||
│ 超时 OnClientMoved │
|
||
│ │ (selfID match) │
|
||
│ │ │ │
|
||
└───┴─────────────────┴─────────────────────────┘
|
||
```
|
||
|
||
### 3.2 数据流向
|
||
|
||
```
|
||
用户操作 ChannelViewModel TSBridge/Go 服务端
|
||
│ │ │ │
|
||
│ 点击频道 │ │ │
|
||
├───────────────────→│ │ │
|
||
│ │ │ │
|
||
│ │ 有密码? │ │
|
||
│ ├─→ 显示密码弹窗 │ │
|
||
│ 输入密码 │ │ │
|
||
├───────────────────→│ │ │
|
||
│ │ │ │
|
||
│ │ MoveToChannel(id, pwd) │ │
|
||
│ ├──────────────────────────→│ clientmove │
|
||
│ │ ├─────────────────→│
|
||
│ │ │ │
|
||
│ │ │ 命令响应 │
|
||
│ │ │←─────────────────┤
|
||
│ │ error == "" ? │ │
|
||
│ │←──────────────────────────┤ │
|
||
│ │ │ │
|
||
│ │ 状态 → WaitingServerEvent │ │
|
||
│ │ │ │
|
||
│ │ │ notifyclientmoved│
|
||
│ │ │←─────────────────┤
|
||
│ │ OnClientMoved(selfID) │ │
|
||
│ │←──────────────────────────┤ │
|
||
│ │ │ │
|
||
│ │ 更新自身频道 │ │
|
||
│ │ 状态 → Idle │ │
|
||
│ │ │ │
|
||
```
|
||
|
||
### 3.3 命令响应与事件事实的区分
|
||
|
||
**关键原则**(对应 `docs/流程/03_切换频道.md`):
|
||
|
||
| 概念 | 含义 | 处理方式 |
|
||
|------|------|----------|
|
||
| ClientMove 返回 error | 命令被服务器拒绝 | 立即显示错误,状态 → Failed |
|
||
| ClientMove 返回 nil | 命令被服务器接受 | 状态 → WaitingServerEvent,继续等待 |
|
||
| OnClientMoved(selfID) | 服务器确认移动完成 | 更新本地频道事实,状态 → Idle |
|
||
|
||
**为什么不能用命令响应直接更新频道?**
|
||
|
||
- 命令响应只表示服务器接受了请求
|
||
- 实际移动可能因权限、密码、容量等原因被延迟拒绝
|
||
- 只有服务端推送的 `notifyclientmoved` 事件才是最终事实
|
||
|
||
---
|
||
|
||
## 四、验收标准
|
||
|
||
### 功能验收
|
||
|
||
- [ ] **无密码频道切换**
|
||
- 点击无密码频道 → 直接发送 ClientMove
|
||
- 显示切换中进度指示
|
||
- 收到 OnClientMoved 后切换完成
|
||
- 当前频道栏更新为目标频道
|
||
|
||
- [ ] **有密码频道切换**
|
||
- 点击有密码频道 → 弹出密码输入框
|
||
- 输入密码后发送 ClientMove
|
||
- 密码错误 → 显示错误提示,清空输入框,允许重试
|
||
- 点击取消 → 关闭弹窗,不发送命令
|
||
|
||
- [ ] **切换状态管理**
|
||
- 切换中禁止发起新的切换
|
||
- 切换超时(10秒)显示失败提示
|
||
- 失败后可重试或取消
|
||
- 被管理员移动时正确更新状态
|
||
|
||
- [ ] **自身状态同步**
|
||
- 只有匹配 selfID 的 OnClientMoved 才更新自身频道
|
||
- 命令响应不直接提交频道事实
|
||
- 切换完成后清除目标频道的未读标记
|
||
|
||
### 错误处理验收
|
||
|
||
| 错误场景 | 预期行为 |
|
||
|----------|----------|
|
||
| 密码错误 | 弹窗显示错误,清空输入框 |
|
||
| 频道已满 | Snackbar 提示"频道已满" |
|
||
| 权限不足 | Snackbar 提示"权限不足" |
|
||
| 网络超时 | 10秒后显示超时提示,可重试 |
|
||
| 被管理员移动 | 静默更新当前频道 |
|
||
|
||
### 性能验收
|
||
|
||
- [ ] 切换响应时间 < 100ms(UI 反馈)
|
||
- [ ] 服务端确认时间 < 3s(正常网络)
|
||
- [ ] 密码弹窗弹出/关闭动画流畅
|
||
|
||
### 测试用例
|
||
|
||
| 场景 | 操作 | 预期结果 |
|
||
|------|------|----------|
|
||
| 无密码切换 | 点击无密码频道 | 进度条 → 切换完成 → 当前频道更新 |
|
||
| 有密码切换 | 点击有密码频道 → 输入密码 → 点击进入 | 密码弹窗 → 进度条 → 切换完成 |
|
||
| 密码错误 | 输入错误密码 | 弹窗显示错误,清空输入框 |
|
||
| 取消密码 | 点击取消 | 弹窗关闭,无网络请求 |
|
||
| 切换超时 | 断网后切换 | 10秒后显示超时提示 |
|
||
| 重试切换 | 失败后点击重试 | 重新发送 ClientMove |
|
||
| 被管理员移动 | 管理员移动你到其他频道 | 当前频道静默更新 |
|
||
| 重复点击 | 快速点击多个频道 | 只处理第一次点击 |
|
||
| 切换中点击 | 切换进行中点击其他频道 | 忽略点击 |
|
||
|
||
---
|
||
|
||
## 五、参考文档
|
||
|
||
- `docs/流程/03_切换频道.md` - 时序图、状态机、事件依赖
|
||
- `docs/流程/08_状态同步.md` - ④ 自身状态同步
|
||
- `docs/UI架构设计.md` - 2.2 频道列表页交互、4.2 密码弹窗
|
||
- `docs/sdk文档-go.md` - ClientMove API、OnClientMoved 事件
|
||
- `docs/implementation/02_Bridge层实现.md` - MoveToChannel、onClientMoved 回调
|
||
- `docs/implementation/05_频道列表页.md` - ChannelViewModel、ChannelListScreen
|