Sfoglia il codice sorgente

refactor(更新)
- 数据库的备份还原完成
- i18n的文件识别完成

周文健 2 mesi fa
parent
commit
d011faf84e
50 ha cambiato i file con 2431 aggiunte e 328 eliminazioni
  1. 16 0
      app/src/main/assets/i18n/zh-CN.csv
  2. 3 2
      app/src/main/java/com/grkj/iscs/ISCSApplication.kt
  3. 4 4
      app/src/main/java/com/grkj/iscs/features/init/viewmodel/InitViewModel.kt
  4. 3 0
      app/src/main/java/com/grkj/iscs/features/login/activity/LoginActivity.kt
  5. 11 11
      app/src/main/java/com/grkj/iscs/features/login/viewmodel/LoginViewModel.kt
  6. 75 0
      app/src/main/java/com/grkj/iscs/features/main/fragment/data_manage/BackupAndRestoreFragment.kt
  7. 10 4
      app/src/main/java/com/grkj/iscs/features/main/fragment/data_manage/DataManageHomeFragment.kt
  8. 3 3
      app/src/main/java/com/grkj/iscs/features/main/viewmodel/common/EditJobWorkflowSettingViewModel.kt
  9. 3 3
      app/src/main/java/com/grkj/iscs/features/main/viewmodel/common/EditSopWorkflowSettingViewModel.kt
  10. 4 4
      app/src/main/java/com/grkj/iscs/features/main/viewmodel/common/SelectMemberViewModel.kt
  11. 3 3
      app/src/main/java/com/grkj/iscs/features/main/viewmodel/common/WorkflowSettingViewModel.kt
  12. 46 0
      app/src/main/java/com/grkj/iscs/features/main/viewmodel/data_manage/BackupAndRestoreViewModel.kt
  13. 7 7
      app/src/main/java/com/grkj/iscs/features/main/viewmodel/data_manage/UserManageViewModel.kt
  14. 6 6
      app/src/main/java/com/grkj/iscs/features/main/viewmodel/exception_manage/ExceptionJobViewModel.kt
  15. 3 3
      app/src/main/java/com/grkj/iscs/features/main/viewmodel/hardware_manage/CardManageViewModel.kt
  16. 3 3
      app/src/main/java/com/grkj/iscs/features/main/viewmodel/home/HomeViewModel.kt
  17. 4 4
      app/src/main/java/com/grkj/iscs/features/main/viewmodel/job_manage/JobViewModel.kt
  18. 2 2
      app/src/main/java/com/grkj/iscs/features/main/viewmodel/job_manage/MyTodoViewModel.kt
  19. 4 4
      app/src/main/java/com/grkj/iscs/features/main/viewmodel/job_manage/SopJobViewModel.kt
  20. 4 4
      app/src/main/java/com/grkj/iscs/features/main/viewmodel/job_manage/SopViewModel.kt
  21. 12 12
      app/src/main/java/com/grkj/iscs/features/main/viewmodel/user_info/UserInfoViewModel.kt
  22. 3 19
      app/src/main/java/com/grkj/iscs/features/splash/activity/SplashActivity.kt
  23. 24 26
      app/src/main/java/com/grkj/iscs/receivers/BootReceiver.kt
  24. 1 1
      app/src/main/res/layout-land/activity_login.xml
  25. 407 0
      app/src/main/res/layout-land/fragment_backup_and_restore.xml
  26. 1 1
      app/src/main/res/layout-land/fragment_home.xml
  27. 1 1
      app/src/main/res/layout/activity_splash.xml
  28. 407 0
      app/src/main/res/layout/fragment_backup_and_restore.xml
  29. 1 1
      app/src/main/res/layout/fragment_home.xml
  30. 1 1
      app/src/main/res/layout/fragment_init_welcome.xml
  31. 63 0
      app/src/main/res/layout/item_backup.xml
  32. BIN
      app/src/main/res/mipmap-xhdpi/icon_backup_and_restore.png
  33. 7 0
      app/src/main/res/navigation/nav_data_manage.xml
  34. 145 0
      data/src/main/java/com/grkj/data/database/BackupScheduler.kt
  35. 61 38
      data/src/main/java/com/grkj/data/database/ISCSDatabase.kt
  36. 348 0
      data/src/main/java/com/grkj/data/database/RoomBackupManager.kt
  37. 151 40
      data/src/main/java/com/grkj/data/database/RoomBackupWorker.kt
  38. 69 0
      data/src/main/java/com/grkj/data/database/SimpleBackupPrefs.kt
  39. 2 1
      data/src/main/java/com/grkj/data/enums/RoleFunctionalPermissionsEnum.kt
  40. 1 0
      data/src/main/java/com/grkj/data/logic/impl/standard/SysMenuLogic.kt
  41. 0 4
      shared/src/main/java/com/grkj/shared/utils/i18n/CsvImporter.kt
  42. 50 24
      shared/src/main/java/com/grkj/shared/utils/i18n/I18nFormatter.kt
  43. 92 19
      shared/src/main/java/com/grkj/shared/utils/i18n/databinding/I18nBindingAdapters.kt
  44. 101 17
      shared/src/main/java/com/grkj/shared/utils/i18n/source/AssetsCsvSource.kt
  45. 101 10
      shared/src/main/java/com/grkj/shared/utils/i18n/source/FileCsvSource.kt
  46. 164 39
      shared/src/main/java/com/grkj/shared/utils/i18n/util/CsvUtils.kt
  47. 4 4
      ui-base/src/main/java/com/grkj/ui_base/base/BaseViewModel.kt
  48. 0 1
      ui-base/src/main/res/values-en/strings.xml
  49. 0 1
      ui-base/src/main/res/values-zh/strings.xml
  50. 0 1
      ui-base/src/main/res/values/strings.xml

+ 16 - 0
app/src/main/assets/i18n/zh-CN.csv

@@ -635,3 +635,19 @@ workstation_manage_title,text,区域管理标题,区域管理
 workstation_manage_workstation_name,text,新增区域显示文本,区域名称
 you_are_not_locker_tip,text,非上锁人执行上锁/解锁作业时提示文本,您不是上锁人,无法执行此操作
 zone,text,首页区域范围,区域范围
+backup_title,text,备份/还原标题,备份/还原
+backup,text,备份文本,备份
+backup_path,text,备份路径文本,备份路径
+maximum_number_of_backups,text,备份数量上限文本,备份数量上限
+auto_backup,text,自动备份文本,自动备份
+common_enable,text,通用启用文本,启用
+common_disable,text,通用停用文本,停用
+backup_frequency,text,备份频率文本,备份频率
+backup_time,text,备份时间文本,备份时间
+backup_tip,text,备份注意文本,注意:自动备份时必须保证应用处于启动状态。
+backup_now,text,立即备份文本,立即备份
+backup_range,text,备份数量上限提示,范围文本,范围:{0}
+restore,text,还原文本,还原
+common_batch_export,text,通用批量导出文本,批量导出
+common_batch_delete,text,通用批量删除文本,批量删除
+common_export,text,通用导出文本,导出

+ 3 - 2
app/src/main/java/com/grkj/iscs/ISCSApplication.kt

@@ -10,6 +10,7 @@ import android.util.TypedValue
 import ch.qos.logback.classic.Level
 import com.drake.statelayout.StateConfig
 import com.grkj.data.data.EventConstants
+import com.grkj.data.database.BackupScheduler
 import com.grkj.data.database.DbReadyGate
 import com.grkj.data.database.ISCSDatabase
 import com.grkj.data.di.LogicManager
@@ -81,8 +82,8 @@ class ISCSApplication : Application() {
         I18nManager.init(
             defaultLocale = LanguageStore.resolveEffectiveLocale(this),
             initialSources = arrayOf(
-                com.grkj.shared.utils.i18n.source.AssetsCsvSource(this, "i18n", mergedMode = false),
-                com.grkj.shared.utils.i18n.source.FileCsvSource(this, "i18n", mergedMode = false)
+                AssetsCsvSource(this, "i18n", mergedMode = false),
+                FileCsvSource(this, "i18n", mergedMode = false)
             ),
             eagerLoad = true
         )

+ 4 - 4
app/src/main/java/com/grkj/iscs/features/init/viewmodel/InitViewModel.kt

@@ -14,15 +14,15 @@ import javax.inject.Inject
  */
 @HiltViewModel
 class InitViewModel @Inject constructor(
-    override val userRepository: IUserLogic,
+    override val userLogic: IUserLogic,
     val hardwareRepository: IHardwareLogic
-) : BaseViewModel(userRepository) {
+) : BaseViewModel(userLogic) {
     /**
      * 移除超级管理员用户
      */
     fun removeAdminUser(): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
-            userRepository.removeAdminUser()
+            userLogic.removeAdminUser()
             emit(true)
         }
     }
@@ -32,7 +32,7 @@ class InitViewModel @Inject constructor(
      */
     fun insertAdminAccount(username: String, password: String): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
-            userRepository.addAdminUser(username, password)
+            userLogic.addAdminUser(username, password)
             emit(true)
         }
     }

+ 3 - 0
app/src/main/java/com/grkj/iscs/features/login/activity/LoginActivity.kt

@@ -31,6 +31,7 @@ import com.grkj.ui_base.utils.event.LoadingEvent
 import com.grkj.ui_base.utils.extension.getAppVersionName
 import com.grkj.shared.utils.extension.toByteArrays
 import com.grkj.shared.utils.extension.toHexStrings
+import com.grkj.shared.utils.i18n.I18nManager
 import com.grkj.ui_base.utils.fingerprint.FingerprintUtil
 import com.sik.sikcore.extension.setDebouncedClickListener
 import com.sik.sikimage.ImageConvertUtils
