# 步骤 03:服务器配置页 > 实现服务器配置页 UI,包括输入验证、连接按钮状态机、最近连接列表。 > 对应流程:01 连接服务器(初始化配置部分) > 依赖步骤:02(Bridge 层) --- ## 一、目标 - [ ] ServerConfigScreen 三段式页面布局(品牌区 / 输入区 / 最近连接) - [ ] 输入框组件(地址、昵称、密码) - [ ] 输入验证逻辑(必填校验、格式校验) - [ ] 连接按钮状态机(空闲 → 连接中 → 成功/失败/超时) - [ ] 最近连接列表(DataStore 持久化,快速连接) - [ ] ServerViewModel 状态管理 --- ## 二、任务清单 ### 3.1 数据模型 **文件**:`android/app/src/main/java/com/tsmobile/app/data/Models.kt` ```kotlin 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 持久化最近连接列表。 ```kotlin 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 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> { return context.recentConnectionsDataStore.data.map { prefs -> val json = prefs[RECENTS_KEY] ?: return@map emptyList() try { Json.decodeFromString>(json) .sortedByDescending { it.lastConnectedAt } } catch (_: Exception) { emptyList() } } } /** * 记录一次连接(成功或失败)。 * 相同 address + nickname 去重,保留最新记录。 */ suspend fun addRecent(recent: RecentConnection) { context.recentConnectionsDataStore.edit { prefs -> val current = try { Json.decodeFromString>(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>(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 结构 ```kotlin @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() } } } ``` #### 品牌区组件 ```kotlin @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 = "切换主题") } } } ``` #### 输入区组件 ```kotlin @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, ) } } ``` #### 最近连接组件 ```kotlin @Composable private fun RecentConnectionsSection( recents: List, 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) | "请输入有效的服务器地址" | | 昵称 | 是 | 非空,满足服务器命名规则 | "请输入昵称" | | 密码 | 否 | 仅当服务器需要密码时必填 | "该服务器需要密码"(连接时由服务端返回) | **验证时机**:用户点击"连接"时一次性校验,不实时校验(避免打断输入流)。 ```kotlin 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 节): ```kotlin 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" | "服务器已满" | | 其他 | 默认 | 原始错误信息 | ```kotlin @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` ```kotlin 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 = _state.asStateFlow() // 最近连接列表 val recents: StateFlow> = 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 相对时间格式化 ```kotlin /** * 格式化时间戳为相对时间描述。 * 例:刚刚、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 接口