872 lines
30 KiB
Markdown
872 lines
30 KiB
Markdown
# 步骤 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<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 接口
|