Files
ts-mobile-go/docs/implementation/03_服务器配置页.md
T
2026-07-20 19:01:03 +08:00

30 KiB
Raw Blame History

步骤 03:服务器配置页

实现服务器配置页 UI,包括输入验证、连接按钮状态机、最近连接列表。 对应流程:01 连接服务器(初始化配置部分) 依赖步骤:02(Bridge 层)


一、目标

  • ServerConfigScreen 三段式页面布局(品牌区 / 输入区 / 最近连接)
  • 输入框组件(地址、昵称、密码)
  • 输入验证逻辑(必填校验、格式校验)
  • 连接按钮状态机(空闲 → 连接中 → 成功/失败/超时)
  • 最近连接列表(DataStore 持久化,快速连接)
  • ServerViewModel 状态管理

二、任务清单

3.1 数据模型

文件android/app/src/main/java/com/tsmobile/app/data/Models.kt

import kotlinx.serialization.Serializable

/**
 * 服务器连接配置。
 * 用于 ViewModel 状态和最近连接列表持久化。
 */
@Serializable
data class ServerConfig(
    val address: String = "",       // 服务器地址(域名/IP/TSDNS
    val nickname: String = "",      // 昵称
    val password: String = "",      // 服务器密码(可选)
    val defaultChannel: String = "",         // 默认频道(可选)
    val defaultChannelPassword: String = "", // 默认频道密码(可选)
)

/**
 * 最近连接记录。
 * 点击可快速连接(复用 address/nickname/password)。
 */
@Serializable
data class RecentConnection(
    val address: String,
    val nickname: String,
    val password: String = "",
    val lastConnectedAt: Long = 0L,  // 最后连接时间戳(epoch ms
    val lastSucceeded: Boolean = false, // 上次连接是否成功
)

3.2 最近连接存储

文件android/app/src/main/java/com/tsmobile/app/data/RecentConnectionsStore.kt

使用 Jetpack DataStore Preferences 持久化最近连接列表。

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.*
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

// Context 扩展属性
private val Context.recentConnectionsDataStore: DataStore<Preferences>
    by preferencesDataStore(name = "recent_connections")

class RecentConnectionsStore(private val context: Context) {

    companion object {
        private const val MAX_RECENT = 10
        private val RECENTS_KEY = stringPreferencesKey("recents_json")
    }

    /**
     * 观察最近连接列表(按 lastConnectedAt 倒序)。
     */
    fun observeRecents(): Flow<List<RecentConnection>> {
        return context.recentConnectionsDataStore.data.map { prefs ->
            val json = prefs[RECENTS_KEY] ?: return@map emptyList()
            try {
                Json.decodeFromString<List<RecentConnection>>(json)
                    .sortedByDescending { it.lastConnectedAt }
            } catch (_: Exception) {
                emptyList()
            }
        }
    }

    /**
     * 记录一次连接(成功或失败)。
     * 相同 address + nickname 去重,保留最新记录。
     */
    suspend fun addRecent(recent: RecentConnection) {
        context.recentConnectionsDataStore.edit { prefs ->
            val current = try {
                Json.decodeFromString<List<RecentConnection>>(prefs[RECENTS_KEY] ?: "[]")
            } catch (_: Exception) {
                emptyList()
            }.toMutableList()

            // 去重:移除相同 address + nickname 的旧记录
            current.removeAll { it.address == recent.address && it.nickname == recent.nickname }
            current.add(0, recent) // 插入到头部

            // 限制最多 MAX_RECENT 条
            val trimmed = current.take(MAX_RECENT)
            prefs[RECENTS_KEY] = Json.encodeToString(trimmed)
        }
    }

    /**
     * 删除单条记录。
     */
    suspend fun removeRecent(address: String, nickname: String) {
        context.recentConnectionsDataStore.edit { prefs ->
            val current = try {
                Json.decodeFromString<List<RecentConnection>>(prefs[RECENTS_KEY] ?: "[]")
            } catch (_: Exception) {
                emptyList()
            }.toMutableList()

            current.removeAll { it.address == address && it.nickname == nickname }
            prefs[RECENTS_KEY] = Json.encodeToString(current)
        }
    }

    /**
     * 清空所有记录。
     */
    suspend fun clearAll() {
        context.recentConnectionsDataStore.edit { prefs ->
            prefs.remove(RECENTS_KEY)
        }
    }
}

