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
|
package com.stec.cmd
|
||||||
|
|
||||||
|
import com.stec.cmd.feature.mine.MineFragment
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 底部导航 5 Tab 与业务模块(功能明细表编号)的挂载点注册中心。
|
* 底部导航 5 Tab 与业务模块(功能明细表编号)的挂载点注册中心。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.stec.cmd
|
|||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.commit
|
import androidx.fragment.app.commit
|
||||||
@@ -11,6 +12,9 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
/**
|
/**
|
||||||
* 单 Activity 导航壳:BottomNavigationView 5 Tab 切换 Fragment。
|
* 单 Activity 导航壳:BottomNavigationView 5 Tab 切换 Fragment。
|
||||||
* show/hide 复用实例以保留各 Tab 视图状态;S1 可迁移 Navigation 组件。
|
* show/hide 复用实例以保留各 Tab 视图状态;S1 可迁移 Navigation 组件。
|
||||||
|
*
|
||||||
|
* 子页面(登录/配置/改密等)经 addToBackStack 压栈并隐藏底栏,
|
||||||
|
* 返回栈清空后底栏恢复(S1·M1)。
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
@@ -23,6 +27,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
|
||||||
binding.bottomNav.setOnItemSelectedListener(::selectTab)
|
binding.bottomNav.setOnItemSelectedListener(::selectTab)
|
||||||
|
supportFragmentManager.addOnBackStackChangedListener {
|
||||||
|
val hasSubPage = supportFragmentManager.backStackEntryCount > 0
|
||||||
|
binding.bottomNav.visibility = if (hasSubPage) View.GONE else View.VISIBLE
|
||||||
|
}
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
binding.bottomNav.selectedItemId = FeatureMount.Tab.HOME.itemId
|
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_background">#F7F9FC</color>
|
||||||
<color name="brand_surface">#FFFFFF</color>
|
<color name="brand_surface">#FFFFFF</color>
|
||||||
<color name="launcher_background">#1E6FD9</color>
|
<color name="launcher_background">#1E6FD9</color>
|
||||||
|
<color name="login_error">#B3261E</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -9,4 +9,54 @@
|
|||||||
<string name="tab_mine">我的</string>
|
<string name="tab_mine">我的</string>
|
||||||
|
|
||||||
<string name="placeholder_hint">占位页 —— 业务功能按 S1–S4 计划经 FeatureMount 挂载</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>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.stec.cmd.core.network
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import java.io.IOException
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务调用安全门面:统一把「传输层异常 / HTTP 错误 / 业务失败码」收敛为 [ApiError]。
|
||||||
|
*
|
||||||
|
* ⚠ 平台契约陷阱(e2e 实测确认):业务失败时服务器返回 **HTTP 500 + ASP.NET
|
||||||
|
* 异常包装**,业务信封 JSON 以字符串嵌在 `ExceptionMessage` 字段:
|
||||||
|
* `{"Message":"发生错误。","ExceptionMessage":"{\"code\":\"500\",\"msg\":\"...\",\"data\":null}",...}`
|
||||||
|
* 本类负责解包,UI 层只捕获 [ApiError]。
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ApiCaller @Inject constructor(
|
||||||
|
private val json: Json,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** 执行并取业务数据;任何失败路径都以 [ApiError] 子类抛出。 */
|
||||||
|
suspend fun <T> call(block: suspend () -> ApiEnvelope<T>): T {
|
||||||
|
val envelope = try {
|
||||||
|
block()
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
throw toApiError(e)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
throw ApiError.NetworkError(e)
|
||||||
|
}
|
||||||
|
if (!envelope.isSuccess) throw ApiError.fromCode(envelope.code, envelope.message)
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
return envelope.data as? T
|
||||||
|
?: (Unit as? T)
|
||||||
|
?: throw ApiError.EmptyBodyError(envelope.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTTP 层错误 → 解析业务信封 → [ApiError];解不出信封时退化为 [ApiError.ServerError]。 */
|
||||||
|
private fun toApiError(e: HttpException): ApiError {
|
||||||
|
val body = try {
|
||||||
|
e.response()?.errorBody()?.string()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
parseEnvelope(body)?.let { raw ->
|
||||||
|
return ApiError.fromCode(raw.code, raw.msg)
|
||||||
|
}
|
||||||
|
return ApiError.ServerError(e.code().toString(), "HTTP ${e.code()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 兼容两种错误体:裸业务信封,或 ASP.NET 异常包装内嵌信封。 */
|
||||||
|
private fun parseEnvelope(body: String?): RawEnvelope? {
|
||||||
|
val trimmed = body?.trim().orEmpty()
|
||||||
|
if (!trimmed.startsWith("{")) return null
|
||||||
|
val direct = runCatching {
|
||||||
|
json.decodeFromString(RawEnvelope.serializer(), trimmed)
|
||||||
|
}.getOrNull() ?: return null
|
||||||
|
if (direct.code != null) return direct
|
||||||
|
|
||||||
|
val embedded = direct.ExceptionMessage
|
||||||
|
?: runCatching {
|
||||||
|
json.parseToJsonElement(trimmed).jsonObject["ExceptionMessage"]?.jsonPrimitive?.content
|
||||||
|
}.getOrNull()
|
||||||
|
?: return null
|
||||||
|
return runCatching {
|
||||||
|
json.decodeFromString(RawEnvelope.serializer(), embedded)
|
||||||
|
}.getOrNull()?.takeIf { it.code != null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 错误体宽容模型:信封字段与 ASP.NET 包装字段并存。 */
|
||||||
|
@Serializable
|
||||||
|
private data class RawEnvelope(
|
||||||
|
val code: String? = null,
|
||||||
|
val msg: String? = null,
|
||||||
|
val data: JsonElement? = null,
|
||||||
|
val ExceptionMessage: String? = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.stec.cmd.core.network
|
||||||
|
|
||||||
|
import java.io.IOException
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行期可配置 baseUrl(M1-06 / M7-04):
|
||||||
|
* Retrofit 实例以占位 baseUrl 构建(Retrofit 要求构造期给出合法 URL 且不可变更),
|
||||||
|
* 真实目标地址在请求期取自 [NetworkConfig.baseUrl] 快照动态改写,
|
||||||
|
* 从而支持「不换包更换服务器/环境」。
|
||||||
|
*
|
||||||
|
* 未配置时快速失败为 IOException(→ ApiError.NetworkError),文案引导配置页。
|
||||||
|
*/
|
||||||
|
class DynamicBaseUrlInterceptor(
|
||||||
|
private val config: NetworkConfig,
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
val base = config.baseUrl.toHttpUrlOrNull()
|
||||||
|
?: throw IOException("服务器地址未配置,请先在「我的-服务器配置」完成配置")
|
||||||
|
val suffix = request.url.encodedPath + (request.url.query?.let { "?$it" } ?: "")
|
||||||
|
val target = base.resolve(suffix)
|
||||||
|
?: throw IOException("服务器地址无效:${config.baseUrl}")
|
||||||
|
return chain.proceed(request.newBuilder().url(target).build())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.stec.cmd.core.network
|
package com.stec.cmd.core.network
|
||||||
|
|
||||||
|
import com.stec.cmd.core.network.api.AuthApi
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -15,7 +16,9 @@ import javax.inject.Singleton
|
|||||||
* 网络层 Hilt 装配。
|
* 网络层 Hilt 装配。
|
||||||
*
|
*
|
||||||
* [NetworkConfig] 的实现由 app 壳通过 @Binds 提供(配置不进代码库);
|
* [NetworkConfig] 的实现由 app 壳通过 @Binds 提供(配置不进代码库);
|
||||||
* Retrofit Service 接口在 S1+ 各业务模块中声明后直接注入使用。
|
* Retrofit 以占位 baseUrl 构建,真实地址由 [DynamicBaseUrlInterceptor] 请求期
|
||||||
|
* 从配置快照改写(运行期可换服务器,M1-06);
|
||||||
|
* Retrofit Service 接口在 S1+ 各业务模块中按 [provideAuthApi] 模式补充。
|
||||||
*/
|
*/
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
@@ -38,6 +41,7 @@ object NetworkModule {
|
|||||||
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
.writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
.writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
|
.addInterceptor(DynamicBaseUrlInterceptor(config))
|
||||||
.addInterceptor(AuthInterceptor(config))
|
.addInterceptor(AuthInterceptor(config))
|
||||||
|
|
||||||
// 外业调试期保留 BODY 级日志;发布构建由 proguard 移除该拦截器装配
|
// 外业调试期保留 BODY 级日志;发布构建由 proguard 移除该拦截器装配
|
||||||
@@ -55,13 +59,20 @@ object NetworkModule {
|
|||||||
fun provideRetrofit(
|
fun provideRetrofit(
|
||||||
client: OkHttpClient,
|
client: OkHttpClient,
|
||||||
json: Json,
|
json: Json,
|
||||||
config: NetworkConfig,
|
|
||||||
): Retrofit = Retrofit.Builder()
|
): Retrofit = Retrofit.Builder()
|
||||||
.baseUrl(config.baseUrl)
|
.baseUrl(PLACEHOLDER_BASE_URL)
|
||||||
.client(client)
|
.client(client)
|
||||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
/** 认证与会话接口(M1)。 */
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
|
||||||
|
|
||||||
|
/** 仅满足 Retrofit 构造约束;真实地址请求期经 DynamicBaseUrlInterceptor 改写。 */
|
||||||
|
private const val PLACEHOLDER_BASE_URL = "https://placeholder.stec.invalid/"
|
||||||
|
|
||||||
private const val CONNECT_TIMEOUT_SECONDS = 15L
|
private const val CONNECT_TIMEOUT_SECONDS = 15L
|
||||||
private const val READ_TIMEOUT_SECONDS = 30L
|
private const val READ_TIMEOUT_SECONDS = 30L
|
||||||
private const val WRITE_TIMEOUT_SECONDS = 30L
|
private const val WRITE_TIMEOUT_SECONDS = 30L
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.stec.cmd.core.network.api
|
||||||
|
|
||||||
|
import com.stec.cmd.core.network.ApiEnvelope
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台认证与会话接口(接口管理规范 V4.5 · 测试环境 https://jcd.stec.p-q.co):
|
||||||
|
* - 2.2.1 账号密码登录(M1-01)
|
||||||
|
* - 2.2.2 发送短信验证码(M1-02)
|
||||||
|
* - 2.2.3 手机验证码登录(M1-03)
|
||||||
|
* - 2.3.1 修改登录密码(M1-04)
|
||||||
|
* - 2.4 获取当前用户信息(M1-05)
|
||||||
|
*
|
||||||
|
* 三要素请求头(SecretKey/SystemCode/Token)由 AuthInterceptor 统一注入;
|
||||||
|
* 登录/验证码接口未登录时天然不携带 Token(接口文档 1.2.3)。
|
||||||
|
*/
|
||||||
|
interface AuthApi {
|
||||||
|
|
||||||
|
/** 2.2.1 账号密码登录:成功 data 为 Token 字符串。 */
|
||||||
|
@POST(PATH_LOGIN_BY_ACCOUNT)
|
||||||
|
suspend fun loginByAccount(
|
||||||
|
@Body request: PasswordLoginRequest,
|
||||||
|
): ApiEnvelope<String>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2.2.2 发送短信验证码:[mobilePhone] 须已按约定归一
|
||||||
|
* (国内号 11 位明文;国际号 "+" 预转 "%2B",[encoded] = true)。
|
||||||
|
* data 形态文档未定死("result:true" 字样),以 [JsonElement] 宽容承载。
|
||||||
|
*/
|
||||||
|
@GET(PATH_LOGIN_SECURITY_CODE)
|
||||||
|
suspend fun sendSecurityCode(
|
||||||
|
@Query(QUERY_MOBILE_PHONE, encoded = true) mobilePhone: String,
|
||||||
|
): ApiEnvelope<JsonElement>
|
||||||
|
|
||||||
|
/** 2.2.3 手机验证码登录:成功 data 为 Token 字符串。 */
|
||||||
|
@POST(PATH_LOGIN_BY_MOBILE)
|
||||||
|
suspend fun loginByMobile(
|
||||||
|
@Body request: MobileLoginRequest,
|
||||||
|
): ApiEnvelope<String>
|
||||||
|
|
||||||
|
/** 2.3.1 修改登录密码:登录态下操作,成功 data 为 null。 */
|
||||||
|
@POST(PATH_MODIFY_PASSWORD)
|
||||||
|
suspend fun modifyPassword(
|
||||||
|
@Body request: ChangePasswordRequest,
|
||||||
|
): ApiEnvelope<Unit>
|
||||||
|
|
||||||
|
/** 2.4 获取当前用户信息:需登录态。 */
|
||||||
|
@GET(PATH_GET_USER_INFO)
|
||||||
|
suspend fun getUserInfo(): ApiEnvelope<UserInfo>
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// ---- 接口管理规范 V4.5 · 章节 2.1 所有 API 目录 ----
|
||||||
|
const val PATH_LOGIN_BY_ACCOUNT = "OutWebApi/api/LoginByAccount"
|
||||||
|
const val PATH_LOGIN_SECURITY_CODE = "OutWebApi/api/LoginSecurityCode"
|
||||||
|
const val PATH_LOGIN_BY_MOBILE = "OutWebApi/api/LoginByMobile"
|
||||||
|
const val PATH_MODIFY_PASSWORD = "OutWebApi/api/ModifyPassword"
|
||||||
|
const val PATH_GET_USER_INFO = "OutWebApi/api/GetUserInfo"
|
||||||
|
|
||||||
|
/** 2.2.2 查询参数名。 */
|
||||||
|
const val QUERY_MOBILE_PHONE = "MobilePhone"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.stec.cmd.core.network.api
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证与会话 DTO(接口管理规范 V4.5)。
|
||||||
|
*
|
||||||
|
* 字段名与平台契约一致(PascalCase);响应侧均留默认值以容忍字段演进
|
||||||
|
* (配合 Json.ignoreUnknownKeys)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 2.2.1 账号密码登录请求(章节 2.2.1.4.1 UserLoginData)。 */
|
||||||
|
@Serializable
|
||||||
|
data class PasswordLoginRequest(
|
||||||
|
val LoginName: String,
|
||||||
|
val Password: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 2.2.3 手机验证码登录请求(章节 2.2.3.4.1 MobileMsg)。 */
|
||||||
|
@Serializable
|
||||||
|
data class MobileLoginRequest(
|
||||||
|
val Mobile: String,
|
||||||
|
val VerificationCode: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2.3.1 修改登录密码请求(章节 2.3.1.4.1 UserLoginData)。
|
||||||
|
*
|
||||||
|
* ⚠ 平台字段语义:[Password] = **原登录密码**,[ConfirmAnswer] = **新密码**
|
||||||
|
* (接口文档原文如此,勿按字面理解)。
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ChangePasswordRequest(
|
||||||
|
val Password: String,
|
||||||
|
val ConfirmAnswer: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2.4 当前用户信息(章节 2.4.6)。
|
||||||
|
* 文档仅约定 4 属性;头像等扩展字段后续由平台补充(ignoreUnknownKeys 容忍)。
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class UserInfo(
|
||||||
|
val Name: String? = null,
|
||||||
|
val DeptName: String? = null,
|
||||||
|
val MobilePhone: String? = null,
|
||||||
|
val LoginName: String? = null,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user