From 029abaa60cd0c518ee2050e2db19b05b5f923704 Mon Sep 17 00:00:00 2001 From: renjianbo <263303411@qq.com> Date: Wed, 1 Jul 2026 22:28:31 +0800 Subject: [PATCH] feat: add Account Security screen (change password, bind phone, delete account) Co-Authored-By: Claude Opus 4.6 --- .../aiagent/data/remote/ApiService.kt | 9 + .../tiangong/aiagent/data/remote/dto/Dtos.kt | 18 + .../aiagent/ui/navigation/NavGraph.kt | 16 +- .../ui/settings/AccountSecurityScreen.kt | 329 ++++++++++++++++++ .../aiagent/ui/settings/SettingsScreen.kt | 13 + 5 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/com/tiangong/aiagent/ui/settings/AccountSecurityScreen.kt diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt b/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt index 09fb835..c082bbf 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt @@ -26,6 +26,15 @@ interface ApiService { @POST("api/v1/auth/revoke") suspend fun revokeToken(@Body request: RefreshRequest): Response + @PUT("api/v1/auth/change-password") + suspend fun changePassword(@Body request: ChangePasswordRequest): MessageResponse + + @PUT("api/v1/auth/phone") + suspend fun bindPhone(@Body request: BindPhoneRequest): MessageResponse + + @DELETE("api/v1/auth/account") + suspend fun deleteAccount(@Body request: DeleteAccountRequest): MessageResponse + // ─── Agents ─── @GET("api/v1/agents") suspend fun getAgents( diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt b/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt index d663193..d2a5ffe 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt @@ -20,6 +20,24 @@ data class RefreshResponse( @SerializedName("refresh_token") val refreshToken: String? = null ) +data class ChangePasswordRequest( + @SerializedName("old_password") val oldPassword: String, + @SerializedName("new_password") val newPassword: String +) + +data class BindPhoneRequest( + @SerializedName("phone") val phone: String, + @SerializedName("code") val code: String? = null +) + +data class DeleteAccountRequest( + @SerializedName("password") val password: String +) + +data class MessageResponse( + @SerializedName("message") val message: String +) + data class UserResponse( val id: String, val username: String, diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/navigation/NavGraph.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/navigation/NavGraph.kt index a615619..f8e0ce4 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/navigation/NavGraph.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/navigation/NavGraph.kt @@ -21,6 +21,7 @@ import com.tiangong.aiagent.ui.notifications.NotificationDetailScreen import com.tiangong.aiagent.ui.notifications.NotificationsScreen import com.tiangong.aiagent.ui.register.RegisterScreen import com.tiangong.aiagent.ui.settings.AboutScreen +import com.tiangong.aiagent.ui.settings.AccountSecurityScreen import com.tiangong.aiagent.ui.settings.SettingsScreen object Routes { @@ -29,6 +30,7 @@ object Routes { const val CHAT = "chat" const val AGENTS = "agents" const val SETTINGS = "settings" + const val ACCOUNT_SECURITY = "account_security" const val HISTORY = "history" const val ABOUT = "about" const val NOTIFICATIONS = "notifications" @@ -137,7 +139,19 @@ fun NavGraph( popUpTo(0) { inclusive = true } } }, - onNavigateToAbout = { navController.navigate(Routes.ABOUT) } + onNavigateToAbout = { navController.navigate(Routes.ABOUT) }, + onNavigateToAccountSecurity = { navController.navigate(Routes.ACCOUNT_SECURITY) } + ) + } + + composable(Routes.ACCOUNT_SECURITY) { + AccountSecurityScreen( + onBack = { navController.popBackStack() }, + onAccountDeleted = { + navController.navigate(Routes.LOGIN) { + popUpTo(0) { inclusive = true } + } + } ) } diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/settings/AccountSecurityScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/settings/AccountSecurityScreen.kt new file mode 100644 index 0000000..71054d2 --- /dev/null +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/settings/AccountSecurityScreen.kt @@ -0,0 +1,329 @@ +package com.tiangong.aiagent.ui.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tiangong.aiagent.data.remote.ApiService +import com.tiangong.aiagent.data.remote.dto.* +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class AccountSecurityUiState( + // Change password + val oldPassword: String = "", + val newPassword: String = "", + val confirmNewPassword: String = "", + // Bind phone + val phone: String = "", + val phoneCode: String = "", + // Delete account + val deleteConfirmPassword: String = "", + // General + val isLoading: Boolean = false, + val message: String? = null, + val isSuccess: Boolean = false, + val error: String? = null, + val showDeleteDialog: Boolean = false +) + +@HiltViewModel +class AccountSecurityViewModel @Inject constructor( + private val apiService: ApiService +) : ViewModel() { + + private val _uiState = MutableStateFlow(AccountSecurityUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun updateOldPassword(v: String) { _uiState.update { it.copy(oldPassword = v, error = null) } } + fun updateNewPassword(v: String) { _uiState.update { it.copy(newPassword = v, error = null) } } + fun updateConfirmNewPassword(v: String) { _uiState.update { it.copy(confirmNewPassword = v, error = null) } } + fun updatePhone(v: String) { _uiState.update { it.copy(phone = v, error = null) } } + fun updatePhoneCode(v: String) { _uiState.update { it.copy(phoneCode = v, error = null) } } + fun updateDeleteConfirmPassword(v: String) { _uiState.update { it.copy(deleteConfirmPassword = v, error = null) } } + fun showDeleteDialog() { _uiState.update { it.copy(showDeleteDialog = true) } } + fun hideDeleteDialog() { _uiState.update { it.copy(showDeleteDialog = false) } } + fun clearMessage() { _uiState.update { it.copy(message = null, isSuccess = false) } } + + fun changePassword() { + val s = _uiState.value + if (s.oldPassword.isBlank() || s.newPassword.isBlank()) { + _uiState.update { it.copy(error = "请填写所有密码字段") } + return + } + if (s.newPassword.length < 6 || s.newPassword.length > 32) { + _uiState.update { it.copy(error = "新密码需要6-32个字符") } + return + } + if (s.newPassword != s.confirmNewPassword) { + _uiState.update { it.copy(error = "两次输入的新密码不一致") } + return + } + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + try { + val resp = apiService.changePassword( + ChangePasswordRequest(s.oldPassword, s.newPassword) + ) + _uiState.update { it.copy(isLoading = false, message = resp.message, isSuccess = true, + oldPassword = "", newPassword = "", confirmNewPassword = "") } + } catch (e: Exception) { + _uiState.update { it.copy(isLoading = false, error = e.message ?: "修改失败") } + } + } + } + + fun bindPhone() { + val s = _uiState.value + if (!Regex("^1[3-9]\\d{9}$").matches(s.phone.trim())) { + _uiState.update { it.copy(error = "请输入有效的手机号") } + return + } + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + try { + val resp = apiService.bindPhone(BindPhoneRequest(s.phone.trim(), s.phoneCode.ifBlank { null })) + _uiState.update { it.copy(isLoading = false, message = resp.message, isSuccess = true, + phone = "", phoneCode = "") } + } catch (e: Exception) { + _uiState.update { it.copy(isLoading = false, error = e.message ?: "绑定失败") } + } + } + } + + fun deleteAccount() { + val s = _uiState.value + if (s.deleteConfirmPassword.isBlank()) { + _uiState.update { it.copy(error = "请输入密码确认") } + return + } + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + try { + val resp = apiService.deleteAccount(DeleteAccountRequest(s.deleteConfirmPassword)) + _uiState.update { it.copy(isLoading = false, message = resp.message, isSuccess = true, + showDeleteDialog = false) } + } catch (e: Exception) { + _uiState.update { it.copy(isLoading = false, error = e.message ?: "注销失败", showDeleteDialog = false) } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountSecurityScreen( + onBack: () -> Unit, + onAccountDeleted: () -> Unit, + viewModel: AccountSecurityViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + var oldPwdVisible by remember { mutableStateOf(false) } + var newPwdVisible by remember { mutableStateOf(false) } + var confirmNewPwdVisible by remember { mutableStateOf(false) } + var deletePwdVisible by remember { mutableStateOf(false) } + + LaunchedEffect(uiState.message) { + uiState.message?.let { + snackbarHostState.showSnackbar(it) + viewModel.clearMessage() + } + } + + LaunchedEffect(uiState.isSuccess) { + if (uiState.isSuccess && uiState.message?.contains("注销") == true) { + onAccountDeleted() + } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text("账号安全") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // ─── 修改密码 ─── + Text("修改密码", style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = uiState.oldPassword, + onValueChange = viewModel::updateOldPassword, + label = { Text("旧密码") }, + singleLine = true, + visualTransformation = if (oldPwdVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { oldPwdVisible = !oldPwdVisible }) { + Icon(if (oldPwdVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, null) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isLoading + ) + OutlinedTextField( + value = uiState.newPassword, + onValueChange = viewModel::updateNewPassword, + label = { Text("新密码") }, + singleLine = true, + visualTransformation = if (newPwdVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { newPwdVisible = !newPwdVisible }) { + Icon(if (newPwdVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, null) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isLoading + ) + OutlinedTextField( + value = uiState.confirmNewPassword, + onValueChange = viewModel::updateConfirmNewPassword, + label = { Text("确认新密码") }, + singleLine = true, + visualTransformation = if (confirmNewPwdVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { confirmNewPwdVisible = !confirmNewPwdVisible }) { + Icon(if (confirmNewPwdVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, null) + } + }, + isError = uiState.confirmNewPassword.isNotEmpty() && uiState.newPassword != uiState.confirmNewPassword, + supportingText = if (uiState.confirmNewPassword.isNotEmpty() && uiState.newPassword != uiState.confirmNewPassword) { + { Text("两次输入不一致") } + } else null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isLoading + ) + Button( + onClick = viewModel::changePassword, + enabled = !uiState.isLoading, + modifier = Modifier.fillMaxWidth() + ) { + if (uiState.isLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + else Text("修改密码") + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + // ─── 绑定手机号 ─── + Text("绑定手机号", style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = uiState.phone, + onValueChange = viewModel::updatePhone, + label = { Text("手机号") }, + placeholder = { Text("输入11位手机号") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone, imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isLoading + ) + Button( + onClick = viewModel::bindPhone, + enabled = !uiState.isLoading, + modifier = Modifier.fillMaxWidth() + ) { + Text("绑定手机号") + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + // ─── 错误提示 ─── + uiState.error?.let { error -> + Text(error, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // ─── 注销账号 ─── + Text("危险操作", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.error) + Button( + onClick = viewModel::showDeleteDialog, + enabled = !uiState.isLoading, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + modifier = Modifier.fillMaxWidth() + ) { + Text("注销账号") + } + + Spacer(modifier = Modifier.height(32.dp)) + } + } + + // ─── 注销确认对话框 ─── + if (uiState.showDeleteDialog) { + AlertDialog( + onDismissRequest = viewModel::hideDeleteDialog, + title = { Text("注销账号", color = MaterialTheme.colorScheme.error) }, + text = { + Column { + Text("注销后账号将无法恢复,所有数据将被清除。请确认这是您的最终决定。") + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = uiState.deleteConfirmPassword, + onValueChange = viewModel::updateDeleteConfirmPassword, + label = { Text("输入密码确认") }, + singleLine = true, + visualTransformation = if (deletePwdVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { deletePwdVisible = !deletePwdVisible }) { + Icon(if (deletePwdVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, null) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isLoading + ) + } + }, + confirmButton = { + TextButton( + onClick = viewModel::deleteAccount, + enabled = !uiState.isLoading + ) { + Text("确认注销", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = viewModel::hideDeleteDialog) { + Text("取消") + } + } + ) + } +} diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/settings/SettingsScreen.kt index 956caa0..fa75598 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/settings/SettingsScreen.kt @@ -148,6 +148,7 @@ fun SettingsScreen( onBack: () -> Unit, onLogout: () -> Unit, onNavigateToAbout: (() -> Unit)? = null, + onNavigateToAccountSecurity: (() -> Unit)? = null, viewModel: SettingsViewModel = hiltViewModel() ) { var showLogoutDialog by remember { mutableStateOf(false) } @@ -192,6 +193,18 @@ fun SettingsScreen( } ) + if (onNavigateToAccountSecurity != null) { + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + ListItem( + headlineContent = { Text("账号安全") }, + supportingContent = { Text("修改密码、绑定手机、注销账号") }, + leadingContent = { + Icon(Icons.Default.Security, contentDescription = null) + }, + modifier = Modifier.clickable { onNavigateToAccountSecurity() } + ) + } + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) // Preferences section