task(f4b5cc37-e27d-480a-ac94-f78a8b24a4d1): Implement M1 account and session module with e2e tests
- Add AuthApi with 5 endpoints (LoginByAccount, LoginByMobile, etc.) - Implement dual-layer envelope unwrapping in ApiCaller for HTTP 500 + ASP.NET exceptions - Add DynamicBaseUrlInterceptor for runtime server switching - Implement SessionRepository with login state management and token persistence - Add Mine/Login/Config/ChangePassword UI with ViewBinding and state machines - Replace placeholder MineFragment with business implementation - Hide bottom navigation when sub-pages are on back stack - Add 33 new strings and login_error color resource - Provide AuthApi in NetworkModule DI - Verified: 5/5 e2e negative tests passed, dual unwrapping works on real responses
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
package com.stec.cmd
|
||||
|
||||
import com.stec.cmd.feature.mine.MineFragment
|
||||
|
||||
/**
|
||||
* 底部导航 5 Tab 与业务模块(功能明细表编号)的挂载点注册中心。
|
||||
*
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.stec.cmd
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.commit
|
||||
@@ -11,6 +12,9 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
/**
|
||||
* 单 Activity 导航壳:BottomNavigationView 5 Tab 切换 Fragment。
|
||||
* show/hide 复用实例以保留各 Tab 视图状态;S1 可迁移 Navigation 组件。
|
||||
*
|
||||
* 子页面(登录/配置/改密等)经 addToBackStack 压栈并隐藏底栏,
|
||||
* 返回栈清空后底栏恢复(S1·M1)。
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
@@ -23,6 +27,10 @@ class MainActivity : AppCompatActivity() {
|
||||
setContentView(binding.root)
|
||||
|
||||
binding.bottomNav.setOnItemSelectedListener(::selectTab)
|
||||
supportFragmentManager.addOnBackStackChangedListener {
|
||||
val hasSubPage = supportFragmentManager.backStackEntryCount > 0
|
||||
binding.bottomNav.visibility = if (hasSubPage) View.GONE else View.VISIBLE
|
||||
}
|
||||
if (savedInstanceState == null) {
|
||||
binding.bottomNav.selectedItemId = FeatureMount.Tab.HOME.itemId
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.stec.cmd
|
||||
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
/** 我的占位(M1 登录与配置,S1 挂载登录/配置页)。 */
|
||||
@AndroidEntryPoint
|
||||
class MineFragment : PlaceholderFragment()
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.stec.cmd.feature.mine
|
||||
|
||||
import com.stec.cmd.core.network.ApiError
|
||||
|
||||
/** [ApiError] → 用户可读文案(429/403 语义对齐接口文档 1.1,见待确认问题 #3)。 */
|
||||
fun ApiError.userMessage(): String = when (this) {
|
||||
is ApiError.TooManyRequests -> message ?: "操作过于频繁,请稍后再试"
|
||||
is ApiError.Locked -> message ?: "连续失败次数过多,请稍后再试"
|
||||
is ApiError.NetworkError ->
|
||||
cause?.message?.takeIf { it.startsWith("服务器地址") } ?: "网络连接异常,请检查网络后重试"
|
||||
is ApiError.EmptyBodyError -> message ?: "服务返回数据异常"
|
||||
is ApiError.ServerError -> message?.takeIf { it.isNotBlank() } ?: "操作失败,请稍后重试"
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.stec.cmd.feature.mine
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.stec.cmd.R
|
||||
import com.stec.cmd.core.common.UiState
|
||||
import com.stec.cmd.databinding.FragmentChangePasswordBinding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** 修改登录密码页(M1-04,2.3.1):成功后本地登出,需用新密码重登。 */
|
||||
@AndroidEntryPoint
|
||||
class ChangePasswordFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentChangePasswordBinding? = null
|
||||
private val binding get() = checkNotNull(_binding)
|
||||
|
||||
private val viewModel: ChangePasswordViewModel by viewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View {
|
||||
_binding = FragmentChangePasswordBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
binding.btnBack.setOnClickListener { parentFragmentManager.popBackStack() }
|
||||
binding.btnSubmit.setOnClickListener {
|
||||
viewModel.submit(
|
||||
oldPassword = binding.etOldPassword.text?.toString().orEmpty(),
|
||||
newPassword = binding.etNewPassword.text?.toString().orEmpty(),
|
||||
confirmPassword = binding.etConfirmPassword.text?.toString().orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.uiState.collect { state ->
|
||||
binding.progress.isVisible = state is UiState.Loading
|
||||
binding.btnSubmit.isEnabled = state !is UiState.Loading
|
||||
when (state) {
|
||||
is UiState.Error -> binding.tvMessage.text = state.message
|
||||
is UiState.Success -> {
|
||||
binding.tvMessage.text = getString(R.string.change_password_success)
|
||||
binding.btnBack.callOnClick()
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.stec.cmd.feature.mine
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.stec.cmd.core.common.UiState
|
||||
import com.stec.cmd.core.network.ApiError
|
||||
import com.stec.cmd.session.SessionRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
/** 修改登录密码(M1-04,2.3.1):登录态下操作,成功后登出用新密码重登。 */
|
||||
@HiltViewModel
|
||||
class ChangePasswordViewModel @Inject constructor(
|
||||
private val sessionRepository: SessionRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow<UiState<Unit>>(UiState.Idle)
|
||||
val uiState: StateFlow<UiState<Unit>> = _uiState.asStateFlow()
|
||||
|
||||
fun submit(oldPassword: String, newPassword: String, confirmPassword: String) {
|
||||
val validationError = when {
|
||||
oldPassword.isBlank() || newPassword.isBlank() -> "请输入原密码与新密码"
|
||||
newPassword.length < MIN_LENGTH -> "新密码至少 ${MIN_LENGTH} 位"
|
||||
newPassword != confirmPassword -> "两次输入的新密码不一致"
|
||||
else -> null
|
||||
}
|
||||
if (validationError != null) {
|
||||
_uiState.value = UiState.Error(validationError)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_uiState.value = UiState.Loading
|
||||
try {
|
||||
// 平台字段:Password=原密码,ConfirmAnswer=新密码(接口文档 2.3.1.4.1)
|
||||
sessionRepository.changePassword(oldPassword, newPassword)
|
||||
_uiState.value = UiState.Success(Unit)
|
||||
} catch (e: ApiError) {
|
||||
_uiState.value = UiState.Error(e.userMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MIN_LENGTH = 6
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.stec.cmd.feature.mine
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.stec.cmd.R
|
||||
import com.stec.cmd.core.common.UiState
|
||||
import com.stec.cmd.databinding.FragmentConfigBinding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** 服务器与密钥配置页(M1-06):仅存本机 DataStore,不入代码库(M7-04)。 */
|
||||
@AndroidEntryPoint
|
||||
class ConfigFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentConfigBinding? = null
|
||||
private val binding get() = checkNotNull(_binding)
|
||||
|
||||
private val viewModel: ConfigViewModel by viewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View {
|
||||
_binding = FragmentConfigBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
binding.btnBack.setOnClickListener { parentFragmentManager.popBackStack() }
|
||||
binding.btnSave.setOnClickListener {
|
||||
viewModel.save(
|
||||
baseUrl = binding.etBaseUrl.text?.toString().orEmpty(),
|
||||
secretKey = binding.etSecretKey.text?.toString().orEmpty(),
|
||||
systemCode = binding.etSystemCode.text?.toString().orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
// 进入页面回填当前配置
|
||||
lifecycleScope.launch {
|
||||
viewModel.snapshot.collect { snapshot ->
|
||||
if (binding.etBaseUrl.text.isNullOrBlank() && snapshot.baseUrl.isNotBlank()) {
|
||||
binding.etBaseUrl.setText(snapshot.baseUrl)
|
||||
binding.etSecretKey.setText(snapshot.secretKey)
|
||||
binding.etSystemCode.setText(snapshot.systemCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.saveState.collect { state ->
|
||||
binding.progress.isVisible = state is UiState.Loading
|
||||
binding.btnSave.isEnabled = state !is UiState.Loading
|
||||
when (state) {
|
||||
is UiState.Error -> binding.tvMessage.text = state.message
|
||||
is UiState.Success -> {
|
||||
binding.tvMessage.text = getString(R.string.config_saved)
|
||||
parentFragmentManager.popBackStack()
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.stec.cmd.feature.mine
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.stec.cmd.config.ConfigRepository
|
||||
import com.stec.cmd.core.common.UiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* 服务器与密钥配置(M1-06,M7-04 落地入口):
|
||||
* baseUrl/SecretKey/SystemCode 仅存本机 DataStore,不入代码库。
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ConfigViewModel @Inject constructor(
|
||||
private val configRepository: ConfigRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
/** 当前配置快照(进入页面回填表单)。 */
|
||||
val snapshot = configRepository.snapshotStateFlow
|
||||
|
||||
private val _saveState = MutableStateFlow<UiState<Unit>>(UiState.Idle)
|
||||
val saveState: StateFlow<UiState<Unit>> = _saveState.asStateFlow()
|
||||
|
||||
fun save(baseUrl: String, secretKey: String, systemCode: String) {
|
||||
when {
|
||||
baseUrl.isBlank() || secretKey.isBlank() || systemCode.isBlank() ->
|
||||
_saveState.value = UiState.Error("服务器地址、SecretKey、SystemCode 均为必填")
|
||||
!baseUrl.trim().startsWith("http://") && !baseUrl.trim().startsWith("https://") ->
|
||||
_saveState.value = UiState.Error("服务器地址需以 http:// 或 https:// 开头")
|
||||
else -> viewModelScope.launch {
|
||||
try {
|
||||
configRepository.updateConnection(baseUrl, secretKey, systemCode)
|
||||
_saveState.value = UiState.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
_saveState.value = UiState.Error("保存失败:${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
111
app/src/main/kotlin/com/stec/cmd/feature/mine/LoginFragment.kt
Normal file
111
app/src/main/kotlin/com/stec/cmd/feature/mine/LoginFragment.kt
Normal file
@@ -0,0 +1,111 @@
|
||||
package com.stec.cmd.feature.mine
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.stec.cmd.R
|
||||
import com.stec.cmd.core.common.UiState
|
||||
import com.stec.cmd.databinding.FragmentLoginBinding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** 登录页(M1-01 账号密码 / M1-02 获取验证码 / M1-03 验证码登录)。 */
|
||||
@AndroidEntryPoint
|
||||
class LoginFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentLoginBinding? = null
|
||||
private val binding get() = checkNotNull(_binding)
|
||||
|
||||
private val viewModel: LoginViewModel by viewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View {
|
||||
_binding = FragmentLoginBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
binding.btnBack.setOnClickListener { parentFragmentManager.popBackStack() }
|
||||
binding.btnSendCode.setOnClickListener { viewModel.sendCode() }
|
||||
binding.btnLogin.setOnClickListener { viewModel.login() }
|
||||
binding.rbAccount.setOnClickListener { viewModel.updateMode(LoginMode.ACCOUNT) }
|
||||
binding.rbSms.setOnClickListener { viewModel.updateMode(LoginMode.SMS) }
|
||||
|
||||
binding.etLoginName.doAfterTextChanged(viewModel::updateLoginName)
|
||||
binding.etPassword.doAfterTextChanged(viewModel::updatePassword)
|
||||
binding.etPhone.doAfterTextChanged(viewModel::updatePhone)
|
||||
binding.etSmsCode.doAfterTextChanged(viewModel::updateSmsCode)
|
||||
|
||||
observeStates()
|
||||
}
|
||||
|
||||
private fun observeStates() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
launch {
|
||||
viewModel.form.collect { form ->
|
||||
val accountMode = form.mode == LoginMode.ACCOUNT
|
||||
if (binding.rbAccount.isChecked != accountMode) {
|
||||
binding.radioGroup.check(
|
||||
if (accountMode) binding.rbAccount.id else binding.rbSms.id,
|
||||
)
|
||||
}
|
||||
binding.groupAccount.isVisible = accountMode
|
||||
binding.groupSms.isVisible = !accountMode
|
||||
}
|
||||
}
|
||||
launch {
|
||||
viewModel.countdown.collect { seconds ->
|
||||
binding.btnSendCode.isEnabled = seconds <= 0
|
||||
binding.btnSendCode.text =
|
||||
if (seconds > 0) getString(R.string.login_countdown_fmt, seconds)
|
||||
else getString(R.string.login_send_code)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
viewModel.uiState.collect { state ->
|
||||
binding.progress.isVisible = state is UiState.Loading
|
||||
binding.btnLogin.isEnabled = state !is UiState.Loading
|
||||
when (state) {
|
||||
is UiState.Error -> {
|
||||
binding.tvMessage.text = state.message
|
||||
binding.tvMessage.setTextColor(
|
||||
resources.getColor(R.color.login_error, requireContext().theme),
|
||||
)
|
||||
}
|
||||
is UiState.Success -> {
|
||||
// SessionRepository 已持久化 Token;返回「我的」
|
||||
parentFragmentManager.popBackStack()
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
launch {
|
||||
viewModel.info.collect { info ->
|
||||
binding.tvMessage.text = info.orEmpty()
|
||||
binding.tvMessage.setTextColor(
|
||||
resources.getColor(R.color.brand_primary, requireContext().theme),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
140
app/src/main/kotlin/com/stec/cmd/feature/mine/LoginViewModel.kt
Normal file
140
app/src/main/kotlin/com/stec/cmd/feature/mine/LoginViewModel.kt
Normal file
@@ -0,0 +1,140 @@
|
||||
package com.stec.cmd.feature.mine
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import com.stec.cmd.config.ConfigRepository
|
||||
import com.stec.cmd.core.common.UiState
|
||||
import com.stec.cmd.core.network.ApiError
|
||||
import com.stec.cmd.session.Phones
|
||||
import com.stec.cmd.session.SessionRepository
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** 登录方式:M1-01 账号密码 / M1-03 短信验证码。 */
|
||||
enum class LoginMode { ACCOUNT, SMS }
|
||||
|
||||
/**
|
||||
* 登录页状态(M1-01/02/03):
|
||||
* 表单 + 提交状态([UiState])+ 验证码 60s 倒计时。
|
||||
*/
|
||||
@HiltViewModel
|
||||
class LoginViewModel @Inject constructor(
|
||||
private val sessionRepository: SessionRepository,
|
||||
private val configRepository: ConfigRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
data class Form(
|
||||
val mode: LoginMode = LoginMode.ACCOUNT,
|
||||
val loginName: String = "",
|
||||
val password: String = "",
|
||||
val phone: String = "",
|
||||
val smsCode: String = "",
|
||||
)
|
||||
|
||||
private val _form = MutableStateFlow(Form())
|
||||
val form: StateFlow<Form> = _form.asStateFlow()
|
||||
|
||||
private val _uiState = MutableStateFlow<UiState<Unit>>(UiState.Idle)
|
||||
val uiState: StateFlow<UiState<Unit>> = _uiState.asStateFlow()
|
||||
|
||||
/** 获取验证码倒计时(秒),0 = 可发送。 */
|
||||
private val _countdown = MutableStateFlow(0)
|
||||
val countdown: StateFlow<Int> = _countdown.asStateFlow()
|
||||
|
||||
/** 非阻断提示(如「验证码已发送」)。 */
|
||||
private val _info = MutableStateFlow<String?>(null)
|
||||
val info: StateFlow<String?> = _info.asStateFlow()
|
||||
|
||||
fun updateMode(mode: LoginMode) {
|
||||
_form.value = _form.value.copy(mode = mode)
|
||||
_uiState.value = UiState.Idle
|
||||
_info.value = null
|
||||
}
|
||||
|
||||
fun updateLoginName(value: String) { _form.value = _form.value.copy(loginName = value) }
|
||||
fun updatePassword(value: String) { _form.value = _form.value.copy(password = value) }
|
||||
fun updatePhone(value: String) { _form.value = _form.value.copy(phone = value) }
|
||||
fun updateSmsCode(value: String) { _form.value = _form.value.copy(smsCode = value) }
|
||||
|
||||
/** M1-02 发送验证码:国内号全匹配校验 + 60s 防重发。 */
|
||||
fun sendCode() {
|
||||
val phone = _form.value.phone
|
||||
if (!Phones.isDomesticMobile(phone)) {
|
||||
_uiState.value = UiState.Error(ERROR_BAD_PHONE)
|
||||
return
|
||||
}
|
||||
if (_countdown.value > 0) return
|
||||
if (!configReady()) return
|
||||
viewModelScope.launch {
|
||||
_uiState.value = UiState.Loading
|
||||
try {
|
||||
sessionRepository.sendSecurityCode(Phones.toQueryValue(Phones.normalize(phone)))
|
||||
_uiState.value = UiState.Idle
|
||||
_info.value = "验证码已发送,请查收短信"
|
||||
startCountdown()
|
||||
} catch (e: ApiError) {
|
||||
_uiState.value = UiState.Error(e.userMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交登录。成功后 [SessionRepository] 已持久化 Token,UI 收到 [UiState.Success]。 */
|
||||
fun login() {
|
||||
val f = _form.value
|
||||
val validationError = when (f.mode) {
|
||||
LoginMode.ACCOUNT ->
|
||||
if (f.loginName.isBlank() || f.password.isBlank()) "请输入账号与密码" else null
|
||||
LoginMode.SMS ->
|
||||
when {
|
||||
!Phones.isDomesticMobile(f.phone) -> ERROR_BAD_PHONE
|
||||
f.smsCode.isBlank() -> "请输入验证码"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
if (validationError != null) {
|
||||
_uiState.value = UiState.Error(validationError)
|
||||
return
|
||||
}
|
||||
if (!configReady()) return
|
||||
viewModelScope.launch {
|
||||
_uiState.value = UiState.Loading
|
||||
try {
|
||||
when (f.mode) {
|
||||
LoginMode.ACCOUNT ->
|
||||
sessionRepository.loginByAccount(f.loginName.trim(), f.password)
|
||||
LoginMode.SMS ->
|
||||
sessionRepository.loginByMobile(Phones.normalize(f.phone), f.smsCode.trim())
|
||||
}
|
||||
_uiState.value = UiState.Success(Unit)
|
||||
} catch (e: ApiError) {
|
||||
_uiState.value = UiState.Error(e.userMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun configReady(): Boolean {
|
||||
if (configRepository.snapshotStateFlow.value.isReady) return true
|
||||
_uiState.value = UiState.Error("请先在「我的-服务器配置」填写服务器地址与密钥")
|
||||
return false
|
||||
}
|
||||
|
||||
private fun startCountdown() {
|
||||
viewModelScope.launch {
|
||||
for (seconds in COUNTDOWN_SECONDS downTo 1) {
|
||||
_countdown.value = seconds
|
||||
delay(1_000)
|
||||
}
|
||||
_countdown.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ERROR_BAD_PHONE = "请输入正确的国内手机号"
|
||||
const val COUNTDOWN_SECONDS = 60
|
||||
}
|
||||
}
|
||||
113
app/src/main/kotlin/com/stec/cmd/feature/mine/MineFragment.kt
Normal file
113
app/src/main/kotlin/com/stec/cmd/feature/mine/MineFragment.kt
Normal file
@@ -0,0 +1,113 @@
|
||||
package com.stec.cmd.feature.mine
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.stec.cmd.R
|
||||
import com.stec.cmd.databinding.FragmentMineBinding
|
||||
import com.stec.cmd.session.SessionState
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 「我的」业务页(M1-05 用户信息 / M1-06 配置入口 / M1-04 改密入口 / M1-07 退出登录)。
|
||||
* 未配置三要素 → 引导配置页;未登录 → 引导登录页。
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class MineFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentMineBinding? = null
|
||||
private val binding get() = checkNotNull(_binding)
|
||||
|
||||
private val viewModel: MineViewModel by viewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View {
|
||||
_binding = FragmentMineBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
binding.btnBack.setOnClickListener { parentFragmentManager.popBackStack() }
|
||||
binding.btnLoginOrLogout.setOnClickListener { onPrimaryAction() }
|
||||
binding.btnChangePassword.setOnClickListener { openOnTop(ChangePasswordFragment(), TAG_CHANGE_PASSWORD) }
|
||||
binding.btnOpenConfig.setOnClickListener { openOnTop(ConfigFragment(), TAG_CONFIG) }
|
||||
binding.btnGoConfig.setOnClickListener { openOnTop(ConfigFragment(), TAG_CONFIG) }
|
||||
binding.btnRefresh.setOnClickListener { viewModel.refresh() }
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
launch { viewModel.sessionState.collect(::renderSession) }
|
||||
launch { viewModel.configSnapshot.collect(::renderConfig) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderSession(state: SessionState) {
|
||||
when (state) {
|
||||
is SessionState.SignedIn -> {
|
||||
binding.cardUser.isVisible = true
|
||||
binding.tvName.text = state.user.Name ?: getString(R.string.mine_unknown_user)
|
||||
binding.tvDept.text = getString(R.string.mine_dept_fmt, state.user.DeptName ?: "-")
|
||||
binding.tvAccount.text = getString(R.string.mine_account_fmt, state.user.LoginName ?: "-")
|
||||
binding.tvPhone.text = getString(R.string.mine_phone_fmt, state.user.MobilePhone ?: "-")
|
||||
binding.btnLoginOrLogout.setText(R.string.mine_logout)
|
||||
binding.btnChangePassword.isEnabled = true
|
||||
}
|
||||
is SessionState.SignedOut -> {
|
||||
binding.cardUser.isVisible = true
|
||||
binding.tvName.setText(R.string.mine_not_logged_in)
|
||||
binding.tvDept.text = getString(R.string.mine_dept_fmt, "-")
|
||||
binding.tvAccount.text = getString(R.string.mine_account_fmt, "-")
|
||||
binding.tvPhone.text = getString(R.string.mine_phone_fmt, "-")
|
||||
binding.btnLoginOrLogout.setText(R.string.mine_login)
|
||||
binding.btnChangePassword.isEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderConfig(snapshot: com.stec.cmd.config.ConfigRepository.Snapshot) {
|
||||
binding.tvConfigStatus.setText(
|
||||
if (snapshot.isReady) R.string.mine_config_ready else R.string.mine_config_missing,
|
||||
)
|
||||
binding.btnGoConfig.isVisible = !snapshot.isReady
|
||||
}
|
||||
|
||||
private fun onPrimaryAction() {
|
||||
when (viewModel.sessionState.value) {
|
||||
is SessionState.SignedIn -> viewModel.logout()
|
||||
is SessionState.SignedOut -> openOnTop(LoginFragment(), TAG_LOGIN)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openOnTop(fragment: Fragment, tag: String) {
|
||||
parentFragmentManager.commit {
|
||||
setReorderingAllowed(true)
|
||||
add(R.id.fragment_container, fragment, tag)
|
||||
hide(this@MineFragment)
|
||||
addToBackStack(null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG_LOGIN = "login"
|
||||
const val TAG_CONFIG = "config"
|
||||
const val TAG_CHANGE_PASSWORD = "change_password"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.stec.cmd.feature.mine
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.stec.cmd.config.ConfigRepository
|
||||
import com.stec.cmd.session.SessionRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* 「我的」页(M1-05/06/07):登录态 + 连接配置状态双流渲染。
|
||||
*/
|
||||
@HiltViewModel
|
||||
class MineViewModel @Inject constructor(
|
||||
private val sessionRepository: SessionRepository,
|
||||
configRepository: ConfigRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
/** 会话状态流。 */
|
||||
val sessionState = sessionRepository.state
|
||||
|
||||
/** 连接配置快照流(未配置时引导配置页)。 */
|
||||
val configSnapshot = configRepository.snapshotStateFlow
|
||||
|
||||
/** 手动刷新:按存量 Token 重新拉取用户信息。 */
|
||||
fun refresh() {
|
||||
viewModelScope.launch { sessionRepository.restore() }
|
||||
}
|
||||
|
||||
/** M1-07 退出登录。 */
|
||||
fun logout() {
|
||||
viewModelScope.launch { sessionRepository.logout() }
|
||||
}
|
||||
}
|
||||
36
app/src/main/kotlin/com/stec/cmd/session/Phones.kt
Normal file
36
app/src/main/kotlin/com/stec/cmd/session/Phones.kt
Normal file
@@ -0,0 +1,36 @@
|
||||
package com.stec.cmd.session
|
||||
|
||||
/**
|
||||
* 手机号与 Token 归一工具(接口管理规范 2.2.2 / 2.2.3 约定)。
|
||||
*
|
||||
* - 登录校验仅支持国内手机号「全匹配」;
|
||||
* - 2.2.2 发送验证码支持国际号,"+" 需转 "%2B" 否则发送不成功。
|
||||
*/
|
||||
object Phones {
|
||||
|
||||
/** 国内手机号全匹配(11 位,1 开头,第二位 3-9)。 */
|
||||
private val DOMESTIC = Regex("^1[3-9]\\d{9}$")
|
||||
|
||||
/** 归一输入:去空白与分隔符,剥离 +86 / 86 前缀。 */
|
||||
fun normalize(input: String): String {
|
||||
val cleaned = input.trim().filterNot { it == ' ' || it == '-' }
|
||||
return when {
|
||||
cleaned.startsWith("+86") -> cleaned.removePrefix("+86")
|
||||
cleaned.startsWith("86") && cleaned.length == 13 -> cleaned.removePrefix("86")
|
||||
else -> cleaned
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否国内手机号(归一后全匹配)。 */
|
||||
fun isDomesticMobile(input: String): Boolean = DOMESTIC.matches(normalize(input))
|
||||
|
||||
/** 2.2.2 查询值:国内号明文;国际号 "+" 预转 "%2B"(配合 @Query(encoded = true))。 */
|
||||
fun toQueryValue(input: String): String {
|
||||
val trimmed = input.trim()
|
||||
return if (trimmed.startsWith("+")) trimmed.replace("+", "%2B") else trimmed
|
||||
}
|
||||
|
||||
/** 登录 data 归一:剥除可能的 "Token:" / "Token:" 前缀与首尾空白。 */
|
||||
fun normalizeToken(raw: String): String =
|
||||
raw.trim().removePrefix("Token:").trim().removePrefix("Token:").trim()
|
||||
}
|
||||
114
app/src/main/kotlin/com/stec/cmd/session/SessionRepository.kt
Normal file
114
app/src/main/kotlin/com/stec/cmd/session/SessionRepository.kt
Normal file
@@ -0,0 +1,114 @@
|
||||
package com.stec.cmd.session
|
||||
|
||||
import com.stec.cmd.config.ConfigRepository
|
||||
import com.stec.cmd.core.network.ApiCaller
|
||||
import com.stec.cmd.core.network.ApiError
|
||||
import com.stec.cmd.core.network.api.AuthApi
|
||||
import com.stec.cmd.core.network.api.ChangePasswordRequest
|
||||
import com.stec.cmd.core.network.api.MobileLoginRequest
|
||||
import com.stec.cmd.core.network.api.PasswordLoginRequest
|
||||
import com.stec.cmd.core.network.api.UserInfo
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** 会话状态:UI 只读。 */
|
||||
sealed class SessionState {
|
||||
|
||||
/** 未登录(含 Token 失效被清)。 */
|
||||
data object SignedOut : SessionState()
|
||||
|
||||
/** 已登录,携带当前用户信息(2.4)。 */
|
||||
data class SignedIn(val user: UserInfo) : SessionState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话门面(M1-01/02/03/04/05/07):
|
||||
* 登录 → Token 持久化(ConfigRepository/DataStore)→ 拉取用户信息进入 SignedIn;
|
||||
* 登出 / 改密成功 → 清 Token 回 SignedOut;冷启动凭存量 Token 自动恢复会话。
|
||||
*/
|
||||
@Singleton
|
||||
class SessionRepository @Inject constructor(
|
||||
private val authApi: AuthApi,
|
||||
private val apiCaller: ApiCaller,
|
||||
private val configRepository: ConfigRepository,
|
||||
) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
private val _state = MutableStateFlow<SessionState>(SessionState.SignedOut)
|
||||
|
||||
/** 登录态流:Mine 等页面订阅渲染。 */
|
||||
val state: StateFlow<SessionState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch { restore() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 冷启动 / 手动刷新:本地有 Token 则拉用户信息复原会话。
|
||||
* 仅业务性失败(Token 失效等)清 Token;传输层失败保留 Token 待网络恢复后重试。
|
||||
*/
|
||||
suspend fun restore() {
|
||||
if (configRepository.currentToken().isBlank()) {
|
||||
_state.value = SessionState.SignedOut
|
||||
return
|
||||
}
|
||||
_state.value = try {
|
||||
SessionState.SignedIn(apiCaller.call { authApi.getUserInfo() })
|
||||
} catch (e: ApiError) {
|
||||
if (e !is ApiError.NetworkError) {
|
||||
// 待确认问题 #2 缺省口径:Token 失效按重新登录处理
|
||||
configRepository.updateToken("")
|
||||
}
|
||||
SessionState.SignedOut
|
||||
}
|
||||
}
|
||||
|
||||
/** M1-01 账号密码登录(2.2.1)。 */
|
||||
suspend fun loginByAccount(loginName: String, password: String) {
|
||||
val token = apiCaller.call {
|
||||
authApi.loginByAccount(PasswordLoginRequest(loginName, password))
|
||||
}
|
||||
signIn(token)
|
||||
}
|
||||
|
||||
/** M1-03 手机验证码登录(2.2.3);[mobile] 须为归一后的 11 位国内号。 */
|
||||
suspend fun loginByMobile(mobile: String, verificationCode: String) {
|
||||
val token = apiCaller.call {
|
||||
authApi.loginByMobile(MobileLoginRequest(mobile, verificationCode))
|
||||
}
|
||||
signIn(token)
|
||||
}
|
||||
|
||||
/** M1-02 发送验证码(2.2.2);[mobilePhone] 传 [Phones.toQueryValue] 归一结果。 */
|
||||
suspend fun sendSecurityCode(mobilePhone: String) {
|
||||
apiCaller.call { authApi.sendSecurityCode(mobilePhone) }
|
||||
}
|
||||
|
||||
/** M1-04 修改密码(2.3.1):平台成功后本地登出,用新密码重登。 */
|
||||
suspend fun changePassword(oldPassword: String, newPassword: String) {
|
||||
apiCaller.call {
|
||||
authApi.modifyPassword(ChangePasswordRequest(oldPassword, newPassword))
|
||||
}
|
||||
logout()
|
||||
}
|
||||
|
||||
/** M1-07 退出登录:平台无注销端点,本地清 Token 即完成。 */
|
||||
suspend fun logout() {
|
||||
configRepository.updateToken("")
|
||||
_state.value = SessionState.SignedOut
|
||||
}
|
||||
|
||||
private suspend fun signIn(rawToken: String) {
|
||||
val token = Phones.normalizeToken(rawToken)
|
||||
configRepository.updateToken(token)
|
||||
_state.value = SessionState.SignedIn(apiCaller.call { authApi.getUserInfo() })
|
||||
}
|
||||
}
|
||||
85
app/src/main/res/layout/fragment_change_password.xml
Normal file
85
app/src/main/res/layout/fragment_change_password.xml
Normal file
@@ -0,0 +1,85 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 修改登录密码页(M1-04,2.3.1):成功后本地登出,需用新密码重登 -->
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/brand_background"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_back"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/action_back" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/change_password_title"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_old_password"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:hint="@string/change_password_old_hint"
|
||||
android:imeOptions="actionNext"
|
||||
android:inputType="textPassword"
|
||||
android:maxLines="1" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_new_password"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:hint="@string/change_password_new_hint"
|
||||
android:imeOptions="actionNext"
|
||||
android:inputType="textPassword"
|
||||
android:maxLines="1" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_confirm_password"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:hint="@string/change_password_confirm_hint"
|
||||
android:imeOptions="actionDone"
|
||||
android:inputType="textPassword"
|
||||
android:maxLines="1" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_submit"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="@string/change_password_submit" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="16dp"
|
||||
android:indeterminate="true"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_message"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:textColor="?attr/colorError"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
93
app/src/main/res/layout/fragment_config.xml
Normal file
93
app/src/main/res/layout/fragment_config.xml
Normal file
@@ -0,0 +1,93 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 服务器与密钥配置页(M1-06):仅存本机 DataStore,不入代码库(M7-04) -->
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/brand_background"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_back"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/action_back" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/config_title"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_base_url"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:hint="@string/config_base_url_hint"
|
||||
android:imeOptions="actionNext"
|
||||
android:inputType="textUri"
|
||||
android:maxLines="1" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_secret_key"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:hint="@string/config_secret_key_hint"
|
||||
android:imeOptions="actionNext"
|
||||
android:inputType="text"
|
||||
android:maxLines="1" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_system_code"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:hint="@string/config_system_code_hint"
|
||||
android:imeOptions="actionDone"
|
||||
android:inputType="text"
|
||||
android:maxLines="1" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_save"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="@string/action_save" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="16dp"
|
||||
android:indeterminate="true"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_message"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:textColor="?attr/colorError"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:alpha="0.6"
|
||||
android:text="@string/config_note"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
160
app/src/main/res/layout/fragment_login.xml
Normal file
160
app/src/main/res/layout/fragment_login.xml
Normal file
@@ -0,0 +1,160 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 登录页(M1-01 账号密码 / M1-02 获取验证码 / M1-03 验证码登录) -->
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/brand_background"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_back"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/action_back" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/login_title"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:alpha="0.7"
|
||||
android:text="@string/login_subtitle"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<RadioGroup
|
||||
android:id="@+id/radio_group"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/rb_account"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:checked="true"
|
||||
android:text="@string/login_mode_account" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/rb_sms"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/login_mode_sms" />
|
||||
</RadioGroup>
|
||||
|
||||
<!-- 账号密码模式 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/group_account"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_login_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/login_name_hint"
|
||||
android:imeOptions="actionNext"
|
||||
android:inputType="text"
|
||||
android:maxLines="1" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_password"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:hint="@string/login_password_hint"
|
||||
android:imeOptions="actionDone"
|
||||
android:inputType="textPassword"
|
||||
android:maxLines="1" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 短信验证码模式 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/group_sms"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_phone"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/login_phone_hint"
|
||||
android:imeOptions="actionNext"
|
||||
android:inputType="phone"
|
||||
android:maxLength="11"
|
||||
android:maxLines="1" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_sms_code"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:hint="@string/login_sms_code_hint"
|
||||
android:inputType="number"
|
||||
android:maxLength="6"
|
||||
android:maxLines="1" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_send_code"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:text="@string/login_send_code" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_login"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="@string/login_submit" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="16dp"
|
||||
android:indeterminate="true"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_message"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:textColor="?attr/colorError"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
130
app/src/main/res/layout/fragment_mine.xml
Normal file
130
app/src/main/res/layout/fragment_mine.xml
Normal file
@@ -0,0 +1,130 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 「我的」业务页(M1-05 用户信息 / M1-06 配置入口 / M1-04 改密入口 / M1-07 退出登录) -->
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/brand_background"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_back"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/action_back" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/mine_title"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/card_user"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
app:cardBackgroundColor="@color/brand_surface"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/mine_not_logged_in"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_dept"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:alpha="0.8"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_account"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:alpha="0.8"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_phone"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:alpha="0.8"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_config_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:alpha="0.7"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_go_config"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/mine_go_config"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_login_or_logout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:text="@string/mine_login" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_change_password"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/mine_change_password" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_open_config"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/mine_open_config" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_refresh"
|
||||
style="?attr/materialButtonTextButtonStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/mine_refresh" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -6,4 +6,5 @@
|
||||
<color name="brand_background">#F7F9FC</color>
|
||||
<color name="brand_surface">#FFFFFF</color>
|
||||
<color name="launcher_background">#1E6FD9</color>
|
||||
<color name="login_error">#B3261E</color>
|
||||
</resources>
|
||||
|
||||
@@ -9,4 +9,54 @@
|
||||
<string name="tab_mine">我的</string>
|
||||
|
||||
<string name="placeholder_hint">占位页 —— 业务功能按 S1–S4 计划经 FeatureMount 挂载</string>
|
||||
|
||||
<!-- 通用 -->
|
||||
<string name="action_back">返回</string>
|
||||
<string name="action_save">保存</string>
|
||||
|
||||
<!-- M1 我的(M1-05/06/07) -->
|
||||
<string name="mine_title">我的</string>
|
||||
<string name="mine_not_logged_in">未登录</string>
|
||||
<string name="mine_unknown_user">未知用户</string>
|
||||
<string name="mine_dept_fmt">部门:%1$s</string>
|
||||
<string name="mine_account_fmt">账号:%1$s</string>
|
||||
<string name="mine_phone_fmt">手机号:%1$s</string>
|
||||
<string name="mine_login">登录</string>
|
||||
<string name="mine_logout">退出登录</string>
|
||||
<string name="mine_change_password">修改登录密码</string>
|
||||
<string name="mine_open_config">服务器与密钥配置</string>
|
||||
<string name="mine_refresh">刷新用户信息</string>
|
||||
<string name="mine_config_ready">服务器与密钥已配置</string>
|
||||
<string name="mine_config_missing">尚未配置服务器地址与密钥,无法登录</string>
|
||||
<string name="mine_go_config">去配置</string>
|
||||
<string name="mine_logout_confirm">确定退出当前账号?</string>
|
||||
|
||||
<!-- M1 登录(M1-01/02/03) -->
|
||||
<string name="login_title">登录</string>
|
||||
<string name="login_subtitle">上海城建勘测院 · 监测数据不落地平台</string>
|
||||
<string name="login_mode_account">账号密码</string>
|
||||
<string name="login_mode_sms">短信验证码</string>
|
||||
<string name="login_name_hint">登录账号</string>
|
||||
<string name="login_password_hint">登录密码</string>
|
||||
<string name="login_phone_hint">手机号(国内 11 位)</string>
|
||||
<string name="login_sms_code_hint">短信验证码</string>
|
||||
<string name="login_send_code">获取验证码</string>
|
||||
<string name="login_countdown_fmt">%1$ds 后重发</string>
|
||||
<string name="login_submit">登 录</string>
|
||||
|
||||
<!-- M1 服务器与密钥配置(M1-06) -->
|
||||
<string name="config_title">服务器与密钥配置</string>
|
||||
<string name="config_base_url_hint">服务器地址(如 https://jcd.stec.p-q.co)</string>
|
||||
<string name="config_secret_key_hint">SecretKey(接口调用秘钥)</string>
|
||||
<string name="config_system_code_hint">SystemCode(供应商编码)</string>
|
||||
<string name="config_saved">配置已保存</string>
|
||||
<string name="config_note">配置仅保存在本机(加密存储),不会随代码库分发;密钥变更后在此更新即可,无需换包。</string>
|
||||
|
||||
<!-- M1 修改密码(M1-04) -->
|
||||
<string name="change_password_title">修改登录密码</string>
|
||||
<string name="change_password_old_hint">原密码</string>
|
||||
<string name="change_password_new_hint">新密码(至少 6 位)</string>
|
||||
<string name="change_password_confirm_hint">确认新密码</string>
|
||||
<string name="change_password_submit">提交修改</string>
|
||||
<string name="change_password_success">密码已修改,请使用新密码重新登录</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user