Jelajahi Sumber

refactor(更新)
- 优化数据导出模块,空数据时也能导出表头和占位符
- 调整硬件管理中默认硬件编码的生成规则,统一使用数据库自增ID
- 用户登录时增加软删除用户判断
- 新增硬件模式枚举及设置项,支持MODBUS和CAN模式切换
- 修复若干已知问题,提升系统稳定性
- 优化部分UI显示和交互体验
- 调整超级管理员默认权限,默认拥有所有功能权限
- 依赖库版本更新
- 新增CAN总线相关工具类和配置

周文健 2 bulan lalu
induk
melakukan
335a912b69
32 mengubah file dengan 722 tambahan dan 107 penghapusan
  1. 6 1
      app/src/main/assets/i18n/en-US.json
  2. 5 0
      app/src/main/assets/i18n/zh-CN.json
  3. 1 0
      app/src/main/java/com/grkj/iscs/features/init/fragment/InitSetAdminAccountFragment.kt
  4. 2 1
      app/src/main/java/com/grkj/iscs/features/main/dialog/QuickEntranceConfigDialog.kt
  5. 7 5
      app/src/main/java/com/grkj/iscs/features/main/fragment/data_manage/SwitchLayoutFragment.kt
  6. 1 1
      app/src/main/java/com/grkj/iscs/features/main/fragment/hardware_manage/SlotsManageFragment.kt
  7. 1 1
      app/src/main/java/com/grkj/iscs/features/main/fragment/job_manage/CreateSopFragment.kt
  8. 12 0
      app/src/main/java/com/grkj/iscs/features/main/fragment/user_info/SettingsFragment.kt
  9. 1 2
      app/src/main/java/com/grkj/iscs/features/main/viewmodel/hardware_manage/CardManageViewModel.kt
  10. 2 3
      app/src/main/java/com/grkj/iscs/features/main/viewmodel/hardware_manage/KeyManageViewModel.kt
  11. 2 3
      app/src/main/java/com/grkj/iscs/features/main/viewmodel/hardware_manage/LockManageViewModel.kt
  12. 2 3
      app/src/main/java/com/grkj/iscs/features/main/viewmodel/hardware_manage/RfidTokenManageViewModel.kt
  13. 31 0
      app/src/main/res/layout/fragment_settings.xml
  14. 1 0
      app/src/main/res/layout/item_device_slot_manage_lock.xml
  15. 10 8
      data/src/main/java/com/grkj/data/dao/HardwareDao.kt
  16. 1 1
      data/src/main/java/com/grkj/data/dao/UserDao.kt
  17. 5 0
      data/src/main/java/com/grkj/data/data/MMKVConstants.kt
  18. 9 0
      data/src/main/java/com/grkj/data/enums/HardwareMode.kt
  19. 234 0
      data/src/main/java/com/grkj/data/hardware/can/CanCommand.kt
  20. 182 0
      data/src/main/java/com/grkj/data/hardware/can/CanHelper.kt
  21. 48 0
      data/src/main/java/com/grkj/data/hardware/can/CanSendDelayInterceptor.kt
  22. 35 0
      data/src/main/java/com/grkj/data/hardware/can/CustomCanConfig.kt
  23. 4 4
      data/src/main/java/com/grkj/data/logic/IHardwareLogic.kt
  24. 4 4
      data/src/main/java/com/grkj/data/logic/impl/network/NetworkHardwareLogic.kt
  25. 10 3
      data/src/main/java/com/grkj/data/logic/impl/standard/DataExportLogic.kt
  26. 11 11
      data/src/main/java/com/grkj/data/logic/impl/standard/HardwareLogic.kt
  27. 1 5
      data/src/main/java/com/grkj/data/logic/impl/standard/SysMenuLogic.kt
  28. 1 0
      data/src/main/java/com/grkj/data/logic/impl/standard/UserLogic.kt
  29. 88 50
      data/src/main/java/com/grkj/data/utils/ExcelExporter.kt
  30. 3 1
      gradle/libs.versions.toml
  31. 1 0
      shared/build.gradle.kts
  32. 1 0
      ui-base/src/main/java/com/grkj/ui_base/utils/ble/BleConnectionManager.kt

+ 6 - 1
app/src/main/assets/i18n/en-US.json

@@ -2332,7 +2332,7 @@
   "rfid_name": {
     "key": "rfid_name",
     "type": "text",
-    "value": "RFID Number"
+    "value": "RFID Code"
   },
   "rfid_token_manage_delete_failed": {
     "key": "rfid_token_manage_delete_failed",
@@ -4107,5 +4107,10 @@
     "key": "data_in_backup",
     "type": "text",
     "value": "Data backup in progress……"
+  },
+  "hardware_mode": {
+    "key": "hardware_mode",
+    "type": "text",
+    "value": "Hardware Mode"
   }
 }

+ 5 - 0
app/src/main/assets/i18n/zh-CN.json

@@ -4113,5 +4113,10 @@
     "key": "data_in_backup",
     "type": "text",
     "value": "数据备份中……"
+  },
+  "hardware_mode": {
+    "key": "hardware_mode",
+    "type": "text",
+    "value": "硬件模式"
   }
 }

+ 1 - 0
app/src/main/java/com/grkj/iscs/features/init/fragment/InitSetAdminAccountFragment.kt

@@ -13,6 +13,7 @@ import com.grkj.shared.utils.KeyboardUtils
 import com.grkj.ui_base.base.BaseFragment
 import com.grkj.ui_base.skin.loadSkinIcon
 import com.grkj.ui_base.utils.CommonUtils
+import com.huyuhui.fastble.BleManager
 import com.kongzue.dialogx.dialogs.PopTip
 import com.sik.sikcore.extension.setDebouncedClickListener
 import com.sik.sikcore.string.RegexUtils

+ 2 - 1
app/src/main/java/com/grkj/iscs/features/main/dialog/QuickEntranceConfigDialog.kt

