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

872 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 步骤 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 接口