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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user