@@ -166,7 +166,8 @@ class QuickEntranceConfigDialog(private val save: (String) -> Unit) :
                 .contains(item.permission.functionalPermission)
         itemBinding.remove.isVisible = !showAdd
         itemBinding.root.setDebouncedClickListener {
-            if (showAdd) {
+            if (!selectedQuickEntranceConfig.map { it.permission.functionalPermission }
+                    .contains(item.permission.functionalPermission)) {
                 if (selectedQuickEntranceConfig.size == 8) {
                     PopTip.build().tip(CommonUtils.getStr("quick_entrance_most_set_tip"))
                     return@setDebouncedClickListener

+ 7 - 5
app/src/main/java/com/grkj/iscs/features/main/fragment/data_manage/SwitchLayoutFragment.kt

@@ -287,11 +287,13 @@ class SwitchLayoutFragment : BaseFragment<FragmentSwitchLayoutBinding>() {
                         lp.leftMargin = screenX.toInt()
                         lp.topMargin = screenY.toInt()
                         binding.dialogPositionPoint.layoutParams = lp
-                        SwitchInfoDialog.show(
-                            binding.dialogPositionPoint, point.entityName,
-                            "${point.pointNfc}",
-                            point.status
-                        )
+                        binding.dialogPositionPoint.postDelayed({
+                            SwitchInfoDialog.show(
+                                binding.dialogPositionPoint, point.entityName,
+                                "${point.pointNfc}",
+                                point.status
+                            )
+                        },100)
                     }
                     binding.mapview.addLayer(stationLayer)
                     stationLayer?.setRatio(viewModel.mapRatio)

+ 1 - 1
app/src/main/java/com/grkj/iscs/features/main/fragment/hardware_manage/SlotsManageFragment.kt

@@ -318,7 +318,7 @@ class SlotsManageFragment : BaseFragment<FragmentSlotsManageBinding>() {
         lockDock: DockData.LockDock,
     ) {
         val itemBinding = getBinding<ItemDeviceRegistrationLockLayoutBinding>()
-        itemBinding.rvLockLayout.grid(10).dividerSpace(10, DividerOrientation.GRID).setup {
+        itemBinding.rvLockLayout.grid(10).setup {
             addType<DockBean.LockBean>(R.layout.item_device_slot_manage_lock)
             onBind {
                 val itemLockBinding = getBinding<ItemDeviceSlotManageLockBinding>()

+ 1 - 1
app/src/main/java/com/grkj/iscs/features/main/fragment/job_manage/CreateSopFragment.kt

@@ -325,7 +325,7 @@ class CreateSopFragment : BaseFormFragment<FragmentCreateSopBinding>() {
                 )?.workstationName
         }
         if (defaultWorkflowModeId != 0L) {
-            selectedModeId = defaultWorkstationId
+            selectedModeId = defaultWorkflowModeId
             binding.lockModeTv.text =
                 viewModel.workflowModes.find { it.modeId == defaultWorkflowModeId }?.modeName
             binding.selectWorkflowTip.isVisible = false

+ 12 - 0
app/src/main/java/com/grkj/iscs/features/main/fragment/user_info/SettingsFragment.kt

@@ -3,8 +3,10 @@ package com.grkj.iscs.features.main.fragment.user_info
 import com.google.android.gms.common.internal.service.Common
 import com.grkj.data.data.CommonConstants
 import com.grkj.data.data.MMKVConstants
+import com.grkj.data.enums.HardwareMode
 import com.grkj.iscs.R
 import com.grkj.iscs.databinding.FragmentSettingsBinding
+import com.grkj.iscs.features.main.dialog.TextDropDownDialog
 import com.grkj.shared.utils.CountdownTimer
 import com.grkj.ui_base.base.BaseFragment
 import com.grkj.ui_base.utils.CommonUtils
@@ -40,6 +42,16 @@ class SettingsFragment : BaseFragment<FragmentSettingsBinding>() {
                 ) / 1000
             }"
         )
+        binding.hardwareMode.text =
+            MMKVConstants.KEY_HARDWARE_MODE.getMMKVData(HardwareMode.MODBUS.name)
+        binding.hardwareMode.setDebouncedClickListener {
+            val hardwareModeData = HardwareMode.values()
+                .map { TextDropDownDialog.SimpleTextDropDownEntity(dataText = it.name) }
+            TextDropDownDialog.showSingle(hardwareModeData, binding.hardwareMode) {
+                binding.hardwareMode.text = it.getShowText()
+                MMKVConstants.KEY_HARDWARE_MODE.saveMMKVData(it.getShowText())
+            }
+        }
         binding.confirm.setDebouncedClickListener {
             if (checkData()) {
                 MMKVConstants.KEY_MAX_FINGERPRINT_INSERT.saveMMKVData(

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

@@ -9,7 +9,6 @@ import com.grkj.data.model.vo.CardManageFilterVo
 import com.grkj.data.model.vo.UpdateCardDataVo
 import com.grkj.data.logic.IHardwareLogic
 import com.grkj.data.logic.IUserLogic
-import com.grkj.iscs.R
 import com.grkj.iscs.features.main.dialog.TextDropDownDialog
 import com.grkj.shared.utils.i18n.I18nManager
 import com.grkj.ui_base.base.BaseViewModel
@@ -84,7 +83,7 @@ class CardManageViewModel @Inject constructor(
      */
     fun addCard(data: AddCardDataVo): LiveData<Boolean> =
         liveData(Dispatchers.IO) {
-            val defaultCardCodeSize = hardwareRepository.getDefaultCardNameCount()
+            val defaultCardCodeSize = hardwareRepository.getLastCardId()
             val isCard = BeanUtils.copyProperties(data, IsJobCard::class.java)
             isCard?.exStatus =
                 if (data.exStatus) {

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

@@ -9,7 +9,6 @@ import com.grkj.data.model.vo.KeyManageFilterVo
 import com.grkj.data.model.vo.UpdateKeyDataVo
 import com.grkj.data.logic.IHardwareLogic
 import com.grkj.data.logic.IJobTicketLogic
-import com.grkj.iscs.R
 import com.grkj.shared.utils.i18n.I18nManager
 import com.grkj.ui_base.base.BaseViewModel
 import com.grkj.ui_base.utils.CommonUtils
@@ -67,7 +66,7 @@ class KeyManageViewModel @Inject constructor(
      */
     fun addKey(data: AddKeyDataVo): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
-            var defaultKeyCodeSize = hardwareRepository.getDefaultKeyNameCount()
+            var defaultKeyCodeSize = hardwareRepository.getLastKeyId()
             var isKey = BeanUtils.copyProperties(data, IsKey::class.java)
             if (isKey?.keyCode.isNullOrEmpty()) {
                 isKey?.keyCode = "KEY_${defaultKeyCodeSize + 1}"
@@ -86,7 +85,7 @@ class KeyManageViewModel @Inject constructor(
      */
     fun updateKey(data: UpdateKeyDataVo): LiveData<Boolean> {
         return liveData(Dispatchers.IO) {
-            var defaultKeyCodeSize = hardwareRepository.getDefaultKeyNameCount()
+            var defaultKeyCodeSize = hardwareRepository.getLastKeyId()
             var isKey = BeanUtils.copyProperties(data, IsKey::class.java)
             if (isKey?.keyCode.isNullOrEmpty()) {
                 isKey?.keyCode = "KEY_${defaultKeyCodeSize + 1}"

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

@@ -9,7 +9,6 @@ import com.grkj.data.model.vo.LockManageFilterVo
 import com.grkj.data.model.vo.UpdateLockDataVo
 import com.grkj.data.logic.IHardwareLogic
 import com.grkj.data.logic.IJobTicketLogic
-import com.grkj.iscs.R
 import com.grkj.shared.utils.i18n.I18nManager
 import com.grkj.ui_base.base.BaseViewModel
 import com.grkj.ui_base.utils.CommonUtils
@@ -66,7 +65,7 @@ class LockManageViewModel @Inject constructor(
      */
     fun addLock(data: AddLockDataVo): LiveData<Boolean> =
         liveData(Dispatchers.IO) {
-            var defaultLockCodeSize = hardwareRepository.getDefaultLockNameCount()
+            var defaultLockCodeSize = hardwareRepository.getLastLockId()
             var isLock = BeanUtils.copyProperties(data, IsLock::class.java)
             if (isLock?.lockCode.isNullOrEmpty()) {
                 isLock?.lockCode = "LOCK_${defaultLockCodeSize + 1}"
@@ -84,7 +83,7 @@ class LockManageViewModel @Inject constructor(
      */
     fun updateLock(data: UpdateLockDataVo): LiveData<Boolean> =
         liveData(Dispatchers.IO) {
-            var defaultLockCodeSize = hardwareRepository.getDefaultLockNameCount()
+            var defaultLockCodeSize = hardwareRepository.getLastLockId()
             var isLock = BeanUtils.copyProperties(data, IsLock::class.java)
             if (isLock?.lockCode.isNullOrEmpty()) {
                 isLock?.lockCode = "LOCK_${defaultLockCodeSize + 1}"

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

@@ -9,7 +9,6 @@ import com.grkj.data.model.vo.RfidTokenManageFilterVo
 import com.grkj.data.model.vo.UpdateRfidTokenDataVo
 import com.grkj.data.logic.IHardwareLogic
 import com.grkj.data.logic.IJobTicketLogic
-import com.grkj.iscs.R
 import com.grkj.shared.utils.i18n.I18nManager
 import com.grkj.ui_base.base.BaseViewModel
 import com.grkj.ui_base.utils.CommonUtils
@@ -68,7 +67,7 @@ class RfidTokenManageViewModel @Inject constructor(
      */
     fun addRfidToken(data: AddRfidTokenDataVo): LiveData<Boolean> =
         liveData(Dispatchers.IO) {
-            val defaultRfidCodeSize = hardwareRepository.getDefaultRFIDNameCount()
+            val defaultRfidCodeSize = hardwareRepository.getLastRFIDId()
             val token = BeanUtils.copyProperties(data, IsRfidToken::class.java)
             if (token?.rfidCode.isNullOrEmpty()) {
                 token?.rfidCode = "RFID_${defaultRfidCodeSize + 1}"
@@ -90,7 +89,7 @@ class RfidTokenManageViewModel @Inject constructor(
      */
     fun updateRfidToken(data: UpdateRfidTokenDataVo): LiveData<Boolean> =
         liveData(Dispatchers.IO) {
-            var defaultRfidCodeSize = hardwareRepository.getDefaultRFIDNameCount()
+            var defaultRfidCodeSize = hardwareRepository.getLastRFIDId()
             var token = BeanUtils.copyProperties(data, IsRfidToken::class.java)
             if (token?.rfidCode.isNullOrEmpty()) {
                 token?.rfidCode = "RFID_${defaultRfidCodeSize + 1}"

+ 31 - 0
app/src/main/res/layout/fragment_settings.xml

@@ -126,6 +126,37 @@
                 app:formRole="field"
                 app:i18nHint='@{"please_input_auto_logout_time"}' />
 
+            <TextView
+                android:id="@+id/hardware_mode_tv"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_below="@+id/auto_logout_time"
+                android:layout_marginTop="@dimen/iscs_space_4"
+                android:textColor="?attr/colorTextPrimary"
+                android:textSize="@dimen/iscs_text_md"
+                app:formRole="label"
+                app:i18nKey='@{"hardware_mode"}'
+                app:markPosition="start"
+                app:required="true" />
+
+            <TextView
+                android:id="@+id/hardware_mode"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_below="@+id/hardware_mode_tv"
+                android:layout_alignLeft="@+id/hardware_mode_tv"
+                android:layout_marginTop="@dimen/iscs_space_2"
+                android:background="@drawable/bg_common_input"
+                android:inputType="number"
+                android:maxLines="1"
+                android:minWidth="@dimen/add_to_map_input_min_width"
+                android:paddingHorizontal="@dimen/iscs_space_2"
+                android:paddingVertical="2dp"
+                android:singleLine="true"
+                android:textColor="?attr/colorTextPrimary"
+                android:textSize="@dimen/iscs_text_md"
+                app:formRole="field" />
+
             <TextView
                 android:id="@+id/confirm"
                 android:layout_width="wrap_content"

+ 1 - 0
app/src/main/res/layout/item_device_slot_manage_lock.xml

@@ -6,6 +6,7 @@
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:gravity="center"
+        android:layout_marginLeft="@dimen/iscs_space_2"
         android:orientation="horizontal">
 
         <FrameLayout

+ 10 - 8
data/src/main/java/com/grkj/data/dao/HardwareDao.kt

@@ -103,26 +103,28 @@ interface HardwareDao {
     /**
      * 获取默认数据数量
      */
-    @Query("select count(1) from is_key")
-    fun getDefaultKeyNameCount(): Int
+    @Query("""
+        SELECT seq FROM sqlite_sequence WHERE name = 'is_key' 
+    """)
+    fun getLastKeyId(): Int
 
     /**
      * 获取默认数据数量
      */
-    @Query("select count(1) from is_lock")
-    fun getDefaultLockNameCount(): Int
+    @Query("SELECT seq FROM sqlite_sequence WHERE name = 'is_lock' ")
+    fun getLastLockId(): Int
 
     /**
      * 获取默认数据数量
      */
-    @Query("select count(1) from is_job_card")
-    fun getDefaultCardNameCount(): Int
+    @Query("SELECT seq FROM sqlite_sequence WHERE name = 'is_job_card'")
+    fun getLastCardId(): Int
 
     /**
      * 获取默认数据数量
      */
-    @Query("select count(1) from is_rfid_token")
-    fun getDefaultRfidTokenNameCount(): Int
+    @Query("SELECT seq FROM sqlite_sequence WHERE name = 'is_rfid_token'")
+    fun getLastRFIDId(): Int
 
     /**
      * 获取所有锁仓数据

+ 1 - 1
data/src/main/java/com/grkj/data/dao/UserDao.kt

@@ -23,7 +23,7 @@ interface UserDao {
     /**
      * 根据用户名查询用户数据
      */
-    @Query("select * from sys_user where user_name = :userName")
+    @Query("select * from sys_user where user_name = :userName and del_flag = 0")
     fun getUserInfoByUsername(userName: String): SysUserDo?
 
     /**

+ 5 - 0
data/src/main/java/com/grkj/data/data/MMKVConstants.kt

@@ -64,4 +64,9 @@ object MMKVConstants {
      * 自动退出时间
      */
     const val KEY_AUTO_LOGOUT_TIME = "key_auto_logout_time"
+
+    /**
+     * 硬件模式
+     */
+    const val KEY_HARDWARE_MODE = "key_hardware_mode"
 }

+ 9 - 0
data/src/main/java/com/grkj/data/enums/HardwareMode.kt

@@ -0,0 +1,9 @@
+package com.grkj.data.enums
+
+/**
+ * 硬件模式
+ */
+enum class HardwareMode {
+    MODBUS, CAN;
+
+}

+ 234 - 0
data/src/main/java/com/grkj/data/hardware/can/CanCommand.kt

@@ -0,0 +1,234 @@
+package com.grkj.data.hardware.can
+
+import com.sik.comm.impl_can.SdoDialect
+import com.sik.comm.impl_can.SdoRequest
+import kotlin.math.max
+import kotlin.math.min
+
+/**
+ * CAN 指令集(无类型探测版)
+ *
+ * - 不再依赖设备类型读取;直接按节点拿到一个“通用命令集”
+ * - 同时提供 EKeyDock / FiveLock / KeyCabinet / MaterialCabinet 的方法
+ * - 你按实际设备只调用相关的方法即可;调用不支持的寄存器会返回 Abort/超时,但不会阻塞分发
+ */
+object CanCommands {
+
+    /** SDO 协议指令集(修正读响应常量) */
+    val sdoDialect: SdoDialect = SdoDialect(
+        READ = 0x40,
+        READ_1B = 0x4F,  // ✅ 读1B响应
+        READ_2B = 0x4B,  // ✅ 读2B响应
+        READ_4B = 0x43,  // ✅ 读4B响应
+        READ_ERROR = 0x80,
+        WRITE_1B = 0x2F,
+        WRITE_2B = 0x2B,
+        WRITE_4B = 0x23,
+        WRITE_ACK = 0x60,
+        WRITE_ERROR = 0x80
+    )
+
+    /**
+     * 指令 主索引
+     */
+    object Command {
+        /**
+         * 设备类型
+         */
+        const val DEVICE_TYPE = 0x6000
+
+        /**
+         * 版本号
+         */
+        const val VERSION = 0x6003
+
+        /**
+         * 状态
+         */
+        const val STATUS = 0x6010
+
+        /**
+         * 控制状态
+         */
+        const val CONTROL_REG = 0x6011
+
+        /**
+         * 物资柜存储状态
+         */
+        const val STORAGE_REG = 0x6012
+
+        /**
+         * 照明/消毒
+         */
+        const val LIGHT_REG = 0x6015
+
+        /**
+         * 灯带控制
+         */
+        const val LED_STRIP_CONTROL = 0x6016
+
+        /**
+         * 温度
+         */
+        const val TEMPERATURE = 0x6017
+
+        /**
+         * 湿度
+         */
+        const val HUMIDITY = 0x6018
+
+        /**
+         * 左钥匙rfid/挂锁RFID/钥匙柜RFID
+         */
+        const val RFID = 0x6020
+
+        /**
+         * 右钥匙RFID
+         */
+        const val RIGHT_KEY_RFID = 0x6024
+
+    }
+
+    // ========= 通用区:所有节点都能用 =========
+    object Common {
+        /** 版本 (R) → 0x6003/0x00, 4B: HW主,HW子,SW主,SW子 */
+        fun getDeviceVersion(nodeId: Int): SdoRequest.Read =
+            SdoRequest.Read(nodeId, Command.VERSION, 0x00)
+
+        /** 大多数设备复用的状态寄存器 (R) → 0x6010/0x00, 2B */
+        fun getStatus(nodeId: Int): SdoRequest.Read =
+            SdoRequest.Read(nodeId, Command.STATUS, 0x00)
+
+        /**
+         * 获取设备类型
+         */
+        fun getDeviceType(nodeId: Int): SdoRequest.Read =
+            SdoRequest.Read(nodeId, Command.DEVICE_TYPE, 0x00)
+    }
+
+    /**
+     * 统一返回“通用命令集”(不做类型判断)
+     */
+    fun forDevice(nodeId: Int): GenericCommands = GenericCommands(nodeId)
+
+    /** 通用命令集:把各家寄存器方法都放这(按需调用) */
+    class GenericCommands(val nodeId: Int) {
+
+        // ---- EKeyDock(左右位)/ 以及很多板子兼容的 0x6011 语义 ----
+
+        /** 控制/状态 (R/W) 0x6011/0x00, 2B:写仅置相关位,其余写0;读回含工作位 */
+        fun readControlReg(): SdoRequest.Read =
+            SdoRequest.Read(nodeId, Command.CONTROL_REG, 0x00)
+
+        /** 设置左右卡扣(bit0=左卡扣,bit4=右卡扣) */
+        fun setLatch(left: Boolean? = null, right: Boolean? = null): SdoRequest.Write {
+            var v = 0
+            if (left != null) v = v or ((if (left) 1 else 0) shl 0)
+            if (right != null) v = v or ((if (right) 1 else 0) shl 4)
+            return SdoRequest.Write(nodeId, Command.CONTROL_REG, 0x00, shortLE(v, 0b0001_0001), 2)
+        }
+
+        /** 设置左右充电(bit1=左充电,bit5=右充电) */
+        fun setCharge(leftOn: Boolean? = null, rightOn: Boolean? = null): SdoRequest.Write {
+            var v = 0
+            if (leftOn != null) v = v or ((if (leftOn) 1 else 0) shl 1)
+            if (rightOn != null) v = v or ((if (rightOn) 1 else 0) shl 5)
+            return SdoRequest.Write(nodeId, Command.CONTROL_REG, 0x00, shortLE(v, 0b0010_0010), 2)
+        }
+
+        /** 单侧卡扣语法糖:keySlotId: 0左/1右;status: 0解锁/1锁住 */
+        fun controlLatch(keySlotId: Int, status: Int): SdoRequest.Write {
+            require(keySlotId in 0..1) { "keySlotId must be 0(left)/1(right)" }
+            require(status == 0 || status == 1) { "status must be 0/1" }
+            val bit = if (keySlotId == 0) 0 else 4
+            return SdoRequest.Write(
+                nodeId,
+                Command.CONTROL_REG,
+                0x00,
+                shortLE((status and 1) shl bit, 1 shl bit),
+                2
+            )
+        }
+
+        /** 左/右 RFID (R) 4B 小端(常见地址) */
+        fun getLeftRfid(): SdoRequest.Read = SdoRequest.Read(nodeId, Command.RFID, 0x00)
+        fun getRightRfid(): SdoRequest.Read = SdoRequest.Read(nodeId, Command.RIGHT_KEY_RFID, 0x00)
+
+        // ---- FiveLock / KeyCabinet(常见 1..5 位同构写法,寄存器通常与 0x6011 兼容) ----
+
+        /** 一次写入 5 位控制(低5位有效),适配 5路/柜体同构 */
+        fun setLatchBits_1to5(bits01to05: Int): SdoRequest.Write {
+            val v = bits01to05 and 0b1_1111
+            return SdoRequest.Write(nodeId, Command.CONTROL_REG, 0x00, shortLE(v, 0b1_1111), 2)
+        }
+
+        /** 单位控制(1..5) */
+        fun controlOne_1to5(slotIndex1to5: Int, locked: Boolean): SdoRequest.Write {
+            require(slotIndex1to5 in 1..5) { "slotIndex must be 1..5" }
+            val v = (if (locked) 1 else 0) shl (slotIndex1to5 - 1)
+            return SdoRequest.Write(
+                nodeId,
+                Command.CONTROL_REG,
+                0x00,
+                shortLE(v, 1 shl (slotIndex1to5 - 1)),
+                2
+            )
+        }
+
+        /** 1..5 位 RFID 常见映射:0x6020..0x6024 */
+        fun getSlotRfid_1to5(slotIndex1to5: Int): SdoRequest.Read {
+            require(slotIndex1to5 in 1..5) { "slotIndex must be 1..5" }
+            return SdoRequest.Read(nodeId, Command.RFID, 0x00 + (slotIndex1to5 - 1))
+        }
+
+        // ---- MaterialCabinet(RGB/温湿度扩展) ----
+
+        /** RGB 状态灯 (R/W) 0x6016/0x00, 4B: B[0..7],G[8..15],R[16..23], 模式[24..26], 时间[27..29], 单位[30], 锁定[31] */
+        fun getRgb(): SdoRequest.Read = SdoRequest.Read(nodeId, Command.LED_STRIP_CONTROL, 0x00)
+
+        fun setRgb(
+            r: Int, g: Int, b: Int,      // 0..255
+            mode: Int,                    // 0关/1常亮/2闪烁/3呼吸/4流水
+            timeStep: Int,                // 0..7(实际=+1)
+            secondsUnit: Boolean,         // false=100ms, true=1000ms
+            lockControl: Boolean          // 是否锁定主控权
+        ): SdoRequest.Write {
+            val rb = clamp8(b)
+            val gg = clamp8(g)
+            val rr = clamp8(r)
+            val mm = clamp3(mode)
+            val tt = clamp3(timeStep)
+            var v = 0
+            v = v or (rb and 0xFF)
+            v = v or ((gg and 0xFF) shl 8)
+            v = v or ((rr and 0xFF) shl 16)
+            v = v or ((mm and 0x07) shl 24)
+            v = v or ((tt and 0x07) shl 27)
+            v = v or ((if (secondsUnit) 1 else 0) shl 30)
+            v = v or ((if (lockControl) 1 else 0) shl 31)
+            return SdoRequest.Write(nodeId, Command.LED_STRIP_CONTROL, 0x00, intLE(v), 4)
+        }
+
+        /** 温湿度(常见扩展) */
+        fun getTemperature(): SdoRequest.Read = SdoRequest.Read(nodeId, Command.TEMPERATURE, 0x00)
+        fun getHumidity(): SdoRequest.Read = SdoRequest.Read(nodeId, Command.HUMIDITY, 0x00)
+
+        // ---- 通用状态 ----
+        fun getStatus(): SdoRequest.Read = Common.getStatus(nodeId)
+        fun getVersion(): SdoRequest.Read = Common.getDeviceVersion(nodeId)
+    }
+
+    // ========= Byte 打包工具(LE) =========
+    private fun shortLE(control: Int, target: Int): ByteArray =
+        byteArrayOf((control and 0xFF).toByte(), (target and 0xFF).toByte())
+
+    private fun intLE(v: Int): ByteArray = byteArrayOf(
+        (v and 0xFF).toByte(),
+        ((v ushr 8) and 0xFF).toByte(),
+        ((v ushr 16) and 0xFF).toByte(),
+        ((v ushr 24) and 0xFF).toByte()
+    )
+
+    private fun clamp8(x: Int) = min(255, max(0, x))
+    private fun clamp3(x: Int) = min(7, max(0, x))
+}

+ 182 - 0
data/src/main/java/com/grkj/data/hardware/can/CanHelper.kt

@@ -0,0 +1,182 @@
+package com.grkj.data.hardware.can
+
+import com.sik.comm.core.protocol.ProtocolManager
+import com.sik.comm.core.protocol.ProtocolType
+import com.sik.comm.impl_can.CanProtocol
+import com.sik.comm.impl_can.SdoRequest
+import com.sik.comm.impl_can.SdoResponse
+import com.sik.comm.impl_can.toCommMessage
+import com.sik.comm.impl_can.toSdoResponse
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+
+/**
+ * can总线帮助工具
+ */
+object CanHelper {
+    private val logger: Logger = LoggerFactory.getLogger(CanHelper::class.java)
+
+    /**
+     * 作用域
+     */
+    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+    /**
+     * 节点对应的设备类型列表
+     */
+    private val nodeMap: HashMap<Int, Int> = hashMapOf()
+
+    /**
+     * 设备数据
+     */
+    private val deviceData: HashMap<Int, List<DeviceModel>> = hashMapOf()
+
+    /**
+     * 设备变更监听器
+     */
+    private val deviceChangeListeners: HashMap<Any, (List<DeviceModel>) -> Unit> = hashMapOf()
+
+    /**
+     * 设备状态监听器
+     */
+    private val deviceStatusListener: CanReadyPlugin.DeviceStatusListener =
+        object : CanReadyPlugin.DeviceStatusListener {
+
+            override fun deviceStatus(nodeId: Int, index: Int, statusData: ByteArray) {
+                when (nodeMap[nodeId]) {
+                    CanDeviceConst.DEVICE_KEY_DOCK -> {
+                        DeviceParseStatus.parseKeyDockStatus(nodeId, index, statusData)
+                    }
+
+                    CanDeviceConst.DEVICE_LOCK_DOCK -> {
+                        DeviceParseStatus.parseLockDockStatus(nodeId, index, statusData)
+
+                    }
+
+                    CanDeviceConst.DEVICE_KEY_CABINET_CONTROL_BOARD -> {
+                        DeviceParseStatus.parseKeyCabinetControlBoardStatus(
+                            nodeId,
+                            index,
+                            statusData
+                        )
+
+                    }
+
+                    CanDeviceConst.DEVICE_MATERIAL_CABINET_CONTROL_BOARD -> {
+                        DeviceParseStatus.parseMaterialCabinetControlBoardStatus(
+                            nodeId,
+                            index,
+                            statusData
+                        )
+
+                    }
+                }
+            }
+        }
+
+    /**
+     * 添加设备变更监听器
+     */
+    fun addDeviceChangeListener(key: Any, listener: (List<DeviceModel>) -> Unit) {
+        deviceChangeListeners.put(key, listener)
+    }
+
+    /**
+     * 移除设备变更监听器
+     */
+    fun removeDeviceChangeListener(key: Any) {
+        deviceChangeListeners.remove(key)
+    }
+
+    /**
+     * 根据节点id获取设备
+     */
+    fun getDeviceByNodeId(nodeId: Int): List<DeviceModel> {
+        return deviceData[nodeId] ?: emptyList()
+    }
+
+    /**
+     * 根据设备类型获取设备
+     */
+    fun getDeviceByDeviceType(deviceType: Int): List<DeviceModel> {
+        return deviceData.flatMap { it.value }.filter { it.deviceType == deviceType }
+    }
+
+    /**
+     * 更新硬件数据
+     */
+    fun updateDeviceData(nodeId: Int, deviceData: List<DeviceModel>) {
+        this.deviceData[nodeId] = deviceData
+        if (deviceData.any { it.deviceChange != 0 }) {
+            deviceChangeListeners.forEach {
+                it.value.invoke(deviceData.filter { it.deviceChange != 0 })
+            }
+            deviceData.forEach { it.deviceChange = 0 }
+        }
+    }
+
+    /**
+     * 连接
+     */
+    fun connect() {
+        val canProtocol = CanProtocol()
+        ProtocolManager.register(ProtocolType.CAN, canProtocol)
+        // 绑定配置(每个节点一个 deviceId,建议 "can0@<nodeId>")
+        ProtocolManager.bindDeviceConfig(CustomCanConfig.instance)
+        canProtocol.registerConfig(CustomCanConfig.instance)
+        CanReadyPlugin.registerDeviceStatusListener(this, deviceStatusListener)
+        // 连接
+        ProtocolManager.connect(CustomCanConfig.instance.deviceId)
+    }
+
+    /**
+     * 根据设备类型获取节点id
+     */
+    fun getNodeIdByDeviceType(deviceType: Int): List<Int> {
+        return nodeMap.filter { it.value == deviceType }.map { it.key }
+    }
+
+    /**
+     * 添加节点
+     */
+    fun addNode(nodeId: Int, deviceType: Int) {
+        nodeMap.put(nodeId, deviceType)
+    }
+
+    /**
+     * 读取
+     */
+    fun readFrom(req: SdoRequest.Read, callback: (SdoResponse.ReadData?) -> Unit) {
+        scope.launch(Dispatchers.IO) {
+            runCatching {
+                ProtocolManager.getProtocol(CustomCanConfig.instance.deviceId)
+                    .send(CustomCanConfig.instance.deviceId, req.toCommMessage())
+            }.onSuccess { rsp ->
+                callback(rsp.toSdoResponse() as SdoResponse.ReadData)
+            }.onFailure {
+                logger.info("读取失败:${it}")
+                callback(null)
+            }
+        }
+    }
+
+    /**
+     * 写入到
+     */
+    fun writeTo(req: SdoRequest.Write, callback: (SdoResponse.WriteAck) -> Unit) {
+        scope.launch(Dispatchers.IO) {
+            runCatching {
+                ProtocolManager.getProtocol(CustomCanConfig.instance.deviceId)
+                    .send(CustomCanConfig.instance.deviceId, req.toCommMessage())
+            }.onSuccess { rsp ->
+                callback(rsp.toSdoResponse() as SdoResponse.WriteAck)
+            }.onFailure {
+                logger.info("写入失败:${it}")
+            }
+        }
+    }
+}

+ 48 - 0
data/src/main/java/com/grkj/data/hardware/can/CanSendDelayInterceptor.kt

@@ -0,0 +1,48 @@
+package com.grkj.data.hardware.can
+
+import com.sik.comm.core.interceptor.CommInterceptor
+import com.sik.comm.core.interceptor.InterceptorChain
+import com.sik.comm.core.model.CommMessage
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import android.os.SystemClock
+import kotlin.math.max
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+
+/**
+ * CAN 总线发送最小间隔拦截器(带协程锁)
+ */
+class CanSendDelayInterceptor(
+    private val minIntervalMs: Long = 50L  // 可配置
+) : CommInterceptor {
+
+    private val logger: Logger = LoggerFactory.getLogger(CanSendDelayInterceptor::class.java)
+
+    companion object {
+        // 用单一互斥量串行化“检查→等待→发送→更新时间”
+        private val mutex = Mutex()
+        // 用 elapsedRealtime 计时间隔,避免受系统时间调整影响
+        @Volatile var lastSendTick: Long = 0L
+    }
+
+    override suspend fun intercept(chain: InterceptorChain, original: CommMessage): CommMessage {
+        return mutex.withLock {
+            val now = SystemClock.elapsedRealtime()
+            val elapsed = now - lastSendTick
+            val waitMs = max(0L, minIntervalMs - elapsed)
+
+            if (waitMs > 0) {
+                delay(waitMs)
+            }
+
+            // 发送
+            val rsp = chain.proceed(original)
+
+            // 注意:以“发送完成时刻”作为下次基准;如果你更想“开始发送时刻”,把这行挪到 proceed() 之前即可
+            lastSendTick = SystemClock.elapsedRealtime()
+            rsp
+        }
+    }
+}

+ 35 - 0
data/src/main/java/com/grkj/data/hardware/can/CustomCanConfig.kt

@@ -0,0 +1,35 @@
+package com.grkj.data.hardware.can
+
+import com.sik.comm.core.interceptor.CommInterceptor
+import com.sik.comm.core.plugin.CommPlugin
+import com.sik.comm.impl_can.CanConfig
+
+/**
+ * 自定义can配置
+ */
+class CustomCanConfig : CanConfig(
+    deviceId = "can0@1",
+    interfaceName = "can0",
+    defaultNodeId = 1,
+    sdo = CanCommands.sdoDialect
+) {
+    companion object {
+        val instance by lazy { CustomCanConfig() }
+    }
+
+    /**
+     * 发送延迟拦截器
+     */
+    private val canSendDelayInterceptor = CanSendDelayInterceptor()
+
+    /**
+     * 状态读取插件
+     */
+    private val canReadyPlugin = CanReadyPlugin()
+
+    override val additionalInterceptors: List<CommInterceptor>
+        get() = super.additionalInterceptors + canSendDelayInterceptor
+
+    override val additionalPlugins: List<CommPlugin>
+        get() = super.additionalPlugins + canReadyPlugin
+}

+ 4 - 4
data/src/main/java/com/grkj/data/logic/IHardwareLogic.kt

@@ -233,22 +233,22 @@ interface IHardwareLogic {
     /**
      * 获取默认卡片名称统计
      */
-    fun getDefaultCardNameCount(): Int
+    fun getLastCardId(): Int
 
     /**
      * 获取默认RFID统计
      */
-    fun getDefaultRFIDNameCount(): Int
+    fun getLastRFIDId(): Int
 
     /**
      * 获取默认钥匙名称统计
      */
-    fun getDefaultKeyNameCount(): Int
+    fun getLastKeyId(): Int
 
     /**
      * 获取默认挂锁名称统计
      */
-    fun getDefaultLockNameCount(): Int
+    fun getLastLockId(): Int
 
     /**
      * 根据用户id获取工卡数据

+ 4 - 4
data/src/main/java/com/grkj/data/logic/impl/network/NetworkHardwareLogic.kt

@@ -220,19 +220,19 @@ class NetworkHardwareLogic  @Inject constructor() : BaseLogic(), IHardwareLogic
         TODO("Not yet implemented")
     }
 
-    override fun getDefaultCardNameCount(): Int {
+    override fun getLastCardId(): Int {
         TODO("Not yet implemented")
     }
 
-    override fun getDefaultRFIDNameCount(): Int {
+    override fun getLastRFIDId(): Int {
         TODO("Not yet implemented")
     }
 
-    override fun getDefaultKeyNameCount(): Int {
+    override fun getLastKeyId(): Int {
         TODO("Not yet implemented")
     }
 
-    override fun getDefaultLockNameCount(): Int {
+    override fun getLastLockId(): Int {
         TODO("Not yet implemented")
     }
 

+ 10 - 3
data/src/main/java/com/grkj/data/logic/impl/standard/DataExportLogic.kt

@@ -65,6 +65,7 @@ class DataExportLogic @Inject constructor(
             when (it) {
                 DataExportTableEnum.USER -> userRepository.getAllUserInfos()
                     .toExportSheet(
+                        skipIfEmpty = false,
                         valueMappers = mapOf(
                             "status" to {
                                 if ("1" == it) I18nManager.t("common_enable") else I18nManager.t(
@@ -75,6 +76,7 @@ class DataExportLogic @Inject constructor(
 
                 DataExportTableEnum.ROLE -> roleLogic.getRoleData()
                     .toExportSheet(
+                        skipIfEmpty = false,
                         valueMappers = mapOf(
                             "status" to {
                                 if ("0" == it) I18nManager.t("common_enable") else I18nManager.t(
@@ -86,10 +88,12 @@ class DataExportLogic @Inject constructor(
                 DataExportTableEnum.WORKSTATION -> flattenWorkstations(
                     workstationLogic.getWorkstationManageData(),
                     { it.sortedBy { it.orderNum } })
-                    .toExportSheet()
+                    .toExportSheet(
+                        skipIfEmpty = false)
 
                 DataExportTableEnum.POINT -> hardwareLogic.getAllDataExportPointData()
                     .toExportSheet(
+                        skipIfEmpty = false,
                         valueMappers = mapOf(
                             "powerType" to {
                                 I18nManager.t(it.toString().lowercase())
@@ -97,10 +101,12 @@ class DataExportLogic @Inject constructor(
                         ))
 
                 DataExportTableEnum.SOP -> sopLogic.getDataExportSopData()
-                    .toExportSheet()
+                    .toExportSheet(
+                        skipIfEmpty = false)
 
                 DataExportTableEnum.JOB -> jobTicketLogic.getAllJobData()
                     .toExportSheet(
+                        skipIfEmpty = false,
                         valueMappers = mapOf(
                             "ticketStatus" to {
                                 JobTicketStatusEnum.getTicketStatusStr(it.toString())
@@ -117,6 +123,7 @@ class DataExportLogic @Inject constructor(
 
                 DataExportTableEnum.LOCKED_POINT -> jobTicketLogic.getAllLockedPointsExportData()
                     .toExportSheet(
+                        skipIfEmpty = false,
                         valueMappers = mapOf(
                             "ticketStatus" to {
                                 JobTicketStatusEnum.getTicketStatusStr(it.toString())
@@ -142,7 +149,7 @@ class DataExportLogic @Inject constructor(
             // 给名字加缩进(可按需改成 "│  " 风格)
             val prefix = buildString {
                 repeat(level) { append("  ") }              // 两个空格一层
-                if (level > 0) append("├─ ")
+                if (level > 0) append("")
             }
             node.workstationName = prefix + node.workstationName
             out += node

+ 11 - 11
data/src/main/java/com/grkj/data/logic/impl/standard/HardwareLogic.kt

@@ -308,7 +308,7 @@ class HardwareLogic @Inject constructor(
 
     override fun saveKeyInfo(keyNfc: String, keyMacAddress: String) {
         val isKey = IsKey()
-        val defaultKeyCodeSize = hardwareDao.getDefaultKeyNameCount()
+        val defaultKeyCodeSize = hardwareDao.getLastKeyId()
         hardwareDao.getAllKeyData().size
         isKey.keyCode = "KEY_${defaultKeyCodeSize + 1}"
         isKey.keyNfc = keyNfc
@@ -324,7 +324,7 @@ class HardwareLogic @Inject constructor(
 
     override fun saveLockInfo(lockNfc: String) {
         val isLock = IsLock()
-        var defaultLockCodeSize = hardwareDao.getDefaultLockNameCount()
+        var defaultLockCodeSize = hardwareDao.getLastLockId()
         isLock.lockCode = "LOCK_${defaultLockCodeSize + 1}"
         isLock.lockNfc = lockNfc
         isLock.exStatus =
@@ -464,20 +464,20 @@ class HardwareLogic @Inject constructor(
         hardwareDao.removeRfidTokenData()
     }
 
-    override fun getDefaultCardNameCount(): Int {
-        return hardwareDao.getDefaultCardNameCount()
+    override fun getLastCardId(): Int {
+        return hardwareDao.getLastCardId()
     }
 
-    override fun getDefaultRFIDNameCount(): Int {
-        return hardwareDao.getDefaultRfidTokenNameCount()
+    override fun getLastRFIDId(): Int {
+        return hardwareDao.getLastRFIDId()
     }
 
-    override fun getDefaultKeyNameCount(): Int {
-        return hardwareDao.getDefaultKeyNameCount()
+    override fun getLastKeyId(): Int {
+        return hardwareDao.getLastKeyId()
     }
 
-    override fun getDefaultLockNameCount(): Int {
-        return hardwareDao.getDefaultLockNameCount()
+    override fun getLastLockId(): Int {
+        return hardwareDao.getLastLockId()
     }
 
     override fun getJobCardDataByUserId(userId: Long?): List<IsJobCard> {
@@ -502,7 +502,7 @@ class HardwareLogic @Inject constructor(
         } else {
             logger.info("没有检测到工卡,重新创建工卡")
             jobCardData = IsJobCard()
-            val defaultCardCodeSize = hardwareDao.getDefaultCardNameCount()
+            val defaultCardCodeSize = hardwareDao.getLastCardId()
             jobCardData.cardCode = "CARD_${defaultCardCodeSize + 1}"
             jobCardData.userId = userId
             jobCardData.cardNfc = rfidNo

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

@@ -32,11 +32,7 @@ class SysMenuLogic @Inject constructor(val sysMenuDao: SysMenuDao, val roleDao:
                     //超管权限
                     RoleEnum.ADMIN.roleKey -> {
                         val roleMenuData = mutableListOf<SysRoleMenu>().apply {
-                            for (permissionsEnum in RoleFunctionalPermissionsEnum.except(
-                                RoleFunctionalPermissionsEnum.CREATE_SOP_JOB,
-                                RoleFunctionalPermissionsEnum.CREATE_SOP,
-                                RoleFunctionalPermissionsEnum.CREATE_JOB
-                            )) {
+                            for (permissionsEnum in RoleFunctionalPermissionsEnum.except()) {
                                 sysMenuData.find { it.perms == permissionsEnum.functionalPermission }?.menuId?.let { menuId ->
                                     val sysRoleMenu = SysRoleMenu()
                                     sysRoleMenu.roleId = roleData.roleId

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

@@ -452,6 +452,7 @@ class UserLogic @Inject constructor(
         val userId = userRepository.insert(sysUserDo)
         val roleId = roleDao.getRoleDataByRoleKey(RoleEnum.ADMIN.roleKey)
         logger.info("超管角色id:$roleId")
+        logger.info("用户id:$userId")
         val userRole = SysUserRole()
         userRole.userId = userId
         userRole.roleId = roleId ?: 0

+ 88 - 50
data/src/main/java/com/grkj/data/utils/ExcelExporter.kt

@@ -42,14 +42,27 @@ data class ExportSheet<T : Any>(
     val sheetName: String? = null,
     val data: List<T>,
     val customHeaders: LinkedHashMap<String, String>? = null,
-    // 可选:按字段名进行值转换(先转再格式化)
-    val valueMappers: Map<String, (Any?) -> Any?> = emptyMap()
+    val valueMappers: Map<String, (Any?) -> Any?> = emptyMap(),
+    val modelClass: KClass<out Any>? = null,         // 空数据时也能拿到列定义
+    val skipIfEmpty: Boolean = true,                 // 默认:跳过空 sheet
+    val emptyPlaceholder: String = "— no data —"     // 不跳过时的占位文本
 )
 
+
 inline fun <reified T : Any> List<T>.toExportSheet(
     sheetNameOverride: String? = null,
-    valueMappers: Map<String, (Any?) -> Any?> = emptyMap()
-): ExportSheet<T> = ExportSheet(sheetNameOverride, this, null, valueMappers)
+    valueMappers: Map<String, (Any?) -> Any?> = emptyMap(),
+    skipIfEmpty: Boolean = true,
+    emptyPlaceholder: String = "— no data —"
+): ExportSheet<T> = ExportSheet(
+    sheetNameOverride,
+    this,
+    null,
+    valueMappers,
+    T::class,
+    skipIfEmpty,
+    emptyPlaceholder
+)
 
 // ======================= 导出器(Kotlin 反射版) =======================
 object ExcelExporter {
@@ -89,17 +102,36 @@ object ExcelExporter {
         val sheetName = decideSheetName(sheetSpec)
         val sheet = wb.createSheet(safeSheetName(sheetName))
 
-        val kClass: KClass<out Any>? = data.firstOrNull()?.let { it::class }
+        // 即使 data 为空,也尝试用 modelClass 推导列
+        val kClass: KClass<out Any>? = sheetSpec.modelClass ?: data.firstOrNull()?.let { it::class }
 
-        val (orderedProps, headers, widths, formatters) =
+        // 列定义(表头/宽度/格式器)
+        var (orderedProps, headers, widths, formatters) =
             if (sheetSpec.customHeaders != null) {
                 pickByCustomHeaders(kClass, sheetSpec.customHeaders, styles.locale)
             } else {
                 buildColumnsFromAnnotationsOrProps(kClass, styles.locale)
             }
 
-        // Header
-        run {
+        // 如果确实拿不到任何列定义,但 data 又是空 → 根据策略处理
+        if (headers.isEmpty() && data.isEmpty()) {
+            if (sheetSpec.skipIfEmpty) {
+                // 完全跳过这个 sheet
+                // 注意:我们已经创建了 sheet,这里删除它再返回
+                val idx = wb.getSheetIndex(sheet)
+                wb.removeSheetAt(idx)
+                return
+            } else {
+                // 写一个占位列
+                headers = listOf(I18nManager.t("No columns"))
+                widths = listOf(-1)
+                formatters = listOf({ v: Any? -> v })
+                orderedProps = emptyList()
+            }
+        }
+
+        // 写表头
+        if (headers.isNotEmpty()) {
             val row = sheet.createRow(0)
             headers.forEachIndexed { idx, title ->
                 val cell = row.createCell(idx)
@@ -108,40 +140,47 @@ object ExcelExporter {
             }
         }
 
-        // Rows
-        data.forEachIndexed { index, item ->
-            val row = sheet.createRow(index + 1)
-            orderedProps.forEachIndexed { c, p ->
-                val cell = row.createCell(c)
-                val raw = getPropertyValue(p, item)
-                val mapped = sheetSpec.valueMappers[p.name]?.invoke(raw) ?: raw
-                val formatted = formatters[c].invoke(mapped)
-                setCellValueCompat(cell, formatted, styles)
+        // 写数据 or 占位
+        if (data.isNotEmpty()) {
+            data.forEachIndexed { index, item ->
+                val row = sheet.createRow(index + 1)
+                orderedProps.forEachIndexed { c, p ->
+                    val cell = row.createCell(c)
+                    val raw = getPropertyValue(p, item)
+                    val mapped = sheetSpec.valueMappers[p.name]?.invoke(raw) ?: raw
+                    val formatted = formatters[c].invoke(mapped)
+                    setCellValueCompat(cell, formatted, styles)
+                }
             }
+        } else if (!sheetSpec.skipIfEmpty) {
+            // 占位一行
+            val row = sheet.createRow(1)
+            val cell = row.createCell(0)
+            cell.setCellValue(I18nManager.t(sheetSpec.emptyPlaceholder))
+            cell.setCellStyle(styles.body)
         }
 
-        // Widths
+        // 列宽估算(仅当有列时)
         val colCount = headers.size
-        for (i in 0 until colCount) {
-            val w = widths[i]
-            if (w > 0) {
-                sheet.setColumnWidth(i, w * 256)
-                continue
-            }
-            if (USE_AUTO_SIZE) {
-                // Android 下不建议开
-                // sheet.autoSizeColumn(i)
-                // sheet.setColumnWidth(i, max(sheet.getColumnWidth(i), 12 * 256))
-            } else {
-                val maxChars = estimateColumnChars(sheet, i, SCAN_ROWS_FOR_WIDTH)
-                val finalChars = max(12, minOf(maxChars + 2, 60))
-                sheet.setColumnWidth(i, finalChars * 256)
+        if (colCount > 0) {
+            for (i in 0 until colCount) {
+                val w = widths[i]
+                if (w > 0) {
+                    sheet.setColumnWidth(i, w * 256)
+                } else if (!USE_AUTO_SIZE) {
+                    val maxChars = estimateColumnChars(sheet, i, SCAN_ROWS_FOR_WIDTH)
+                    val finalChars = max(12, minOf(maxChars + 2, 60))
+                    sheet.setColumnWidth(i, finalChars * 256)
+                } else {
+                    // Android 不推荐,但保留逻辑
+                    // sheet.autoSizeColumn(i)
+                    // sheet.setColumnWidth(i, max(sheet.getColumnWidth(i), 12 * 256))
+                }
             }
+            // 冻结表头 & 筛选(仅当有表头列时)
+            sheet.createFreezePane(0, 1)
+            sheet.setAutoFilter(org.apache.poi.ss.util.CellRangeAddress(0, 0, 0, colCount - 1))
         }
-
-        // 冻结表头 & 筛选
-        sheet.createFreezePane(0, 1)
-        sheet.setAutoFilter(org.apache.poi.ss.util.CellRangeAddress(0, 0, 0, colCount - 1))
     }
 
     /** 扫描前 N 行单元格内容估算列宽(ASCII 算 1,中文等算 2) */
@@ -156,18 +195,14 @@ object ExcelExporter {
             val row = sheet.getRow(r) ?: continue
             val cell = row.getCell(col) ?: continue
             val text = when (cell.cellType) {
-                CellType.STRING.code -> cell.stringCellValue
+                CellType.STRING.code  -> cell.stringCellValue
                 CellType.NUMERIC.code -> {
                     if (org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell))
                         cell.dateCellValue?.toString() ?: ""
                     else cell.numericCellValue.toString()
                 }
                 CellType.BOOLEAN.code -> cell.booleanCellValue.toString()
-                CellType.FORMULA.code -> try {
-                    cell.stringCellValue
-                } catch (_: Throwable) {
-                    cell.cellFormula
-                }
+                CellType.FORMULA.code -> try { cell.stringCellValue } catch (_: Throwable) { cell.cellFormula }
                 else -> ""
             }
             val len = visualLength(text)
@@ -223,7 +258,12 @@ object ExcelExporter {
         headersMap: LinkedHashMap<String, String>,
         locale: Locale
     ): Quad<List<KProperty1<out Any, *>>, List<String>, List<Int>, List<(Any?) -> Any?>> {
-        if (kClass == null) return Quad(emptyList(), emptyList(), emptyList(), emptyList())
+        if (kClass == null) {
+            val headers = headersMap.values.map { I18nManager.t(it) }
+            val widths = List(headers.size) { -1 }
+            val fmts = List(headers.size) { { v: Any? -> v } }
+            return Quad(emptyList(), headers, widths, fmts)
+        }
 
         val all = getAllExportableProps(kClass)
         val byName = all.associateBy { it.name }
@@ -234,17 +274,15 @@ object ExcelExporter {
         val formatters = ArrayList<(Any?) -> Any?>(headersMap.size)
 
         headersMap.forEach { (propName, headerText) ->
-            val p = byName[propName]
-                ?: error("Property '$propName' not found or not exportable.")
+            val p = byName[propName] ?: error("Property '$propName' not found or not exportable.")
             selected += p
-            headers += I18nManager.t(headerText)
+            headers  += I18nManager.t(headerText)
 
             val ann = p.findExcelColumn()
             widths += (ann?.width ?: -1)
             val pattern = ann?.datePattern ?: "yyyy-MM-dd HH:mm:ss"
             formatters += buildFormatterForProp(p, pattern, locale)
         }
-
         return Quad(selected, headers, widths, formatters)
     }
 
@@ -352,9 +390,9 @@ object ExcelExporter {
     // ----------- 表名 & 注解读取 -----------
     private fun decideSheetName(spec: ExportSheet<out Any>): String {
         if (!spec.sheetName.isNullOrBlank()) return I18nManager.t(spec.sheetName)
-        val first = spec.data.firstOrNull() ?: return I18nManager.t("Sheet1")
-        val ann = first::class.findAnnotation<ExcelSheet>()
-        val raw = ann?.name?.takeIf { it.isNotBlank() } ?: first::class.simpleName ?: "Sheet1"
+        val cls = spec.modelClass ?: spec.data.firstOrNull()?.let { it::class }
+        val raw = cls?.findAnnotation<ExcelSheet>()?.name?.takeIf { it.isNotBlank() }
+            ?: cls?.simpleName ?: "Sheet1"
         return I18nManager.t(raw)
     }
 

+ 3 - 1
gradle/libs.versions.toml

@@ -11,7 +11,7 @@ material = "1.10.0"
 activity = "1.8.0"
 constraintlayout = "2.1.4"
 jetbrainsKotlinJvm = "2.0.21"
-sikextension = "1.1.66"
+sikextension = "1.1.67"
 sikcamera = "1.0.11"
 sikcronjob = "1.0.3"
 sikfontmanager = "1.0.2"
@@ -24,6 +24,7 @@ ksp = "2.1.10-1.0.31"
 nav_version = "2.9.0"
 kotlin_serialization_json = "1.7.3"
 fastble = "1.4.2"
+sikcomm = "1.0.13"
 
 [libraries]
 androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -47,6 +48,7 @@ sik-extension-sensors = { group = "com.github.SilverIceKey.SIKExtension", name =
 sik-camera = { group = "com.github.SilverIceKey", name = "SIKCamera", version.ref = "sikcamera" }
 sik-cronjob = { group = "com.github.SilverIceKey", name = "SIKCronJob", version.ref = "sikcronjob" }
 sik-fontmanager = { group = "com.github.SilverIceKey", name = "SIKFontManager", version.ref = "sikfontmanager" }
+sik-comm = { group = "com.github.SilverIceKey", name = "SIKComm", version.ref = "sikcomm" }
 
 brv = { group = "com.github.liangjingkanji", name = "brv", version.ref = "brv" }
 dialogx = { group = "com.github.kongzue.DialogX", name = "DialogX", version.ref = "dialogx" }

+ 1 - 0
shared/build.gradle.kts

@@ -56,6 +56,7 @@ dependencies {
     api(libs.sik.extension.core)
     api(libs.sik.extension.encrypt)
     api(libs.sik.extension.image)
+    api(libs.sik.comm)
     api("org.mindrot:jbcrypt:0.4")
     implementation("com.machinezoo.sourceafis:sourceafis:3.15.0")
     implementation("com.google.dagger:hilt-android:2.56.2")

+ 1 - 0
ui-base/src/main/java/com/grkj/ui_base/utils/ble/BleConnectionManager.kt

@@ -760,6 +760,7 @@ object BleConnectionManager {
      */
     fun scanOnlineKeyLockMac(existsMac: List<String>, callback: (String?) -> Unit) {
         BLEScanner.stopScan()
+        BLEScanner.resetExistingDevices()
         BLEScanner.startScan(object : IBluetoothScanCallback {
             override fun onDeviceFound(device: BluetoothDevice) {
                 logger.info(" |:${device.address}")