Files

872 lines
30 KiB
Markdown
Raw Permalink Normal View History

2026-07-20 19:01:03 +08:00
# 步骤 03:服务器配置页
> 实现服务器配置页 UI,包括输入验证、连接按钮状态机、最近连接列表。
> 对应流程:01 连接服务器(初始化配置部分)
> 依赖步骤:02Bridge 层)
---
## 一、目标
- [ ] 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<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 结构
```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<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) | "请输入有效的服务器地址" |
| 昵称 | 是 | 非空,满足服务器命名规则 | "请输入昵称" |
| 密码 | 否 | 仅当服务器需要密码时必填 | "该服务器需要密码"(连接时由服务端返回) |
**验证时机**:用户点击"连接"时一次性校验,不实时校验(避免打断输入流)。
```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<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 相对时间格式化
```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 接口