存储方案选择

方案 优缺点 结论
SharedPreferences 简单,但已弃用
DataStore Preferences 现代、协程友好、类型安全 采用
Room DB 过重,数据量小(最多 10 条)

3.3 页面布局

文件android/app/src/main/java/com/tsmobile/app/ui/screens/ServerConfigScreen.kt

三段式结构

┌──────────────────────────────┐
│           上:品牌区          │
│   [Logo]                     │
│   TeamSpeak Mobile           │
│   连接到你的 TeamSpeak 服务器 │
│                    [🌙 主题] │  ← 右上角主题切换
├──────────────────────────────┤
│           中:输入区          │
│  服务器地址                   │
│  ┌──────────────────────────┐│
│  │ ts.example.com           ││
│  └──────────────────────────┘│
│  昵称                        │
│  ┌──────────────────────────┐│
│  │ 我的昵称                  ││
│  └──────────────────────────┘│
│  密码(可选)                 │
│  ┌──────────────────────────┐│
│  │ ••••••                   ││
│  └──────────────────────────┘│
│  ┌──────────────────────────┐│
│  │      连接服务器           ││  ← 按钮状态见 3.5
│  └──────────────────────────┘│
├──────────────────────────────┤
│           下:最近连接        │
│  最近连接                     │
│  ┌──────────────────────────┐│
│  │ 🟢 ts.myserver.com       ││  ← 点击快速连接
│  │    MyNickname · 2小时前   ││
│  ├──────────────────────────┤│
│  │ 🔴 ts.other.com          ││
│  │    Bob · 昨天             ││
│  └──────────────────────────┘│
│  [清空最近记录]               │  ← 长按删除单条
└──────────────────────────────┘

Compose 结构

@Composable
fun ServerConfigScreen(
    viewModel: ServerViewModel,
    onNavigateToChannelList: () -> Unit, // 连接成功后跳转
) {
    val state by viewModel.state.collectAsState()
    val recents by viewModel.recents.collectAsState()

    Column(modifier = Modifier.fillMaxSize()) {
        // 上:品牌区
        BrandSection(
            onToggleTheme = { viewModel.toggleTheme() }
        )

        // 中:输入区
        InputSection(
            address = state.address,
            nickname = state.nickname,
            password = state.password,
            onAddressChange = viewModel::updateAddress,
            onNicknameChange = viewModel::updateNickname,
            onPasswordChange = viewModel::updatePassword,
            connectState = state.connectState,
            errorMessage = state.errorMessage,
            onConnect = { viewModel.connect() },
        )

        // 下:最近连接
        RecentConnectionsSection(
            recents = recents,
            onConnectRecent = { recent -> viewModel.quickConnect(recent) },
            onRemoveRecent = { recent -> viewModel.removeRecent(recent) },
            onClearAll = { viewModel.clearRecents() },
        )
    }

    // 连接成功后自动跳转
    LaunchedEffect(state.connectState) {
        if (state.connectState == ConnectState.SUCCESS) {
            onNavigateToChannelList()
        }
    }
}

品牌区组件

@Composable
private fun BrandSection(onToggleTheme: () -> Unit) {
    Box(modifier = Modifier.fillMaxWidth().padding(top = 48.dp)) {
        Column(
            modifier = Modifier.align(Alignment.Center),
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
            // Logo(使用 drawable 资源或 placeholder
            Icon(
                imageVector = Icons.Default.Dns, // 临时图标
                contentDescription = "TeamSpeak",
                modifier = Modifier.size(72.dp),
                tint = MaterialTheme.colorScheme.primary,
            )
            Spacer(Modifier.height(12.dp))
            Text(
                text = "TeamSpeak Mobile",
                style = MaterialTheme.typography.headlineMedium,
                fontWeight = FontWeight.Bold,
            )
            Text(
                text = "连接到你的 TeamSpeak 服务器",
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
            )
        }
        // 主题切换按钮(右上角)
        IconButton(
            onClick = onToggleTheme,
            modifier = Modifier.align(Alignment.TopEnd).padding(end = 8.dp),
        ) {
            Icon(Icons.Default.DarkMode, contentDescription = "切换主题")
        }
    }
}

输入区组件

