# 步骤 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 接口