@@ -52,6 +53,8 @@ class LoginActivity : BaseActivity<ActivityLoginBinding>() {
     }
 
     override fun initView() {
+        logger.info("i18n值:${I18nManager.t("loto")}")
+        logger.info("i18n值:${I18nManager.t("back")}")
         binding.loginTypeRv.apply {
             if (isLandscape()) {
                 linear(orientation = LinearLayout.HORIZONTAL)

+ 11 - 11
app/src/main/java/com/grkj/iscs/features/login/viewmodel/LoginViewModel.kt

@@ -16,8 +16,8 @@ import javax.inject.Inject
  */
 @HiltViewModel
 class LoginViewModel @Inject constructor(
-    override val userRepository: IUserLogic,
-) : BaseViewModel(userRepository) {
+    override val userLogic: IUserLogic,
+) : BaseViewModel(userLogic) {
 
 
     /**
@@ -27,8 +27,8 @@ class LoginViewModel @Inject constructor(
         username: String = "admin", password: String = "123456"
     ): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
-            userRepository.removeAdminUser()
-            userRepository.addAdminUser(username, password)
+            userLogic.removeAdminUser()
+            userLogic.addAdminUser(username, password)
             emit(true)
         }
     }
@@ -41,7 +41,7 @@ class LoginViewModel @Inject constructor(
         password: String,
     ): LiveData<LoginResultEnum> {
         return liveData(Dispatchers.IO) {
-            val loginSuccess = userRepository.loginWithAccount(username, password)
+            val loginSuccess = userLogic.loginWithAccount(username, password)
             emit(loginSuccess)
         }
     }
@@ -51,7 +51,7 @@ class LoginViewModel @Inject constructor(
      */
     fun loginWithCard(cardNo: String): LiveData<LoginResultEnum> {
         return liveData(Dispatchers.IO) {
-            val loginSuccess = userRepository.loginWithCard(cardNo)
+            val loginSuccess = userLogic.loginWithCard(cardNo)
             emit(loginSuccess)
         }
     }
@@ -61,7 +61,7 @@ class LoginViewModel @Inject constructor(
      */
     fun loginWithFingerprint(fingerprint: String): LiveData<LoginResultEnum> {
         return liveData(Dispatchers.IO) {
-            val loginSuccess = userRepository.loginWithFingerprint(fingerprint)
+            val loginSuccess = userLogic.loginWithFingerprint(fingerprint)
             emit(loginSuccess)
         }
     }
@@ -71,7 +71,7 @@ class LoginViewModel @Inject constructor(
      */
     fun loginWithFace(face: String): LiveData<LoginResultEnum> {
         return liveData(Dispatchers.IO) {
-            val loginSuccess = userRepository.loginWithFace(face)
+            val loginSuccess = userLogic.loginWithFace(face)
             emit(loginSuccess)
         }
     }
@@ -81,7 +81,7 @@ class LoginViewModel @Inject constructor(
      */
     fun loginWithUserId(userId: Long?): LiveData<LoginResultEnum> {
         return liveData(Dispatchers.IO) {
-            val loginSuccess = userRepository.loginWithUserId(userId)
+            val loginSuccess = userLogic.loginWithUserId(userId)
             emit(loginSuccess)
         }
     }
@@ -91,7 +91,7 @@ class LoginViewModel @Inject constructor(
      */
     fun logout(): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
-            val logoutSuccess = userRepository.logout()
+            val logoutSuccess = userLogic.logout()
             emit(logoutSuccess)
         }
     }
@@ -101,7 +101,7 @@ class LoginViewModel @Inject constructor(
      */
     fun registerFaceFeature(): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
-            val user = userRepository.getAllFaceData()
+            val user = userLogic.getAllFaceData()
             val userFaceData = user.filter { it.content.file().exists() }
                 .map { it.userId to it.content.file().readText() }
             ArcSoftUtil.registerFace(userFaceData)

+ 75 - 0
app/src/main/java/com/grkj/iscs/features/main/fragment/data_manage/BackupAndRestoreFragment.kt

@@ -0,0 +1,75 @@
+package com.grkj.iscs.features.main.fragment.data_manage
+
+import androidx.fragment.app.viewModels
+import com.drake.brv.BindingAdapter
+import com.drake.brv.utils.linear
+import com.drake.brv.utils.models
+import com.drake.brv.utils.setup
+import com.grkj.data.database.BackupScheduler
+import com.grkj.data.database.RoomBackupManager
+import com.grkj.iscs.R
+import com.grkj.iscs.databinding.FragmentBackupAndRestoreBinding
+import com.grkj.iscs.databinding.ItemBackupBinding
+import com.grkj.iscs.features.main.viewmodel.data_manage.BackupAndRestoreViewModel
+import com.grkj.ui_base.base.BaseFragment
+import com.sik.sikcore.extension.setDebouncedClickListener
+import dagger.hilt.android.AndroidEntryPoint
+
+/**
+ * 备份/还原界面
+ */
+@AndroidEntryPoint
+class BackupAndRestoreFragment : BaseFragment<FragmentBackupAndRestoreBinding>() {
+    private val viewModel: BackupAndRestoreViewModel by viewModels()
+
+    override fun getLayoutId(): Int {
+        return R.layout.fragment_backup_and_restore
+    }
+
+    override fun initView() {
+        binding.back.setDebouncedClickListener {
+            navController.popBackStack()
+        }
+        binding.backupPath.isEnabled = false
+        binding.backupNow.setDebouncedClickListener {
+            BackupScheduler.backupNow(requireContext())
+        }
+        binding.batchExport.setDebouncedClickListener {
+
+        }
+        binding.listRv.linear().setup {
+            addType<RoomBackupManager.BackupItem>(R.layout.item_backup)
+            onBind {
+                onRVListBinding(this)
+            }
+        }
+    }
+
+    override fun initData() {
+        super.initData()
+        getData()
+    }
+
+    private fun getData() {
+        viewModel.getBackupList().observe(this) {
+            binding.listRv.models = it
+        }
+    }
+
+    private fun BindingAdapter.BindingViewHolder.onRVListBinding(holder: BindingAdapter.BindingViewHolder) {
+        val item = getModel<RoomBackupManager.BackupItem>()
+        val itemBinding = getBinding<ItemBackupBinding>()
+        itemBinding.backupName.text = item.name
+        itemBinding.delete.setDebouncedClickListener {
+
+        }
+        itemBinding.export.setDebouncedClickListener {
+
+        }
+        itemBinding.restore.setDebouncedClickListener {
+            viewModel.restoreBackUp(item).observe(this@BackupAndRestoreFragment) {
+                showToast("还原成功")
+            }
+        }
+    }
+}

+ 10 - 4
app/src/main/java/com/grkj/iscs/features/main/fragment/data_manage/DataManageHomeFragment.kt

@@ -1,16 +1,12 @@
 package com.grkj.iscs.features.main.fragment.data_manage
 
-import android.widget.LinearLayout
 import androidx.annotation.OptIn
 import com.drake.brv.BindingAdapter
 import com.drake.brv.annotaion.DividerOrientation
 import com.drake.brv.utils.dividerSpace
 import com.drake.brv.utils.grid
-import com.drake.brv.utils.linear
 import com.drake.brv.utils.models
 import com.drake.brv.utils.setup
-import com.google.android.material.badge.BadgeDrawable
-import com.google.android.material.badge.BadgeUtils
 import com.google.android.material.badge.ExperimentalBadgeUtils
 import com.grkj.data.data.MainDomainData
 import com.grkj.data.enums.RoleFunctionalPermissionsEnum
@@ -53,6 +49,12 @@ class DataManageHomeFragment : BaseFragment<FragmentDataManageHomeBinding>() {
             RoleFunctionalPermissionsEnum.POINT_MANAGE.description,
             RoleFunctionalPermissionsEnum.POINT_MANAGE.functionalPermission
         ),
+        MenuItemEntity(
+            4,
+            R.mipmap.icon_backup_and_restore,
+            RoleFunctionalPermissionsEnum.BACKUP_AND_RESTORE.description,
+            RoleFunctionalPermissionsEnum.BACKUP_AND_RESTORE.functionalPermission
+        ),
     )
 
     override fun getLayoutId(): Int {
@@ -119,6 +121,10 @@ class DataManageHomeFragment : BaseFragment<FragmentDataManageHomeBinding>() {
             3 -> {
                 navController.navigate(R.id.action_dataManageHomeFragment_to_pointMangeFragment)
             }
+
+            4 -> {
+                navController.navigate(R.id.action_dataManageHomeFragment_to_backupAndRestoreFragment)
+            }
         }
     }
 }

+ 3 - 3
app/src/main/java/com/grkj/iscs/features/main/viewmodel/common/EditJobWorkflowSettingViewModel.kt

@@ -25,9 +25,9 @@ import javax.inject.Inject
 class EditJobWorkflowSettingViewModel @Inject constructor(
     val workflowRepository: IWorkflowLogic,
     val jobTicketRepository: IJobTicketLogic,
-    override val userRepository: IUserLogic,
+    override val userLogic: IUserLogic,
     val roleRepository: IRoleLogic
-) : BaseViewModel(userRepository) {
+) : BaseViewModel(userLogic) {
     var modeId: Long = 0
     var ticketId: Long = 0
     var workflowSteps: List<IsJobTicketStep> = mutableListOf()
@@ -55,7 +55,7 @@ class EditJobWorkflowSettingViewModel @Inject constructor(
      */
     fun getSettingData(): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
-            allUser = userRepository.getAllUsersWithRole()
+            allUser = userLogic.getAllUsersWithRole()
             allRole = roleRepository.getRoleData()
             emit(true)
         }

+ 3 - 3
app/src/main/java/com/grkj/iscs/features/main/viewmodel/common/EditSopWorkflowSettingViewModel.kt

@@ -25,9 +25,9 @@ import javax.inject.Inject
 class EditSopWorkflowSettingViewModel @Inject constructor(
     val workflowRepository: IWorkflowLogic,
     val sopRepository: ISopLogic,
-    override val userRepository: IUserLogic,
+    override val userLogic: IUserLogic,
     val roleRepository: IRoleLogic
-) : BaseViewModel(userRepository) {
+) : BaseViewModel(userLogic) {
     var modeId: Long = 0
     var sopId: Long = 0
     var workflowSteps: List<IsSopWorkflowStep> = mutableListOf()
@@ -55,7 +55,7 @@ class EditSopWorkflowSettingViewModel @Inject constructor(
      */
     fun getSettingData(): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
-            allUser = userRepository.getAllUsersWithRole()
+            allUser = userLogic.getAllUsersWithRole()
             allRole = roleRepository.getRoleData()
             emit(true)
         }

+ 4 - 4
app/src/main/java/com/grkj/iscs/features/main/viewmodel/common/SelectMemberViewModel.kt

@@ -20,10 +20,10 @@ import javax.inject.Inject
  */
 @HiltViewModel
 class SelectMemberViewModel @Inject constructor(
-    override val userRepository: IUserLogic,
+    override val userLogic: IUserLogic,
     val jobTicketRepository: IJobTicketLogic
 ) :
-    BaseViewModel(userRepository) {
+    BaseViewModel(userLogic) {
     var workstationId: Long = 0
     var ticketUsers: List<IsJobTicketUserDataVo> = listOf()
     var jobTicketData: IsJobTicketDataVo? = null
@@ -41,11 +41,11 @@ class SelectMemberViewModel @Inject constructor(
     fun getUserData(): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
             userData = BeanUtils.copyList(
-                userRepository.getAllUserDataWithWorkstation(workstationId),
+                userLogic.getAllUserDataWithWorkstation(workstationId),
                 JobUserVo::class.java
             )?.filterNotNull() ?: mutableListOf()
             userBiometricDataVo =
-                userRepository.getUserBiometricDataByUserIds(userData.map { it.userId })
+                userLogic.getUserBiometricDataByUserIds(userData.map { it.userId })
             emit(true)
         }
     }

+ 3 - 3
app/src/main/java/com/grkj/iscs/features/main/viewmodel/common/WorkflowSettingViewModel.kt

@@ -23,9 +23,9 @@ import javax.inject.Inject
 @HiltViewModel
 class WorkflowSettingViewModel @Inject constructor(
     val workflowRepository: IWorkflowLogic,
-    override val userRepository: IUserLogic,
+    override val userLogic: IUserLogic,
     val roleRepository: IRoleLogic
-) : BaseViewModel(userRepository) {
+) : BaseViewModel(userLogic) {
     var modeId: Long = 0
     var workflowSteps: List<WorkflowStep> = mutableListOf()
     var currentStep: WorkflowStep? = null
@@ -52,7 +52,7 @@ class WorkflowSettingViewModel @Inject constructor(
      */
     fun getSettingData(): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
-            allUser = userRepository.getAllUsersWithRole()
+            allUser = userLogic.getAllUsersWithRole()
             allRole = roleRepository.getRoleData()
             emit(true)
         }

+ 46 - 0
app/src/main/java/com/grkj/iscs/features/main/viewmodel/data_manage/BackupAndRestoreViewModel.kt

@@ -0,0 +1,46 @@
+package com.grkj.iscs.features.main.viewmodel.data_manage
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.liveData
+import com.grkj.data.database.RoomBackupManager
+import com.grkj.ui_base.base.BaseViewModel
+import com.sik.sikcore.SIKCore
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import okhttp3.Dispatcher
+import javax.inject.Inject
+
+/**
+ * 备份/还原数据结构
+ */
+@HiltViewModel
+class BackupAndRestoreViewModel @Inject constructor() : BaseViewModel() {
+    /**
+     * 备份还原
+     */
+    fun restoreBackUp(item: RoomBackupManager.BackupItem): LiveData<Boolean> {
+        return liveData(Dispatchers.IO) {
+            RoomBackupManager.restore(SIKCore.getApplication(), item.file)
+            emit(true)
+        }
+    }
+
+    /**
+     * 获取备份文件
+     */
+    fun getBackupList(): LiveData<List<RoomBackupManager.BackupItem>> {
+        return liveData(Dispatchers.IO) {
+            emit(RoomBackupManager.listBackups(SIKCore.getApplication()))
+        }
+    }
+
+    /**
+     * 删除备份文件
+     */
+    fun deleteBackupFile(item: RoomBackupManager.BackupItem): LiveData<Boolean>{
+        return liveData(Dispatchers.IO){
+            emit(RoomBackupManager.deleteBackup(item.file))
+        }
+    }
+
+}

+ 7 - 7
app/src/main/java/com/grkj/iscs/features/main/viewmodel/data_manage/UserManageViewModel.kt

@@ -25,12 +25,12 @@ import javax.inject.Inject
  */
 @HiltViewModel
 class UserManageViewModel @Inject constructor(
-    override val userRepository: IUserLogic,
+    override val userLogic: IUserLogic,
     val roleRepository: IRoleLogic,
     val workstationRepository: IWorkstationLogic,
     val hardwareRepository: IHardwareLogic,
     val jobTicketRepository: IJobTicketLogic
-) : BaseViewModel(userRepository) {
+) : BaseViewModel(userLogic) {
     private var current: Int = 0
     private var size: Int = 50
     var userManageDataList: MutableList<UserManageVo> = mutableListOf()
@@ -43,7 +43,7 @@ class UserManageViewModel @Inject constructor(
      */
     fun deleteSelectedUsers(userIds: List<Long>): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
-            userRepository.deleteUserById(userIds)
+            userLogic.deleteUserById(userIds)
             roleRepository.deleteUserRoleByUserId(userIds)
             workstationRepository.deleteUserWorkstationByUserIds(userIds)
             emit(true)
@@ -64,7 +64,7 @@ class UserManageViewModel @Inject constructor(
             userManageDataList.clear()
         }
         return liveData(Dispatchers.IO) {
-            val userManageDataPage = userRepository.getUserManagerData(filterData, current, size)
+            val userManageDataPage = userLogic.getUserManagerData(filterData, current, size)
             userManageDataList.addAll(userManageDataPage)
             emit(true)
         }
@@ -75,7 +75,7 @@ class UserManageViewModel @Inject constructor(
      */
     fun addUser(userData: AddUserDataVo): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
-            val userId = userRepository.addUserData(userData)
+            val userId = userLogic.addUserData(userData)
             userData.workstationId?.let {
                 workstationRepository.addUserWorkstationData(userId, it)
             }
@@ -100,7 +100,7 @@ class UserManageViewModel @Inject constructor(
      */
     fun updateUser(updateUserDataVo: UpdateUserDataVo): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
-            userRepository.updateUserData(updateUserDataVo)
+            userLogic.updateUserData(updateUserDataVo)
             workstationRepository.deleteUserWorkstationByUserIds(listOf(updateUserDataVo.userId))
             roleRepository.deleteUserRoleByUserId(listOf(updateUserDataVo.userId))
             updateUserDataVo.workstationId?.let {
@@ -119,7 +119,7 @@ class UserManageViewModel @Inject constructor(
      */
     fun validateUserData(username: String): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
-            val user = userRepository.getUserByUserName(username)
+            val user = userLogic.getUserByUserName(username)
             if (user != null) {
                 showTip(CommonUtils.getStr(R.string.user_already_exists).toString())
             } else {

+ 6 - 6
app/src/main/java/com/grkj/iscs/features/main/viewmodel/exception_manage/ExceptionJobViewModel.kt

@@ -38,8 +38,8 @@ import javax.inject.Inject
 class ExceptionJobViewModel @Inject constructor(
     val exceptionRepository: IExceptionLogic,
     val jobTicketRepository: IJobTicketLogic,
-    override val userRepository: IUserLogic
-) : BaseViewModel(userRepository) {
+    override val userLogic: IUserLogic
+) : BaseViewModel(userLogic) {
     var ticketId: Long = 0
     var exceptionId: Long = 0
     var ticketData: IsJobTicketDataVo? = null
@@ -222,7 +222,7 @@ class ExceptionJobViewModel @Inject constructor(
                     }
             val jobTicketUsers = jobTicketRepository.getJobTicketUserDataByTicketId(ticketId)
             val locker =
-                userRepository.getJobUserDataByUserIdAndTicketId(
+                userLogic.getJobUserDataByUserIdAndTicketId(
                     ticketId,
                     jobTicketUsers.filter { it.userRole == RoleEnum.JTLOCKER.roleKey }
                         .map { it.userId },
@@ -235,7 +235,7 @@ class ExceptionJobViewModel @Inject constructor(
                         )
                     }
             val coLocker =
-                userRepository.getJobUserDataByUserIdAndTicketId(
+                userLogic.getJobUserDataByUserIdAndTicketId(
                     ticketId,
                     jobTicketUsers.filter { it.userRole == RoleEnum.JTCOLOCKER.roleKey }
                         .map {
@@ -275,7 +275,7 @@ class ExceptionJobViewModel @Inject constructor(
                     }
             val jobTicketUsers = jobTicketRepository.getJobTicketUserDataByTicketId(ticketId)
             val locker =
-                userRepository.getJobUserDataByUserIdAndTicketId(
+                userLogic.getJobUserDataByUserIdAndTicketId(
                     ticketId,
                     jobTicketUsers.filter { it.userRole == RoleEnum.JTLOCKER.roleKey }
                         .map { it.userId },
@@ -288,7 +288,7 @@ class ExceptionJobViewModel @Inject constructor(
                         )
                     }
             val coLocker =
-                userRepository.getJobUserDataByUserIdAndTicketId(
+                userLogic.getJobUserDataByUserIdAndTicketId(
                     ticketId,
                     jobTicketUsers.filter { it.userRole == RoleEnum.JTCOLOCKER.roleKey }
                         .map {

+ 3 - 3
app/src/main/java/com/grkj/iscs/features/main/viewmodel/hardware_manage/CardManageViewModel.kt

@@ -24,8 +24,8 @@ import javax.inject.Inject
 @HiltViewModel
 class CardManageViewModel @Inject constructor(
     private val hardwareRepository: IHardwareLogic,
-    override val userRepository: IUserLogic
-) : BaseViewModel(userRepository) {
+    override val userLogic: IUserLogic
+) : BaseViewModel(userLogic) {
     private var current: Int = 0
     private val size: Int = 50
     var cardManageDataList: MutableList<IsJobCard> = mutableListOf()
@@ -67,7 +67,7 @@ class CardManageViewModel @Inject constructor(
      */
     fun getAllUserData(): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
-            val userData = userRepository.getAllUsers().map {
+            val userData = userLogic.getAllUsers().map {
                 TextDropDownDialog.SimpleTextDropDownEntity(
                     dataId = it.userId,
                     dataText = it.userName

+ 3 - 3
app/src/main/java/com/grkj/iscs/features/main/viewmodel/home/HomeViewModel.kt

@@ -28,9 +28,9 @@ class HomeViewModel @Inject constructor(
     val jobTicketRepository: IJobTicketLogic,
     val hardwareRepository: IHardwareLogic,
     val workflowRepository: IWorkflowLogic,
-    override val userRepository: IUserLogic
+    override val userLogic: IUserLogic
 ) :
-    BaseViewModel(userRepository) {
+    BaseViewModel(userLogic) {
     var workstationData: List<WorkstationManageVo> = listOf()
     var inProgressJobNum = 0
     var lockedPointNum = 0
@@ -107,7 +107,7 @@ class HomeViewModel @Inject constructor(
     fun saveQuickEntranceData(): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
             MainDomainData.userInfo?.let {
-                userRepository.updateUser(it)
+                userLogic.updateUser(it)
                 emit(true)
             }
         }

+ 4 - 4
app/src/main/java/com/grkj/iscs/features/main/viewmodel/job_manage/JobViewModel.kt

@@ -31,8 +31,8 @@ class JobViewModel @Inject constructor(
     val sopRepository: ISopLogic,
     val jobTicketRepository: IJobTicketLogic,
     val workflowRepository: IWorkflowLogic,
-    override val userRepository: UserLogic
-) : BaseViewModel(userRepository) {
+    override val userLogic: UserLogic
+) : BaseViewModel(userLogic) {
     var workstationData: List<WorkstationManageVo> = listOf()
     var jobTicketData: JobTicketManageVo? = null
     var jobPointsData: List<JobPointVo> = mutableListOf()
@@ -164,7 +164,7 @@ class JobViewModel @Inject constructor(
                 jobTicketRepository.getTicketPointsByTicketId(ticketId)
             val tempJobTicketUserId = jobTicketRepository.getTicketUsersByTicketId(ticketId)
             userBiometricDataVo =
-                userRepository.getUserBiometricDataByUserIds(tempJobTicketUserId.map { it.userId })
+                userLogic.getUserBiometricDataByUserIds(tempJobTicketUserId.map { it.userId })
             jobLockerData =
                 tempJobTicketUserId.filter { it.roleKeys.contains(RoleEnum.JTLOCKER.roleKey) }
             jobColockerData =
@@ -179,7 +179,7 @@ class JobViewModel @Inject constructor(
     fun getUserBiometricDataByUserIds(userIds: List<Long>): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
             userBiometricDataVo =
-                userRepository.getUserBiometricDataByUserIds(userIds)
+                userLogic.getUserBiometricDataByUserIds(userIds)
             emit(true)
         }
     }

+ 2 - 2
app/src/main/java/com/grkj/iscs/features/main/viewmodel/job_manage/MyTodoViewModel.kt

@@ -30,8 +30,8 @@ import javax.inject.Inject
 @HiltViewModel
 class MyTodoViewModel @Inject constructor(
     val jobTicketRepository: IJobTicketLogic,
-    override val userRepository: IUserLogic
-) : BaseViewModel(userRepository) {
+    override val userLogic: IUserLogic
+) : BaseViewModel(userLogic) {
     /**
      * 开始读卡
      */

+ 4 - 4
app/src/main/java/com/grkj/iscs/features/main/viewmodel/job_manage/SopJobViewModel.kt

@@ -33,8 +33,8 @@ class SopJobViewModel @Inject constructor(
     val sopRepository: ISopLogic,
     val jobTicketRepository: IJobTicketLogic,
     val workflowRepository: IWorkflowLogic,
-    override val userRepository: IUserLogic
-) : BaseViewModel(userRepository) {
+    override val userLogic: IUserLogic
+) : BaseViewModel(userLogic) {
     var workstationData: List<WorkstationManageVo> = listOf()
     var sopData: List<SopManageVo> = listOf()
     var sopPoints: List<JobTicketGroupDataVo<JobPointVo>> = listOf()
@@ -171,7 +171,7 @@ class SopJobViewModel @Inject constructor(
             sopData = sopRepository.getSopDataByWorkstationId(jobTicketData?.workstationId ?: 0)
             val sopJobUsers = jobTicketRepository.getTicketUsersByTicketId(ticketId)
             userBiometricDataVo =
-                userRepository.getUserBiometricDataByUserIds(sopJobUsers.map { it.userId })
+                userLogic.getUserBiometricDataByUserIds(sopJobUsers.map { it.userId })
             sopLockerData = sopJobUsers.filter { it.roleKeys.contains(RoleEnum.JTLOCKER.roleKey) }
                 .groupBy { it.groupId to it.groupName }.map {
                     JobTicketGroupDataVo(
@@ -203,7 +203,7 @@ class SopJobViewModel @Inject constructor(
     fun getUserBiometricDataByUserIds(userIds: List<Long>): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
             userBiometricDataVo =
-                userRepository.getUserBiometricDataByUserIds(userIds)
+                userLogic.getUserBiometricDataByUserIds(userIds)
             emit(true)
         }
     }

+ 4 - 4
app/src/main/java/com/grkj/iscs/features/main/viewmodel/job_manage/SopViewModel.kt

@@ -28,8 +28,8 @@ class SopViewModel @Inject constructor(
     val workstationRepository: IWorkstationLogic,
     val sopRepository: ISopLogic,
     val workflowRepository: IWorkflowLogic,
-    override val userRepository: UserLogic
-) : BaseViewModel(userRepository) {
+    override val userLogic: UserLogic
+) : BaseViewModel(userLogic) {
     var workstationData: List<WorkstationManageVo> = listOf()
     var selectedSopData: SopManageVo? = null
     var selectedSopPointData: List<JobPointVo> = mutableListOf()
@@ -93,7 +93,7 @@ class SopViewModel @Inject constructor(
             selectedSopUserData = sopRepository.getSopUsersBySopId(sopId)
             selectedSopData = sopRepository.getSopDataBySopId(sopId)
             userBiometricDataVo =
-                userRepository.getUserBiometricDataByUserIds(selectedSopUserData.map { it.userId })
+                userLogic.getUserBiometricDataByUserIds(selectedSopUserData.map { it.userId })
             emit(true)
         }
     }
@@ -104,7 +104,7 @@ class SopViewModel @Inject constructor(
     fun getUserBiometricDataByUserIds(userIds: List<Long>): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
             userBiometricDataVo =
-                userRepository.getUserBiometricDataByUserIds(userIds)
+                userLogic.getUserBiometricDataByUserIds(userIds)
             emit(true)
         }
     }

+ 12 - 12
app/src/main/java/com/grkj/iscs/features/main/viewmodel/user_info/UserInfoViewModel.kt

@@ -18,9 +18,9 @@ import javax.inject.Inject
 
 @HiltViewModel
 class UserInfoViewModel @Inject constructor(
-    override val userRepository: IUserLogic,
+    override val userLogic: IUserLogic,
     val hardwareRepository: IHardwareLogic
-) : BaseViewModel(userRepository) {
+) : BaseViewModel(userLogic) {
     /**
      * 生物数据
      */
@@ -43,8 +43,8 @@ class UserInfoViewModel @Inject constructor(
     fun saveUserInfo(nickName: String, phone: String): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
             MainDomainData.userInfo?.userId?.let {
-                userRepository.updateUserInfo(it, nickName, phone)
-                MainDomainData.userInfo = userRepository.getUserByUserId(it)
+                userLogic.updateUserInfo(it, nickName, phone)
+                MainDomainData.userInfo = userLogic.getUserByUserId(it)
                 emit(true)
             } ?: emit(false)
         }
@@ -58,7 +58,7 @@ class UserInfoViewModel @Inject constructor(
             MainDomainData.userInfo?.userId?.let {
                 val password = BCryptUtils.encryptPassword(newPassword)
                 logger.info("用户id:${it}")
-                userRepository.updatePassword(it, password)
+                userLogic.updatePassword(it, password)
                 emit(true)
             } ?: emit(false)
         }
@@ -70,7 +70,7 @@ class UserInfoViewModel @Inject constructor(
     fun getFingerprintData(): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
             fingerprintData =
-                userRepository.getFingerprintDataByUserId(MainDomainData.userInfo?.userId)
+                userLogic.getFingerprintDataByUserId(MainDomainData.userInfo?.userId)
                     .groupBy { it.group }.map {
                         FingerprintDataVo().apply {
                             group = it.key
@@ -87,7 +87,7 @@ class UserInfoViewModel @Inject constructor(
     fun getFaceData(): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
             faceData =
-                userRepository.getFaceDataByUserId(MainDomainData.userInfo?.userId)
+                userLogic.getFaceDataByUserId(MainDomainData.userInfo?.userId)
             emit(true)
         }
     }
@@ -97,7 +97,7 @@ class UserInfoViewModel @Inject constructor(
      */
     fun deleteFingerprintByIds(fingerprintIds: List<Long>): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
-            userRepository.deleteFingerprintByIds(fingerprintIds)
+            userLogic.deleteFingerprintByIds(fingerprintIds)
             emit(true)
         }
     }
@@ -112,7 +112,7 @@ class UserInfoViewModel @Inject constructor(
             sysUserCharacteristicDo.content = b64
             sysUserCharacteristicDo.type = "1"
             sysUserCharacteristicDo.group = group
-            val fingerprintId = userRepository.saveUserCharacteristic(sysUserCharacteristicDo)
+            val fingerprintId = userLogic.saveUserCharacteristic(sysUserCharacteristicDo)
             emit(fingerprintId)
         }
     }
@@ -122,13 +122,13 @@ class UserInfoViewModel @Inject constructor(
      */
     fun saveUserFace(savePath: String): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
-            userRepository.deleteFaceDataByUserId(MainDomainData.userInfo?.userId!!)
+            userLogic.deleteFaceDataByUserId(MainDomainData.userInfo?.userId!!)
             val sysUserCharacteristicDo = SysUserCharacteristicDo()
             sysUserCharacteristicDo.userId = MainDomainData.userInfo?.userId!!
             sysUserCharacteristicDo.content = savePath
             sysUserCharacteristicDo.type = "2"
             logger.info("保存的人脸数据:${sysUserCharacteristicDo}")
-            userRepository.saveUserCharacteristic(sysUserCharacteristicDo)
+            userLogic.saveUserCharacteristic(sysUserCharacteristicDo)
             emit(true)
         }
     }
@@ -161,7 +161,7 @@ class UserInfoViewModel @Inject constructor(
         return liveData(Dispatchers.IO) {
             MainDomainData.userInfo?.let {
                 it.avatar = avatarSavePath
-                userRepository.updateUser(it)
+                userLogic.updateUser(it)
             }
             emit(true)
         }

+ 3 - 19
app/src/main/java/com/grkj/iscs/features/splash/activity/SplashActivity.kt

@@ -9,6 +9,7 @@ import androidx.work.ExistingPeriodicWorkPolicy
 import androidx.work.PeriodicWorkRequestBuilder
 import androidx.work.WorkManager
 import com.grkj.data.data.MMKVConstants
+import com.grkj.data.database.BackupScheduler
 import com.grkj.data.database.DbReadyGate
 import com.grkj.data.database.ISCSDatabase
 import com.grkj.data.database.RoomBackupWorker
@@ -39,12 +40,8 @@ class SplashActivity : BaseActivity<ActivitySplashBinding>() {
     }
 
     override fun initView() {
-        WorkManager.getInstance(this)
-            .enqueueUniquePeriodicWork(
-                "room_backup_work",
-                ExistingPeriodicWorkPolicy.KEEP,
-                backupWork
-            )
+        // 应用启动时按已存配置安排下一次(默认=每天 00:00)
+        BackupScheduler.applySaved(this)
         GlobalManager.cronJobManager.bindService()
         val dialogXTextInfo = TextInfo()
         val dialogXTitleTextInfo = TextInfo().apply {
@@ -95,17 +92,4 @@ class SplashActivity : BaseActivity<ActivitySplashBinding>() {
             }
         }
     }
-
-    /**
-     * 数据库备份
-     */
-    val backupWork = PeriodicWorkRequestBuilder<RoomBackupWorker>(
-        1, TimeUnit.DAYS
-    )
-        .setConstraints(
-            Constraints.Builder()
-                .setRequiresBatteryNotLow(true)
-                .build()
-        )
-        .build()
 }

+ 24 - 26
app/src/main/java/com/grkj/iscs/receivers/BootReceiver.kt

@@ -5,40 +5,38 @@ import android.app.PendingIntent
 import android.content.BroadcastReceiver
 import android.content.Context
 import android.content.Intent
-import com.grkj.iscs.features.login.activity.LoginActivity
+import com.grkj.data.database.BackupScheduler
 import com.grkj.iscs.features.splash.activity.SplashActivity
-import org.slf4j.Logger
 import org.slf4j.LoggerFactory
-import kotlin.apply
-import kotlin.jvm.java
-import kotlin.text.equals
 
 /**
+ * 开机启动接收器:
+ * - 仅做两件事:
+ *   1) 调用 BackupScheduler.applySaved(context) 按已保存的配置重新安排下一次备份
+ *   2) (可选)自启动 App(你已有逻辑,保留)
  *
- * 开机启动接收器
- * */
+ * 注:WorkManager 的任务会持久化,理论上重启后也在;
+ *     这里再次 applySaved() 是“保险丝”,避免用户还没打开 App、计划丢失的极端情况。
+ */
 class BootReceiver : BroadcastReceiver() {
-    private val logger: Logger = LoggerFactory.getLogger(BootReceiver::class.java)
+    private val logger = LoggerFactory.getLogger(BootReceiver::class.java)
     override fun onReceive(context: Context, intent: Intent?) {
-        if (intent!!.action.equals("android.intent.action.BOOT_COMPLETED")) {
-            logger.debug("接收到启动通知,开始启动应用")
-            //开机2秒后启动程序
-            val startAppIntent = Intent(
-                context, SplashActivity::class.java
-            ).apply {
+        if (Intent.ACTION_BOOT_COMPLETED == intent?.action) {
+            logger.debug("接收到 BOOT_COMPLETED,应用备份计划")
+            // 1) 重新应用备份计划(读取 SimpleBackupPrefs 并排下一次)
+            BackupScheduler.applySaved(context)
+
+            // 2) (可选)2 秒后自启动 App(你原有逻辑)
+            val startAppIntent = Intent(context, SplashActivity::class.java).apply {
                 flags = Intent.FLAG_ACTIVITY_NEW_TASK
             }
-            //启动应用,得使用PendingIntent
-            val startAppPendingIntent =
-                PendingIntent.getActivity(
-                    context, 0, startAppIntent,
-                    PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE
-                );
-            val mAlarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
-            mAlarmManager.set(
-                AlarmManager.RTC, System.currentTimeMillis() + 2000,
-                startAppPendingIntent
-            ) // 2秒钟后重启应用
+            val pi = PendingIntent.getActivity(
+                context, 0, startAppIntent,
+                PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE
+            )
+            (context.getSystemService(Context.ALARM_SERVICE) as AlarmManager).set(
+                AlarmManager.RTC, System.currentTimeMillis() + 2000, pi
+            )
         }
     }
-}
+}

+ 1 - 1
app/src/main/res/layout-land/activity_login.xml

@@ -65,7 +65,7 @@
                 android:layout_height="wrap_content"
                 android:layout_gravity="center_horizontal"
                 android:layout_marginTop="@dimen/login_main_title_margin_top"
-                android:text="@string/loto"
+                app:i18nKey='@{"loto"}'
                 android:textColor="@color/white"
                 android:textSize="@dimen/login_main_title_text_size"
                 android:textStyle="bold" />

+ 407 - 0
app/src/main/res/layout-land/fragment_backup_and_restore.xml

@@ -0,0 +1,407 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools">
+
+    <data>
+
+        <import type="kotlin.collections.CollectionsKt" />
+    </data>
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:layout_margin="@dimen/common_spacing_2x"
+        android:background="@drawable/home_card_bg"
+        android:orientation="vertical">
+
+        <LinearLayout
+            android:id="@+id/title_layout"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:gravity="center_vertical"
+            android:orientation="horizontal"
+            android:paddingHorizontal="@dimen/common_spacing">
+
+            <ImageView
+                android:layout_width="@dimen/title_icon_size"
+                android:layout_height="@dimen/title_icon_size"
+                android:src="@mipmap/icon_backup_and_restore" />
+
+            <TextView
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginLeft="@dimen/common_spacing"
+                android:layout_weight="1"
+                android:textColor="@color/black"
+                android:textSize="@dimen/normal_text_size_25"
+                android:textStyle="bold"
+                app:i18nKey='@{"backup_title"}' />
+
+            <TextView
+                android:id="@+id/back"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginVertical="5dp"
+                android:layout_marginLeft="@dimen/common_spacing"
+                android:background="@drawable/common_btn"
+                android:drawableLeft="@mipmap/icon_back"
+                android:drawablePadding="@dimen/common_spacing"
+                android:gravity="center"
+                android:minHeight="@dimen/common_btn_height"
+                android:paddingHorizontal="@dimen/common_spacing_2x"
+                android:textColor="@color/black"
+                android:textSize="@dimen/common_btn_text_size"
+                app:i18nKey='@{"back"}' />
+        </LinearLayout>
+
+        <View
+            android:layout_width="match_parent"
+            android:layout_height="@dimen/divider_line_space"
+            android:background="@color/black" />
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:orientation="horizontal">
+
+            <LinearLayout
+                android:layout_width="0dp"
+                android:layout_height="match_parent"
+                android:layout_margin="@dimen/common_spacing_2x"
+                android:layout_weight="1"
+                android:background="@drawable/common_card_bg"
+                android:orientation="vertical">
+
+                <LinearLayout
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:orientation="horizontal">
+
+                    <TextView
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_gravity="center_vertical"
+                        android:layout_marginLeft="@dimen/common_spacing"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/normal_text_size_25"
+                        android:textStyle="bold"
+                        app:i18nKey='@{"backup"}' />
+
+                    <View
+                        android:layout_width="0dp"
+                        android:layout_height="1dp"
+                        android:layout_weight="1" />
+
+                    <TextView
+                        android:id="@+id/backup_now"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_marginVertical="5dp"
+                        android:layout_marginLeft="@dimen/common_spacing"
+                        android:layout_marginRight="@dimen/common_spacing_2x"
+                        android:background="@drawable/common_btn"
+                        android:gravity="center"
+                        android:minHeight="@dimen/common_btn_height"
+                        android:paddingHorizontal="@dimen/common_spacing_2x"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_btn_text_size"
+                        app:i18nKey='@{"backup_now"}' />
+                </LinearLayout>
+
+                <View
+                    android:layout_width="match_parent"
+                    android:layout_height="@dimen/divider_line_space"
+                    android:background="@color/black" />
+
+                <androidx.constraintlayout.widget.ConstraintLayout
+                    android:layout_width="match_parent"
+                    android:layout_height="match_parent"
+                    android:paddingHorizontal="@dimen/dialog_content_normal_padding_horizontal">
+
+                    <TextView
+                        android:id="@+id/backup_path_tv"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_marginTop="@dimen/common_spacing_2x"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_text_size"
+                        app:i18nKey='@{"backup_path"}'
+                        app:layout_constraintEnd_toEndOf="@+id/end_line"
+                        app:layout_constraintStart_toStartOf="parent"
+                        app:layout_constraintTop_toTopOf="parent" />
+
+                    <TextView
+                        android:id="@+id/backup_path"
+                        android:layout_width="0dp"
+                        android:layout_height="wrap_content"
+                        android:layout_marginLeft="@dimen/common_spacing"
+                        android:background="@drawable/bg_common_input"
+                        android:maxLines="1"
+                        android:paddingHorizontal="@dimen/common_spacing"
+                        android:paddingVertical="2dp"
+                        android:singleLine="true"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_text_size"
+                        app:layout_constraintBottom_toBottomOf="@+id/backup_path_tv"
+                        app:layout_constraintEnd_toEndOf="parent"
+                        app:layout_constraintStart_toEndOf="@+id/backup_path_tv" />
+
+                    <TextView
+                        android:id="@+id/maximum_number_of_backups_tv"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_marginTop="@dimen/common_spacing_2x"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_text_size"
+                        app:i18nKey='@{"maximum_number_of_backups"}'
+                        app:layout_constraintEnd_toEndOf="@+id/end_line"
+                        app:layout_constraintTop_toBottomOf="@+id/backup_path_tv" />
+
+                    <TextView
+                        android:id="@+id/maximum_number_of_backups"
+                        android:layout_width="0dp"
+                        android:layout_height="wrap_content"
+                        android:layout_marginLeft="@dimen/common_spacing"
+                        android:background="@drawable/bg_common_input"
+                        android:maxLines="1"
+                        android:paddingHorizontal="@dimen/common_spacing"
+                        android:paddingVertical="2dp"
+                        android:singleLine="true"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_text_size"
+                        app:layout_constraintBottom_toBottomOf="@+id/maximum_number_of_backups_tv"
+                        app:layout_constraintEnd_toStartOf="@+id/maximum_number_of_backups_range"
+                        app:layout_constraintStart_toEndOf="@+id/maximum_number_of_backups_tv" />
+
+                    <TextView
+                        android:id="@+id/maximum_number_of_backups_range"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_marginLeft="@dimen/common_spacing"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_text_size"
+                        app:i18nArg0='@{"5-20"}'
+                        app:i18nKey='@{"backup_range"}'
+                        app:layout_constraintEnd_toEndOf="@+id/backup_path"
+                        app:layout_constraintTop_toBottomOf="@+id/backup_path_tv"
+                        app:layout_constraintTop_toTopOf="@+id/maximum_number_of_backups_tv" />
+
+                    <TextView
+                        android:id="@+id/status_tv"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_marginTop="@dimen/common_spacing_2x"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_text_size"
+                        app:i18nKey='@{"auto_backup"}'
+                        app:layout_constraintEnd_toEndOf="@+id/end_line"
+                        app:layout_constraintTop_toBottomOf="@+id/maximum_number_of_backups_tv" />
+
+                    <RadioGroup
+                        android:id="@+id/status_rg"
+                        android:layout_width="0dp"
+                        android:layout_height="wrap_content"
+                        android:orientation="horizontal"
+                        app:layout_constraintBottom_toBottomOf="@+id/status_tv"
+                        app:layout_constraintStart_toEndOf="@+id/end_line"
+                        app:layout_constraintTop_toTopOf="@+id/status_tv">
+
+                        <RadioButton
+                            android:id="@+id/enable_rb"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:layout_marginStart="@dimen/common_spacing"
+                            android:textSize="@dimen/common_text_size"
+                            app:i18nKey='@{"common_enable"}' />
+
+                        <RadioButton
+                            android:id="@+id/disable_rb"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:layout_marginStart="@dimen/common_spacing"
+                            android:textSize="@dimen/common_text_size"
+                            app:i18nKey='@{"common_disable"}' />
+                    </RadioGroup>
+
+                    <com.grkj.ui_base.widget.RequiredTextView
+                        android:id="@+id/backup_frequency_tv"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_marginTop="@dimen/common_spacing_2x"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_text_size"
+                        app:i18nKey='@{"backup_frequency"}'
+                        app:layout_constraintEnd_toEndOf="@+id/end_line"
+                        app:layout_constraintTop_toBottomOf="@+id/status_tv" />
+
+                    <TextView
+                        android:id="@+id/backup_quency"
+                        android:layout_width="0dp"
+                        android:layout_height="wrap_content"
+                        android:layout_marginLeft="@dimen/common_spacing"
+                        android:background="@drawable/bg_common_input"
+                        android:drawableRight="@mipmap/icon_drop_down"
+                        android:maxLines="1"
+                        android:paddingHorizontal="@dimen/common_spacing"
+                        android:paddingVertical="2dp"
+                        android:singleLine="true"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_text_size"
+                        app:layout_constraintBottom_toBottomOf="@+id/backup_frequency_tv"
+                        app:layout_constraintEnd_toEndOf="parent"
+                        app:layout_constraintStart_toEndOf="@+id/backup_frequency_tv"
+                        app:layout_constraintTop_toTopOf="@+id/backup_frequency_tv" />
+
+                    <com.grkj.ui_base.widget.RequiredTextView
+                        android:id="@+id/backup_time_tv"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_marginTop="@dimen/common_spacing_2x"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_text_size"
+                        app:i18nKey='@{"backup_frequency"}'
+                        app:layout_constraintEnd_toEndOf="@+id/end_line"
+                        app:layout_constraintTop_toBottomOf="@+id/status_tv" />
+
+                    <TextView
+                        android:id="@+id/backup_time"
+                        android:layout_width="0dp"
+                        android:layout_height="wrap_content"
+                        android:layout_marginLeft="@dimen/common_spacing"
+                        android:background="@drawable/bg_common_input"
+                        android:maxLines="1"
+                        android:paddingHorizontal="@dimen/common_spacing"
+                        android:paddingVertical="2dp"
+                        android:singleLine="true"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_text_size"
+                        app:layout_constraintBottom_toBottomOf="@+id/backup_time_tv"
+                        app:layout_constraintEnd_toEndOf="parent"
+                        app:layout_constraintStart_toEndOf="@+id/backup_time_tv"
+                        app:layout_constraintTop_toTopOf="@+id/backup_time_tv" />
+
+                    <androidx.constraintlayout.widget.Barrier
+                        android:id="@+id/end_line"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        app:barrierDirection="left"
+                        app:constraint_referenced_ids="maximum_number_of_backups_tv,backup_path_tv" />
+                </androidx.constraintlayout.widget.ConstraintLayout>
+            </LinearLayout>
+
+            <LinearLayout
+                android:layout_width="0dp"
+                android:layout_height="match_parent"
+                android:layout_margin="@dimen/common_spacing_2x"
+                android:layout_weight="1"
+                android:background="@drawable/common_card_bg"
+                android:orientation="vertical">
+
+                <LinearLayout
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:orientation="horizontal">
+
+                    <TextView
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_gravity="center_vertical"
+                        android:layout_marginLeft="@dimen/common_spacing"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/normal_text_size_25"
+                        android:textStyle="bold"
+                        app:i18nKey='@{"restore"}' />
+
+                    <View
+                        android:layout_width="0dp"
+                        android:layout_height="1dp"
+                        android:layout_weight="1" />
+
+                    <TextView
+                        android:id="@+id/batch_export"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_marginVertical="5dp"
+                        android:background="@drawable/common_btn"
+                        android:gravity="center"
+                        android:minHeight="@dimen/common_btn_height"
+                        android:paddingHorizontal="@dimen/common_spacing_2x"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_btn_text_size"
+                        app:i18nKey='@{"common_batch_export"}' />
+
+                    <TextView
+                        android:id="@+id/batch_delete"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_marginVertical="5dp"
+                        android:layout_marginLeft="@dimen/common_spacing"
+                        android:layout_marginRight="@dimen/common_spacing_2x"
+                        android:background="@drawable/common_btn"
+                        android:gravity="center"
+                        android:minHeight="@dimen/common_btn_height"
+                        android:paddingHorizontal="@dimen/common_spacing_2x"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_btn_text_size"
+                        app:i18nKey='@{"common_batch_delete"}' />
+                </LinearLayout>
+
+                <View
+                    android:layout_width="match_parent"
+                    android:layout_height="@dimen/divider_line_space"
+                    android:background="@color/black" />
+
+                <LinearLayout
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_marginHorizontal="@dimen/common_spacing_2x"
+                    android:layout_marginTop="@dimen/common_spacing"
+                    android:background="@drawable/common_card_bg"
+                    android:divider="@drawable/divider_table"
+                    android:paddingVertical="@dimen/common_spacing"
+                    android:showDividers="middle">
+
+                    <CheckBox
+                        android:id="@+id/select_all"
+                        android:layout_width="30dp"
+                        android:layout_height="30dp"
+                        android:layout_gravity="center"
+                        android:layout_margin="@dimen/common_spacing" />
+
+                    <TextView
+                        android:layout_width="0dp"
+                        android:layout_height="match_parent"
+                        android:layout_weight="1"
+                        android:gravity="center"
+                        android:textSize="@dimen/common_text_size"
+                        app:i18nKey='@{"backup"}' />
+
+                    <TextView
+                        android:layout_width="0dp"
+                        android:layout_height="match_parent"
+                        android:layout_weight="1"
+                        android:gravity="center"
+                        android:textSize="@dimen/common_text_size"
+                        app:i18nKey='@{"operation"}' />
+                </LinearLayout>
+
+                <com.drake.statelayout.StateLayout
+                    android:id="@+id/state"
+                    android:layout_width="match_parent"
+                    android:layout_height="match_parent"
+                    android:layout_marginHorizontal="@dimen/common_spacing_2x"
+                    android:layout_marginBottom="@dimen/common_spacing"
+                    android:background="@drawable/common_card_bg">
+
+                    <androidx.recyclerview.widget.RecyclerView
+                        android:id="@+id/list_rv"
+                        android:layout_width="match_parent"
+                        android:layout_height="match_parent"
+                        android:background="@drawable/common_card_bg"
+                        tools:listitem="@layout/item_backup" />
+                </com.drake.statelayout.StateLayout>
+            </LinearLayout>
+        </LinearLayout>
+    </LinearLayout>
+</layout>

+ 1 - 1
app/src/main/res/layout-land/fragment_home.xml

@@ -13,7 +13,7 @@
             android:layout_height="wrap_content"
             android:layout_gravity="center_horizontal"
             android:layout_marginTop="@dimen/common_spacing_2x"
-            android:text="@string/loto"
+            app:i18nKey='@{"loto"}'
             android:textColor="@color/white"
             android:textSize="50sp"
             android:textStyle="bold" />

+ 1 - 1
app/src/main/res/layout/activity_splash.xml

@@ -24,7 +24,7 @@
                 android:layout_width="wrap_content"
                 android:layout_height="wrap_content"
                 android:layout_gravity="center_horizontal"
-                android:text="@string/loto"
+                app:i18nKey='@{"loto"}'
                 android:textColor="@color/white"
                 android:textSize="@dimen/login_main_title_text_size"
                 android:textStyle="bold" />

+ 407 - 0
app/src/main/res/layout/fragment_backup_and_restore.xml

@@ -0,0 +1,407 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools">
+
+    <data>
+
+        <import type="kotlin.collections.CollectionsKt" />
+    </data>
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:layout_margin="@dimen/common_spacing_2x"
+        android:background="@drawable/home_card_bg"
+        android:orientation="vertical">
+
+        <LinearLayout
+            android:id="@+id/title_layout"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:gravity="center_vertical"
+            android:orientation="horizontal"
+            android:paddingHorizontal="@dimen/common_spacing">
+
+            <ImageView
+                android:layout_width="@dimen/title_icon_size"
+                android:layout_height="@dimen/title_icon_size"
+                android:src="@mipmap/icon_backup_and_restore" />
+
+            <TextView
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginLeft="@dimen/common_spacing"
+                android:layout_weight="1"
+                android:textColor="@color/black"
+                android:textSize="@dimen/normal_text_size_25"
+                android:textStyle="bold"
+                app:i18nKey='@{"backup_title"}' />
+
+            <TextView
+                android:id="@+id/back"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginVertical="5dp"
+                android:layout_marginLeft="@dimen/common_spacing"
+                android:background="@drawable/common_btn"
+                android:drawableLeft="@mipmap/icon_back"
+                android:drawablePadding="@dimen/common_spacing"
+                android:gravity="center"
+                android:minHeight="@dimen/common_btn_height"
+                android:paddingHorizontal="@dimen/common_spacing_2x"
+                android:textColor="@color/black"
+                android:textSize="@dimen/common_btn_text_size"
+                app:i18nKey='@{"back"}' />
+        </LinearLayout>
+
+        <View
+            android:layout_width="match_parent"
+            android:layout_height="@dimen/divider_line_space"
+            android:background="@color/black" />
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:orientation="vertical">
+
+            <LinearLayout
+                android:layout_width="match_parent"
+                android:layout_height="0dp"
+                android:layout_margin="@dimen/common_spacing_2x"
+                android:layout_weight="1"
+                android:background="@drawable/common_card_bg"
+                android:orientation="vertical">
+
+                <LinearLayout
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:orientation="horizontal">
+
+                    <TextView
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_gravity="center_vertical"
+                        android:layout_marginLeft="@dimen/common_spacing"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/normal_text_size_25"
+                        android:textStyle="bold"
+                        app:i18nKey='@{"backup"}' />
+
+                    <View
+                        android:layout_width="0dp"
+                        android:layout_height="1dp"
+                        android:layout_weight="1" />
+
+                    <TextView
+                        android:id="@+id/backup_now"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_marginVertical="5dp"
+                        android:layout_marginLeft="@dimen/common_spacing"
+                        android:layout_marginRight="@dimen/common_spacing_2x"
+                        android:background="@drawable/common_btn"
+                        android:gravity="center"
+                        android:minHeight="@dimen/common_btn_height"
+                        android:paddingHorizontal="@dimen/common_spacing_2x"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_btn_text_size"
+                        app:i18nKey='@{"backup_now"}' />
+                </LinearLayout>
+
+                <View
+                    android:layout_width="match_parent"
+                    android:layout_height="@dimen/divider_line_space"
+                    android:background="@color/black" />
+
+                <androidx.constraintlayout.widget.ConstraintLayout
+                    android:layout_width="match_parent"
+                    android:layout_height="match_parent"
+                    android:paddingHorizontal="@dimen/dialog_content_normal_padding_horizontal">
+
+                    <TextView
+                        android:id="@+id/backup_path_tv"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_marginTop="@dimen/common_spacing_2x"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_text_size"
+                        app:i18nKey='@{"backup_path"}'
+                        app:layout_constraintEnd_toEndOf="@+id/end_line"
+                        app:layout_constraintStart_toStartOf="parent"
+                        app:layout_constraintTop_toTopOf="parent" />
+
+                    <TextView
+                        android:id="@+id/backup_path"
+                        android:layout_width="0dp"
+                        android:layout_height="wrap_content"
+                        android:layout_marginLeft="@dimen/common_spacing"
+                        android:background="@drawable/bg_common_input"
+                        android:maxLines="1"
+                        android:paddingHorizontal="@dimen/common_spacing"
+                        android:paddingVertical="2dp"
+                        android:singleLine="true"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_text_size"
+                        app:layout_constraintBottom_toBottomOf="@+id/backup_path_tv"
+                        app:layout_constraintEnd_toEndOf="parent"
+                        app:layout_constraintStart_toEndOf="@+id/backup_path_tv" />
+
+                    <TextView
+                        android:id="@+id/maximum_number_of_backups_tv"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_marginTop="@dimen/common_spacing_2x"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_text_size"
+                        app:i18nKey='@{"maximum_number_of_backups"}'
+                        app:layout_constraintEnd_toEndOf="@+id/end_line"
+                        app:layout_constraintTop_toBottomOf="@+id/backup_path_tv" />
+
+                    <TextView
+                        android:id="@+id/maximum_number_of_backups"
+                        android:layout_width="0dp"
+                        android:layout_height="wrap_content"
+                        android:layout_marginLeft="@dimen/common_spacing"
+                        android:background="@drawable/bg_common_input"
+                        android:maxLines="1"
+                        android:paddingHorizontal="@dimen/common_spacing"
+                        android:paddingVertical="2dp"
+                        android:singleLine="true"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_text_size"
+                        app:layout_constraintBottom_toBottomOf="@+id/maximum_number_of_backups_tv"
+                        app:layout_constraintEnd_toStartOf="@+id/maximum_number_of_backups_range"
+                        app:layout_constraintStart_toEndOf="@+id/maximum_number_of_backups_tv" />
+
+                    <TextView
+                        android:id="@+id/maximum_number_of_backups_range"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_marginLeft="@dimen/common_spacing"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_text_size"
+                        app:i18nArg0='@{"5-20"}'
+                        app:i18nKey='@{"backup_range"}'
+                        app:layout_constraintEnd_toEndOf="@+id/backup_path"
+                        app:layout_constraintTop_toBottomOf="@+id/backup_path_tv"
+                        app:layout_constraintTop_toTopOf="@+id/maximum_number_of_backups_tv" />
+
+                    <TextView
+                        android:id="@+id/status_tv"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_marginTop="@dimen/common_spacing_2x"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_text_size"
+                        app:i18nKey='@{"auto_backup"}'
+                        app:layout_constraintEnd_toEndOf="@+id/end_line"
+                        app:layout_constraintTop_toBottomOf="@+id/maximum_number_of_backups_tv" />
+
+                    <RadioGroup
+                        android:id="@+id/status_rg"
+                        android:layout_width="0dp"
+                        android:layout_height="wrap_content"
+                        android:orientation="horizontal"
+                        app:layout_constraintBottom_toBottomOf="@+id/status_tv"
+                        app:layout_constraintStart_toEndOf="@+id/end_line"
+                        app:layout_constraintTop_toTopOf="@+id/status_tv">
+
+                        <RadioButton
+                            android:id="@+id/enable_rb"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:layout_marginStart="@dimen/common_spacing"
+                            android:textSize="@dimen/common_text_size"
+                            app:i18nKey='@{"common_enable"}' />
+
+                        <RadioButton
+                            android:id="@+id/disable_rb"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:layout_marginStart="@dimen/common_spacing"
+                            android:textSize="@dimen/common_text_size"
+                            app:i18nKey='@{"common_disable"}' />
+                    </RadioGroup>
+
+                    <com.grkj.ui_base.widget.RequiredTextView
+                        android:id="@+id/backup_frequency_tv"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_marginTop="@dimen/common_spacing_2x"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_text_size"
+                        app:i18nKey='@{"backup_frequency"}'
+                        app:layout_constraintEnd_toEndOf="@+id/end_line"
+                        app:layout_constraintTop_toBottomOf="@+id/status_tv" />
+
+                    <TextView
+                        android:id="@+id/backup_quency"
+                        android:layout_width="0dp"
+                        android:layout_height="wrap_content"
+                        android:layout_marginLeft="@dimen/common_spacing"
+                        android:background="@drawable/bg_common_input"
+                        android:drawableRight="@mipmap/icon_drop_down"
+                        android:maxLines="1"
+                        android:paddingHorizontal="@dimen/common_spacing"
+                        android:paddingVertical="2dp"
+                        android:singleLine="true"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_text_size"
+                        app:layout_constraintBottom_toBottomOf="@+id/backup_frequency_tv"
+                        app:layout_constraintEnd_toEndOf="parent"
+                        app:layout_constraintStart_toEndOf="@+id/backup_frequency_tv"
+                        app:layout_constraintTop_toTopOf="@+id/backup_frequency_tv" />
+
+                    <com.grkj.ui_base.widget.RequiredTextView
+                        android:id="@+id/backup_time_tv"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_marginTop="@dimen/common_spacing_2x"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_text_size"
+                        app:i18nKey='@{"backup_frequency"}'
+                        app:layout_constraintEnd_toEndOf="@+id/end_line"
+                        app:layout_constraintTop_toBottomOf="@+id/status_tv" />
+
+                    <TextView
+                        android:id="@+id/backup_time"
+                        android:layout_width="0dp"
+                        android:layout_height="wrap_content"
+                        android:layout_marginLeft="@dimen/common_spacing"
+                        android:background="@drawable/bg_common_input"
+                        android:maxLines="1"
+                        android:paddingHorizontal="@dimen/common_spacing"
+                        android:paddingVertical="2dp"
+                        android:singleLine="true"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_text_size"
+                        app:layout_constraintBottom_toBottomOf="@+id/backup_time_tv"
+                        app:layout_constraintEnd_toEndOf="parent"
+                        app:layout_constraintStart_toEndOf="@+id/backup_time_tv"
+                        app:layout_constraintTop_toTopOf="@+id/backup_time_tv" />
+
+                    <androidx.constraintlayout.widget.Barrier
+                        android:id="@+id/end_line"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        app:barrierDirection="left"
+                        app:constraint_referenced_ids="maximum_number_of_backups_tv,backup_path_tv" />
+                </androidx.constraintlayout.widget.ConstraintLayout>
+            </LinearLayout>
+
+            <LinearLayout
+                android:layout_width="match_parent"
+                android:layout_height="0dp"
+                android:layout_margin="@dimen/common_spacing_2x"
+                android:layout_weight="1"
+                android:background="@drawable/common_card_bg"
+                android:orientation="vertical">
+
+                <LinearLayout
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:orientation="horizontal">
+
+                    <TextView
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_gravity="center_vertical"
+                        android:layout_marginLeft="@dimen/common_spacing"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/normal_text_size_25"
+                        android:textStyle="bold"
+                        app:i18nKey='@{"restore"}' />
+
+                    <View
+                        android:layout_width="0dp"
+                        android:layout_height="1dp"
+                        android:layout_weight="1" />
+
+                    <TextView
+                        android:id="@+id/batch_export"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_marginVertical="5dp"
+                        android:background="@drawable/common_btn"
+                        android:gravity="center"
+                        android:minHeight="@dimen/common_btn_height"
+                        android:paddingHorizontal="@dimen/common_spacing_2x"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_btn_text_size"
+                        app:i18nKey='@{"common_batch_export"}' />
+
+                    <TextView
+                        android:id="@+id/batch_delete"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_marginVertical="5dp"
+                        android:layout_marginLeft="@dimen/common_spacing"
+                        android:layout_marginRight="@dimen/common_spacing_2x"
+                        android:background="@drawable/common_btn"
+                        android:gravity="center"
+                        android:minHeight="@dimen/common_btn_height"
+                        android:paddingHorizontal="@dimen/common_spacing_2x"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_btn_text_size"
+                        app:i18nKey='@{"common_batch_delete"}' />
+                </LinearLayout>
+
+                <View
+                    android:layout_width="match_parent"
+                    android:layout_height="@dimen/divider_line_space"
+                    android:background="@color/black" />
+
+                <LinearLayout
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_marginHorizontal="@dimen/common_spacing_2x"
+                    android:layout_marginTop="@dimen/common_spacing"
+                    android:background="@drawable/common_card_bg"
+                    android:divider="@drawable/divider_table"
+                    android:paddingVertical="@dimen/common_spacing"
+                    android:showDividers="middle">
+
+                    <CheckBox
+                        android:id="@+id/select_all"
+                        android:layout_width="30dp"
+                        android:layout_height="30dp"
+                        android:layout_gravity="center"
+                        android:layout_margin="@dimen/common_spacing" />
+
+                    <TextView
+                        android:layout_width="0dp"
+                        android:layout_height="match_parent"
+                        android:layout_weight="1"
+                        android:gravity="center"
+                        android:textSize="@dimen/common_text_size"
+                        app:i18nKey='@{"backup"}' />
+
+                    <TextView
+                        android:layout_width="0dp"
+                        android:layout_height="match_parent"
+                        android:layout_weight="1"
+                        android:gravity="center"
+                        android:textSize="@dimen/common_text_size"
+                        app:i18nKey='@{"operation"}' />
+                </LinearLayout>
+
+                <com.drake.statelayout.StateLayout
+                    android:id="@+id/state"
+                    android:layout_width="match_parent"
+                    android:layout_height="match_parent"
+                    android:layout_marginHorizontal="@dimen/common_spacing_2x"
+                    android:layout_marginBottom="@dimen/common_spacing"
+                    android:background="@drawable/common_card_bg">
+
+                    <androidx.recyclerview.widget.RecyclerView
+                        android:id="@+id/list_rv"
+                        android:layout_width="match_parent"
+                        android:layout_height="match_parent"
+                        android:background="@drawable/common_card_bg"
+                        tools:listitem="@layout/item_backup" />
+                </com.drake.statelayout.StateLayout>
+            </LinearLayout>
+        </LinearLayout>
+    </LinearLayout>
+</layout>

+ 1 - 1
app/src/main/res/layout/fragment_home.xml

@@ -13,7 +13,7 @@
             android:layout_height="wrap_content"
             android:layout_gravity="center_horizontal"
             android:layout_marginTop="@dimen/common_margin_spacing_big"
-            android:text="@string/loto"
+            app:i18nKey='@{"loto"}'
             android:textColor="@color/white"
             android:textSize="50sp"
             android:textStyle="bold" />

+ 1 - 1
app/src/main/res/layout/fragment_init_welcome.xml

@@ -26,7 +26,7 @@
             android:layout_alignLeft="@+id/welcome_tip"
             android:layout_gravity="center_horizontal"
             android:layout_marginTop="@dimen/common_margin_spacing_big"
-            android:text="@string/loto"
+            app:i18nKey='@{"loto"}'
             android:textColor="@color/black"
             android:textSize="@dimen/login_main_title_text_size"
             android:textStyle="bold" />

+ 63 - 0
app/src/main/res/layout/item_backup.xml

@@ -0,0 +1,63 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:background="@drawable/common_card_bg"
+        android:divider="@drawable/divider_table"
+        android:paddingVertical="@dimen/common_spacing"
+        android:showDividers="middle">
+
+        <CheckBox
+            android:id="@+id/select_all"
+            android:layout_width="30dp"
+            android:layout_height="30dp"
+            android:layout_gravity="center"
+            android:layout_margin="@dimen/common_spacing" />
+
+        <TextView
+            android:id="@+id/backup_name"
+            android:layout_width="0dp"
+            android:layout_height="match_parent"
+            android:layout_weight="1"
+            android:gravity="center"
+            android:textSize="@dimen/common_text_size" />
+
+        <LinearLayout
+            android:layout_width="0dp"
+            android:layout_height="match_parent"
+            android:layout_weight="1"
+            android:orientation="horizontal">
+
+            <TextView
+                android:id="@+id/export"
+                android:layout_width="0dp"
+                android:layout_height="match_parent"
+                android:layout_weight="1"
+                android:gravity="center"
+                android:textSize="@dimen/common_text_size"
+                app:i18nKey='@{"common_export"}' />
+
+            <TextView
+                android:id="@+id/delete"
+                android:layout_width="0dp"
+                android:layout_height="match_parent"
+                android:layout_weight="1"
+                android:gravity="center"
+                android:textColor="@color/common_status_red"
+                android:textSize="@dimen/common_text_size"
+                app:i18nKey='@{"delete"}' />
+
+            <TextView
+                android:id="@+id/restore"
+                android:layout_width="0dp"
+                android:layout_height="match_parent"
+                android:layout_weight="1"
+                android:gravity="center"
+                android:textSize="@dimen/common_text_size"
+                app:i18nKey='@{"restore"}' />
+        </LinearLayout>
+    </LinearLayout>
+</layout>

BIN
app/src/main/res/mipmap-xhdpi/icon_backup_and_restore.png


+ 7 - 0
app/src/main/res/navigation/nav_data_manage.xml

@@ -20,6 +20,9 @@
         <action
             android:id="@+id/action_dataManageHomeFragment_to_pointMangeFragment"
             app:destination="@id/pointMangeFragment" />
+        <action
+            android:id="@+id/action_dataManageHomeFragment_to_backupAndRestoreFragment"
+            app:destination="@id/backupAndRestoreFragment" />
     </fragment>
     <fragment
         android:id="@+id/userManageFragment"
@@ -37,4 +40,8 @@
         android:id="@+id/pointMangeFragment"
         android:name="com.grkj.iscs.features.main.fragment.data_manage.PointMangeFragment"
         android:label="PointMangeFragment" />
+    <fragment
+        android:id="@+id/backupAndRestoreFragment"
+        android:name="com.grkj.iscs.features.main.fragment.data_manage.BackupAndRestoreFragment"
+        android:label="BackupAndRestoreFragment" />
 </navigation>

+ 145 - 0
data/src/main/java/com/grkj/data/database/BackupScheduler.kt

@@ -0,0 +1,145 @@
+package com.grkj.data.database
+
+import android.content.Context
+import androidx.work.*
+import java.util.Calendar
+import java.util.concurrent.TimeUnit
+
+/**
+ * 简易备份调度器:
+ * - setAndApply(...):保存配置并立刻按配置排一次下一次执行
+ * - applySaved(...) :读取已存配置并排下一次(App 启动/开机时调用)
+ * - backupNow(...)  :立即备份一次
+ *
+ * 任务名:
+ * - UNIQUE_RECURRING:我们自己的“下一次”任务(每次 REPLACE)
+ * - OLD_PERIODIC    :你曾经的 PeriodicWork 任务名(首次迁移时取消掉)
+ */
+object BackupScheduler {
+    private const val UNIQUE_RECURRING = "iscs_backup_recurring"
+    private const val UNIQUE_NOW       = "iscs_backup_now"
+    private const val OLD_PERIODIC     = "room_backup_work"
+
+    /**
+     * 保存并生效(供设置页“保存”按钮调用)
+     * @param enabled 是否启用
+     * @param hour    小时 0..23
+     * @param minute  分钟 0..59
+     * @param daysMask 周几掩码(全选=每天)
+     * @param keep    保留份数(传给 Worker)
+     */
+    fun setAndApply(ctx: Context, enabled: Boolean, hour: Int, minute: Int, daysMask: Int, keep: Int) {
+        SimpleBackupPrefs.save(ctx, SimpleBackupConfig(enabled, hour, minute, daysMask, keep))
+        applySaved(ctx)
+    }
+
+    /**
+     * 读取已存配置并排“下一次”。
+     * - 若未启用或未选周几 -> 取消任务
+     * - 首次迁移:顺便把旧的 PeriodicWork 取消
+     */
+    fun applySaved(ctx: Context) {
+        // 迁移:停旧任务,避免双份
+        WorkManager.getInstance(ctx).cancelUniqueWork(OLD_PERIODIC)
+
+        val cfg = SimpleBackupPrefs.get(ctx)
+        if (!cfg.enabled || cfg.daysMask == 0) {
+            WorkManager.getInstance(ctx).cancelUniqueWork(UNIQUE_RECURRING)
+            return
+        }
+        scheduleNext(ctx, cfg)
+    }
+
+    /** 立即备份(按钮用) */
+    fun backupNow(ctx: Context) {
+        val cfg = SimpleBackupPrefs.get(ctx)
+        val data = workDataOf(RoomBackupWorker.KEY_KEEP_COUNT to cfg.keep)
+        val req = OneTimeWorkRequestBuilder<RoomBackupWorker>()
+            .setInputData(data)
+            .setConstraints(defaultConstraints())
+            .build()
+        WorkManager.getInstance(ctx).enqueueUniqueWork(
+            UNIQUE_NOW, ExistingWorkPolicy.REPLACE, req
+        )
+    }
+
+    /** 给 Worker 成功后调用:再铺下一次 */
+    fun scheduleNextFromPrefsSync(ctx: Context) {
+        val cfg = SimpleBackupPrefs.get(ctx)
+        if (cfg.enabled && cfg.daysMask != 0) {
+            scheduleNext(ctx, cfg)
+        } else {
+            WorkManager.getInstance(ctx).cancelUniqueWork(UNIQUE_RECURRING)
+        }
+    }
+
+    // ---------- 内部实现 ----------
+
+    private fun scheduleNext(ctx: Context, cfg: SimpleBackupConfig) {
+        val delayMs = nextDelayMillis(cfg).coerceAtLeast(0L)
+        val data = workDataOf(RoomBackupWorker.KEY_KEEP_COUNT to cfg.keep)
+
+        val req = OneTimeWorkRequestBuilder<RoomBackupWorker>()
+            .setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
+            .setInputData(data)
+            .setConstraints(defaultConstraints())
+            .addTag(UNIQUE_RECURRING)
+            .build()
+
+        WorkManager.getInstance(ctx).enqueueUniqueWork(
+            UNIQUE_RECURRING,
+            ExistingWorkPolicy.REPLACE,  // 每次保存都覆盖下一次
+            req
+        )
+    }
+
+    /**
+     * 计算“从现在起到最近一个被勾选的周几 @ 指定时:分”的毫秒数。
+     * - 掩码位:Mon=bit0 ... Sun=bit6
+     * - Calendar.DAY_OF_WEEK:Sun=1, Mon=2, ... Sat=7
+     */
+    private fun nextDelayMillis(cfg: SimpleBackupConfig): Long {
+        val now = Calendar.getInstance()
+        for (i in 0..7) {
+            val cand = now.clone() as Calendar
+            cand.add(Calendar.DAY_OF_YEAR, i)
+
+            val dow = cand.get(Calendar.DAY_OF_WEEK) // 1..7
+            val bit = when (dow) {
+                Calendar.MONDAY    -> 0
+                Calendar.TUESDAY   -> 1
+                Calendar.WEDNESDAY -> 2
+                Calendar.THURSDAY  -> 3
+                Calendar.FRIDAY    -> 4
+                Calendar.SATURDAY  -> 5
+                Calendar.SUNDAY    -> 6
+                else -> 0
+            }
+            val selected = (cfg.daysMask and (1 shl bit)) != 0
+
+            if (selected) {
+                cand.set(Calendar.HOUR_OF_DAY, cfg.hour)
+                cand.set(Calendar.MINUTE, cfg.minute)
+                cand.set(Calendar.SECOND, 0)
+                cand.set(Calendar.MILLISECOND, 0)
+                if (cand.after(now)) {
+                    return cand.timeInMillis - now.timeInMillis
+                }
+                // 同一天但已过时刻 -> 继续找后面的被选日
+            }
+        }
+        // 理论到不了,兜底:明天同一时刻
+        val cand = now.clone() as Calendar
+        cand.add(Calendar.DAY_OF_YEAR, 1)
+        cand.set(Calendar.HOUR_OF_DAY, cfg.hour)
+        cand.set(Calendar.MINUTE, cfg.minute)
+        cand.set(Calendar.SECOND, 0)
+        cand.set(Calendar.MILLISECOND, 0)
+        return cand.timeInMillis - now.timeInMillis
+    }
+
+    /** 省电约束:电量不低再跑 */
+    private fun defaultConstraints() = Constraints.Builder()
+        .setRequiresBatteryNotLow(true)
+        .build()
+}

+ 61 - 38
data/src/main/java/com/grkj/data/database/ISCSDatabase.kt

@@ -11,9 +11,7 @@ import com.grkj.data.converters.Converters
 import com.grkj.data.dao.*
 import com.grkj.data.model.dos.*
 import com.grkj.shared.config.AESConfig
-import com.grkj.shared.config.Constants
 import com.sik.sikcore.SIKCore
-import kotlinx.coroutines.Dispatchers
 import net.zetetic.database.sqlcipher.SQLiteConnection
 import net.zetetic.database.sqlcipher.SQLiteDatabaseHook
 import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
@@ -21,7 +19,6 @@ import org.slf4j.Logger
 import org.slf4j.LoggerFactory
 import java.io.File
 import java.io.FileOutputStream
-import java.util.concurrent.Executors
 
 // 显式区分两个 SQLite,避免 IDE 导错:
 import net.zetetic.database.sqlcipher.SQLiteDatabase as CipherDB
@@ -62,7 +59,6 @@ abstract class ISCSDatabase : RoomDatabase() {
     companion object {
         const val DB_NAME = "iscs_database.db"
         private val logger: Logger = LoggerFactory.getLogger(ISCSDatabase::class.java)
-        // 放在 companion 里
         private val MIGRATION_LOCK = Any()
 
         // 外部存储目录路径
@@ -86,14 +82,12 @@ abstract class ISCSDatabase : RoomDatabase() {
 
             // 首次落地模板库(可能是明文,也可能本来就是加密库)
             if (!EXTERNAL_DB_FILE.exists()) {
-                try {
+                runCatching {
                     context.assets.open("data.db").use { input ->
                         FileOutputStream(EXTERNAL_DB_FILE).use { output -> input.copyTo(output) }
                     }
                     logger.info("已从 assets 复制数据库到: ${EXTERNAL_DB_FILE.absolutePath}")
-                } catch (e: Exception) {
-                    logger.error("复制数据库失败", e)
-                }
+                }.onFailure { e -> logger.error("复制数据库失败", e) }
             }
 
             // 确保成为加密库(首启自动迁移)
@@ -102,16 +96,17 @@ abstract class ISCSDatabase : RoomDatabase() {
             // Room + SQLCipher
             val passphrase: ByteArray = AESConfig.defaultConfig.key()
             val hook = object : SQLiteDatabaseHook {
-                override fun preKey(connection: SQLiteConnection) { /* no-op */ }
+                override fun preKey(connection: SQLiteConnection) { /* no-op */
+                }
+
                 override fun postKey(connection: SQLiteConnection) {
-                    // 这些 PRAGMA 都要把三个参数补全(bindArgs, cancellationSignal)
                     connection.execute("PRAGMA foreign_keys=ON", null, null)
-                    // journal_mode 建议用 executeForString,返回当前模式字符串
                     connection.executeForString("PRAGMA journal_mode=WAL", null, null)
                     connection.execute("PRAGMA synchronous=NORMAL", null, null)
                 }
             }
-            val factory = SupportOpenHelperFactory(passphrase)
+            // ✅ 修正:把 hook 传进来
+            val factory = SupportOpenHelperFactory(passphrase, hook, true)
 
             val db = Room.databaseBuilder(
                 context,
@@ -132,7 +127,39 @@ abstract class ISCSDatabase : RoomDatabase() {
             // 强制触发实际打开(避免“懒打开”延后 onOpen 回调)
             db.openHelper.writableDatabase
 
-            db  // ← 作为 instance 返回
+            db
+        }
+
+        /**
+         * 提供给备份/维护窗口:
+         * - 先做 FULL checkpoint,尽量把 -wal 合并进主库,降低锁竞争
+         * - 再 close() 连接池,释放文件锁;同一个 Room 实例仍然可复用,之后可重新打开
+         */
+        @JvmStatic
+        fun checkpointAndCloseForMaintenance() {
+            val db = instance
+            runCatching {
+                db.openHelper.writableDatabase.query("PRAGMA wal_checkpoint(FULL)").close()
+            }.onFailure { logger.warn("checkpoint 失败:${it.message}") }
+            runCatching { db.close() }
+                .onSuccess { logger.info("Room 已关闭,进入维护窗口") }
+                .onFailure { logger.warn("关闭 Room 失败:${it.message}") }
+        }
+
+        /**
+         * 维护结束后“预热重开”连接,避免首个访问卡顿。
+         * 注意:RoomDatabase.close() 只是关闭连接池,调用 openHelper.writableDatabase 会自动重建连接。
+         */
+        @JvmStatic
+        fun warmReopen() {
+            val db = instance
+            runCatching {
+                db.openHelper.writableDatabase // 触发重连
+            }.onSuccess {
+                logger.info("Room 已预热重开")
+            }.onFailure {
+                logger.warn("Room 预热重开失败:${it.message}")
+            }
         }
 
         /**
@@ -156,8 +183,8 @@ abstract class ISCSDatabase : RoomDatabase() {
 
                     val app = SIKCore.getApplication()
                     val backup = File(targetDbFile.parentFile, "${targetDbFile.name}.bak")
-                    val tmpCipher = File(app.cacheDir, "${targetDbFile.name}.tmp.cipher") // ✅ 内部缓存
-                    if (tmpCipher.exists()) tmpCipher.delete()                            // ✅ 先清理
+                    val tmpCipher = File(app.cacheDir, "${targetDbFile.name}.tmp.cipher")
+                    if (tmpCipher.exists()) tmpCipher.delete()
 
                     try {
                         targetDbFile.copyTo(backup, overwrite = true)
@@ -171,7 +198,7 @@ abstract class ISCSDatabase : RoomDatabase() {
                         // 二次校验:确保 tmp 真是可开的加密库
                         check(tryOpenCipherDb(tmpCipher, passphrase)) { "迁移后加密库校验失败" }
 
-                        // 安全替换(见下一个改动)
+                        // 安全替换
                         safeReplaceFile(tmpCipher, targetDbFile)
 
                         logger.info("已将明文库迁移为加密库:${targetDbFile.absolutePath}")
@@ -188,29 +215,33 @@ abstract class ISCSDatabase : RoomDatabase() {
 
         // 用 SQLCipher 测试是否“已加密并且口令正确”
         private fun tryOpenCipherDb(dbFile: File, passphrase: ByteArray): Boolean = try {
-            val db = net.zetetic.database.sqlcipher.SQLiteDatabase.openOrCreateDatabase(
-                dbFile, passphrase, null, null, null
-            )
+            val db = CipherDB.openOrCreateDatabase(dbFile, passphrase, null, null, null)
             db.close()
             true
-        } catch (_: Throwable) { false }
+        } catch (_: Throwable) {
+            false
+        }
 
         // 用系统 SQLite 判定是否“明文库”
         private fun tryOpenPlainDb(dbFile: File): Boolean = try {
-            val db = android.database.sqlite.SQLiteDatabase.openDatabase(
+            val db = SysDB.openDatabase(
                 dbFile.absolutePath, null,
-                android.database.sqlite.SQLiteDatabase.OPEN_READONLY
-                        or android.database.sqlite.SQLiteDatabase.NO_LOCALIZED_COLLATORS
+                SysDB.OPEN_READONLY or SysDB.NO_LOCALIZED_COLLATORS
             )
             db.close()
             true
-        } catch (_: Throwable) { false }
+        } catch (_: Throwable) {
+            false
+        }
 
         // 明文 → 加密(sqlcipher_export)
-        private fun migratePlainToCipher(plainPath: String, cipherPath: String, passphrase: ByteArray) {
-            val cipherDb = net.zetetic.database.sqlcipher.SQLiteDatabase.openOrCreateDatabase(
-                File(cipherPath), passphrase, null, null, null
-            )
+        private fun migratePlainToCipher(
+            plainPath: String,
+            cipherPath: String,
+            passphrase: ByteArray
+        ) {
+            val cipherDb =
+                CipherDB.openOrCreateDatabase(File(cipherPath), passphrase, null, null, null)
             try {
                 // 保险起见,确认 main 为空(可选)
                 val cur = cipherDb.rawQuery("SELECT COUNT(*) FROM sqlite_master", null)
@@ -230,21 +261,15 @@ abstract class ISCSDatabase : RoomDatabase() {
             }
         }
 
-
         // 明文库做一次 WAL checkpoint,避免数据停在 -wal
         private fun safePlainWalCheckpoint(dbFile: File) {
-            try {
-                val db = android.database.sqlite.SQLiteDatabase.openDatabase(
-                    dbFile.absolutePath, null,
-                    android.database.sqlite.SQLiteDatabase.OPEN_READWRITE
-                )
+            runCatching {
+                val db = SysDB.openDatabase(dbFile.absolutePath, null, SysDB.OPEN_READWRITE)
                 db.rawQuery("PRAGMA wal_checkpoint(TRUNCATE)", null).close()
                 db.execSQL("PRAGMA journal_mode=DELETE")
                 db.close()
                 File(dbFile.parent, dbFile.name + "-wal").delete()
                 File(dbFile.parent, dbFile.name + "-shm").delete()
-            } catch (_: Throwable) {
-                // 忽略:老库未启用 WAL 也无妨
             }
         }
 
@@ -280,7 +305,5 @@ abstract class ISCSDatabase : RoomDatabase() {
                 }
             }
         }
-
-
     }
 }

+ 348 - 0
data/src/main/java/com/grkj/data/database/RoomBackupManager.kt

@@ -0,0 +1,348 @@
+package com.grkj.data.database
+
+import android.content.Context
+import android.os.Build
+import android.os.Environment
+import com.grkj.shared.config.AESConfig
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import net.zetetic.database.sqlcipher.SQLiteDatabase as CipherDB
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+/**
+ * # RoomBackupManager
+ *
+ * 面向应用的**一站式数据库备份/还原管理器**,固定备份目录:
+ * `/sdcard/ISCS/backup/`(= `Environment.getExternalStorageDirectory()/ISCS/backup/`)
+ *
+ * 功能:
+ * 1. **立即备份**:使用 SQLCipher 的 `sqlcipher_export` 生成**单文件加密备份**(一致性好、无需 -wal/-shm)
+ * 2. **备份列表**:按时间倒序列出,附带“是否可用”校验(能否用当前口令打开)
+ * 3. **删除备份**:删除指定文件
+ * 4. **还原**:关闭 Room → 覆盖写替换原库 → 重新打开数据库(你的 `DbReadyGate` 会在 onOpen 放闸)
+ * 5. **清理**:只保留最近 N 份
+ *
+ * 约束/前置条件:
+ * - 库文件路径:`ISCSDatabase.EXTERNAL_DB_FILE`
+ * - 加密口令:`AESConfig.defaultConfig.key()`
+ * - **权限**:公共目录写入需要权限(≤28: WRITE_EXTERNAL_STORAGE;≥30: MANAGE_EXTERNAL_STORAGE)
+ * - 所有重 I/O 都在 `Dispatchers.IO` 上执行
+ *
+ * 用法示例(UI 层):
+ * ```kotlin
+ * // 立即备份
+ * lifecycleScope.launch {
+ *   val r = RoomBackupManager.backupNow(requireContext(), keep = 10)
+ *   if (r.isSuccess) toast("备份成功:${r.getOrNull()!!.name}") else toast("备份失败:${r.exceptionOrNull()?.message}")
+ * }
+ *
+ * // 列表
+ * lifecycleScope.launch {
+ *   val items = RoomBackupManager.listBackups(requireContext())
+ *   adapter.submitList(items)
+ * }
+ *
+ * // 删除
+ * lifecycleScope.launch { RoomBackupManager.deleteBackup(item.file); refreshList() }
+ *
+ * // 还原(请先进入“维护模式”对话框,暂停业务)
+ * lifecycleScope.launch {
+ *   val r = RoomBackupManager.restore(requireContext(), item.file)
+ *   toast(if (r.isSuccess && r.getOrNull()==true) "还原成功" else "还原失败:${r.exceptionOrNull()?.message}")
+ * }
+ * ```
+ */
+object RoomBackupManager {
+
+    // ---------- 对外数据模型 ----------
+
+    /**
+     * 备份条目。
+     * @property file 备份文件(SQLCipher 单文件)
+     * @property name 文件名(如:backup_20250101_000000.db)
+     * @property sizeBytes 文件大小(字节)
+     * @property lastModified 最后修改时间(ms)
+     * @property verified 是否通过“用当前口令试开”的校验(true=可用)
+     */
+    data class BackupItem(
+        val file: File,
+        val name: String = file.name,
+        val sizeBytes: Long = file.length(),
+        val lastModified: Long = file.lastModified(),
+        val verified: Boolean
+    )
+
+    // ---------- 对外 API(全部为挂起函数,自动切换到 IO 线程) ----------
+
+    /**
+     * 立即执行一次备份。
+     *
+     * 流程:
+     * 1. 检查/要求公共目录写权限(不足则抛出 SecurityException,Result.failure)
+     * 2. 以 **sqlcipher_export** 从**当前加密库**导出到**内部缓存 tmp 文件**(避免外部目录 rename 抽风)
+     * 3. 校验 tmp 能用当前口令打开
+     * 4. **覆盖写**拷贝到固定备份目录 `/sdcard/ISCS/backup/`
+     * 5. 清理历史备份,仅保留 keep 份
+     *
+     * @param ctx 上下文
+     * @param keep 保留的份数上限(默认 10)
+     * @return Result<BackupItem> 成功时携带新备份条目;失败时含异常信息
+     */
+    suspend fun backupNow(ctx: Context, keep: Int = 10): Result<BackupItem> =
+        withContext(Dispatchers.IO) {
+            runCatching {
+                ensureWriteAccessOrThrow(ctx)
+
+                val src = ISCSDatabase.EXTERNAL_DB_FILE
+                val pass = AESConfig.defaultConfig.key()
+
+                // 备份目录(固定路径):/sdcard/ISCS/backup/
+                val outDir = fixedBackupDir().also { if (!it.exists()) it.mkdirs() }
+
+                // 文件名:backup_yyyyMMdd_HHmmss.db
+                val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
+                val finalFile = File(outDir, "backup_${ts}.db")
+
+                // 先导出到内部缓存(更稳定),再复制到公共目录
+                val tmp = File(ctx.cacheDir, finalFile.name + ".tmp")
+                if (tmp.exists()) tmp.delete()
+
+                exportCipherToCipher(src, tmp, pass)
+                check(verifyCanOpen(tmp, pass)) { "备份校验失败:临时备份无法打开" }
+
+                safeCopyTo(tmp, finalFile) // 覆盖写 + fsync
+                tmp.delete()
+
+                cleanupOld(outDir, keep)
+
+                BackupItem(
+                    file = finalFile,
+                    verified = verifyCanOpen(finalFile, pass)
+                )
+            }
+        }
+
+    /**
+     * 列出所有备份(按时间倒序)。
+     *
+     * 说明:
+     * - verified 字段会尝试用**当前口令**试开文件;若历史口令已变,可能返回 false(UI 可给出“不可用”标记)
+     */
+    suspend fun listBackups(ctx: Context): List<BackupItem> =
+        withContext(Dispatchers.IO) {
+            val dir = fixedBackupDir()
+            val pass = AESConfig.defaultConfig.key()
+            dir.listFiles { f ->
+                f.isFile && f.name.startsWith("backup_") && f.name.endsWith(".db")
+            }?.sortedByDescending { it.lastModified() }
+                ?.map { f -> BackupItem(f, verified = verifyCanOpen(f, pass)) }
+                ?: emptyList()
+        }
+
+    /**
+     * 删除指定备份文件。
+     * @return 是否删除成功(文件存在且删除)
+     */
+    suspend fun deleteBackup(file: File): Boolean =
+        withContext(Dispatchers.IO) { file.exists() && file.delete() }
+
+    /**
+     * 从指定备份**还原**数据库。
+     *
+     * 强烈建议:
+     * - 调用前进入“维护模式”(弹出全屏对话框),暂停业务层一切 DB 访问;
+     * - 调用后刷新页面/必要时重启相关组件。
+     *
+     * 流程:
+     * 1. 检查公共目录写权限
+     * 2. **试开**备份文件(验证口令匹配且文件完整)
+     * 3. 关闭 Room(释放句柄)
+     * 4. 删除旧的 `-wal/-shm`,避免历史页污染
+     * 5. **覆盖写**复制备份到目标库(比 rename 更稳)
+     * 6. 重新打开数据库(内部会触发 Room 的 onOpen → 你的 DbReadyGate 放闸)
+     *
+     * @return Result<Boolean> 成功:true;失败:携带异常信息
+     */
+    suspend fun restore(ctx: Context, backupFile: File): Result<Boolean> =
+        withContext(Dispatchers.IO) {
+            runCatching {
+                ensureWriteAccessOrThrow(ctx)
+
+                val pass = AESConfig.defaultConfig.key()
+                val target = ISCSDatabase.EXTERNAL_DB_FILE
+                require(backupFile.isFile && backupFile.canRead()) { "备份文件不可读" }
+
+                // 0) 验证备份库口令 & 基本可用性
+                check(verifyCanOpen(backupFile, pass)) { "备份文件无法用当前口令打开" }
+
+                // 1) 与 Room 协作:checkpoint → close(释放所有连接)
+                runCatching { ISCSDatabase.checkpointAndCloseForMaintenance() }
+
+                synchronized(ISCSDatabase::class.java) { // 复用你 ensureCiphered 的全局锁思路
+                    // 2) 备份当前现场,清理历史 WAL/SHM
+                    val bak = File(target.parentFile, target.name + ".pre_restore.bak")
+                    runCatching { if (target.exists()) target.copyTo(bak, overwrite = true) }
+                    File(target.parentFile, target.name + "-wal").delete()
+                    File(target.parentFile, target.name + "-shm").delete()
+
+                    // 3) 原子替换(同目录临时文件 → fsync → rename)
+                    val tmp = File(target.parentFile, target.name + ".tmp_restore")
+                    if (tmp.exists()) tmp.delete()
+                    safeCopyTo(backupFile, tmp)        // 用 NIO channel + force(true)
+                    // 强制公共权限(视你项目而定)
+                    tmp.setReadable(true, false); tmp.setWritable(true, false)
+
+                    // 同目录 rename,尽可能原子
+                    if (target.exists()) target.delete()
+                    if (!tmp.renameTo(target)) {
+                        // 个别设备/文件系统 rename 不稳,fallback 覆盖复制
+                        safeCopyTo(tmp, target)
+                        tmp.delete()
+                    }
+
+                    // 4) 清理新库的历史 WAL/SHM(防老文件影响)
+                    File(target.parentFile, target.name + "-wal").delete()
+                    File(target.parentFile, target.name + "-shm").delete()
+
+                    // 5) 预热重开(触发 onOpen & DbReadyGate.open())
+                    runCatching { ISCSDatabase.warmReopen() }
+                        .getOrElse { e ->
+                            // 失败回滚到旧库
+                            if (bak.exists()) {
+                                safeCopyTo(bak, target)
+                                runCatching { ISCSDatabase.warmReopen() }
+                                    .getOrElse { throw IllegalStateException("还原失败且回滚失败:${it.message}", it) }
+                            }
+                            throw IllegalStateException("还原失败:${e.message}", e)
+                        }
+
+                    // 6) 成功后移除现场备份
+                    bak.delete()
+                }
+
+                true
+            }
+        }
+
+    /**
+     * 清理历史备份,只保留最近 [keep] 份。
+     * @return 实际删除的文件数
+     */
+    suspend fun cleanup(ctx: Context, keep: Int): Int =
+        withContext(Dispatchers.IO) { cleanupOld(fixedBackupDir(), keep) }
+
+    // ---------- 内部实现(工具方法) ----------
+
+    /** 固定备份目录:/sdcard/ISCS/backup/ */
+    private fun fixedBackupDir(): File =
+        File(Environment.getExternalStorageDirectory(), "ISCS/backup/")
+
+    /**
+     * 校验公共目录写权限:
+     * - API 30+:需要 MANAGE_EXTERNAL_STORAGE(“所有文件访问”)
+     * - API 29-:需要 WRITE_EXTERNAL_STORAGE;这里用 mkdir/canWrite 兜底
+     * 不满足时抛出 SecurityException,交由上层 UI 提示用户授权。
+     */
+    private fun ensureWriteAccessOrThrow(ctx: Context) {
+        if (Build.VERSION.SDK_INT >= 30) {
+            if (!Environment.isExternalStorageManager()) {
+                throw SecurityException("缺少 MANAGE_EXTERNAL_STORAGE 权限,无法写入 /sdcard/ISCS/backup/")
+            }
+        } else {
+            val dir = fixedBackupDir()
+            if (!dir.exists() && !dir.mkdirs()) {
+                throw SecurityException("无法创建备份目录:${dir.absolutePath}")
+            }
+            if (!dir.canWrite()) {
+                throw SecurityException("备份目录不可写:${dir.absolutePath}")
+            }
+        }
+    }
+
+    /**
+     * SQLCipher → SQLCipher 导出(单文件一致性备份)。
+     *
+     * - 用 ATTACH ... KEY + sqlcipher_export('backup') 导出到 dst
+     * - 复制 user_version:先从 main 读出,再写入 backup
+     * - 为了兼容“新库”的 API,这里用 openOrCreateDatabase(...) 重载
+     */
+    private fun exportCipherToCipher(src: File, dst: File, pass: ByteArray) {
+        val db = CipherDB.openOrCreateDatabase(
+            src,                 // 源库文件
+            pass,                // 密钥(ByteArray)
+            null,                // CursorFactory
+            null,                // DatabaseErrorHandler
+            null                 // SQLiteDatabaseHook(不需要就传 null)
+        )
+        try {
+            if (dst.exists()) dst.delete()
+
+            // 读取源库的 user_version
+            val userVersion = run {
+                val c = db.rawQuery("PRAGMA user_version", null)
+                try { if (c.moveToFirst()) c.getInt(0) else 0 } finally { c.close() }
+            }
+
+            // 目标路径与密钥
+            val escapedPath = dst.absolutePath.replace("'", "''")
+            val hexKey = pass.joinToString("") { "%02x".format(it) }
+
+            // 导出到 backup(目标库)
+            db.rawExecSQL("ATTACH DATABASE '$escapedPath' AS backup KEY X'$hexKey'")
+            db.rawExecSQL("SELECT sqlcipher_export('backup')")
+            db.rawExecSQL("PRAGMA backup.user_version=$userVersion")
+            db.rawExecSQL("DETACH DATABASE backup")
+        } finally {
+            db.close()
+        }
+    }
+
+
+    /**
+     * 清理工具:仅保留最近 [keep] 份备份,其他全部删除。
+     * @return 删除的文件数
+     */
+    private fun cleanupOld(dir: File, keep: Int): Int {
+        val toDelete = dir.listFiles { f ->
+            f.isFile && f.name.startsWith("backup_") && f.name.endsWith(".db")
+        }?.sortedByDescending { it.lastModified() }?.drop(keep).orEmpty()
+
+        toDelete.forEach { it.delete() }
+        return toDelete.size
+    }
+
+    /**
+     * **覆盖写**复制文件并 `fsync` 落盘。
+     *
+     * 为何不用 rename?
+     * - 公共外部目录在部分机型/ROM 下通过 FUSE 提供,`renameTo`/`unlink` 在并发或系统扫描时可能失败或“文件被解除链接”
+     * - 覆盖写更稳:写入完成再替换内容,减少“只写了一半”的概率
+     */
+    private fun safeCopyTo(src: File, dst: File) {
+        dst.parentFile?.mkdirs()
+        java.io.FileInputStream(src).channel.use { inCh ->
+            java.io.FileOutputStream(dst).channel.use { outCh ->
+                var pos = 0L; val size = inCh.size()
+                while (pos < size) pos += inCh.transferTo(pos, size - pos, outCh)
+                outCh.force(true) // fsync
+            }
+        }
+    }
+
+    /**
+     * 校验备份是否可用:用当前密钥尝试打开即可。
+     * 注意:这里同样用 openOrCreateDatabase 的 File 重载,避免签名不匹配。
+     */
+    private fun verifyCanOpen(file: File, pass: ByteArray): Boolean = runCatching {
+        val db = net.zetetic.database.sqlcipher.SQLiteDatabase.openOrCreateDatabase(file, pass, null, null, null)
+        // 尽量再跑个轻量校验
+        db.rawQuery("PRAGMA user_version", null).use { /* no-op */ }
+        db.close(); true
+    }.getOrElse { false }
+
+}

+ 151 - 40
data/src/main/java/com/grkj/data/database/RoomBackupWorker.kt

@@ -1,61 +1,172 @@
 package com.grkj.data.database
 
 import android.content.Context
-import android.database.sqlite.SQLiteDatabase
+import android.os.Build
+import android.os.Environment
+import androidx.work.Data
 import androidx.work.Worker
 import androidx.work.WorkerParameters
-import com.grkj.data.utils.FileStorageUtils.exists
+import com.grkj.shared.config.AESConfig
 import com.sik.sikcore.date.TimeUtils
+import net.zetetic.database.sqlcipher.SQLiteDatabase as CipherDB
 import java.io.File
 import java.io.FileInputStream
 import java.io.FileOutputStream
 
-class RoomBackupWorker(
-    context: Context,
-    params: WorkerParameters
-) : Worker(context, params) {
+/**
+ * 备份 Worker:把当前 SQLCipher 库导出为**单文件加密备份**
+ * - 备份文件落在 /sdcard/ISCS/backup/backup_yyyyMMdd_HHmmss.db
+ * - 输入参数:KEEP_COUNT(保留份数,默认10)
+ */
+class RoomBackupWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params) {
 
     override fun doWork(): Result {
         return try {
-            // 1️⃣ 找到数据库文件
-            val dbName = ISCSDatabase.DB_NAME
-            val dbFile = ISCSDatabase.EXTERNAL_DB_FILE
-
-            // 3️⃣ 准备备份目录
-            val backupDir = File(
-                ISCSDatabase.EXTERNAL_DB_FILE.parent,
-                "room_backups"
-            ).apply { if (!exists()) mkdirs() }
-
-            // 4️⃣ 要拷贝的三个文件
-            val suffixes = listOf("", "-wal", "-shm")
-            val timeStamp = TimeUtils.nowString("yyyyMMdd_HHmmss")
-
-            suffixes.forEach { suffix ->
-                val src = File(dbFile.parent, "$dbName$suffix")
-                if (src.exists()) {
-                    val dest = File(backupDir, "backup_${timeStamp}${suffix}.db")
-                    FileInputStream(src).use { inp ->
-                        FileOutputStream(dest).use { out ->
-                            inp.copyTo(out)
-                        }
-                    }
-                }
+            // 0) 权限兜底(UI 层最好先确保授权)
+            if (!canWritePublicDir(applicationContext)) {
+                return Result.failure(Data.Builder().putString("reason", "NO_WRITE_PERMISSION").build())
             }
 
-            // 5️⃣ (可选)清理过旧备份,比如只留最近 30 份
-            cleanupOldBackups(backupDir, keepCount = 30)
+            // 1) 与 Room 协作:先 checkpoint,再关闭连接,释放文件锁
+            runCatching { ISCSDatabase.checkpointAndCloseForMaintenance() }
+
+            // 2) 目标目录
+            val backupDir = File(Environment.getExternalStorageDirectory(), "ISCS/backup/").apply {
+                if (!exists()) mkdirs()
+            }
+
+            // 3) 先导出到内部 tmp,再复制到公共目录
+            val ts = TimeUtils.nowString("yyyyMMdd_HHmmss")
+            val finalOut = File(backupDir, "backup_${ts}.db")
+            val tmp = File(applicationContext.cacheDir, finalOut.name + ".tmp")
+            if (tmp.exists()) tmp.delete()
+
+            val srcDb = ISCSDatabase.EXTERNAL_DB_FILE
+            val pass = AESConfig.defaultConfig.key()
 
-            return Result.success()
-        } catch (e: Exception) {
-            e.printStackTrace()
+            exportCipherToCipherWithLockRetry(srcDb, tmp, pass)
+            check(verifyCanOpen(tmp, pass)) { "备份校验失败:临时备份无法打开" }
+
+            copyOverwrite(tmp, finalOut)
+            tmp.delete()
+
+            // 4) 清理旧备份
+            val keep = inputData.getInt(KEY_KEEP_COUNT, 10)
+            cleanupOld(backupDir, keep)
+
+            // 5) 维护结束:预热重开 Room 连接
+            runCatching { ISCSDatabase.warmReopen() }
+
+            BackupScheduler.scheduleNextFromPrefsSync(applicationContext)
+            Result.success()
+        } catch (t: Throwable) {
+            t.printStackTrace()
+            // 失败也尽量恢复 Room
+            runCatching { ISCSDatabase.warmReopen() }
             Result.retry()
         }
     }
 
-    private fun cleanupOldBackups(dir: File, keepCount: Int) {
-        val files = dir.listFiles { f -> f.name.startsWith("backup_") }
-            ?.sortedByDescending { it.lastModified() } ?: return
-        files.drop(keepCount).forEach { it.delete() }
+    // --- SQLCipher → SQLCipher 导出:busy_timeout + 写锁 + checkpoint + 重试 ---
+    private fun exportCipherToCipherWithLockRetry(src: File, dst: File, pass: ByteArray) {
+        retry(times = 5, sleepMs = 400) {
+            val db = CipherDB.openOrCreateDatabase(src, pass, null, null, null)
+            try {
+                // 1) 等待锁,拉写锁,尽量把 WAL 合并
+                db.rawExecSQL("PRAGMA busy_timeout=10000")
+                runCatching { db.rawQuery("PRAGMA wal_checkpoint(FULL)", null).use { } }
+                db.rawExecSQL("BEGIN IMMEDIATE")
+
+                // 2) 读源库版本
+                val userVersion = db.rawQuery("PRAGMA user_version", null).use {
+                    if (it.moveToFirst()) it.getInt(0) else 0
+                }
+
+                // 3) ATTACH 备份库并导出
+                if (dst.exists()) dst.delete()
+                val escaped = dst.absolutePath.replace("'", "''")
+                val hex = pass.joinToString("") { "%02x".format(it) }
+
+                db.rawExecSQL("ATTACH DATABASE '$escaped' AS backup KEY X'$hex'")
+                db.rawExecSQL("SELECT sqlcipher_export('backup')")
+                db.rawExecSQL("PRAGMA backup.user_version=$userVersion")
+
+                // 4) **先提交事务**(释放对附加库的事务持有)
+                db.rawExecSQL("COMMIT")
+
+                // 5) **提交之后再 DETACH**(此时不在事务中,允许分离)
+                db.rawExecSQL("DETACH DATABASE backup")
+
+                // 6) 可选:再做一次 checkpoint,确保源库干净
+                runCatching { db.rawQuery("PRAGMA wal_checkpoint(FULL)", null).use { } }
+
+            } catch (t: Throwable) {
+                // 只在事务没提交时尝试回滚
+                runCatching { db.rawExecSQL("ROLLBACK") }
+                if (t.message?.contains("database is locked", ignoreCase = true) == true) {
+                    throw LockedRetry
+                }
+                throw t
+            } finally {
+                db.close()
+            }
+        }
+    }
+
+    private object LockedRetry : RuntimeException()
+
+    private inline fun <T> retry(times: Int, sleepMs: Long, block: () -> T): T {
+        var last: Throwable? = null
+        repeat(times) { i ->
+            try { return block() } catch (t: Throwable) {
+                last = t
+                val locked = (t is LockedRetry) || t.message?.contains("database is locked", true) == true
+                if (locked) {
+                    try { Thread.sleep(sleepMs * (i + 1)) } catch (_: InterruptedException) {}
+                } else {
+                    throw t
+                }
+            }
+        }
+        throw last ?: IllegalStateException("retry failed")
+    }
+
+    private fun verifyCanOpen(file: File, pass: ByteArray): Boolean = runCatching {
+        val db = CipherDB.openOrCreateDatabase(file, pass, null, null, null)
+        db.close(); true
+    }.getOrElse { false }
+
+    private fun copyOverwrite(src: File, dst: File) {
+        dst.parentFile?.mkdirs()
+        FileInputStream(src).channel.use { inCh ->
+            FileOutputStream(dst).channel.use { outCh ->
+                var pos = 0L; val size = inCh.size()
+                while (pos < size) pos += inCh.transferTo(pos, size - pos, outCh)
+                outCh.force(true)
+            }
+        }
+        dst.setReadable(true, false)
+        dst.setWritable(true, false)
+    }
+
+    private fun cleanupOld(dir: File, keep: Int) {
+        dir.listFiles { f -> f.isFile && f.name.startsWith("backup_") && f.name.endsWith(".db") }
+            ?.sortedByDescending { it.lastModified() }
+            ?.drop(keep)
+            ?.forEach { it.delete() }
+    }
+
+    companion object {
+        const val KEY_KEEP_COUNT = "KEEP_COUNT"
+    }
+}
+
+// 公共目录写权限检测(Worker 兜底)
+private fun canWritePublicDir(ctx: Context): Boolean {
+    return if (Build.VERSION.SDK_INT >= 30) {
+        Environment.isExternalStorageManager()
+    } else {
+        val dir = File(Environment.getExternalStorageDirectory(), "ISCS/backup/")
+        dir.exists() || dir.mkdirs() || dir.canWrite()
     }
-}
+}

+ 69 - 0
data/src/main/java/com/grkj/data/database/SimpleBackupPrefs.kt

@@ -0,0 +1,69 @@
+package com.grkj.data.database
+
+import android.content.Context
+import android.content.SharedPreferences
+
+/**
+ * 备份计划的最简配置:
+ * - enabled   是否启用
+ * - hour/min  备份时刻(到分钟即可)
+ * - daysMask  周几掩码(Mon=1<<0 ... Sun=1<<6;全选=每天)
+ * - keep      保留备份份数(给 Work 传参用)
+ */
+data class SimpleBackupConfig(
+    val enabled: Boolean = true,
+    val hour: Int = 0,      // 0..23
+    val minute: Int = 0,    // 0..59
+    val daysMask: Int = ALL_DAYS,
+    val keep: Int = 10
+)
+
+const val ALL_DAYS = 0b0111_1111  // 七天全开
+
+object SimpleBackupPrefs {
+    private const val FILE = "iscs_backup_simple"
+    private const val K_ENABLED = "enabled"
+    private const val K_HOUR = "hour"
+    private const val K_MINUTE = "minute"
+    private const val K_DAYS = "daysMask"
+    private const val K_KEEP = "keep"
+
+    private fun prefs(ctx: Context): SharedPreferences =
+        ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE)
+
+    /** 读取配置;若未设置,返回默认:每天 00:00,保留 10 份 */
+    fun get(ctx: Context): SimpleBackupConfig {
+        val p = prefs(ctx)
+        return SimpleBackupConfig(
+            enabled = p.getBoolean(K_ENABLED, true),
+            hour = p.getInt(K_HOUR, 0),
+            minute = p.getInt(K_MINUTE, 0),
+            daysMask = p.getInt(K_DAYS, ALL_DAYS),
+            keep = p.getInt(K_KEEP, 10)
+        )
+    }
+
+    /** 保存配置(原子 apply) */
+    fun save(ctx: Context, cfg: SimpleBackupConfig) {
+        prefs(ctx).edit()
+            .putBoolean(K_ENABLED, cfg.enabled)
+            .putInt(K_HOUR, cfg.hour)
+            .putInt(K_MINUTE, cfg.minute)
+            .putInt(K_DAYS, cfg.daysMask)
+            .putInt(K_KEEP, cfg.keep)
+            .apply()
+    }
+}
+
+/** 由 7 个勾选转换为掩码(Mon..Sun) */
+fun daysMaskOf(mon:Boolean, tue:Boolean, wed:Boolean, thu:Boolean, fri:Boolean, sat:Boolean, sun:Boolean): Int {
+    var m = 0
+    if (mon) m = m or (1 shl 0)
+    if (tue) m = m or (1 shl 1)
+    if (wed) m = m or (1 shl 2)
+    if (thu) m = m or (1 shl 3)
+    if (fri) m = m or (1 shl 4)
+    if (sat) m = m or (1 shl 5)
+    if (sun) m = m or (1 shl 6)
+    return m
+}

+ 2 - 1
data/src/main/java/com/grkj/data/enums/RoleFunctionalPermissionsEnum.kt

@@ -16,6 +16,7 @@ enum class RoleFunctionalPermissionsEnum(
     ROLE_MANAGE("data_manage:role_manage", "角色管理", 1, listOf()),
     WORKSTATION_MANAGE("data_manage:workstation_manage", "区域管理", 1, listOf()),
     POINT_MANAGE("data_manage:point_manage", "点位管理", 1, listOf()),
+    BACKUP_AND_RESTORE("data_manage:backup_and_restore", "备份/还原", 1, listOf()),
     IN_PROGRESS_JOB("job_ticket_manage:in_progress_job", "进行中的作业", 1, listOf()),
     CREATE_SOP("job_ticket_manage:create_sop", "新建SOP", 1, listOf()),
     SOP_MANAGE("job_ticket_manage:sop_manage", "SOP管理", 1, listOf()),
@@ -83,7 +84,7 @@ enum class RoleFunctionalPermissionsEnum(
     DATA_HOME_MANAGE(
         "data_manage",
         "数据管理", 0,
-        listOf(USER_MANAGE, ROLE_MANAGE, WORKSTATION_MANAGE, POINT_MANAGE)
+        listOf(USER_MANAGE, ROLE_MANAGE, WORKSTATION_MANAGE, POINT_MANAGE, BACKUP_AND_RESTORE)
     ),
     HOME("Home", "主页", 0, listOf()),
     ;

+ 1 - 0
data/src/main/java/com/grkj/data/logic/impl/standard/SysMenuLogic.kt

@@ -52,6 +52,7 @@ class SysMenuLogic @Inject constructor(val sysMenuDao: SysMenuDao, val roleDao:
                             for (permissionsEnum in RoleFunctionalPermissionsEnum.except(
                                 RoleFunctionalPermissionsEnum.USER_MANAGE,
                                 RoleFunctionalPermissionsEnum.ROLE_MANAGE,
+                                RoleFunctionalPermissionsEnum.BACKUP_AND_RESTORE,
                                 RoleFunctionalPermissionsEnum.HARDWARE_HOME_MANAGE
                             )) {
                                 sysMenuData.find { it.perms == permissionsEnum.functionalPermission }?.menuId?.let { menuId ->

+ 0 - 4
shared/src/main/java/com/grkj/shared/utils/i18n/CsvImporter.kt

@@ -1,4 +0,0 @@
-package com.grkj.shared.utils.i18n
-
-class CsvImporter {
-}

+ 50 - 24
shared/src/main/java/com/grkj/shared/utils/i18n/I18nFormatter.kt

@@ -1,35 +1,61 @@
 package com.grkj.shared.utils.i18n
 
 import java.util.Locale
+import java.util.regex.Matcher
 import java.util.regex.Pattern
 
 /**
- * 轻量格式化器
+ * 轻量占位格式化:
+ * - 命名占位:{name}
+ * - 位置占位:{0}、{1}…(我们会把 DataBinding 传入的 "0","1" 从 Map 中取)
+ * - 逃逸:用 '{{' 和 '}}' 输出字面花括号
  *
- * - 命名占位:{name} / {count} → 先转下标占位,再用 java.text.MessageFormat 处理
- *   (原因:MessageFormat 对部分语言/数字分组/转义更稳,且性能可接受)
- * - 性能:仅在存在占位时分配 StringBuilder/数组,绝大多数文本为直接返回。
+ * 注意:正则里花括号必须 **两边都转义**:\{ ... \}
  */
 object I18nFormatter {
-    private val namedPattern: Pattern = Pattern.compile("\\{([a-zA-Z_][a-zA-Z0-9_]*)}")
-
-    fun format(pattern: String, args: Map<String, Any?>?, locale: Locale): String {
-        if (args.isNullOrEmpty() || !pattern.contains('{')) return pattern
-        // 命名转下标
-        val order = ArrayList<String>(args.size)
-        val sb = StringBuilder(pattern.length + 8)
-        val m = namedPattern.matcher(pattern)
-        var last = 0
-        while (m.find()) {
-            sb.append(pattern, last, m.start())
-            val name = m.group(1)
-            var idx = order.indexOf(name)
-            if (idx == -1) { order.add(name); idx = order.size - 1 }
-            sb.append('{').append(idx).append('}')
-            last = m.end()
+
+    // {name}
+    private val NAMED: Pattern =
+        Pattern.compile("\\{([a-zA-Z_][a-zA-Z0-9_]*)\\}") // ← 右花括号已转义
+
+    // {0}
+    private val INDEXED: Pattern =
+        Pattern.compile("\\{(\\d+)\\}")
+
+    /** 把 '{{' / '}}' 变回 '{' / '}'(在全部替换完成后再做) */
+    private fun unescapeBraces(s: String): String =
+        s.replace("{{", "{").replace("}}", "}")
+
+    private fun quote(s: Any?): String = Matcher.quoteReplacement(s?.toString() ?: "")
+
+    @JvmStatic
+    fun format(raw: String, args: Map<String, Any?>?, locale: Locale): String {
+        if (args.isNullOrEmpty()) return unescapeBraces(raw)
+
+        // 1) 先替换命名占位 {name}
+        var tmp = StringBuffer()
+        val m1 = NAMED.matcher(raw)
+        while (m1.find()) {
+            val key = m1.group(1)
+            val valAny = args[key]
+                ?: args[key.lowercase(locale)]
+                ?: args[key.uppercase(locale)]
+            m1.appendReplacement(tmp, quote(valAny))
+        }
+        m1.appendTail(tmp)
+
+        // 2) 再替换索引占位 {0}、{1}
+        val mid = tmp.toString()
+        tmp = StringBuffer()
+        val m2 = INDEXED.matcher(mid)
+        while (m2.find()) {
+            val idx = m2.group(1) // e.g. "0"
+            val valAny = args[idx]
+            m2.appendReplacement(tmp, quote(valAny))
         }
-        sb.append(pattern, last, pattern.length)
-        val values = Array(order.size) { i -> args[order[i]] }
-        return java.text.MessageFormat(sb.toString(), locale).format(values)
+        m2.appendTail(tmp)
+
+        // 3) 还原被逃逸的花括号
+        return unescapeBraces(tmp.toString())
     }
-}
+}

+ 92 - 19
shared/src/main/java/com/grkj/shared/utils/i18n/databinding/I18nBindingAdapters.kt

@@ -2,6 +2,7 @@
 
 package com.grkj.shared.utils.i18n.databinding
 
+import android.util.Log
 import android.view.View
 import androidx.databinding.BindingAdapter
 import androidx.lifecycle.findViewTreeLifecycleOwner
@@ -21,13 +22,12 @@ private val listenerAdded = WeakHashMap<View, Boolean>()
 private fun View.observeLocale(onChange: () -> Unit) {
     val owner = findViewTreeLifecycleOwner()
     if (owner == null) {
-        // 还没挂到带生命周期的树上:等 attach 后再订阅一次
         if (listenerAdded[this] != true) {
             addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
                 override fun onViewAttachedToWindow(v: View) {
                     v.removeOnAttachStateChangeListener(this)
                     listenerAdded.remove(v)
-                    v.observeLocale(onChange) // 重新尝试
+                    v.observeLocale(onChange)
                 }
                 override fun onViewDetachedFromWindow(v: View) { /* no-op */ }
             })
@@ -36,14 +36,18 @@ private fun View.observeLocale(onChange: () -> Unit) {
         return
     }
 
-    // 取消旧订阅,建立新订阅
     i18nJobs.remove(this)?.cancel()
     val job = owner.lifecycleScope.launch {
-        I18nManager.locale.collectLatest { onChange() }
+        I18nManager.locale.collectLatest {
+            try {
+                onChange()
+            } catch (t: Throwable) {
+                Log.e("I18n/Binding", "apply on locale change failed", t)
+            }
+        }
     }
     i18nJobs[this] = job
 
-    // 确保从窗口分离时清理订阅(防泄漏)
     if (listenerAdded[this] != true) {
         addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
             override fun onViewDetachedFromWindow(v: View) {
@@ -64,7 +68,6 @@ private fun findSetter(view: View, method: String): Method? {
     val cls = view.javaClass
     val map = methodCache.getOrPut(cls) { ConcurrentHashMap() }
     return map.getOrPut(method) {
-        // 优先 (CharSequence) 入参
         cls.methods.firstOrNull { m ->
             m.name == method && m.parameterTypes.size == 1 &&
                     CharSequence::class.java.isAssignableFrom(m.parameterTypes[0])
@@ -98,26 +101,73 @@ private fun applyTextVia(
             // 下一个候选
         }
     }
-    // 兜底:写 contentDescription
     runCatching { findSetter(view, "setContentDescription")?.invoke(view, value) }
 }
 
-// ---- 通用 BindingAdapter:一个适配所有控件 ----
-/**
- * 通用 i18n 绑定:
- *  - i18nTarget: "text" | "hint" | "title" | "contentDescription"
- *  - i18nMethod: 自定义方法名(优先级最高),如 "setSubtitle"、"setHelperText"
- *
- * 未指定 target/method 时,按 setText → setHint → setTitle 顺序尝试。
- */
+// ---- 参数汇总:将多种输入方式统一成 Map<String, Any?>: "0"->list[0] ----
+private fun buildArgsMap(
+    // A) 直接传 Map(最高优先级)
+    mapArgs: Map<String, Any?>?,
+    // B) 位置参数
+    a0: Any?, a1: Any?, a2: Any?, a3: Any?, a4: Any?, a5: Any?,
+    // C) 数组/列表
+    arrayArgs: Any?,
+    listArgs: List<Any?>?,
+    // D) 管道/逗号分隔
+    pipe: String?
+): Map<String, Any?> {
+    if (mapArgs != null && mapArgs.isNotEmpty()) return mapArgs
+
+    val list = mutableListOf<Any?>()
+
+    fun push(vararg v: Any?) = v.forEach { if (it != null) list.add(it) }
+    push(a0, a1, a2, a3, a4, a5)
+
+    when (arrayArgs) {
+        is Array<*> -> list.addAll(arrayArgs.asList())
+        is IntArray -> list.addAll(arrayArgs.toTypedArray())
+        is LongArray -> list.addAll(arrayArgs.toTypedArray())
+        is FloatArray -> list.addAll(arrayArgs.toTypedArray())
+        is DoubleArray -> list.addAll(arrayArgs.toTypedArray())
+        is BooleanArray -> list.addAll(arrayArgs.toTypedArray())
+        is CharArray -> list.addAll(arrayArgs.toTypedArray())
+        is List<*> -> list.addAll(arrayArgs)
+        null -> {}
+        else -> list.add(arrayArgs)
+    }
+
+    if (listArgs != null) list.addAll(listArgs)
+
+    if (!pipe.isNullOrBlank()) {
+        list.addAll(pipe.split('|', ',').map { it.trim() })
+    }
+
+    return list.mapIndexed { i, v -> i.toString() to v }.toMap()
+}
+
+// ---- 通用 BindingAdapter:一个适配所有控件 + 多输入方式 ----
 @BindingAdapter(
-    value = ["i18nKey", "i18nArgs", "i18nCount", "i18nSelect", "i18nFallback", "i18nTarget", "i18nMethod"],
+    value = [
+        "i18nKey",
+        "i18nArgs",
+        "i18nArg0","i18nArg1","i18nArg2","i18nArg3","i18nArg4","i18nArg5",
+        "i18nArgsArray",
+        "i18nArgsList",
+        "i18nArgsPipe",
+        "i18nCount","i18nSelect","i18nFallback",
+        "i18nTarget","i18nMethod"
+    ],
     requireAll = false
 )
 fun bindI18n(
     view: View,
     key: String?,
-    args: Map<String, Any?>? = null,
+    mapArgs: Map<String, Any?>? = null,
+    a0: Any? = null, a1: Any? = null, a2: Any? = null,
+    a3: Any? = null, a4: Any? = null, a5: Any? = null,
+    argsArray: Any? = null,
+    argsList: List<Any?>? = null,
+    argsPipe: String? = null,
     count: Number? = null,
     select: String? = null,
     fallback: String? = null,
@@ -126,7 +176,25 @@ fun bindI18n(
 ) {
     if (key.isNullOrBlank()) return
 
-    val compute: () -> CharSequence = { I18nManager.t(key, args, count, select, fallback) }
+    val args = buildArgsMap(mapArgs, a0, a1, a2, a3, a4, a5, argsArray, argsList, argsPipe)
+
+    // —— 关键兜底:任何异常都不让它炸到 UI ——
+    val compute: () -> CharSequence = {
+        try {
+            I18nManager.t(key, args, count, select, fallback)
+        } catch (t: Throwable) {
+            // 把真正的 cause 打出来,尤其是 ExceptionInInitializerError
+            val cause = if (t is ExceptionInInitializerError) t.cause else t
+            Log.e(
+                "I18n/Binding",
+                "I18nManager.t failed. key=$key, locale=${try { I18nManager.locale.value.toLanguageTag() } catch (_:Throwable) { "?" }}, args=$args",
+                cause
+            )
+            // 兜底:优先 fallback,其次用 key
+            (fallback ?: key)
+        }
+    }
+
     val applyOnce: () -> Unit = {
         val text = compute()
         val preferred = when (target?.lowercase()) {
@@ -137,9 +205,14 @@ fun bindI18n(
             null, "" -> listOf("setText", "setHint", "setTitle")
             else -> emptyList()
         }
-        applyTextVia(view, text, preferred, method)
+        try {
+            applyTextVia(view, text, preferred, method)
+        } catch (t: Throwable) {
+            Log.e("I18n/Binding", "applyTextVia failed. key=$key", t)
+        }
     }
 
+    // 初次应用 & 订阅语言变化
     applyOnce()
     view.observeLocale { applyOnce() }
 }

+ 101 - 17
shared/src/main/java/com/grkj/shared/utils/i18n/source/AssetsCsvSource.kt

@@ -4,16 +4,19 @@ import android.content.Context
 import com.grkj.shared.utils.i18n.I18nEntry
 import com.grkj.shared.utils.i18n.I18nSource
 import com.grkj.shared.utils.i18n.util.CsvUtils
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
 import java.io.BufferedReader
 import java.io.InputStreamReader
+import java.nio.charset.StandardCharsets
 import java.util.Locale
 
 /**
- * 从 assets 目录读取 CSV 词库。
+ * 从 assets 目录读取 CSV 词库(更鲁棒:UTF-8、BOM、文件名回退)
  *
- * @param dir assets 子目录(默认 "i18n")
- * @param mergedMode false=推荐:一语言一表(en-US.csv);true=多语合表(all.csv)
- * @param mergedFileName 合表文件名(仅 mergedMode=true 时生效
+ * dir: assets 子目录(默认 "i18n")
+ * mergedMode=false:单语一表(zh-CN.csv)
+ * mergedMode=true :合表(all.csv
  */
 class AssetsCsvSource(
     private val context: Context,
@@ -21,25 +24,106 @@ class AssetsCsvSource(
     private val mergedMode: Boolean = false,
     private val mergedFileName: String = "all.csv"
 ) : I18nSource {
-
+    private val logger: Logger = LoggerFactory.getLogger(this::class.java)
     override fun load(locale: Locale): Map<String, I18nEntry> {
         return if (!mergedMode) {
-            val name = "${locale.toLanguageTag()}.csv"
-            readSingle("$dir/$name")
+            // 依次尝试:zh-CN.csv -> zh.csv -> zh_CN.csv -> ZH-CN.csv(大小写/下划线容错)
+            for (name in candidateNames(locale)) {
+                logger.info("语言文本文件:${name}")
+                val path = "$dir/$name"
+                val map = runCatching {
+                    context.assets.open(path).use { input ->
+                        BufferedReader(InputStreamReader(input, StandardCharsets.UTF_8)).use {
+                            CsvUtils.parseSingleLang(it) // 确保 CsvUtils 里做了 BOM/表头大小写处理
+                        }
+                    }
+                }.getOrNull()
+                if (!map.isNullOrEmpty()) return map
+            }
+            emptyMap()
         } else {
-            readMerged("$dir/$mergedFileName", locale)
+            val path = "$dir/$mergedFileName"
+            runCatching {
+                context.assets.open(path).use { input ->
+                    BufferedReader(InputStreamReader(input, StandardCharsets.UTF_8)).use {
+                        CsvUtils.parseMerged(it, locale)
+                    }
+                }
+            }.getOrElse { emptyMap() }
+        }
+    }
+
+    /**
+     * 基于 BCP47 解析:忽略脚本/变体,仅提取 language + region,并生成最可能的文件名:
+     * zh-CN.csv → zh_CN.csv → zh.csv
+     */
+    /** 生成优先级:zh-CN.csv → zh_CN.csv →(小写变体)→ zh.csv */
+    /** 生成优先名:lang-Script-Region / lang_Script_Region / lang-Region / lang_Script / lang */
+    private fun candidateNames(locale: Locale): List<String> {
+        val (lang, script, region) = parseLangScriptRegion(locale)
+        val list = mutableListOf<String>()
+        if (lang.isEmpty()) return list
+
+        fun addName(l: String, s: String?, r: String?, sep: Char) {
+            val sb = StringBuilder(l)
+            if (!s.isNullOrEmpty()) sb.append(sep).append(s)
+            if (!r.isNullOrEmpty()) sb.append(sep).append(r)
+            sb.append(".csv")
+            list += sb.toString()
         }
+
+        // 1) lang-Script-Region(最高优先)
+        if (!script.isNullOrEmpty() && !region.isNullOrEmpty()) {
+            addName(lang, script, region, '-')
+            addName(lang, script, region, '_')
+            // 小写变体也加上,防止资产里用了全小写
+            addName(lang, script.lowercase(), region.lowercase(), '-')
+            addName(lang, script.lowercase(), region.lowercase(), '_')
+        }
+        // 2) lang-Region
+        if (!region.isNullOrEmpty()) {
+            addName(lang, null, region, '-')
+            addName(lang, null, region, '_')
+            addName(lang, null, region.lowercase(), '-')
+            addName(lang, null, region.lowercase(), '_')
+        }
+        // 3) lang-Script
+        if (!script.isNullOrEmpty()) {
+            addName(lang, script, null, '-')
+            addName(lang, script, null, '_')
+            addName(lang, script.lowercase(), null, '-')
+            addName(lang, script.lowercase(), null, '_')
+        }
+        // 4) lang
+        list += "$lang.csv"
+        logger.info("匹配到的语言:${list.joinToString(",")}")
+        return list.distinct()
     }
 
-    private fun readSingle(path: String): Map<String, I18nEntry> = runCatching {
-        context.assets.open(path).use { input ->
-            BufferedReader(InputStreamReader(input)).use { CsvUtils.parseSingleLang(it) }
+    /** 从 Locale 提取 (lang, script, region),遵循 BCP-47:lang 小写、script TitleCase、region 大写 */
+    private fun parseLangScriptRegion(locale: Locale): Triple<String, String?, String?> {
+        // 优先用标准 API
+        var lang = locale.language?.lowercase(Locale.ROOT).orEmpty()
+        var script = locale.script?.let {
+            if (it.isNotBlank()) it.substring(0,1).uppercase(Locale.ROOT) + it.substring(1).lowercase(Locale.ROOT)
+            else null
         }
-    }.getOrElse { emptyMap() }
+        var region = locale.country?.uppercase(Locale.ROOT).orEmpty().ifBlank { null }
 
-    private fun readMerged(path: String, locale: Locale): Map<String, I18nEntry> = runCatching {
-        context.assets.open(path).use { input ->
-            BufferedReader(InputStreamReader(input)).use { CsvUtils.parseMerged(it, locale) }
+        // 有些厂商定制 Locale 会把脚本/地区塞进 variant,额外兜底一次
+        if (script.isNullOrEmpty() || region == null) {
+            val parts = locale.toLanguageTag().replace('_','-').split('-').filter { it.isNotBlank() }
+            if (parts.isNotEmpty()) {
+                lang = parts.first().lowercase(Locale.ROOT)
+                // BCP47:脚本 = 4 个字母,首字母大写;地区=2字母或3数字
+                val sc = parts.firstOrNull { it.length == 4 && it.all { ch -> ch.isLetter() } }
+                val rg = parts.firstOrNull { (it.length == 2 && it.all { ch -> ch.isLetter() }) || (it.length == 3 && it.all { ch -> ch.isDigit() }) }
+                if (script.isNullOrEmpty() && sc != null) {
+                    script = sc.substring(0,1).uppercase(Locale.ROOT) + sc.substring(1).lowercase(Locale.ROOT)
+                }
+                if (region == null && rg != null) region = rg.uppercase(Locale.ROOT)
+            }
         }
-    }.getOrElse { emptyMap() }
-}
+        return Triple(lang, script, region)
+    }
+}

+ 101 - 10
shared/src/main/java/com/grkj/shared/utils/i18n/source/FileCsvSource.kt

@@ -6,14 +6,15 @@ import com.grkj.shared.utils.i18n.I18nSource
 import com.grkj.shared.utils.i18n.util.CsvUtils
 import java.io.BufferedReader
 import java.io.File
-import java.io.FileReader
+import java.io.FileInputStream
+import java.io.InputStreamReader
+import java.nio.charset.StandardCharsets
 import java.util.Locale
 
 /**
  * 从 app 私有 files 目录读取 CSV(导入产物)。
- *
- * 目录结构(默认 dirName="i18n"):
- *  - 单语:files/i18n/zh-CN.csv / en-US.csv
+ * 结构:
+ *  - 单语:files/i18n/zh-CN.csv / zh.csv
  *  - 合表:files/i18n/all.csv
  */
 class FileCsvSource(
@@ -26,13 +27,103 @@ class FileCsvSource(
     override fun load(locale: Locale): Map<String, I18nEntry> {
         val dir = File(context.filesDir, dirName).apply { mkdirs() }
         return if (!mergedMode) {
-            val f = File(dir, "${locale.toLanguageTag()}.csv")
-            if (f.exists()) BufferedReader(FileReader(f)).use { CsvUtils.parseSingleLang(it) }
-            else emptyMap()
+            // 同样做文件名回退
+            for (name in candidateNames(locale)) {
+                val f = File(dir, name)
+                if (!f.exists()) continue
+                val map = runCatching {
+                    FileInputStream(f).use { fis ->
+                        BufferedReader(InputStreamReader(fis, StandardCharsets.UTF_8)).use {
+                            CsvUtils.parseSingleLang(it)
+                        }
+                    }
+                }.getOrNull()
+                if (!map.isNullOrEmpty()) return map
+            }
+            emptyMap()
         } else {
             val f = File(dir, mergedFileName)
-            if (f.exists()) BufferedReader(FileReader(f)).use { CsvUtils.parseMerged(it, locale) }
-            else emptyMap()
+            if (!f.exists()) return emptyMap()
+            runCatching {
+                FileInputStream(f).use { fis ->
+                    BufferedReader(InputStreamReader(fis, StandardCharsets.UTF_8)).use {
+                        CsvUtils.parseMerged(it, locale)
+                    }
+                }
+            }.getOrElse { emptyMap() }
         }
     }
-}
+
+    /**
+     * 基于 BCP47 解析:忽略脚本/变体,仅提取 language + region,并生成最可能的文件名:
+     * zh-CN.csv → zh_CN.csv → zh.csv
+     */
+    /** 生成优先名:lang-Script-Region / lang_Script_Region / lang-Region / lang_Script / lang */
+    private fun candidateNames(locale: Locale): List<String> {
+        val (lang, script, region) = parseLangScriptRegion(locale)
+        val list = mutableListOf<String>()
+        if (lang.isEmpty()) return list
+
+        fun addName(l: String, s: String?, r: String?, sep: Char) {
+            val sb = StringBuilder(l)
+            if (!s.isNullOrEmpty()) sb.append(sep).append(s)
+            if (!r.isNullOrEmpty()) sb.append(sep).append(r)
+            sb.append(".csv")
+            list += sb.toString()
+        }
+
+        // 1) lang-Script-Region(最高优先)
+        if (!script.isNullOrEmpty() && !region.isNullOrEmpty()) {
+            addName(lang, script, region, '-')
+            addName(lang, script, region, '_')
+            // 小写变体也加上,防止资产里用了全小写
+            addName(lang, script.lowercase(), region.lowercase(), '-')
+            addName(lang, script.lowercase(), region.lowercase(), '_')
+        }
+        // 2) lang-Region
+        if (!region.isNullOrEmpty()) {
+            addName(lang, null, region, '-')
+            addName(lang, null, region, '_')
+            addName(lang, null, region.lowercase(), '-')
+            addName(lang, null, region.lowercase(), '_')
+        }
+        // 3) lang-Script
+        if (!script.isNullOrEmpty()) {
+            addName(lang, script, null, '-')
+            addName(lang, script, null, '_')
+            addName(lang, script.lowercase(), null, '-')
+            addName(lang, script.lowercase(), null, '_')
+        }
+        // 4) lang
+        list += "$lang.csv"
+
+        return list.distinct()
+    }
+
+    /** 从 Locale 提取 (lang, script, region),遵循 BCP-47:lang 小写、script TitleCase、region 大写 */
+    private fun parseLangScriptRegion(locale: Locale): Triple<String, String?, String?> {
+        // 优先用标准 API
+        var lang = locale.language?.lowercase(Locale.ROOT).orEmpty()
+        var script = locale.script?.let {
+            if (it.isNotBlank()) it.substring(0,1).uppercase(Locale.ROOT) + it.substring(1).lowercase(Locale.ROOT)
+            else null
+        }
+        var region = locale.country?.uppercase(Locale.ROOT).orEmpty().ifBlank { null }
+
+        // 有些厂商定制 Locale 会把脚本/地区塞进 variant,额外兜底一次
+        if (script.isNullOrEmpty() || region == null) {
+            val parts = locale.toLanguageTag().replace('_','-').split('-').filter { it.isNotBlank() }
+            if (parts.isNotEmpty()) {
+                lang = parts.first().lowercase(Locale.ROOT)
+                // BCP47:脚本 = 4 个字母,首字母大写;地区=2字母或3数字
+                val sc = parts.firstOrNull { it.length == 4 && it.all { ch -> ch.isLetter() } }
+                val rg = parts.firstOrNull { (it.length == 2 && it.all { ch -> ch.isLetter() }) || (it.length == 3 && it.all { ch -> ch.isDigit() }) }
+                if (script.isNullOrEmpty() && sc != null) {
+                    script = sc.substring(0,1).uppercase(Locale.ROOT) + sc.substring(1).lowercase(Locale.ROOT)
+                }
+                if (region == null && rg != null) region = rg.uppercase(Locale.ROOT)
+            }
+        }
+        return Triple(lang, script, region)
+    }
+}

+ 164 - 39
shared/src/main/java/com/grkj/shared/utils/i18n/util/CsvUtils.kt

@@ -2,19 +2,20 @@ package com.grkj.shared.utils.i18n.util
 
 import com.grkj.shared.utils.i18n.I18nEntry
 import com.grkj.shared.utils.i18n.I18nType
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
 import java.io.BufferedReader
 import java.util.Locale
 
 /**
  * 轻量 CSV 解析(近 RFC4180)
- * - 支持引号、双引号转义、逗号/换行
- * - 倾向一次 pass,少分配
- * - 表头要求:
- *   单语模式:key,type,comment,value
- *   合表模式:key,type,comment,<localeTag...>
+ * - 单语:宽松模式(前三逗号切 key/type/comment,剩余全 value,支持换行)
+ * - 合表:按 “语言+脚本+地区” 选择最佳列(BCP-47:zh-Hans-CN > zh-CN > zh-Hans > zh)
  */
 object CsvUtils {
+    private val logger: Logger = LoggerFactory.getLogger(CsvUtils::class.java)
 
+    // --- 基础工具 ---
     private fun parseLine(line: String): List<String> {
         val out = ArrayList<String>(8)
         val sb = StringBuilder(line.length + 4)
@@ -39,63 +40,187 @@ object CsvUtils {
         return out
     }
 
-    /** 单语 CSV:key,type,comment,value */
+    private fun String.stripBom() = if (startsWith("\uFEFF")) substring(1) else this
+    private fun String.trimCR() = if (isNotEmpty() && last() == '\r') dropLast(1) else this
+
+    private fun isTypeToken(s: String): Boolean =
+        when (s.trim().lowercase(Locale.ROOT)) { "text", "plural", "select" -> true; else -> false }
+
+    /** 返回 [key, type, comment, valueRest];找不到3个逗号时 size < 4 */
+    private fun splitFirst3Commas(raw: String): List<String> {
+        val a = raw.indexOf(',')
+        if (a < 0) return listOf(raw)
+        val b = raw.indexOf(',', a + 1)
+        if (b < 0) return listOf(raw.substring(0, a), raw.substring(a + 1))
+        val c = raw.indexOf(',', b + 1)
+        if (c < 0) return listOf(
+            raw.substring(0, a),
+            raw.substring(a + 1, b),
+            raw.substring(b + 1)
+        )
+        return listOf(
+            raw.substring(0, a),
+            raw.substring(a + 1, b),
+            raw.substring(b + 1, c),
+            raw.substring(c + 1)
+        )
+    }
+
+    // --- 单语:宽松解析(保持你的实现) ---
     fun parseSingleLang(br: BufferedReader): Map<String, I18nEntry> {
-        val header = parseLine(br.readLine() ?: return emptyMap())
-        val idxKey = header.indexOf("key")
-        val idxType = header.indexOf("type")
-        val idxVal = header.indexOf("value")
-        if (idxKey < 0 || idxType < 0 || idxVal < 0) return emptyMap()
+        val out = LinkedHashMap<String, I18nEntry>(512)
 
-        val map = LinkedHashMap<String, I18nEntry>(1024)
-        br.lineSequence().forEach { raw ->
-            if (raw.isBlank()) return@forEach
-            val cols = parseLine(raw)
-            val key = cols.getOrNull(idxKey)?.trim().orEmpty()
-            val type = cols.getOrNull(idxType)?.trim()?.lowercase(Locale.ROOT).orEmpty()
-            if (key.isEmpty()) return@forEach
-            when (type) {
-                "text" -> {
-                    val value = cols.getOrNull(idxVal).orEmpty()
-                    map[key] = I18nEntry(key, I18nType.TEXT, value = value)
-                }
-                "plural" -> {
-                    val value = cols.getOrNull(idxVal).orEmpty()
-                    val parts = splitPairs(value)
-                    map[key] = I18nEntry(key, I18nType.PLURAL, plurals = parts)
+        logger.info("开始转换")
+        var curKey: String? = null
+        var curType: I18nType = I18nType.TEXT
+        var curVal = StringBuilder()
+        var isFirstLine = true
+
+        fun flush() {
+            val k = curKey ?: return
+            out[k] = I18nEntry(
+                key = k,
+                type = curType,
+                value = curVal.toString()
+            )
+            logger.info("csv值:$k,${out[k]}")
+            curKey = null
+            curVal = StringBuilder()
+        }
+
+        while (true) {
+            val line0 = br.readLine() ?: break
+            val raw = line0.stripBom().trimCR()
+            logger.info("原始数据:$raw")
+
+            if (raw.isBlank()) {
+                if (curKey != null) curVal.append('\n')
+                continue
+            }
+
+            val parts = splitFirst3Commas(raw)
+
+            // header 跳过
+            if (isFirstLine && parts.size >= 3 && parts[0].equals("key", true)) {
+                isFirstLine = false
+                continue
+            }
+            isFirstLine = false
+
+            val isNewRecord = parts.size >= 4 &&
+                    parts[0].isNotBlank() &&
+                    isTypeToken(parts[1])
+
+            if (isNewRecord) {
+                flush()
+
+                val key = parts[0].trim()
+                val typeToken = parts[1].trim().lowercase(Locale.ROOT)
+                val valueRest = parts[3]
+
+                curKey = key
+                curType = when (typeToken) {
+                    "plural" -> I18nType.PLURAL
+                    "select" -> I18nType.SELECT
+                    else -> I18nType.TEXT
                 }
-                "select" -> {
-                    val value = cols.getOrNull(idxVal).orEmpty()
-                    val parts = splitPairs(value, lowerKey = true)
-                    map[key] = I18nEntry(key, I18nType.SELECT, selects = parts)
+                curVal.append(valueRest)
+            } else {
+                if (curKey != null) {
+                    if (curVal.isNotEmpty()) curVal.append('\n')
+                    curVal.append(raw)
                 }
             }
         }
-        return map
+
+        flush()
+        return out
+    }
+
+    // --- 合表:最佳列匹配(语言+脚本+地区) ---
+    private data class LR(val lang: String, val script: String?, val region: String?)
+
+    /** 从 BCP-47 tag 提取 (lang, script, region):lang 小写、script TitleCase、region 大写 */
+    private fun parseLR(tag: String): LR {
+        val p = tag.replace('_', '-').split('-').filter { it.isNotBlank() }
+        if (p.isEmpty()) return LR("", null, null)
+        val lang = p.first().lowercase(Locale.ROOT)
+        val script = p.firstOrNull { it.length == 4 && it.all { ch -> ch.isLetter() } }
+            ?.let { it.substring(0, 1).uppercase(Locale.ROOT) + it.substring(1).lowercase(Locale.ROOT) }
+        val region = p.firstOrNull {
+            (it.length == 2 && it.all { ch -> ch.isLetter() }) ||
+                    (it.length == 3 && it.all { ch -> ch.isDigit() })
+        }?.uppercase(Locale.ROOT)
+        return LR(lang, script, region)
+    }
+
+    private fun localeLR(locale: Locale): LR {
+        // 先用 API,拿不到再用 tag 兜底
+        val lang = locale.language?.lowercase(Locale.ROOT).orEmpty()
+        val script = locale.script?.takeIf { it.isNotBlank() }
+            ?.let { it.substring(0,1).uppercase(Locale.ROOT) + it.substring(1).lowercase(Locale.ROOT) }
+        val region = locale.country?.takeIf { it.isNotBlank() }?.uppercase(Locale.ROOT)
+        val lr = LR(lang, script, region)
+        return if (lr.lang.isEmpty() || (lr.script == null && lr.region == null)) parseLR(locale.toLanguageTag()) else lr
+    }
+
+    /** 评分:lang 必须一致;命中 region +4,命中 script +2,只有 lang +1;满分 7(lang+script+region) */
+    private fun score(want: LR, got: LR): Int {
+        if (want.lang != got.lang) return -1
+        var s = 1
+        if (want.region != null && want.region == got.region) s += 4
+        if (want.script != null && want.script.equals(got.script, ignoreCase = true)) s += 2
+        return s
+    }
+
+    /** 在 header 里挑选“最优 locale 列” */
+    private fun pickLocaleColumn(headers: List<String>, locale: Locale): Int? {
+        val want = localeLR(locale)
+        var bestIdx: Int? = null
+        var bestScore = -1
+        headers.forEachIndexed { idx, raw ->
+            val lr = parseLR(raw)
+            val sc = score(want, lr)
+            if (sc > bestScore) {
+                bestScore = sc
+                bestIdx = idx
+                if (bestScore >= 7) return@forEachIndexed // 满分提前退出
+            }
+        }
+        return bestIdx
     }
 
     /** 合表 CSV:key,type,comment,<localeTag...> */
     fun parseMerged(br: BufferedReader, locale: Locale): Map<String, I18nEntry> {
-        val header = parseLine(br.readLine() ?: return emptyMap())
-        val idxKey = header.indexOf("key")
-        val idxType = header.indexOf("type")
-        val idxVal = header.indexOf(locale.toLanguageTag())
-        if (idxKey < 0 || idxType < 0 || idxVal < 0) return emptyMap()
+        val headerRaw = br.readLine() ?: return emptyMap()
+        val header = parseLine(headerRaw.stripBom())
+        val idxKey = header.indexOfFirst { it.equals("key", true) }
+        val idxType = header.indexOfFirst { it.equals("type", true) }
+        if (idxKey < 0 || idxType < 0) return emptyMap()
+
+        val idxVal = pickLocaleColumn(header, locale)
+            ?: header.indexOfFirst { it.equals("value", true) || it.equals("text", true) }
+                .takeIf { it != -1 }
+            ?: run {
+                logger.warn("No locale column for ${locale.toLanguageTag()} in header=$header")
+                return emptyMap()
+            }
 
         val map = LinkedHashMap<String, I18nEntry>(1024)
         br.lineSequence().forEach { raw ->
             if (raw.isBlank()) return@forEach
             val cols = parseLine(raw)
             val key = cols.getOrNull(idxKey)?.trim().orEmpty()
+            if (key.isEmpty()) return@forEach
             val type = cols.getOrNull(idxType)?.trim()?.lowercase(Locale.ROOT).orEmpty()
             val valCol = cols.getOrNull(idxVal).orEmpty()
-            if (key.isEmpty()) return@forEach
             when (type) {
                 "text" -> map[key] = I18nEntry(key, I18nType.TEXT, value = valCol)
                 "plural" -> map[key] = I18nEntry(key, I18nType.PLURAL, plurals = splitPairs(valCol))
                 "select" -> map[key] = I18nEntry(key, I18nType.SELECT, selects = splitPairs(valCol, true))
             }
         }
+        logger.info("Merged CSV loaded: ${map.size} keys (locale=${locale.toLanguageTag()}, chosenCol=${header.getOrNull(idxVal)})")
         return map
     }
 
@@ -113,4 +238,4 @@ object CsvUtils {
         }
         return out
     }
-}
+}

+ 4 - 4
ui-base/src/main/java/com/grkj/ui_base/base/BaseViewModel.kt

@@ -22,7 +22,7 @@ import org.slf4j.LoggerFactory
  * 界面模型基类
  */
 open class BaseViewModel constructor(
-    open val userRepository: IUserLogic? = null
+    open val userLogic: IUserLogic? = null
 ) : ViewModel() {
     protected val logger: Logger = LoggerFactory.getLogger(this::class.java)
 
@@ -45,7 +45,7 @@ open class BaseViewModel constructor(
      */
     fun checkFace(faceB64: String): LiveData<LoginResultEnum> {
         return liveData(Dispatchers.IO) {
-            emit(userRepository?.checkFace(faceB64) ?: LoginResultEnum.FACE_VERIFY_FAILED)
+            emit(userLogic?.checkFace(faceB64) ?: LoginResultEnum.FACE_VERIFY_FAILED)
         }
     }
 
@@ -55,7 +55,7 @@ open class BaseViewModel constructor(
     fun checkFinger(fingerB64: String): LiveData<LoginResultEnum> {
         return liveData(Dispatchers.IO) {
             emit(
-                userRepository?.checkFingerprint(fingerB64)
+                userLogic?.checkFingerprint(fingerB64)
                     ?: LoginResultEnum.FINGERPRINTER_VERIFY_FAILED
             )
         }
@@ -66,7 +66,7 @@ open class BaseViewModel constructor(
      */
     fun checkCard(cardNo: String): LiveData<LoginResultEnum> {
         return liveData(Dispatchers.IO) {
-            emit(userRepository?.checkCard(cardNo) ?: LoginResultEnum.JOB_CARD_LOGIN_FAILED)
+            emit(userLogic?.checkCard(cardNo) ?: LoginResultEnum.JOB_CARD_LOGIN_FAILED)
         }
     }
 

+ 0 - 1
ui-base/src/main/res/values-en/strings.xml

@@ -24,7 +24,6 @@
 
 
     <!--  Design  -->
-    <string name="loto">智能锁控系统</string>
     <string name="settings">Settings</string>
     <string name="back">Back</string>
     <string name="finish_the_job">Finish</string>

+ 0 - 1
ui-base/src/main/res/values-zh/strings.xml

@@ -20,7 +20,6 @@
     <string name="lock_key_return_tip">作业票尚未完成,是否强制上传数据</string>
 
     <!--  设计图  -->
-    <string name="loto">智能锁控系统</string>
     <string name="settings">设置</string>
     <string name="back">返回</string>
     <string name="finish_the_job">结束作业</string>

+ 0 - 1
ui-base/src/main/res/values/strings.xml

@@ -24,7 +24,6 @@
 
 
     <!--  设计图  -->
-    <string name="loto">智能锁控系统</string>
     <string name="settings">设置</string>
     <string name="back">返回</string>
     <string name="finish_the_job">结束作业</string>