@Composable
private fun InputSection(
    address: String,
    nickname: String,
    password: String,
    onAddressChange: (String) -> Unit,
    onNicknameChange: (String) -> Unit,
    onPasswordChange: (String) -> Unit,
    connectState: ConnectState,
    errorMessage: String?,
    onConnect: () -> Unit,
) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 24.dp, vertical = 16.dp),
    ) {
        // 服务器地址
        OutlinedTextField(
            value = address,
            onValueChange = onAddressChange,
            label = { Text("服务器地址") },
            placeholder = { Text("ts.example.com") },
            singleLine = true,
            isError = connectState == ConnectState.FAILED && address.isBlank(),
            modifier = Modifier.fillMaxWidth(),
            keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
        )

        Spacer(Modifier.height(12.dp))

        // 昵称
        OutlinedTextField(
            value = nickname,
            onValueChange = onNicknameChange,
            label = { Text("昵称") },
            placeholder = { Text("我的昵称") },
            singleLine = true,
            isError = connectState == ConnectState.FAILED && nickname.isBlank(),
            modifier = Modifier.fillMaxWidth(),
            keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
        )

        Spacer(Modifier.height(12.dp))

        // 密码(可选)
        OutlinedTextField(
            value = password,
            onValueChange = onPasswordChange,
            label = { Text("密码(可选)") },
            placeholder = { Text("••••••") },
            singleLine = true,
            visualTransformation = PasswordVisualTransformation(),
            modifier = Modifier.fillMaxWidth(),
            keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
        )

        Spacer(Modifier.height(24.dp))

        // 连接按钮(状态机驱动)
        ConnectButton(
            state = connectState,
            errorMessage = errorMessage,
            onClick = onConnect,
        )
    }
}

最近连接组件

@Composable
private fun RecentConnectionsSection(
    recents: List<RecentConnection>,
    onConnectRecent: (RecentConnection) -> Unit,
    onRemoveRecent: (RecentConnection) -> Unit,
    onClearAll: () -> Unit,
) {
    if (recents.isEmpty()) return

    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 24.dp, vertical = 8.dp),
    ) {
        Text(
            text = "最近连接",
            style = MaterialTheme.typography.titleSmall,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
        )
        Spacer(Modifier.height(8.dp))

        recents.forEach { recent ->
            RecentConnectionItem(
                recent = recent,
                onClick = { onConnectRecent(recent) },
                onLongClick = { onRemoveRecent(recent) },
            )
            Spacer(Modifier.height(4.dp))
        }

        Spacer(Modifier.height(8.dp))
        TextButton(
            onClick = onClearAll,
            modifier = Modifier.align(Alignment.CenterHorizontally),
        ) {
            Text("清空最近记录")
        }
    }
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun RecentConnectionItem(
    recent: RecentConnection,
    onClick: () -> Unit,
    onLongClick: () -> Unit,
) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .combinedClickable(onClick = onClick, onLongClick = onLongClick),
    ) {
        Row(
            modifier = Modifier.padding(12.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            // 状态指示灯
            Box(
                modifier = Modifier
                    .size(8.dp)
                    .background(
                        color = if (recent.lastSucceeded)
                            MaterialTheme.colorScheme.primary
                        else
                            MaterialTheme.colorScheme.error,
                        shape = CircleShape,
                    ),
            )
            Spacer(Modifier.width(12.dp))
            Column {
                Text(
                    text = recent.address,
                    style = MaterialTheme.typography.bodyMedium,
                    fontWeight = FontWeight.Medium,
                )
                Text(
                    text = "${recent.nickname} · ${formatRelativeTime(recent.lastConnectedAt)}",
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                )
            }
        }
    }
}

3.4 输入验证逻辑

验证规则(依据 UI 架构设计 2.1 节):

字段 必填 验证规则 错误提示
服务器地址 非空,格式合法(域名/IP/TSDNS) "请输入有效的服务器地址"
昵称 非空,满足服务器命名规则 "请输入昵称"
密码 仅当服务器需要密码时必填 "该服务器需要密码"(连接时由服务端返回)

验证时机:用户点击"连接"时一次性校验,不实时校验(避免打断输入流)。

data class ValidationErrors(
    val address: String? = null,
    val nickname: String? = null,
)

