feat: add Account Security screen (change password, bind phone, delete account)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 22:28:31 +08:00
parent 50de6f3dee
commit 029abaa60c
5 changed files with 384 additions and 1 deletions

View File

@@ -26,6 +26,15 @@ interface ApiService {
@POST("api/v1/auth/revoke")
suspend fun revokeToken(@Body request: RefreshRequest): Response<Unit>
@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(

View File

@@ -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,

View File

@@ -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 }
}
}
)
}

View File

@@ -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<AccountSecurityUiState> = _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("取消")
}
}
)
}
}

View File

@@ -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