fun validate(config: ServerConfig): ValidationErrors {
    val errors = ValidationErrors()

    // 地址验证:非空 + 基本格式(包含字母或数字,含可选端口)
    if (config.address.isBlank()) {
        errors.copy(address = "请输入服务器地址")
    } else if (!isValidServerAddress(config.address)) {
        errors.copy(address = "请输入有效的服务器地址")
    }

    // 昵称验证:非空
    if (config.nickname.isBlank()) {
        errors.copy(nickname = "请输入昵称")
    }

    return errors
}

/**
 * 服务器地址格式验证。
 * 支持:域名、IP(v4/v6)、TSDNS、带端口号。
 */
private fun isValidServerAddress(address: String): Boolean {
    val trimmed = address.trim()
    if (trimmed.isBlank()) return false

    // 允许格式:
    // - example.com
    // - example.com:9987
    // - 192.168.1.1
    // - 192.168.1.1:9987
    // - [::1]:9987
    // - _ts3._udp.example.com (TSDNS SRV)
    val ip4Pattern = Regex("""^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?$""")
    val domainPattern = Regex("""^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*(:\d+)?$""")
    val ip6Pattern = Regex("""^\[?[a-fA-F0-9:]+\]?(:\d+)?$""")

    return ip4Pattern.matches(trimmed) ||
           domainPattern.matches(trimmed) ||
           ip6Pattern.matches(trimmed)
}

3.5 连接按钮状态机

状态定义(依据 UI 架构设计 5.1 节):

enum class ConnectState {
    IDLE,       // 空闲:等待用户输入并点击
    CONNECTING, // 连接中:Connect + WaitConnected 进行中
    SUCCESS,    // 连接成功:跳转频道列表页
    FAILED,     // 连接失败:显示错误信息和重试
    TIMEOUT,    // 连接超时:显示超时提示
}

按钮外观对应

状态 按钮文本 样式 可点击
IDLE "连接服务器" Primary Filled
CONNECTING "连接中..." Outlined + loading indicator
SUCCESS 自动跳转,按钮不显示
FAILED "连接失败,点击重试" Error container 色
TIMEOUT "连接超时,点击重试" Orange container 色

错误信息分类

错误类型 判断方式 提示信息
密码错误 含 "password" 或 "密码" "服务器密码错误"
昵称冲突 含 "nickname" 或 "昵称" "昵称已被使用,请更换"
网络不可达 含 "timeout"、"unreachable"、"network" "无法连接到服务器,请检查网络"
地址无效 含 "resolve"、"dns"、"lookup" "服务器地址无法解析"
服务器满 含 "full"、"limit" "服务器已满"
其他 默认 原始错误信息
@Composable
fun ConnectButton(
    state: ConnectState,
    errorMessage: String?,
    onClick: () -> Unit,
) {
    Button(
        onClick = onClick,
        enabled = state != ConnectState.CONNECTING,
        modifier = Modifier.fillMaxWidth().height(48.dp),
        colors = when (state) {
            ConnectState.FAILED -> ButtonDefaults.buttonColors(
                containerColor = MaterialTheme.colorScheme.errorContainer,
                contentColor = MaterialTheme.colorScheme.onErrorContainer,
            )
            ConnectState.TIMEOUT -> ButtonDefaults.buttonColors(
                containerColor = Color(0xFFFFF3E0), // 橙色背景
                contentColor = Color(0xFFE65100),
            )
            else -> ButtonDefaults.buttonColors()
        },
    ) {
        when (state) {
            ConnectState.IDLE -> Text("连接服务器")
            ConnectState.CONNECTING -> {
                CircularProgressIndicator(
                    modifier = Modifier.size(20.dp),
                    strokeWidth = 2.dp,
                    color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
                )
                Spacer(Modifier.width(8.dp))
                Text("连接中...")
            }
            ConnectState.SUCCESS -> { /* 不会到达,自动跳转 */ }
            ConnectState.FAILED -> {
                Text(errorMessage ?: "连接失败,点击重试")
            }
            ConnectState.TIMEOUT -> Text("连接超时,点击重试")
        }
    }
}

3.6 ViewModel 状态管理

文件android/app/src/main/java/com/tsmobile/app/viewmodel/ServerViewModel.kt

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

data class ServerScreenState(
    val address: String = "",
    val nickname: String = "",
    val password: String = "",
    val connectState: ConnectState = ConnectState.IDLE,
    val errorMessage: String? = null,
    val validationErrors: ValidationErrors = ValidationErrors(),
)

class ServerViewModel(application: Application) : AndroidViewModel(application) {

    private val recentStore = RecentConnectionsStore(application)

    // 页面状态
    private val _state = MutableStateFlow(ServerScreenState())
    val state: StateFlow<ServerScreenState> = _state.asStateFlow()

    // 最近连接列表
    val recents: StateFlow<List<RecentConnection>> =
        recentStore.observeRecents()
            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

    // --- 输入更新 ---

    fun updateAddress(value: String) {
        _state.update { it.copy(address = value, errorMessage = null) }
    }

    fun updateNickname(value: String) {
        _state.update { it.copy(nickname = value, errorMessage = null) }
    }

    fun updatePassword(value: String) {
        _state.update { it.copy(password = value) }
    }

    // --- 连接 ---

    fun connect() {
        val current = _state.value
        if (current.connectState == ConnectState.CONNECTING) return

        // 输入验证
        val config = ServerConfig(
            address = current.address.trim(),
            nickname = current.nickname.trim(),
            password = current.password,
        )
        val errors = validate(config)
        if (errors.address != null || errors.nickname != null) {
            _state.update { it.copy(
                validationErrors = errors,
                connectState = ConnectState.IDLE,
                errorMessage = errors.address ?: errors.nickname,
            )}
            return
        }

        // 进入连接中状态
        _state.update { it.copy(
            connectState = ConnectState.CONNECTING,
            errorMessage = null,
            validationErrors = ValidationErrors(),
        )}

        // 异步连接(调用 TSBridge
        viewModelScope.launch {
            val result = TSBridge.connect(
                host = config.address,
                nickname = config.nickname,
                password = config.password,
                callbacks = createBridgeCallbacks(),
            )

            if (result.isEmpty()) {
                // 连接成功(实际成功由 onConnected 回调确认)
                // 此处 Connect 已成功启动,等待 WaitConnected
            } else {
                // 连接失败
                val errorMsg = classifyError(result)
                _state.update { it.copy(
                    connectState = ConnectState.FAILED,
                    errorMessage = errorMsg,
                )}

                // 记录到最近连接(标记失败)
                recentStore.addRecent(RecentConnection(
                    address = config.address,
                    nickname = config.nickname,
                    password = config.password,
                    lastConnectedAt = System.currentTimeMillis(),
                    lastSucceeded = false,
                ))
            }
        }
    }

    /**
     * 最近连接快速连接。
     * 自动填充所有字段并触发连接。
     */
    fun quickConnect(recent: RecentConnection) {
        _state.update { it.copy(
            address = recent.address,
            nickname = recent.nickname,
            password = recent.password,
        )}
        connect()
    }

    fun removeRecent(recent: RecentConnection) {
        viewModelScope.launch {
            recentStore.removeRecent(recent.address, recent.nickname)
        }
    }

    fun clearRecents() {
        viewModelScope.launch {
            recentStore.clearAll()
        }
    }

    // --- Bridge 回调 ---

    private fun createBridgeCallbacks(): TSBridge.Callbacks = object : TSBridge.Callbacks {
        override fun onConnected() {
            _state.update { it.copy(connectState = ConnectState.SUCCESS) }

            // 记录到最近连接(标记成功)
            viewModelScope.launch {
                recentStore.addRecent(RecentConnection(
                    address = _state.value.address.trim(),
                    nickname = _state.value.nickname.trim(),
                    password = _state.value.password,
                    lastConnectedAt = System.currentTimeMillis(),
                    lastSucceeded = true,
                ))
            }
        }

        override fun onDisconnected(message: String) {
            // 连接阶段断开视为失败
            if (_state.value.connectState == ConnectState.CONNECTING) {
                _state.update { it.copy(
                    connectState = ConnectState.FAILED,
                    errorMessage = classifyError(message),
                )}
            }
        }

        override fun onTextMessage(msg: TextMsg) { /* 此阶段不处理 */ }
        override fun onClientEnter(client: Client) { /* 此阶段不处理 */ }
        override fun onClientLeave(id: Int, reasonMsg: String) { /* 此阶段不处理 */ }
        override fun onClientMoved(id: Int, targetChannelID: String) { /* 此阶段不处理 */ }
        override fun onKicked(reason: String) { /* 此阶段不处理 */ }
        override fun onVoiceData(clientID: Int, data: ByteArray, codec: Int) { /* 此阶段不处理 */ }
    }

    // --- 错误分类 ---

    private fun classifyError(raw: String): String {
        val lower = raw.lowercase()
        return when {
            "password" in lower || "密码" in lower -> "服务器密码错误"
            "nickname" in lower || "昵称" in lower -> "昵称已被使用,请更换"
            "timeout" in lower || "unreachable" in lower || "network" in lower ->
                "无法连接到服务器,请检查网络"
            "resolve" in lower || "dns" in lower || "lookup" in lower ->
                "服务器地址无法解析"
            "full" in lower || "limit" in lower -> "服务器已满"
            else -> raw
        }
    }
}

3.7 相对时间格式化

/**
 * 格式化时间戳为相对时间描述。
 * 例:刚刚、5分钟前、2小时前、昨天、3天前、2024-01-15
 */
fun formatRelativeTime(timestamp: Long): String {
    if (timestamp <= 0) return ""
    val now = System.currentTimeMillis()
    val diff = now - timestamp

    return when {
        diff < 60_000L -> "刚刚"
        diff < 3_600_000L -> "${diff / 60_000}分钟前"
        diff < 86_400_000L -> "${diff / 3_600_000}小时前"
        diff < 172_800_000L -> "昨天"
        diff < 604_800_000L -> "${diff / 86_400_000}天前"
        else -> {
            val sdf = java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault())
            sdf.format(java.util.Date(timestamp))
        }
    }
}

三、连接流程数据流

用户点击 "连接服务器"
    │
    ▼
ServerViewModel.connect()
    │
    ├─ validate(config)
    │   ├─ 失败 → 显示验证错误,状态保持 IDLE
    │   └─ 通过 ↓
    │
    ├─ state → CONNECTING(按钮显示 "连接中...",禁用)
    │
    ├─ TSBridge.connect(host, nickname, password, callbacks)
    │   │
    │   ▼
    │   Go: TSClient.Connect(...)
    │   │
    │   ├─ 返回 ""(启动成功)→ 等待回调
    │   │   ├─ callbacks.onConnected() → state → SUCCESS → 跳转频道列表页
    │   │   └─ callbacks.onDisconnected(msg) → state → FAILED → 显示错误
    │   │
    │   └─ 返回 "error msg"(启动失败)→ state → FAILED → 显示错误
    │
    ├─ 记录到 RecentConnectionsStore
    │   └─ 成功:lastSucceeded = true(绿色)
    │   └─ 失败:lastSucceeded = false(红色)
    │
    └─ 用户可重试(点击按钮,state 回到 IDLE → 重新走连接流程)

四、文件清单

文件 说明
data/Models.kt ServerConfig、RecentConnection 数据类
data/RecentConnectionsStore.kt DataStore 持久化最近连接
ui/screens/ServerConfigScreen.kt 页面 Composable(品牌区 + 输入区 + 最近连接)
ui/components/ConnectButton.kt 连接按钮状态机组件
viewmodel/ServerViewModel.kt 状态管理、验证、连接、错误分类
ui/navigation/NavGraph.kt 导航路由(步骤 01 已建,此处补充配置页路由)

五、验收标准

# 验证项 验证方法
1 页面布局正确 启动应用,确认三段式布局(品牌/输入/最近连接)
2 输入验证生效 地址为空点击连接 → 提示 "请输入服务器地址";昵称为空 → 提示 "请输入昵称"
3 按钮状态机正确 点击连接 → 按钮变为 "连接中..." 并禁用 → 成功跳转 / 失败显示错误
4 错误信息分类正确 输入错误密码连接 → 显示 "服务器密码错误"
5 最近连接记录 连接成功/失败后返回配置页,列表显示对应记录
6 最近连接快速连接 点击最近连接条目 → 自动填充并触发连接
7 最近连接删除 长按条目 → 删除;点击 "清空" → 全部清空
8 最多 10 条记录 连接超过 10 个不同服务器,列表只保留最新 10 条
9 状态灯颜色 成功的记录显示绿色,失败的显示红色
10 主题切换 点击右上角 🌙 → 主题切换,状态持久化

六、参考文档

  • docs/UI架构设计.md — 2.1 服务器配置页(布局、验证规则、最近连接)
  • docs/流程/01_连接服务器.md — 初始化配置、状态树、连接时序
  • docs/implementation/02_Bridge层实现.md — TSBridge API 接口