Kaynağa Gözat

Merge remote-tracking branch 'origin/dev'

liaoxg 3 ay önce
ebeveyn
işleme
a9c05cebb7
48 değiştirilmiş dosya ile 3166 ekleme ve 354 silme
  1. 0 0
      code/README.md
  2. 9 0
      code/ajyApp/App.vue
  3. 4 1
      code/ajyApp/main.js
  4. 360 0
      code/ajyApp/package-lock.json
  5. 14 0
      code/ajyApp/package.json
  6. 4 1
      code/ajyApp/pages.json
  7. 30 23
      code/ajyApp/pages/ble-demo/ble-demo.nvue
  8. 114 36
      code/ajyApp/pages/device/detail/detail.nvue
  9. 30 16
      code/ajyApp/pages/device/deviceInfo/deviceInfo.nvue
  10. 118 146
      code/ajyApp/pages/device/deviceInfo/mixins/ble-mixin.js
  11. 26 0
      code/ajyApp/pages/device/list/list.nvue
  12. 67 67
      code/ajyApp/pages/device/search/search.nvue
  13. 814 0
      code/ajyApp/stores/ble.js
  14. 3 3
      code/ajyApp/utils/api/user.js
  15. 2 0
      code/ajyApp/utils/ble/BleManager.js
  16. 6 16
      code/ajyApp/utils/ble/index.js
  17. 52 2
      code/ajyApp/utils/ble/permission.js
  18. 48 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/AppDeviceController.java
  19. 52 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/AppGroupController.java
  20. 13 39
      code/backend/src/main/java/com/aijiuyi/admin/controller/AppProfileController.java
  21. 16 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppDeviceBindDTO.java
  22. 22 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppDeviceSelectDTO.java
  23. 62 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppGroupMemberDTO.java
  24. 52 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppProfileDTO.java
  25. 3 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/DeviceQueryDTO.java
  26. 3 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/DeviceSaveDTO.java
  27. 3 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/Device.java
  28. 41 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/DeviceGroup.java
  29. 50 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/DeviceGroupMember.java
  30. 12 0
      code/backend/src/main/java/com/aijiuyi/admin/mapper/DeviceGroupMapper.java
  31. 12 0
      code/backend/src/main/java/com/aijiuyi/admin/mapper/DeviceGroupMemberMapper.java
  32. 31 0
      code/backend/src/main/java/com/aijiuyi/admin/service/AppDeviceGroupService.java
  33. 962 0
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/AppDeviceGroupServiceImpl.java
  34. 12 0
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/DeviceServiceImpl.java
  35. 4 0
      code/backend/src/main/resources/mapper/DeviceGroupMapper.xml
  36. 4 0
      code/backend/src/main/resources/mapper/DeviceGroupMemberMapper.xml
  37. 9 0
      code/backend/src/main/resources/mapper/DeviceMapper.xml
  38. 16 0
      code/backend/src/main/resources/rebel.xml
  39. 31 0
      code/backend/src/main/resources/sql/migration_device_group.sql
  40. 1 0
      code/frontend/src/components/ExcelImport.vue
  41. 47 0
      code/frontend/src/views/device/index.vue
  42. 1 1
      code/frontend/src/views/device/users.vue
  43. 1 0
      code/frontend/src/views/log/index.vue
  44. 1 1
      code/frontend/src/views/plan/index.vue
  45. 1 0
      code/frontend/src/views/user/app-user/index.vue
  46. 1 1
      code/frontend/src/views/user/device/index.vue
  47. 1 1
      code/frontend/src/views/user/plan/index.vue
  48. 1 0
      code/frontend/src/views/user/profile/index.vue

+ 0 - 0
code/README.md


+ 9 - 0
code/ajyApp/App.vue

@@ -1,8 +1,17 @@
 <script>
+	import { useBleStore } from '@/stores/ble'
+
 	export default {
+		globalData: {
+			bleManager: null
+		},
 		onLaunch: function() {
 			console.log('App Launch')
 			this.checkLoginState()
+			// 在 App.vue 上下文中绑定 BLE 系统监听器
+			// 确保回调关联的 taskCenter 永不销毁,避免页面切换后报错
+			const bleStore = useBleStore()
+			bleStore.bindGlobalListeners()
 		},
 		onShow: function() {
 			console.log('App Show')

+ 4 - 1
code/ajyApp/main.js

@@ -15,11 +15,14 @@ app.$mount()
 
 // #ifdef VUE3
 import { createSSRApp } from 'vue'
+import * as Pinia from 'pinia'
 export function createApp() {
   const app = createSSRApp(App)
+  app.use(Pinia.createPinia())
   app.mixin(toastMixin)
   return {
-    app
+    app,
+    Pinia // 此处必须将 Pinia 返回
   }
 }
 // #endif

+ 360 - 0
code/ajyApp/package-lock.json

@@ -0,0 +1,360 @@
+{
+  "name": "ajyapp",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "ajyapp",
+      "version": "1.0.0",
+      "license": "ISC",
+      "dependencies": {
+        "pinia": "^2.1.7"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+      "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+      "license": "MIT",
+      "peer": true,
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+      "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+      "license": "MIT",
+      "peer": true,
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+      "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@babel/types": "^7.29.7"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+      "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.29.7",
+        "@babel/helper-validator-identifier": "^7.29.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "license": "MIT",
+      "peer": true
+    },
+    "node_modules/@vue/compiler-core": {
+      "version": "3.5.35",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz",
+      "integrity": "sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@babel/parser": "^7.29.3",
+        "@vue/shared": "3.5.35",
+        "entities": "^7.0.1",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.5.35",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.35.tgz",
+      "integrity": "sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@vue/compiler-core": "3.5.35",
+        "@vue/shared": "3.5.35"
+      }
+    },
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.5.35",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.35.tgz",
+      "integrity": "sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@babel/parser": "^7.29.3",
+        "@vue/compiler-core": "3.5.35",
+        "@vue/compiler-dom": "3.5.35",
+        "@vue/compiler-ssr": "3.5.35",
+        "@vue/shared": "3.5.35",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.21",
+        "postcss": "^8.5.15",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.5.35",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.35.tgz",
+      "integrity": "sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.35",
+        "@vue/shared": "3.5.35"
+      }
+    },
+    "node_modules/@vue/devtools-api": {
+      "version": "6.6.4",
+      "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+      "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+      "license": "MIT"
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.5.35",
+      "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.35.tgz",
+      "integrity": "sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@vue/shared": "3.5.35"
+      }
+    },
+    "node_modules/@vue/runtime-core": {
+      "version": "3.5.35",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.35.tgz",
+      "integrity": "sha512-A/xFNX9loIcWDygeQuNCfKuh0CoYBzxhqEMNah5TSFg9Z53DrFYEN2qi5CU9necjM1OWYegYREUTHmXTmhfXtg==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@vue/reactivity": "3.5.35",
+        "@vue/shared": "3.5.35"
+      }
+    },
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.5.35",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.35.tgz",
+      "integrity": "sha512-odrJ1C391dbGnyDRh8U+rnP7J2amIEzfmRk5vXy7xi3aZhEXofTvpi0T4HJb6jlNqQZTNPR5MPHSB3RHNkIORA==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@vue/reactivity": "3.5.35",
+        "@vue/runtime-core": "3.5.35",
+        "@vue/shared": "3.5.35",
+        "csstype": "^3.2.3"
+      }
+    },
+    "node_modules/@vue/server-renderer": {
+      "version": "3.5.35",
+      "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.35.tgz",
+      "integrity": "sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@vue/compiler-ssr": "3.5.35",
+        "@vue/shared": "3.5.35"
+      },
+      "peerDependencies": {
+        "vue": "3.5.35"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.5.35",
+      "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.35.tgz",
+      "integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==",
+      "license": "MIT",
+      "peer": true
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "license": "MIT",
+      "peer": true
+    },
+    "node_modules/entities": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+      "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+      "license": "BSD-2-Clause",
+      "peer": true,
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+      "license": "MIT",
+      "peer": true
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.12",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+      "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "peer": true,
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC",
+      "peer": true
+    },
+    "node_modules/pinia": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz",
+      "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-api": "^6.6.3",
+        "vue-demi": "^0.14.10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.4.4",
+        "vue": "^2.7.0 || ^3.5.11"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.15",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+      "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "nanoid": "^3.3.12",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "license": "BSD-3-Clause",
+      "peer": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/vue": {
+      "version": "3.5.35",
+      "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.35.tgz",
+      "integrity": "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.35",
+        "@vue/compiler-sfc": "3.5.35",
+        "@vue/runtime-dom": "3.5.35",
+        "@vue/server-renderer": "3.5.35",
+        "@vue/shared": "3.5.35"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-demi": {
+      "version": "0.14.10",
+      "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
+      "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "vue-demi-fix": "bin/vue-demi-fix.js",
+        "vue-demi-switch": "bin/vue-demi-switch.js"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "@vue/composition-api": "^1.0.0-rc.1",
+        "vue": "^3.0.0-0 || ^2.6.0"
+      },
+      "peerDependenciesMeta": {
+        "@vue/composition-api": {
+          "optional": true
+        }
+      }
+    }
+  }
+}

+ 14 - 0
code/ajyApp/package.json

@@ -0,0 +1,14 @@
+{
+  "name": "ajyapp",
+  "version": "1.0.0",
+  "description": "",
+  "main": "main.js",
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "keywords": [],
+  "author": "",
+  "license": "ISC",
+  "dependencies": {
+  }
+}

+ 4 - 1
code/ajyApp/pages.json

@@ -89,7 +89,10 @@
 			"path": "pages/device/search/search",
 			"style": {
 				"navigationStyle": "custom",
-				"navigationBarTitleText": ""
+				"navigationBarTitleText": "",
+				"app-plus": {
+					"backgroundColor": "#cee7ec"
+				}
 			}
 		},
 		{

+ 30 - 23
code/ajyApp/pages/ble-demo/ble-demo.nvue

@@ -28,10 +28,11 @@
 </template>
 
 <script>
-import bleManager, {
-	BLE_STATE, MODE, SUB_MODE, TEMPERATURE,
-	ensureBlePrerequisite, openLocationSettings
-} from '@/utils/ble'
+import { useBleStore } from '@/stores/ble'
+import {
+	BLE_STATE, MODE, SUB_MODE, TEMPERATURE
+} from '@/utils/ble/constants.js'
+import { ensureBlePrerequisite, openLocationSettings } from '@/utils/ble/permission.js'
 import ayToast from '@/components/ay-toast/ay-toast.nvue'
 
 export default {
@@ -45,20 +46,26 @@ export default {
 			logs: []
 		}
 	},
+	computed: {
+		bleStore() {
+			return useBleStore()
+		}
+	},
 	onLoad() {
-		bleManager.configure({ debug: true })
+		this.bleStore.configure({ debug: true })
 
-		this._unbinders = [
-			bleManager.on('state', s => { this.state = s }),
-			bleManager.on('connected', d => { this.device = d; this.log('已连接 ' + d.deviceId) }),
-			bleManager.on('disconnected', e => { this.log('已断开 ' + (e.reason || '')) }),
-			bleManager.on('reconnected', d => { this.log('自动重连成功') }),
-			bleManager.on('report:GROUP_1', d => { this.log('参数组1 ' + JSON.stringify(d)) }),
-			bleManager.on('report:GROUP_2', d => { this.log('参数组2 ' + JSON.stringify(d)) })
-		]
-	},
-	onUnload() {
-		this._unbinders.forEach(fn => fn && fn())
+		// 监听store状态变化
+		this.$watch(() => this.bleStore.bleState, (s) => {
+			this.state = s
+		})
+		this.$watch(() => this.bleStore.linked, (linked) => {
+			if (linked) {
+				this.device = this.bleStore.device
+				this.log('已连接 ' + (this.bleStore.device && this.bleStore.device.deviceId || ''))
+			} else if (this.device) {
+				this.log('已断开')
+			}
+		})
 	},
 	methods: {
 		log(msg) {
@@ -69,8 +76,8 @@ export default {
 		async onScanConnect() {
 			try {
 				await ensureBlePrerequisite()
-				await bleManager.init()
-				const dev = await bleManager.scanAndConnect({ timeout: 8000 })
+				await this.bleStore.init()
+				const dev = await this.bleStore.scanAndConnect({ timeout: 8000 })
 				this.device = dev
 			} catch (e) {
 				this.log('失败: ' + e.message)
@@ -84,18 +91,18 @@ export default {
 			}
 		},
 		async onDisconnect() {
-			await bleManager.disconnect()
+			await this.bleStore.disconnect()
 			this.device = null
 		},
-		async onPowerOn()  { await this._safe(() => bleManager.powerOn()) },
-		async onPowerOff() { await this._safe(() => bleManager.powerOff()) },
+		async onPowerOn()  { await this._safe(() => this.bleStore.powerOn()) },
+		async onPowerOff() { await this._safe(() => this.bleStore.powerOff()) },
 		async onStart() {
-			await this._safe(() => bleManager.startMoxi({
+			await this._safe(() => this.bleStore.startMoxi({
 				mode: MODE.BASIC, subMode: SUB_MODE.SUB_1,
 				temperature: TEMPERATURE.MID, duration: 30
 			}))
 		},
-		async onPause() { await this._safe(() => bleManager.pauseMoxi()) },
+		async onPause() { await this._safe(() => this.bleStore.pauseMoxi()) },
 
 		async _safe(fn) {
 			try { await fn(); this.log('指令已下发') }

+ 114 - 36
code/ajyApp/pages/device/detail/detail.nvue

@@ -13,8 +13,8 @@
 			<view class="connect-status">
 				<view class="status-dot" :style="{backgroundColor: connected ? '#389588' : '#CCCCCC'}"></view>
 				<text class="status-text">{{connected ? '已连接' : '未连接'}}</text>
-				<text class="connect-btn" v-if="!connected && !connecting" @click="connectDevice">连接设备</text>
-				<text class="connect-btn" v-if="connecting">连接中...</text>
+			<!-- 	<text class="connect-btn" v-if="!connected && !connecting" @click="connectDevice">连接设备</text>
+				<text class="connect-btn" v-if="connecting">连接中...</text> -->
 			</view>
 
 			<text class="txt">通用设置</text>
@@ -28,7 +28,15 @@
 			</view>
 			<view class="menuitem">
 				<text>信号强度</text>
-				<text class="gray">{{rssiText}}</text>
+				<view class="signal-value-wrap">
+					<view class="signal-wrap">
+						<view class="signal-bar signal-bar1" :class="{'signal-active': signalLevel >= 1}"></view>
+						<view class="signal-bar signal-bar2" :class="{'signal-active': signalLevel >= 2}"></view>
+						<view class="signal-bar signal-bar3" :class="{'signal-active': signalLevel >= 3}"></view>
+						<view class="signal-bar signal-bar4" :class="{'signal-active': signalLevel >= 4}"></view>
+					</view>
+					<text class="gray" style="padding-right: 0;">{{rssiText}}</text>
+				</view>
 			</view>
 			<view class="menuitem">
 				<text>固件版本</text>
@@ -58,7 +66,8 @@
 </template>
 
 <script>
-import bleManager, { BLE_STATE } from '@/utils/ble'
+import { useBleStore } from '@/stores/ble'
+import { BLE_STATE } from '@/utils/ble/constants.js'
 import ayToast from '@/components/ay-toast/ay-toast.nvue'
 
 export default {
@@ -79,42 +88,68 @@ export default {
 	},
 	computed: {
 		rssiText() {
+			console.log("信号强度",this.rssi)
 			if (!this.rssi) return '未知'
 			const val = Number(this.rssi)
-			if (val >= -50) return '极强 (' + this.rssi + 'dBm)'
-			if (val >= -65) return '强 (' + this.rssi + 'dBm)'
-			if (val >= -80) return '中 (' + this.rssi + 'dBm)'
-			return '弱 (' + this.rssi + 'dBm)'
+			if (val >= -50) return '非常强'
+			if (val >= -65) return '强'
+			if (val >= -80) return '中'
+			
+			return '弱'
+		},
+		signalLevel() {
+			if (!this.rssi) return 0
+			const val = Number(this.rssi)
+			if (val >= -50) return 4
+			if (val >= -65) return 3
+			if (val >= -80) return 2
+			if (val >= -95) return 1
+			return 1
 		}
 	},
 	onLoad(options) {
 		const sysInfo = uni.getSystemInfoSync()
 		this.statusBarHeight = sysInfo.statusBarHeight || 44
-		this.name = options.name || ''
-		this.deviceId = options.deviceId || ''
-		this.rssi = options.rssi || ''
+		this.name = decodeURIComponent(options.name || '')
+		this.deviceId = decodeURIComponent(options.deviceId || '')
+		this.rssi = decodeURIComponent(options.rssi || '')
 	},
-	onShow() {
-		// 监听BLE状态
-		this._unbinders = [
-			bleManager.on('state', (s) => {
+	async onShow() {
+		const bleStore = useBleStore()
+		// 用bleState精确判断连接状态
+		this.connected = (bleStore.bleState === BLE_STATE.READY_COMM)
+		console.log("连接状态", this.connected, "bleState:", bleStore.bleState)
+		
+		// 已连接时获取实时RSSI(使用store中实际连接的deviceId)
+		const connectedId = bleStore.device && bleStore.device.deviceId
+		if (this.connected && connectedId) {
+			console.log("获取RSSI使用deviceId:", connectedId, "页面deviceId:", this.deviceId)
+			uni.getBLEDeviceRSSI({
+				deviceId: connectedId,
+				success: (res) => {
+					console.log("实时信号强度", res.RSSI)
+					this.rssi = res.RSSI
+				},
+				fail: (err) => {
+					console.log("获取RSSI失败", err)
+					// 连接实际已断开,同步状态
+					if (err.code === 10004) {
+						this.connected = false
+					}
+				}
+			})
+		}
+		
+		// 监听store状态变化(使用$watch)
+		this._stopWatch = this.$watch(
+			() => bleStore.bleState,
+			(s) => {
 				this.connected = (s === BLE_STATE.READY_COMM)
 				if (s === BLE_STATE.DISCONNECTED) {
 					this.connecting = false
 				}
-			}),
-			bleManager.on('disconnected', () => {
-				this.connected = false
-				this.connecting = false
-			}),
-			bleManager.on('report:GROUP_2', (data) => {
-				if (data && data.firmwareVersion) {
-					this.firmwareVersion = 'V' + data.firmwareVersion
-				}
-			})
-		]
-		// 检查当前连接状态
-		this.connected = bleManager.isConnected
+			}
+		)
 	},
 	onHide() {
 		this._cleanListeners()
@@ -127,17 +162,18 @@ export default {
 			uni.navigateBack()
 		},
 		_cleanListeners() {
-			if (this._unbinders && this._unbinders.length) {
-				this._unbinders.forEach(fn => fn && fn())
-				this._unbinders = []
+			if (this._stopWatch) {
+				this._stopWatch()
+				this._stopWatch = null
 			}
 		},
 		async connectDevice() {
 			if (this.connecting || this.connected) return
 			this.connecting = true
+			const bleStore = useBleStore()
 			try {
-				await bleManager.init()
-				await bleManager.connect(this.deviceId)
+				await bleStore.init()
+				await bleStore.connectDevice(this.deviceId)
 				this.connected = true
 				this.$refs.ayToast.success('连接成功')
 			} catch (e) {
@@ -147,8 +183,9 @@ export default {
 			}
 		},
 		async disconnectDevice() {
+			const bleStore = useBleStore()
 			try {
-				await bleManager.disconnect()
+				await bleStore.disconnect()
 				this.connected = false
 				this.$refs.ayToast.success('已断开连接')
 			} catch (e) {
@@ -156,6 +193,7 @@ export default {
 			}
 		},
 		delDevice() {
+			const bleStore = useBleStore()
 			uni.showModal({
 				title: '提示',
 				content: '此操作将删除该设备,是否继续?',
@@ -163,9 +201,8 @@ export default {
 				confirmText: '继续',
 				success: (res) => {
 					if (res.confirm) {
-						// 断开连接
 						if (this.connected) {
-							bleManager.disconnect().catch(() => {})
+							bleStore.disconnect().catch(() => {})
 						}
 						// 从本地存储中移除
 						try {
@@ -312,6 +349,47 @@ export default {
 	font-size: 22rpx;
 }
 
+.signal-value-wrap {
+	flex-direction: row;
+	align-items: center;
+	padding-right: 20rpx;
+}
+
+.signal-wrap {
+	flex-direction: row;
+	align-items: flex-end;
+	margin-right: 12rpx;
+	height: 38rpx;
+	padding-bottom: 4rpx;
+}
+
+.signal-bar {
+	width: 8rpx;
+	margin-left: 4rpx;
+	border-radius: 4rpx;
+	background-color: #ddd;
+}
+
+.signal-bar1 {
+	height: 14rpx;
+}
+
+.signal-bar2 {
+	height: 22rpx;
+}
+
+.signal-bar3 {
+	height: 30rpx;
+}
+
+.signal-bar4 {
+	height: 38rpx;
+}
+
+.signal-active {
+	background-color: #389588;
+}
+
 .arrowright {
 	width: 40rpx;
 	height: 40rpx;

+ 30 - 16
code/ajyApp/pages/device/deviceInfo/deviceInfo.nvue

@@ -231,7 +231,7 @@
 					</view>
 				</view>
 				<view class="modeItem" @click="changeMode(3)">
-					<text class="modeItemTxt">专家模式</text>
+					<text class="modeItemTxt">延年圣手模式</text>
 					<view class="checkboxCustom">
 						<view v-if="modeType == 3" class="checkboxCircle"></view>
 					</view>
@@ -322,7 +322,8 @@ import bleMixin from './mixins/ble-mixin.js'
 import audioMixin from './mixins/audio-mixin.js'
 import acupointMixin from './mixins/acupoint-mixin.js'
 import planMixin from './mixins/plan-mixin.js'
-import bleManager, { CHAIR_ANGLE } from '@/utils/ble'
+import { useBleStore } from '@/stores/ble'
+import { CHAIR_ANGLE } from '@/utils/ble/constants.js'
 
 export default {
 	components: {
@@ -337,26 +338,37 @@ export default {
 			deviceId: '',
 			deviceName: '',
 			rssi: '',
-			subTime: '00:00:00',
 			isShowDrawer2: false,
-			modeType: 0, // 0无艾灸 1专业 2自定义 3专家
-			modelist: ['无艾灸模式', '专业模式', '自定义模式', '专家模式'],
-			chairAngle: 90,
-			otherSetting: {
-				aijiuNum: '0',
-				lvxinNum: '0',
-				huishouNum: '0'
-			},
+			modelist: ['无艾灸模式', '专业模式', '自定义模式', '延年圣手模式'],
 			currentUserName: ''
 		}
 	},
+	computed: {
+		_bleStore() {
+			return useBleStore()
+		},
+		subTime() {
+			return this._bleStore.subTime
+		},
+		modeType: {
+			get() { return this._bleStore.modeType },
+			set(val) { this._bleStore.modeType = val }
+		},
+		chairAngle: {
+			get() { return this._bleStore.chairAngle },
+			set(val) { this._bleStore.chairAngle = val }
+		},
+		otherSetting() {
+			return this._bleStore.otherSetting
+		}
+	},
 	onLoad(options) {
 		const sysInfo = uni.getSystemInfoSync()
 		this.statusBarHeight = sysInfo.statusBarHeight || 44
 
-		this.deviceId = options.deviceId || ''
-		this.deviceName = options.name || ''
-		this.rssi = options.rssi || ''
+		this.deviceId = decodeURIComponent(options.deviceId || '')
+		this.deviceName = decodeURIComponent(options.name || '')
+		this.rssi = decodeURIComponent(options.rssi || '')
 
 		// TODO: 后续接口确定后实现 - 下载穴位列表
 		// TODO: 后续接口确定后实现 - 下载专业模式推荐方案
@@ -371,7 +383,9 @@ export default {
 	},
 	onUnload() {
 		this.destroyAudio()
-		this.disconnectBle()
+		// 离开页面时不断开蓝牙连接,仅停止扫描
+		// 下次进入相同设备可复用连接
+		this.cleanupOnLeave()
 	},
 	methods: {
 		goBack() {
@@ -412,7 +426,7 @@ export default {
 			}
 			const level = angleMap[this.chairAngle] || CHAIR_ANGLE.LEVEL_1
 			if (this.linked) {
-				bleManager.setChairAngle(level).catch(e => console.error('setAngle fail', e))
+				this._bleStore.setChairAngle(level).catch(e => console.error('setAngle fail', e))
 			}
 		}
 	}

+ 118 - 146
code/ajyApp/pages/device/deviceInfo/mixins/ble-mixin.js

@@ -1,29 +1,55 @@
 /**
  * BLE 蓝牙连接与设备控制 mixin
+ * 基于 Pinia Store 管理蓝牙单例状态
  * 负责:权限检查、连接/断连/重连、指令收发、设备状态管理
  */
-import bleManager, { BLE_STATE, MODE, MOXI_STATE, POWER, MUTE, TEMPERATURE, CHAIR_ANGLE } from '@/utils/ble'
-import { ensureBlePrerequisite } from '@/utils/ble/permission.js'
+import { useBleStore } from '@/stores/ble'
+import { MODE, MOXI_STATE, POWER, MUTE, TEMPERATURE, CHAIR_ANGLE } from '@/utils/ble/constants.js'
+import { ensureBlePrerequisite, openBluetoothSettings, openLocationSettings } from '@/utils/ble/permission.js'
 
 export default {
 	data() {
 		return {
-			linked: false,
-			deviceStatus: 0, // 0停止 1预热 2点火 3艾灸 4灭火 5暂停
-			hotPercentage: '0%',
-			ispreHot: false,
-			reconnectDrawer: false,
-			reconnectCount: 0,
-			excepDrawer: false,
-			exceTxt: 0,
-			isShowConfirm: false,
-			_unbinders: []
+			isShowConfirm: false
+		}
+	},
+	computed: {
+		bleStore() {
+			return useBleStore()
+		},
+		linked() {
+			return this.bleStore.linked
+		},
+		deviceStatus: {
+			get() { return this.bleStore.deviceStatus },
+			set(val) { this.bleStore.deviceStatus = val }
+		},
+		hotPercentage() {
+			return this.bleStore.hotPercentage
+		},
+		ispreHot: {
+			get() { return this.bleStore.ispreHot },
+			set(val) { this.bleStore.ispreHot = val }
+		},
+		reconnectDrawer() {
+			return this.bleStore.reconnectDrawer
+		},
+		reconnectCount() {
+			return this.bleStore.reconnectCount
+		},
+		excepDrawer: {
+			get() { return this.bleStore.excepDrawer },
+			set(val) { this.bleStore.excepDrawer = val }
+		},
+		exceTxt: {
+			get() { return this.bleStore.exceTxt },
+			set(val) { this.bleStore.exceTxt = val }
 		}
 	},
 	methods: {
 		// ========== BLE 初始化 ==========
 		async initBle() {
-			// 1. Android权限检查(iOS由系统自动弹窗,无需手动处理)
+			// 1. Android权限检查
 			try {
 				await ensureBlePrerequisite()
 			} catch (e) {
@@ -35,137 +61,58 @@ export default {
 					this.$refs.ayToast.error('请开启位置服务后重试')
 					return
 				}
+				if (e.message === 'BLE_ADAPTER_OFF') {
+					uni.showModal({
+						title: '提示',
+						content: '蓝牙未开启,请先开启蓝牙',
+						cancelText: '取消',
+						confirmText: '去开启',
+						success: (res) => {
+							if (res.confirm) {
+								openBluetoothSettings()
+							}
+						}
+					})
+					return
+				}
 			}
 
-			// 2. 重置BLE适配器,清除search页面可能残留的扫描状态
-			try { await bleManager.destroy() } catch (_) {}
-
-			// 3. 重新初始化适配器
-			await bleManager.init()
+			// 2. 判断当前是否已连接同一设备,如果是则跳过重连
+			const currentDevice = this.bleStore.device
+			console.log('[BLE] 当前设备', currentDevice,this.bleStore.linked,this.deviceId)
+			if (this.bleStore.linked && currentDevice && currentDevice.deviceId === this.deviceId) {
+				console.log('[BLE] 已连接相同设备,跳过重连', this.deviceId)
+				return
+			}
 
-			// 4. 监听BLE事件(必须在destroy之后注册,因为destroy会清除所有监听器)
-			this._unbinders = [
-				bleManager.on('state', (s) => {
-					this.linked = (s === BLE_STATE.READY_COMM)
-				}),
-				bleManager.on('disconnected', (info) => {
-					this.linked = false
-					if (!info.manual) {
-						this.reconnectDrawer = true
-					}
-				}),
-				bleManager.on('reconnecting', ({ count }) => {
-					this.reconnectCount = count
-					this.reconnectDrawer = true
-				}),
-				bleManager.on('reconnected', () => {
-					this.reconnectDrawer = false
-					this.reconnectCount = 0
-					this.linked = true
-					this.$refs.ayToast.success('重连成功')
-				}),
-				bleManager.on('reconnectFailed', () => {
-					this.reconnectDrawer = false
-					this.$refs.ayToast.error('连接失败,请返回重试')
-					setTimeout(() => {
-						uni.navigateBack()
-					}, 1500)
-				}),
-				bleManager.on('report:GROUP_1', (data) => {
-					this._handleGroup1(data)
-				}),
-				bleManager.on('report:GROUP_2', (data) => {
-					this._handleGroup2(data)
-				})
-			]
+			// 3. 不同设备或未连接:断开旧连接并关闭适配器(不解绑全局监听器)
+			try { await this.bleStore.disconnect() } catch (_) {}
+			try { await this.bleStore.resetAdapter() } catch (_) {}
 
-			// 5. 扫描并连接设备(与ble-demo一致的可靠方式:先扫描发现设备,再连接
+			// 4. 扫描并连接设备(优先直连,跳过扫描避免 Android 限流)
 			try {
 				const scanOpt = { timeout: 8000 }
+				// 已有 deviceId 时优先直连,不需要扫描
+				if (this.deviceId) {
+					scanOpt.deviceId = this.deviceId
+				}
 				if (this.deviceName) {
 					scanOpt.deviceName = this.deviceName
 				}
-				await bleManager.scanAndConnect(scanOpt)
-				this.linked = true
+				await this.bleStore.scanAndConnect(scanOpt)
+				// BLE连接建立后需短暂等待设备就绪再发首条指令
+				await new Promise(r => setTimeout(r, 1500))
 				this._sendCurrentState()
 			} catch (e) {
 				console.error('BLE连接失败', e)
-				this.$refs.ayToast.error('连接失败: ' + (e.message || e.code || ''))
-			}
-		},
-
-		_cleanListeners() {
-			if (this._unbinders && this._unbinders.length) {
-				this._unbinders.forEach(fn => fn && fn())
-				this._unbinders = []
-			}
-		},
-
-		// ========== 设备上报数据解析 ==========
-		_handleGroup1(data) {
-			// 参数组1: 设备状态
-			switch (data.runtimeState) {
-				case 0x00:
-					this.deviceStatus = 0
-					break
-				case 0x01:
-				case 0x02:
-					this.deviceStatus = 1
-					break
-				case 0x03:
-					this.deviceStatus = 3
-					break
-				case 0x04:
-					this.deviceStatus = 4
-					break
-				case 0x05:
-					this.deviceStatus = 5
-					break
-			}
-			// 模式
-			switch (data.mode) {
-				case MODE.LEISURE:
-					this.modeType = 0; break
-				case MODE.PROFESSIONAL:
-					this.modeType = 1; break
-				case MODE.PERSONAL:
-					this.modeType = 2; break
-				case MODE.EXPERT:
-					this.modeType = 3; break
-			}
-			// 剩余时间
-			const m = data.remainMinute || 0
-			const s = data.remainSecond || 0
-			const h = Math.floor(m / 60)
-			const m1 = m % 60
-			this.subTime = `${h.toString().padStart(2,'0')}:${m1.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`
-
-			// 异常检测
-			if (data.foreignDetect === 0x02) {
-				this.excepDrawer = true
-				this.exceTxt = 1
-			}
-			// 耗材状态
-			this.otherSetting.aijiuNum = String(data.consumable || 0)
-			this.otherSetting.lvxinNum = String(data.filterPercent || 0)
-			this.otherSetting.huishouNum = String(data.recycleBinPct || 0)
-		},
-
-		_handleGroup2(data) {
-			// 参数组2: 预热/点火进度
-			if (data.preheatPercent < 100) {
-				this.ispreHot = false
-				this.hotPercentage = data.preheatPercent + '%'
-			} else {
-				this.ispreHot = true
-				this.hotPercentage = data.ignitePercent + '%'
+				this.$refs.ayToast.error(this._bleFriendlyMsg(e))
 			}
 		},
 
 		// ========== 发送指令 ==========
 		_sendCurrentState() {
 			const modeMap = [MODE.LEISURE, MODE.PROFESSIONAL, MODE.PERSONAL, MODE.EXPERT]
-			bleManager.sendBasic({
+			this.bleStore.sendBasic({
 				power: POWER.ON,
 				moxiState: MOXI_STATE.DONE,
 				preheatState: MOXI_STATE.DONE,
@@ -180,15 +127,14 @@ export default {
 
 		// ========== 开始艾灸 ==========
 		startDeviceEvt() {
-			this.deviceStatus = 1
-			this.ispreHot = false
+			this.bleStore.deviceStatus = 1
+			this.bleStore.ispreHot = false
 			let totalDuration = 0
 			this.curCase.forEach(item => {
 				totalDuration += item.time
 			})
-			// 发送开始指令
 			const modeMap = [MODE.LEISURE, MODE.PROFESSIONAL, MODE.PERSONAL, MODE.EXPERT]
-			bleManager.sendBasic({
+			this.bleStore.sendBasic({
 				power: POWER.ON,
 				moxiState: MOXI_STATE.START,
 				preheatState: MOXI_STATE.START,
@@ -200,27 +146,24 @@ export default {
 				duration: totalDuration || 30
 			}).catch(e => console.error('startDevice fail', e))
 
-			// 发送穴位坐标
 			if (this.curCase.length > 0) {
 				const points = this.curCase.map(item => ({
 					point: item.id,
 					x: item._x,
 					y: item._y
 				}))
-				bleManager.sendAcupoints(points).catch(e => console.error('sendAcupoints fail', e))
+				this.bleStore.sendAcupoints(points).catch(e => console.error('sendAcupoints fail', e))
 			}
 		},
 
 		// ========== 暂停/继续 ==========
 		stopAijiu() {
 			if (this.deviceStatus == 5) {
-				// 继续
-				this.deviceStatus = 3
-				bleManager.sendBasic({ moxiState: MOXI_STATE.START }).catch(() => {})
+				this.bleStore.deviceStatus = 3
+				this.bleStore.sendBasic({ moxiState: MOXI_STATE.START }).catch(() => {})
 			} else if (this.deviceStatus == 3) {
-				// 暂停
-				this.deviceStatus = 5
-				bleManager.sendBasic({ moxiState: MOXI_STATE.PAUSE }).catch(() => {})
+				this.bleStore.deviceStatus = 5
+				this.bleStore.sendBasic({ moxiState: MOXI_STATE.PAUSE }).catch(() => {})
 			}
 		},
 
@@ -229,9 +172,9 @@ export default {
 			this.isShowConfirm = true
 		},
 		confirmStop() {
-			this.deviceStatus = 0
+			this.bleStore.deviceStatus = 0
 			this.isShowConfirm = false
-			bleManager.sendBasic({ moxiState: MOXI_STATE.DONE }).catch(() => {})
+			this.bleStore.sendBasic({ moxiState: MOXI_STATE.DONE }).catch(() => {})
 		},
 
 		// ========== 模式切换 ==========
@@ -243,15 +186,14 @@ export default {
 				confirmText: '继续',
 				success: (res) => {
 					if (res.confirm) {
-						this.modeType = num
-						this.deviceStatus = 0
+						this.bleStore.modeType = num
+						this.bleStore.deviceStatus = 0
 						this.isShowDrawer2 = false
 						if (this._stopAudioOnModeChange) {
 							this._stopAudioOnModeChange()
 						}
-						// 发送模式切换指令
 						const modeMap = [MODE.LEISURE, MODE.PROFESSIONAL, MODE.PERSONAL, MODE.EXPERT]
-						bleManager.sendBasic({
+						this.bleStore.sendBasic({
 							power: POWER.ON,
 							moxiState: MOXI_STATE.DONE,
 							preheatState: MOXI_STATE.DONE,
@@ -269,8 +211,38 @@ export default {
 
 		// ========== 断开BLE ==========
 		disconnectBle() {
-			this._cleanListeners()
-			bleManager.disconnect().catch(() => {})
+			// 先停止扫描(防止页面销毁后 onBluetoothDeviceFound 回调找不到 taskCenter)
+			this.bleStore.stopScan()
+			this.bleStore.disconnect().catch(() => {})
+		},
+
+		/**
+		 * 页面离开时的轻量清理:只停止扫描,不断开BLE连接
+		 * 保持蓝牙连接状态,下次进入相同设备页面可复用
+		 */
+		cleanupOnLeave() {
+			this.bleStore.stopScan()
+		},
+
+		// ========== BLE错误码转用户友好提示 ==========
+		_bleFriendlyMsg(e) {
+			const code = e && (e.code || e.message || '')
+			const originMsg = e && e.origin && e.origin.msg
+			const map = {
+				BLE_SCAN_FAIL: this.bleStore.scanThrottled
+					? '扫描过于频繁,请等待30秒后再试'
+					: (originMsg || '未搜索到设备,请确保设备已开机并靠近手机'),
+				BLE_CONNECT_FAIL: '连接设备失败,请确保设备在范围内并重试',
+				BLE_CONNECT_TIMEOUT: '连接超时,请靠近设备后重试',
+				BLE_ADAPTER_OFF: '蓝牙未开启,请先开启蓝牙',
+				BLE_PERMISSION_DENIED: '蓝牙权限未授予,请在设置中开启',
+				BLE_LOCATION_OFF: '位置服务未开启,请开启后重试',
+				BLE_DISCONNECTED: '设备已断开连接',
+				BLE_SERVICE_NOT_FOUND: '设备服务异常,请重启设备后重试',
+				BLE_WRITE_FAIL: '指令发送失败,请重试',
+				BLE_BUSY: '设备正忙,请稍后再试'
+			}
+			return map[code] || ('连接失败,请重试 (' + code + ')')
 		}
 	}
 }

+ 26 - 0
code/ajyApp/pages/device/list/list.nvue

@@ -32,6 +32,7 @@
 
 <script>
 import ayToast from '@/components/ay-toast/ay-toast.nvue'
+import { useBleStore } from '@/stores/ble'
 
 export default {
 	components: {
@@ -47,6 +48,7 @@ export default {
 		const sysInfo = uni.getSystemInfoSync()
 		this.statusBarHeight = sysInfo.statusBarHeight || 44
 		this.init()
+		this.updateConnectedDeviceRSSI()
 	},
 	onPullDownRefresh() {
 		uni.stopPullDownRefresh()
@@ -75,6 +77,30 @@ export default {
 			uni.navigateTo({
 				url: '/pages/device/deviceInfo/deviceInfo?name=' + encodeURIComponent(item.name || '') + '&deviceId=' + encodeURIComponent(item.deviceId || '') + '&rssi=' + encodeURIComponent(item.RSSI || '')
 			})
+		},
+		/** 如果设备已连接,实时获取RSSI并更新缓存 */
+		updateConnectedDeviceRSSI() {
+			const bleStore = useBleStore()
+			if (!bleStore.linked || !bleStore.device || !bleStore.device.deviceId) return
+			const connectedDeviceId = bleStore.device.deviceId
+			uni.getBLEDeviceRSSI({
+				deviceId: connectedDeviceId,
+				success: (res) => {
+					console.log('实时RSSI更新:', res.RSSI)
+					// 更新列表中对应设备的RSSI
+					const idx = this.list.findIndex(item => item.deviceId === connectedDeviceId)
+					if (idx !== -1) {
+						this.list[idx].RSSI = res.RSSI
+						// 同步更新到本地缓存
+						try {
+							uni.setStorageSync('deviceList', this.list)
+						} catch (e) {}
+					}
+				},
+				fail: (err) => {
+					console.log('获取RSSI失败:', err)
+				}
+			})
 		}
 	}
 }

+ 67 - 67
code/ajyApp/pages/device/search/search.nvue

@@ -17,6 +17,11 @@
 			<text class="search-btn" @click="onRefresh">{{searching ? '停止' : '重新搜索'}}</text>
 		</view>
 
+		<!-- 扫描限流警告 -->
+		<view class="throttle-warning" v-if="scanThrottled">
+			<text class="throttle-text">扫描过于频繁,可能无法搜索到设备,请稍等片刻再试</text>
+		</view>
+
 		<!-- 设备列表 -->
 		<scroll-view class="device-scroll" scroll-y="true">
 			<view class="device-item" v-for="(item, index) in deviceList" :key="index" @click="onSelectDevice(item)">
@@ -46,9 +51,8 @@
 </template>
 
 <script>
-import bleManager, {
-	BLE_STATE, ensureBlePrerequisite, openLocationSettings
-} from '@/utils/ble'
+import { useBleStore } from '@/stores/ble'
+import { ensureBlePrerequisite, openLocationSettings, openBluetoothSettings } from '@/utils/ble/permission.js'
 import ayToast from '@/components/ay-toast/ay-toast.nvue'
 
 export default {
@@ -58,64 +62,47 @@ export default {
 	data() {
 		return {
 			statusBarHeight: 44,
-			searching: false,
-			deviceList: [],
-			_unbinders: []
+			btModalShowing: false
+		}
+	},
+	computed: {
+		bleStore() {
+			return useBleStore()
+		},
+		searching() {
+			return this.bleStore.searching
+		},
+		deviceList() {
+			return this.bleStore.scannedDevices
+		},
+		scanThrottled() {
+			return this.bleStore.scanThrottled
 		}
 	},
 	onLoad() {
 		const sysInfo = uni.getSystemInfoSync()
 		this.statusBarHeight = sysInfo.statusBarHeight || 44
 
-		// 配置并监听事件
-		bleManager.configure({ debug: true })
-
-		this._unbinders = [
-			bleManager.on('state', s => {
-				this.searching = (s === BLE_STATE.SCANNING)
-			}),
-			bleManager.on('deviceFound', d => {
-				// 避免重复
-				const exists = this.deviceList.find(item => item.deviceId === d.deviceId)
-				if (!exists) {
-					this.deviceList.push({
-						deviceId: d.deviceId,
-						name: d.name || d.localName || '',
-						RSSI: d.RSSI || ''
-					})
-				}
-			})
-		]
-
+		// 配置并开始扫描
+		this.bleStore.configure({ debug: true })
 		this.startScan()
 	},
 	onUnload() {
-		// 离开页面停止扫描
-		this.stopScan()
-		if (this._unbinders && this._unbinders.length) {
-			this._unbinders.forEach(fn => fn && fn())
-		}
+		// 离开页面才真正停止底层BLE发现
+		this.bleStore.stopScan()
 	},
 	methods: {
 		goBack() {
 			uni.navigateBack()
 		},
 		async startScan() {
-			this.deviceList = []
 			try {
 				await ensureBlePrerequisite()
-				await bleManager.init()
-				// 使用returnAll模式,超时后resolve所有设备
-				bleManager.scan({ timeout: 15000, returnAll: true }).then(devices => {
-					// 扫描结束
-					this.searching = false
-				}).catch(e => {
-					this.searching = false
-					console.error('扫描异常', e)
-				})
+				// startScan 内部自动初始化适配器,扫描结果自动更新 store.scannedDevices
+				await this.bleStore.startScan({ timeout: 15000, returnAll: true })
 			} catch (e) {
-				this.searching = false
-				if (e.message === 'BLE_LOCATION_OFF') {
+				const msg = e && (e.message || e.code || '')
+				if (msg === 'BLE_LOCATION_OFF') {
 					uni.showModal({
 						title: '提示',
 						content: '请先开启位置服务以搜索蓝牙设备',
@@ -123,43 +110,45 @@ export default {
 							if (r.confirm) openLocationSettings()
 						}
 					})
-				} else if (e.message === 'BLE_ADAPTER_OFF') {
-					this.$refs.ayToast.error('请先开启蓝牙')
-				} else if (e.message === 'BLE_PERMISSION_DENIED') {
+				} else if (msg === 'BLE_ADAPTER_OFF' || msg === 'BLE_ADAPTER_OFF') {
+					if (this.btModalShowing) return
+					this.btModalShowing = true
+					uni.showModal({
+						title: '蓝牙未开启',
+						content: '请先开启手机蓝牙,才能搜索和连接艾灸椅设备',
+						confirmText: '去设置',
+						cancelText: '取消',
+						success: r => {
+							this.btModalShowing = false
+							if (r.confirm) openBluetoothSettings()
+						}
+					})
+				} else if (msg === 'BLE_PERMISSION_DENIED') {
 					this.$refs.ayToast.error('请授权蓝牙权限')
 				} else {
-					this.$refs.ayToast.error('扫描失败: ' + (e.message || ''))
+					console.error('扫描异常', e)
+					this.$refs.ayToast.error('扫描失败: ' + (msg || ''))
 				}
 			}
 		},
-		stopScan() {
-			try {
-				uni.stopBluetoothDevicesDiscovery({
-					success() {},
-					fail() {}
-				})
-			} catch (e) {}
-		},
 		onRefresh() {
 			if (this.searching) {
-				this.stopScan()
-				this.searching = false
+				this.bleStore.stopScan()
 			} else {
 				this.startScan()
 			}
 		},
 		getSignalLevel(rssi) {
-			// RSSI转信号格数: 4格(强) / 3格 / 2格 / 1格(弱)
+			console.log("信号",rssi)
 			if (!rssi) return 0
 			const val = Number(rssi)
-			if (val >= -50) return 4   // 非常强
-			if (val >= -65) return 3   // 强
-			if (val >= -80) return 2   // 中
-			if (val >= -95) return 1   // 弱
+			if (val >= -50) return 4
+			if (val >= -65) return 3
+			if (val >= -80) return 2
+			if (val >= -95) return 1
 			return 1
 		},
 		onSelectDevice(device) {
-			// 将设备添加到本地存储的设备列表
 			let deviceList = []
 			try {
 				const data = uni.getStorageSync('deviceList')
@@ -168,14 +157,12 @@ export default {
 				}
 			} catch (e) {}
 
-			// 检查是否已添加
 			const exists = deviceList.find(item => item.deviceId === device.deviceId)
 			if (exists) {
 				this.$refs.ayToast.show('该设备已添加')
 				return
 			}
 
-			// 添加设备
 			deviceList.push({
 				deviceId: device.deviceId,
 				name: device.name || '艾灸椅',
@@ -184,9 +171,10 @@ export default {
 			uni.setStorageSync('deviceList', deviceList)
 			this.$refs.ayToast.success('设备添加成功')
 
-			// 延迟返回
 			setTimeout(() => {
-				uni.navigateBack()
+				uni.redirectTo({
+					url: `/pages/device/deviceInfo/deviceInfo?deviceId=${encodeURIComponent(device.deviceId)}&name=${encodeURIComponent(device.name || '艾灸椅')}&rssi=${device.RSSI || ''}`
+				})
 			}, 1000)
 		}
 	}
@@ -331,4 +319,16 @@ export default {
 	font-size: 28rpx;
 	color: #999;
 }
+.throttle-warning {
+	background-color: #FFF3E0;
+	padding: 16rpx 38rpx;
+	margin-left: 38rpx;
+	margin-right: 38rpx;
+	border-radius: 12rpx;
+	margin-bottom: 16rpx;
+}
+.throttle-text {
+	font-size: 24rpx;
+	color: #E65100;
+}
 </style>

+ 814 - 0
code/ajyApp/stores/ble.js

@@ -0,0 +1,814 @@
+/**
+ * 艾灸椅 BLE Pinia Store
+ * 真正的应用级单例,跨页面共享蓝牙状态
+ *
+ * 用法:
+ *   import { useBleStore } from '@/stores/ble'
+ *   const bleStore = useBleStore()
+ *
+ *   // 扫描
+ *   await bleStore.startScan({ timeout: 15000, returnAll: true })
+ *
+ *   // 连接
+ *   await bleStore.connectDevice(deviceId)
+ *
+ *   // 发送指令
+ *   await bleStore.sendBasic({ power: 1, moxiState: 1 })
+ *
+ *   // 读取状态(自动响应式)
+ *   bleStore.linked / bleStore.searching / bleStore.deviceStatus
+ */
+
+import { defineStore } from 'pinia'
+import logger from '@/utils/ble/logger.js'
+import {
+	DEFAULT_CONFIG, BLE_STATE, BLE_ERROR,
+	POWER, MOXI_STATE, MUTE, MODE, SUB_MODE, TEMPERATURE, CHAIR_ANGLE
+} from '@/utils/ble/constants.js'
+import {
+	encodeBasic, encodeModeParam1, encodeModeParam2, encodeAcupoints,
+	bufferToArrayBuffer, arrayBufferToU8, bytesToHex, FrameParser
+} from '@/utils/ble/protocol.js'
+import { ensureBlePrerequisite, openLocationSettings } from '@/utils/ble/permission.js'
+
+// ========== 跨 nvue 页面共享内部变量(通过 globalData 确保单例) ==========
+function _getShared() {
+	const app = getApp()
+	if (!app.globalData) app.globalData = {}
+	if (!app.globalData._bleInternal) {
+		app.globalData._bleInternal = {
+			instanceId: Date.now() + '_' + Math.random().toString(36).slice(2, 6),
+			serviceId: null,
+			writeCharId: null,
+			notifyCharId: null,
+			reconnectTimer: null,
+			reconnectCount: 0,
+			reconnectGeneration: 0,  // 重连代数,用于丢弃僵尸回调
+			writeLock: Promise.resolve(),
+			bound: false,
+			cancelScan: null,
+			scanAborted: false,      // 标记扫描已被外部中止,防止僵尸扫描
+			onDeviceFound: null,     // 当前扫描的设备发现回调
+			scanStartHistory: [],    // 近期 startDiscovery 调用时间戳,用于检测 Android 限流
+			intentionalDisconnect: false, // 标记主动断开,防止系统回调误触发重连
+			_connectedBeforeScan: false, // 标记扫描前是否处于已连接状态
+			parser: null,
+			config: { ...DEFAULT_CONFIG }
+		}
+		console.log(`[BLE Store] 首次创建共享实例,instanceId = ${app.globalData._bleInternal.instanceId}`)
+	}
+	return app.globalData._bleInternal
+}
+
+// 兼容模块加载阶段(getApp()可能未就绪),延迟到首次调用时获取
+let _shared = null
+function _S() {
+	if (!_shared) _shared = _getShared()
+	return _shared
+}
+
+// 便捷访问
+function _instanceId() { return _S().instanceId }
+
+// ========== 工具函数 ==========
+function _invoke(apiFn, params) {
+	return new Promise((resolve, reject) => {
+		apiFn({ ...params, success: resolve, fail: reject, complete: () => {} })
+	})
+}
+
+function _err(code, origin) {
+	const err = new Error(code)
+	err.code = code
+	if (origin) err.origin = origin
+	return err
+}
+
+function _uuidEq(a, b) {
+	return String(a || '').toLowerCase() === String(b || '').toLowerCase()
+}
+
+export const useBleStore = defineStore('ble', {
+	state: () => ({
+		// ===== 连接状态 =====
+		bleState: BLE_STATE.IDLE,
+		device: null, // { deviceId, name, RSSI }
+		linked: false,
+
+		// ===== 蓝牙开关状态 =====
+		bleAdapterOff: false, // 蓝牙适配器未开启,页面可监听此状态展示提示
+
+		// ===== 扫描 =====
+		searching: false,
+		scannedDevices: [],
+		scanThrottled: false, // 检测到 Android 扫描限流时为 true,页面可监听此状态提示用户
+
+		// ===== 设备运行状态(来自上报) =====
+		deviceStatus: 0, // 0停止 1预热 2点火 3艾灸 4灭火 5暂停
+		hotPercentage: '0%',
+		ispreHot: false,
+		subTime: '00:00:00',
+		modeType: 0, // 0无艾灸 1专业 2自定义 3专家
+		chairAngle: 90,
+
+		// ===== 重连 =====
+		reconnectDrawer: false,
+		reconnectCount: 0,
+
+		// ===== 异常 =====
+		excepDrawer: false,
+		exceTxt: 0,
+
+		// ===== 耗材状态 =====
+		otherSetting: {
+			aijiuNum: '0',
+			lvxinNum: '0',
+			huishouNum: '0'
+		}
+	}),
+
+	actions: {
+		// ============== 配置 ==============
+		configure(opt = {}) {
+			const s = _S()
+			s.config = { ...s.config, ...opt }
+			logger.setEnabled(s.config.debug)
+		},
+
+		// ============== 内部状态管理 ==============
+		_setState(s) {
+			if (this.bleState === s) return
+			const prevState = this.bleState
+			this.bleState = s
+			// 进入扫描状态时,如果当前有活跃连接,不应丢失 linked 状态
+			if (s === BLE_STATE.SCANNING && prevState === BLE_STATE.READY_COMM) {
+				_S()._connectedBeforeScan = true
+				// linked 保持 true,仅更新 searching
+			} else if (s === BLE_STATE.READY && _S()._connectedBeforeScan && this.device) {
+				// 扫描结束恢复连接状态:物理连接仍存活
+				_S()._connectedBeforeScan = false
+				this.bleState = BLE_STATE.READY_COMM
+				this.linked = true
+			} else {
+				_S()._connectedBeforeScan = false
+				this.linked = (s === BLE_STATE.READY_COMM)
+			}
+			this.searching = (s === BLE_STATE.SCANNING)
+			logger.info(`[instanceId=${_instanceId()}] state ->`, s, this.linked ? '(linked)' : '')
+		},
+
+		// ============== 初始化 / 释放 ==============
+		async init() {
+			console.log(`[BLE Store] init() called, instanceId = ${_instanceId()}`)
+			if (this.bleState !== BLE_STATE.IDLE && this.bleState !== BLE_STATE.DISCONNECTED) {
+				logger.info('蓝牙已初始化,跳过重复初始化')
+				return
+			}
+			try {
+				await _invoke(uni.openBluetoothAdapter, {})
+				this.bleAdapterOff = false
+			} catch (e) {
+				logger.error('openBluetoothAdapter fail', e)
+				const code = e && (e.errCode || e.code)
+				const isOff = code === 10001
+				if (isOff) {
+					this.bleAdapterOff = true
+					this._showBleOffPrompt('请先开启手机蓝牙,才能连接艾灸椅设备')
+				}
+				throw _err(isOff ? BLE_ERROR.ADAPTER_OFF : BLE_ERROR.NOT_SUPPORT, e)
+			}
+			this._bindSystemListeners()
+			this._setState(BLE_STATE.READY)
+			logger.info('蓝牙适配器已初始化')
+		},
+
+		async destroy() {
+			this._clearReconnect()
+			const s = _S()
+			if (s.cancelScan) { s.cancelScan(); s.cancelScan = null }
+			s.onDeviceFound = null
+			try { await this.disconnect() } catch (_) {}
+			try { await _invoke(uni.closeBluetoothAdapter, {}) } catch (_) {}
+			this._unbindSystemListeners()
+			this._setState(BLE_STATE.IDLE)
+			if (s.parser) s.parser.reset()
+		},
+
+		/**
+		 * 轻量级重置:只关闭适配器并重置状态,不解绑全局监听器
+		 * 适用于页面切换时清除残留扫描/连接状态
+		 */
+		async resetAdapter() {
+			const s = _S()
+			s.intentionalDisconnect = true
+			this._clearReconnect()
+			if (s.cancelScan) { s.cancelScan(); s.cancelScan = null }
+			s.onDeviceFound = null
+			try { await _invoke(uni.closeBluetoothAdapter, {}) } catch (_) {}
+			this._setState(BLE_STATE.IDLE)
+			if (s.parser) s.parser.reset()
+			s.intentionalDisconnect = false
+		},
+
+		/**
+		 * 绑定BLE系统监听器 - 必须在 App.vue onLaunch 中调用
+		 * 确保回调绑定到 App.vue 的 taskCenter(永不销毁)
+		 */
+		bindGlobalListeners() {
+			if (_S().bound) return
+			this._bindSystemListeners()
+		},
+
+		_bindSystemListeners() {
+			const s = _S()
+			if (s.bound) return
+
+			// 初始化帧解析器
+			if (!s.parser) {
+				s.parser = new FrameParser(
+					(decoded, frame) => this._onFrame(decoded, frame),
+					(err, frame) => logger.warn('frame parse error', err.message, bytesToHex(frame))
+				)
+			}
+
+			uni.onBluetoothAdapterStateChange(this._onAdapterStateChange = (res) => {
+				logger.info('adapterStateChange', res)
+				const s = _S()
+				if (!res.available) {
+					// 主动关闭适配器时不显示提示且不触发重连
+					if (s.intentionalDisconnect) return
+					this.bleAdapterOff = true
+					this._showBleOffPrompt('蓝牙已关闭,设备连接已断开。请重新开启蓝牙以继续使用')
+					this._handleDisconnected(BLE_ERROR.ADAPTER_OFF)
+				} else {
+					this.bleAdapterOff = false
+					// 蓝牙重新开启:如果之前有连接过的设备且当前处于断连状态,自动发起重连
+					this._onAdapterRestored()
+				}
+			})
+			uni.onBLEConnectionStateChange(this._onConnStateChange = (res) => {
+				logger.info('connectionStateChange', res)
+				if (!res.connected && this.device && res.deviceId === this.device.deviceId) {
+					this._handleDisconnected(BLE_ERROR.DISCONNECTED)
+				}
+			})
+			uni.onBLECharacteristicValueChange(this._onCharChange = (res) => {
+				const u8 = arrayBufferToU8(res.value)
+				logger.log('<=', bytesToHex(u8))
+				if (s.parser) s.parser.feed(u8)
+			})
+			// 永久注册设备发现监听器(不可反复 on/off,否则多次后系统丢失监听)
+			uni.onBluetoothDeviceFound((res) => {
+				if (s.onDeviceFound) s.onDeviceFound(res)
+			})
+			s.bound = true
+			logger.info(`[instanceId=${s.instanceId}] BLE系统监听器已全局绑定(含onBluetoothDeviceFound)`)
+		},
+
+		_unbindSystemListeners() {
+			// 全局监听器不再主动解绑,防止 taskCenter 丢失
+			// 仅在极端情况下(如蓝牙完全不再使用)才解绑
+		},
+
+		// ============== 确保就绪 ==============
+		async _ensureReady() {
+			if (this.bleState === BLE_STATE.IDLE) await this.init()
+			try {
+				const res = await _invoke(uni.getBluetoothAdapterState, {})
+				if (!res.available) {
+					this.bleAdapterOff = true
+					this._showBleOffPrompt('请先开启手机蓝牙,才能连接艾灸椅设备')
+					throw _err(BLE_ERROR.ADAPTER_OFF)
+				}
+				this.bleAdapterOff = false
+			} catch (e) {
+				if (e && e.code && String(e.code).startsWith('BLE_')) throw e
+				throw _err(BLE_ERROR.ADAPTER_OFF, e)
+			}
+		},
+
+		// ============== 扫描 ==============
+		async startScan(opt = {}) {
+			const s = _S()
+			console.log(`[BLE Store] startScan() called, instanceId = ${s.instanceId}`)
+			// 重置中止标记
+			s.scanAborted = false
+			this.scanThrottled = false
+			// 取消上一次残留扫描
+			if (s.cancelScan) { s.cancelScan(); s.cancelScan = null }
+			// 停止上一次发现
+			await _invoke(uni.stopBluetoothDevicesDiscovery, {}).catch(() => {})
+
+			await this._ensureReady()
+
+			// 如果在 await 期间页面已卸载并调用了 stopScan,直接中止
+			if (s.scanAborted) {
+				logger.info('startScan aborted (page already unloaded)')
+				return opt.returnAll ? [] : null
+			}
+
+			const {
+				namePrefix = s.config.deviceNamePrefix,
+				deviceName,
+				services,
+				timeout = s.config.scanTimeout,
+				returnAll = false
+			} = opt
+
+			// 清空上次扫描结果
+			this.scannedDevices = []
+
+			// Android 限流检测(30秒内最多5次,iOS 无此限制)
+			const platform = uni.getSystemInfoSync().platform
+			if (platform === 'android') {
+				const now = Date.now()
+				s.scanStartHistory = s.scanStartHistory.filter(t => now - t < 30000)
+				if (s.scanStartHistory.length >= 4) {
+					this.scanThrottled = true
+					logger.warn(`BLE scan throttle: ${s.scanStartHistory.length + 1} starts in 30s, system may ignore`)
+				}
+				s.scanStartHistory.push(now)
+			}
+
+			const devices = new Map()
+			const matched = []
+
+			return new Promise(async (resolve, reject) => {
+				let finished = false
+
+				const onFound = (res) => {
+					if (finished) return
+					for (const d of res.devices) {
+						if (devices.has(d.deviceId)) continue
+						devices.set(d.deviceId, d)
+						const name = d.name || d.localName || ''
+						const hit =
+							(deviceName && name === deviceName) ||
+							(namePrefix && name.startsWith(namePrefix)) ||
+							(!deviceName && !namePrefix)
+						if (hit) {
+							matched.push(d)
+							const exists = this.scannedDevices.find(item => item.deviceId === d.deviceId)
+							if (!exists) {
+								this.scannedDevices.push({
+									deviceId: d.deviceId,
+									name: name,
+									RSSI: d.RSSI || ''
+								})
+							}
+							if (!returnAll) { finish(null, d); return }
+						}
+					}
+				}
+
+				const timer = setTimeout(() => {
+					if (returnAll) finish(null, matched)
+					else if (matched.length) finish(null, matched[0])
+					else finish(_err(BLE_ERROR.SCAN_FAIL, { msg: '扫描超时,未发现目标设备' }))
+				}, timeout)
+
+				const finish = (err, data) => {
+					if (finished) return
+					finished = true
+					s.cancelScan = null
+					s.onDeviceFound = null
+					clearTimeout(timer)
+					_invoke(uni.stopBluetoothDevicesDiscovery, {}).catch(() => {})
+					this._setState(BLE_STATE.READY)
+					err ? reject(err) : resolve(data)
+				}
+
+				s.cancelScan = () => finish(null, returnAll ? matched : (matched[0] || null))
+
+				// 通过共享回调分发事件(监听器已在 _bindSystemListeners 中永久注册)
+				s.onDeviceFound = onFound
+				this._setState(BLE_STATE.SCANNING)
+
+				try {
+					await _invoke(uni.startBluetoothDevicesDiscovery, {
+						allowDuplicatesKey: false,
+						interval: 0,
+						services
+					})
+				} catch (e) {
+					finish(_err(BLE_ERROR.SCAN_FAIL, e))
+				}
+			})
+		},
+
+		/** 停止扫描 */
+		stopScan() {
+			const s = _S()
+			s.scanAborted = true
+			s.onDeviceFound = null
+			if (s.cancelScan) {
+				s.cancelScan()
+				s.cancelScan = null
+			} else {
+				try {
+					uni.stopBluetoothDevicesDiscovery({ success() {}, fail() {} })
+				} catch (e) {}
+			}
+			this.searching = false
+			this.scanThrottled = false
+			if (this.bleState === BLE_STATE.SCANNING) {
+				// _setState(READY) 内部会检测 _connectedBeforeScan,
+				// 若扫描前有连接则自动恢复为 READY_COMM
+				this._setState(BLE_STATE.READY)
+			}
+		},
+
+		// ============== 连接 ==============
+		async connectDevice(deviceId) {
+			const s = _S()
+			console.log(`[BLE Store] connectDevice() called, instanceId = ${s.instanceId}`)
+			if (!deviceId) throw _err(BLE_ERROR.CONNECT_FAIL, { msg: 'deviceId 不能为空' })
+			await this._ensureReady()
+			if (this.bleState === BLE_STATE.CONNECTING) throw _err(BLE_ERROR.BUSY)
+			this._setState(BLE_STATE.CONNECTING)
+			this.device = { deviceId, name: '' }
+
+			try {
+				await _invoke(uni.createBLEConnection, {
+					deviceId,
+					timeout: s.config.connectTimeout
+				})
+				this._setState(BLE_STATE.CONNECTED)
+
+				// Android 提升 MTU
+				// #ifdef APP-PLUS
+				if (uni.getSystemInfoSync().platform === 'android' && uni.setBLEMTU) {
+					try { await _invoke(uni.setBLEMTU, { deviceId, mtu: 185 }) } catch (_) {}
+				}
+				// #endif
+
+				await this._discoverAndSubscribe(deviceId)
+				this._setState(BLE_STATE.READY_COMM)
+				s.reconnectCount = 0
+			} catch (e) {
+				this._setState(BLE_STATE.DISCONNECTED)
+				this.device = null // 连接失败,清空 device 防止延迟回调误触发重连
+				try { await _invoke(uni.closeBLEConnection, { deviceId }) } catch (_) {}
+				const code = e && (e.errCode || e.code)
+				if (code === 10003 || code === -1) {
+					throw _err(BLE_ERROR.CONNECT_TIMEOUT, e)
+				}
+				throw _err(BLE_ERROR.CONNECT_FAIL, e)
+			}
+		},
+
+		/** 扫描 + 连接一步到位(支持 deviceId 直连优先,跳过扫描) */
+		async scanAndConnect(opt = {}) {
+			const { deviceId: directId, deviceName, ...restOpt } = opt
+
+			// 策略:如果已有 deviceId,先尝试直连(不扫描),失败后回退扫描
+			if (directId) {
+				try {
+					logger.info(`directConnect attempt, deviceId=${directId}`)
+					await this.connectDevice(directId)
+					this.device.name = deviceName || this.device.name || ''
+					logger.info('directConnect success, scan skipped')
+					return this.device
+				} catch (e) {
+					logger.warn('directConnect failed, fallback to scan', e.message || e.code)
+					// connectDevice 失败时已清空 this.device 并关闭连接,无需额外处理
+				}
+			}
+
+			// 回退:扫描 + 连接
+			const scanOpt = { ...restOpt }
+			if (deviceName) scanOpt.deviceName = deviceName
+			const device = await this.startScan(scanOpt)
+			await this.connectDevice(device.deviceId)
+			this.device.name = device.name || device.localName || ''
+			return this.device
+		},
+
+		/** 主动断开 */
+		async disconnect() {
+			const s = _S()
+			s.intentionalDisconnect = true
+			this._clearReconnect()
+			if (!this.device) {
+				s.intentionalDisconnect = false
+				return
+			}
+			const { deviceId } = this.device
+			// 先置空 device,防止 onBLEConnectionStateChange 回调误匹配
+			this.device = null
+			this._setState(BLE_STATE.DISCONNECTED)
+			try { await _invoke(uni.closeBLEConnection, { deviceId }) } catch (_) {}
+			s.intentionalDisconnect = false
+		},
+
+		/** 发现服务并订阅通知 */
+		async _discoverAndSubscribe(deviceId) {
+			const s = _S()
+			const svcRes = await _invoke(uni.getBLEDeviceServices, { deviceId })
+			const services = svcRes.services || []
+			const targetSvc = services.find(sv => _uuidEq(sv.uuid, s.config.serviceId))
+				|| services.find(sv => sv.isPrimary)
+				|| services[0]
+			if (!targetSvc) throw _err(BLE_ERROR.SERVICE_NOT_FOUND)
+			s.serviceId = targetSvc.uuid
+
+			const charRes = await _invoke(uni.getBLEDeviceCharacteristics, {
+				deviceId, serviceId: s.serviceId
+			})
+			const chars = charRes.characteristics || []
+
+			const writeChar = chars.find(c => _uuidEq(c.uuid, s.config.writeCharId))
+				|| chars.find(c => c.properties && (c.properties.write || c.properties.writeNoResponse || c.properties.writeDefault))
+			const notifyChar = chars.find(c => _uuidEq(c.uuid, s.config.notifyCharId))
+				|| chars.find(c => c.properties && (c.properties.notify || c.properties.indicate))
+
+			if (!writeChar) throw _err(BLE_ERROR.CHAR_NOT_FOUND, { msg: '未找到写特征' })
+			if (!notifyChar) throw _err(BLE_ERROR.CHAR_NOT_FOUND, { msg: '未找到通知特征' })
+
+			s.writeCharId = writeChar.uuid
+			s.notifyCharId = notifyChar.uuid
+
+			await _invoke(uni.notifyBLECharacteristicValueChange, {
+				deviceId,
+				serviceId: s.serviceId,
+				characteristicId: s.notifyCharId,
+				state: true
+			})
+		},
+
+		// ============== 断连处理 & 自动重连 ==============
+		_handleDisconnected(reason) {
+			const s = _S()
+			// 主动断开/重置时不触发重连
+			if (s.intentionalDisconnect) return
+			if (this.bleState === BLE_STATE.DISCONNECTED || this.bleState === BLE_STATE.IDLE) return
+			const device = this.device
+			this._setState(BLE_STATE.DISCONNECTED)
+			if (s.parser) s.parser.reset()
+			this._attemptReconnect(device, reason)
+		},
+
+		_attemptReconnect(device, reason) {
+			const s = _S()
+			if (!s.config.autoReconnect || !device || s.reconnectCount >= s.config.maxReconnect) {
+				if (s.reconnectCount >= s.config.maxReconnect) {
+					logger.warn('reconnect max reached, waiting for adapter restore')
+					s.reconnectCount = 0
+					this.reconnectDrawer = false
+					this.reconnectCount = 0
+				}
+				return
+			}
+			s.reconnectCount++
+			this.reconnectCount = s.reconnectCount
+			this.reconnectDrawer = true
+			const delay = Math.min(1000 * s.reconnectCount, 5000)
+			logger.warn(`reconnect in ${delay}ms (${s.reconnectCount}/${s.config.maxReconnect})`)
+
+			const gen = s.reconnectGeneration
+			s.reconnectTimer = setTimeout(async () => {
+				if (s.reconnectGeneration !== gen) {
+					logger.info('reconnect callback aborted (generation mismatch)')
+					return
+				}
+				try {
+					// 先关闭适配器清除脏状态(加标志防止回调干扰)
+					s.intentionalDisconnect = true
+					try { await _invoke(uni.closeBluetoothAdapter, {}) } catch (_) {}
+					s.intentionalDisconnect = false
+					this._setState(BLE_STATE.IDLE)
+					if (s.reconnectGeneration !== gen) return
+					// 优先使用 deviceId 直连,避免不必要的扫描触发 Android 限流
+					const scanOpt = { timeout: 8000 }
+					if (device.deviceId) scanOpt.deviceId = device.deviceId
+					if (device.name) scanOpt.deviceName = device.name
+					await this.scanAndConnect(scanOpt)
+					if (s.reconnectGeneration !== gen) return
+					this.reconnectDrawer = false
+					this.reconnectCount = 0
+					logger.info('reconnect success')
+				} catch (e) {
+					if (s.reconnectGeneration !== gen) return
+					logger.error('reconnect fail', e)
+					this._attemptReconnect(device, reason)
+				}
+			}, delay)
+		},
+
+		_clearReconnect() {
+			const s = _S()
+			if (s.reconnectTimer) { clearTimeout(s.reconnectTimer); s.reconnectTimer = null }
+			s.reconnectCount = 0
+			s.reconnectGeneration++ // 递增代数,使正在执行的僵尸回调自动失效
+			this.reconnectCount = 0
+			this.reconnectDrawer = false
+		},
+
+		/**
+		 * 蓝牙适配器从关闭恢复为开启时调用
+		 * 如果之前有连接过的设备且当前处于断连状态,自动扫描并重新连接
+		 */
+		_onAdapterRestored() {
+			const device = this.device
+			if (!device || !device.deviceId) return
+			if (this.bleState !== BLE_STATE.DISCONNECTED && this.bleState !== BLE_STATE.IDLE) return
+			logger.info('adapter restored, auto reconnecting to', device.name || device.deviceId)
+			// 重置重连计数,发起新一轮重连
+			this._clearReconnect()
+			this._setState(BLE_STATE.IDLE)
+			// 延迟 1.5s 等待适配器完全就绪(Android 蓝牙状态延迟)
+			const s = _S()
+			const gen = s.reconnectGeneration
+			this.reconnectDrawer = true
+			this.reconnectCount = 1
+			s.reconnectTimer = setTimeout(async () => {
+				if (s.reconnectGeneration !== gen) return
+				try {
+					// 关闭旧适配器,确保重新打开时状态干净
+					s.intentionalDisconnect = true
+					try { await _invoke(uni.closeBluetoothAdapter, {}) } catch (_) {}
+					s.intentionalDisconnect = false
+					if (s.reconnectGeneration !== gen) return
+					// 优先直连,避免扫描触发限流
+					const scanOpt = { timeout: 8000 }
+					if (device.deviceId) scanOpt.deviceId = device.deviceId
+					if (device.name) scanOpt.deviceName = device.name
+					await this.scanAndConnect(scanOpt)
+					if (s.reconnectGeneration !== gen) return
+					this.reconnectDrawer = false
+					this.reconnectCount = 0
+					logger.info('adapter restore reconnect success')
+				} catch (e) {
+					if (s.reconnectGeneration !== gen) return
+					logger.error('adapter restore reconnect fail', e)
+					// 失败后进入常规重连流程(还有2次机会)
+					s.reconnectCount = 1
+					this._attemptReconnect(device, BLE_ERROR.ADAPTER_OFF)
+				}
+			}, 1500)
+		},
+
+		// ============== 收包处理 ==============
+		_onFrame(decoded, frame) {
+			logger.info('frame received', decoded.funcCode)
+			if (decoded.parsed) {
+				if (decoded.parsed.type === 'GROUP_1') {
+					this._handleGroup1(decoded.parsed)
+				} else if (decoded.parsed.type === 'GROUP_2') {
+					this._handleGroup2(decoded.parsed)
+				}
+			}
+		},
+
+		/** 解析参数组1 */
+		_handleGroup1(data) {
+			// 设备运行状态
+			switch (data.runtimeState) {
+				case 0x00: this.deviceStatus = 0; break
+				case 0x01:
+				case 0x02: this.deviceStatus = 1; break
+				case 0x03: this.deviceStatus = 3; break
+				case 0x04: this.deviceStatus = 4; break
+				case 0x05: this.deviceStatus = 5; break
+			}
+			// 模式
+			switch (data.mode) {
+				case MODE.LEISURE: this.modeType = 0; break
+				case MODE.PROFESSIONAL: this.modeType = 1; break
+				case MODE.PERSONAL: this.modeType = 2; break
+				case MODE.EXPERT: this.modeType = 3; break
+			}
+			// 剩余时间
+			const m = data.remainMinute || 0
+			const s = data.remainSecond || 0
+			const h = Math.floor(m / 60)
+			const m1 = m % 60
+			this.subTime = `${h.toString().padStart(2, '0')}:${m1.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
+			// 异常检测
+			if (data.foreignDetect === 0x02) {
+				this.excepDrawer = true
+				this.exceTxt = 1
+			}
+			// 耗材状态
+			this.otherSetting.aijiuNum = String(data.consumable || 0)
+			this.otherSetting.lvxinNum = String(data.filterPercent || 0)
+			this.otherSetting.huishouNum = String(data.recycleBinPct || 0)
+		},
+
+		/** 解析参数组2 */
+		_handleGroup2(data) {
+			if (data.preheatPercent < 100) {
+				this.ispreHot = false
+				this.hotPercentage = data.preheatPercent + '%'
+			} else {
+				this.ispreHot = true
+				this.hotPercentage = data.ignitePercent + '%'
+			}
+			// 椅子角度
+			if (data.chairAngle) {
+				const angleMap = {
+					1: 90, 2: 105, 3: 120, 4: 135, 5: 150
+				}
+				if (angleMap[data.chairAngle]) {
+					this.chairAngle = angleMap[data.chairAngle]
+				}
+			}
+		},
+
+		// ============== 发送指令 ==============
+		async writeRaw(u8) {
+			if (!this.linked) throw _err(BLE_ERROR.DISCONNECTED)
+			const s = _S()
+			const payload = bufferToArrayBuffer(u8)
+			s.writeLock = s.writeLock.then(async () => {
+				logger.log('=>', bytesToHex(u8))
+				logger.log('write params:', {
+					deviceId: this.device.deviceId,
+					serviceId: s.serviceId,
+					characteristicId: s.writeCharId,
+					valueByteLength: payload.byteLength
+				})
+				try {
+					await _invoke(uni.writeBLECharacteristicValue, {
+						deviceId: this.device.deviceId,
+						serviceId: s.serviceId,
+						characteristicId: s.writeCharId,
+						value: payload
+					})
+				} catch (e) {
+					logger.error('writeBLE origin error:', JSON.stringify(e))
+					throw _err(BLE_ERROR.WRITE_FAIL, e)
+				}
+			})
+			return s.writeLock
+		},
+
+		/** 下发基本功能指令 (0x01) */
+		sendBasic(opt) { return this.writeRaw(encodeBasic(opt)) },
+
+		/** 下发模式参数 1 (步骤 1-7) */
+		sendModeParam1(opt) { return this.writeRaw(encodeModeParam1(opt)) },
+
+		/** 下发模式参数 2 (步骤 8-14) */
+		sendModeParam2(opt) { return this.writeRaw(encodeModeParam2(opt)) },
+
+		/** 下发穴位坐标 */
+		async sendAcupoints(points = []) {
+			for (let i = 0; i < points.length; i += 2) {
+				const pair = points.slice(i, i + 2)
+				await this.writeRaw(encodeAcupoints(pair))
+			}
+		},
+
+		// ---- 常用快捷方法 ----
+		powerOn() { return this.sendBasic({ power: POWER.ON }) },
+		powerOff() { return this.sendBasic({ power: POWER.OFF }) },
+		startMoxi(opt = {}) { return this.sendBasic({ power: POWER.ON, moxiState: MOXI_STATE.START, ...opt }) },
+		pauseMoxi() { return this.sendBasic({ moxiState: MOXI_STATE.PAUSE }) },
+		stopMoxi() { return this.sendBasic({ moxiState: MOXI_STATE.DONE }) },
+		setMute(on) { return this.sendBasic({ mute: on ? MUTE.ON : MUTE.OFF }) },
+		setTemperature(v) { return this.sendBasic({ temperature: v }) },
+		setChairAngle(v) { return this.sendBasic({ angle: v }) },
+
+		// ============== 蓝牙关闭提示 ==============
+		/**
+		 * 弹窗提示用户蓝牙未开启,引导用户前往设置开启
+		 * 内部做防抖,避免短时间内重复弹窗
+		 */
+		_showBleOffPrompt(message) {
+			const s = _S()
+			// 弹窗正在显示中,不重复弹出
+			if (s._bleOffPromptShowing) return
+			s._bleOffPromptShowing = true
+
+			uni.showModal({
+				title: '蓝牙未开启',
+				content: message || '请开启手机蓝牙后重试',
+				confirmText: '去设置',
+				cancelText: '取消',
+				success: (res) => {
+					s._bleOffPromptShowing = false
+					if (res.confirm) {
+						// #ifdef APP-PLUS
+						const platform = uni.getSystemInfoSync().platform
+						if (platform === 'android') {
+							try {
+								const main = plus.android.runtimeMainActivity()
+								const Intent = plus.android.importClass('android.content.Intent')
+								const Settings = plus.android.importClass('android.provider.Settings')
+								const intent = new Intent(Settings.ACTION_BLUETOOTH_SETTINGS)
+								main.startActivity(intent)
+							} catch (e) {
+								logger.error('跳转蓝牙设置失败', e)
+							}
+						} else if (platform === 'ios') {
+							// iOS 可以打开 App 设置页
+							plus.runtime.openURL('App-Prefs:root=Bluetooth')
+						}
+						// #endif
+					}
+				}
+			})
+		}
+	}
+})

+ 3 - 3
code/ajyApp/utils/api/user.js

@@ -5,15 +5,15 @@ import http from '@/utils/http'
 
 /**
  * 获取当前用户个人资料
- * @returns {Promise<{userId, nickname, avatar, phone, appId}>}
+ * @returns {Promise<Object>} 当前账号与完整用户档案信息
  */
 export function getProfile() {
 	return http.get('/app/user/profile')
 }
 
 /**
- * 修改当前用户个人资料(昵称、头像)
- * @param {Object} data - { nickname?, avatar? }
+ * 修改当前用户个人资料
+ * @param {Object} data - 昵称、头像及用户档案字段
  */
 export function updateProfile(data) {
 	return http.put('/app/user/profile', data)

+ 2 - 0
code/ajyApp/utils/ble/BleManager.js

@@ -78,6 +78,7 @@ class BleManager extends EventEmitter {
   /** 打开蓝牙适配器 */
   async init() {
     if (this._state !== BLE_STATE.IDLE && this._state !== BLE_STATE.DISCONNECTED) {
+		console.log("蓝牙已经初始化不用重复初始化")
       return
     }
     try {
@@ -90,6 +91,7 @@ class BleManager extends EventEmitter {
     }
     this._bindSystemListeners()
     this._setState(BLE_STATE.READY)
+	console.log("蓝牙始化")
   }
 
   /** 彻底释放 */

+ 6 - 16
code/ajyApp/utils/ble/index.js

@@ -1,25 +1,15 @@
 /**
- * BLE 模块出口 —— 直接拿到单例
+ * BLE 模块出口
  *
- * 用法:
- *   import bleManager, { BLE_STATE, BLE_ERROR, MODE, TEMPERATURE } from '@/utils/ble'
+ * 蓝牙状态管理已迁移至 Pinia Store,请使用:
+ *   import { useBleStore } from '@/stores/ble'
+ *   const bleStore = useBleStore()
  *
- *   bleManager.configure({ deviceNamePrefix: 'AJY-', debug: true })
- *   await bleManager.init()
- *   const dev = await bleManager.scanAndConnect()
- *   bleManager.on('report:GROUP_1', data => console.log('参数组1', data))
- *   bleManager.on('report:GROUP_2', data => console.log('参数组2', data))
- *   await bleManager.startMoxi({ mode: 2, subMode: 1, temperature: 2, duration: 30 })
+ * 本文件仅导出协议常量、编解码工具、权限检查等基础工具
  */
 
-import BleManager from './BleManager.js'
-
 export * from './constants.js'
 export * from './protocol.js'
 export * from './permission.js'
 export { default as EventEmitter } from './EventEmitter.js'
-export { BleManager }
-
-// 单例导出
-const bleManager = BleManager.getInstance()
-export default bleManager
+export { default as logger } from './logger.js'

+ 52 - 2
code/ajyApp/utils/ble/permission.js

@@ -99,13 +99,63 @@ export function requestAndroidPermissions() {
   })
 }
 
-/** 一键: 申请权限 + 校验定位服务. 失败抛异常 */
+/** 检查系统蓝牙是否已开启 (Android 使用原生 API,iOS 依赖 uni API) */
+export function isBluetoothEnabled() {
+  return new Promise(resolve => {
+    const platform = getPlatform()
+    if (!isAppPlus()) return resolve(true)
+    // #ifdef APP-PLUS
+    if (platform === 'android') {
+      try {
+        const BluetoothAdapter = plus.android.importClass('android.bluetooth.BluetoothAdapter')
+        const adapter = BluetoothAdapter.getDefaultAdapter()
+        resolve(adapter != null && adapter.isEnabled())
+      } catch (e) {
+        // 无法检测时放行,让后续流程处理
+        resolve(true)
+      }
+    } else if (platform === 'ios') {
+      // iOS 无法直接通过原生 API 检测,依赖 uni.openBluetoothAdapter 的结果
+      resolve(true)
+    } else {
+      resolve(true)
+    }
+    // #endif
+    // #ifndef APP-PLUS
+    resolve(true)
+    // #endif
+  })
+}
+
+/** 跳转到系统蓝牙设置页面 */
+export function openBluetoothSettings() {
+  // #ifdef APP-PLUS
+  const platform = getPlatform()
+  if (platform === 'android') {
+    try {
+      const main = plus.android.runtimeMainActivity()
+      const Intent = plus.android.importClass('android.content.Intent')
+      const Settings = plus.android.importClass('android.provider.Settings')
+      const intent = new Intent(Settings.ACTION_BLUETOOTH_SETTINGS)
+      main.startActivity(intent)
+    } catch (e) { console.error('跳转蓝牙设置失败', e) }
+  } else if (platform === 'ios') {
+    plus.runtime.openURL('App-Prefs:root=Bluetooth')
+  }
+  // #endif
+}
+
+/** 一键: 申请权限 + 校验定位服务 + 检测蓝牙开启. 失败抛异常 */
 export async function ensureBlePrerequisite() {
-  if (getPlatform() === 'android') {
+  const platform = getPlatform()
+  if (platform === 'android') {
     const ok = await requestAndroidPermissions()
     if (!ok) throw new Error('BLE_PERMISSION_DENIED')
     const loc = await isLocationEnabled()
     if (!loc) throw new Error('BLE_LOCATION_OFF')
   }
+  // 检测蓝牙是否开启(Android 原生检测,iOS 跳过此步)
+  const bleOn = await isBluetoothEnabled()
+  if (!bleOn) throw new Error('BLE_ADAPTER_OFF')
   return true
 }

+ 48 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/AppDeviceController.java

@@ -0,0 +1,48 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.Log;
+import com.aijiuyi.admin.common.context.RequestContext;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.common.enums.OperationType;
+import com.aijiuyi.admin.controller.dto.AppDeviceBindDTO;
+import com.aijiuyi.admin.controller.dto.AppDeviceSelectDTO;
+import com.aijiuyi.admin.service.AppDeviceGroupService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.validation.Valid;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * App设备使用入口。
+ */
+@RestController
+@RequestMapping("/app/device")
+public class AppDeviceController {
+
+    @Autowired
+    private AppDeviceGroupService appDeviceGroupService;
+
+    @PostMapping("/bind")
+    @Log(value = "App绑定设备", module = "App设备", operationType = OperationType.INSERT)
+    public Result<Map<String, Object>> bind(@RequestBody @Valid AppDeviceBindDTO dto) {
+        return Result.success(appDeviceGroupService.bindDevice(RequestContext.getUserId(), dto));
+    }
+
+    @GetMapping("/list")
+    @Log(value = "App查询可用设备", module = "App设备", operationType = OperationType.QUERY)
+    public Result<List<Map<String, Object>>> list() {
+        return Result.success(appDeviceGroupService.listDevices(RequestContext.getUserId()));
+    }
+
+    @PostMapping("/select")
+    @Log(value = "App选择当前设备", module = "App设备", operationType = OperationType.UPDATE)
+    public Result<Map<String, Object>> select(@RequestBody @Valid AppDeviceSelectDTO dto) {
+        return Result.success(appDeviceGroupService.selectDevice(RequestContext.getUserId(), dto));
+    }
+}

+ 52 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/AppGroupController.java

@@ -0,0 +1,52 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.Log;
+import com.aijiuyi.admin.common.context.RequestContext;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.common.enums.OperationType;
+import com.aijiuyi.admin.controller.dto.AppGroupMemberDTO;
+import com.aijiuyi.admin.service.AppDeviceGroupService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.validation.Valid;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * App家庭群组成员管理。
+ */
+@RestController
+@RequestMapping("/app/group")
+public class AppGroupController {
+
+    @Autowired
+    private AppDeviceGroupService appDeviceGroupService;
+
+    @GetMapping("/{groupId}/members")
+    @Log(value = "App查询群组成员", module = "App群组", operationType = OperationType.QUERY)
+    public Result<List<Map<String, Object>>> members(@PathVariable Long groupId) {
+        return Result.success(appDeviceGroupService.listGroupMembers(RequestContext.getUserId(), groupId));
+    }
+
+    @PostMapping("/{groupId}/members")
+    @Log(value = "App新增群组成员", module = "App群组", operationType = OperationType.INSERT)
+    public Result<Map<String, Object>> addMember(@PathVariable Long groupId,
+                                                 @RequestBody @Valid AppGroupMemberDTO dto) {
+        return Result.success(appDeviceGroupService.addGroupMember(RequestContext.getUserId(), groupId, dto));
+    }
+
+    @PutMapping("/{groupId}/members/{profileId}")
+    @Log(value = "App修改群组成员", module = "App群组", operationType = OperationType.UPDATE)
+    public Result<Map<String, Object>> updateMember(@PathVariable Long groupId,
+                                                    @PathVariable Long profileId,
+                                                    @RequestBody @Valid AppGroupMemberDTO dto) {
+        return Result.success(appDeviceGroupService.updateGroupMember(RequestContext.getUserId(), groupId, profileId, dto));
+    }
+}

+ 13 - 39
code/backend/src/main/java/com/aijiuyi/admin/controller/AppProfileController.java

@@ -4,63 +4,37 @@ import com.aijiuyi.admin.common.annotation.Log;
 import com.aijiuyi.admin.common.context.RequestContext;
 import com.aijiuyi.admin.common.entity.Result;
 import com.aijiuyi.admin.common.enums.OperationType;
-import com.aijiuyi.admin.entity.AppUser;
-import com.aijiuyi.admin.service.AppUserService;
+import com.aijiuyi.admin.controller.dto.AppProfileDTO;
+import com.aijiuyi.admin.service.AppDeviceGroupService;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.web.bind.annotation.*;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
 
-import java.util.HashMap;
+import javax.validation.Valid;
 import java.util.Map;
 
 /**
- * App 端用户个人资料 Controller
- * 已登录用户获取/修改自己的个人信息
+ * App端用户个人资料 Controller。
  */
 @RestController
 @RequestMapping("/app/user")
 public class AppProfileController {
 
     @Autowired
-    private AppUserService appUserService;
+    private AppDeviceGroupService appDeviceGroupService;
 
-    /**
-     * 获取当前用户个人信息
-     *
-     * @return 用户基本信息(nickname、avatar、phone、appId)
-     */
     @GetMapping("/profile")
     @Log(value = "获取个人资料", module = "App用户", operationType = OperationType.QUERY)
     public Result<Map<String, Object>> getProfile() {
-        Long userId = RequestContext.getUserId();
-        AppUser user = appUserService.getById(userId);
-        Map<String, Object> data = new HashMap<>(8);
-        data.put("userId", user.getId());
-        data.put("nickname", user.getNickname());
-        data.put("avatar", user.getAvatar());
-        data.put("phone", user.getPhone());
-        data.put("appId", user.getAppId());
-        return Result.success(data);
+        return Result.success(appDeviceGroupService.getCurrentProfile(RequestContext.getUserId()));
     }
 
-    /**
-     * 修改当前用户个人信息(昵称、头像)
-     *
-     * @param body 包含 nickname 和/或 avatar 的请求体
-     * @return 操作结果
-     */
     @PutMapping("/profile")
     @Log(value = "修改个人资料", module = "App用户", operationType = OperationType.UPDATE)
-    public Result<Void> updateProfile(@RequestBody Map<String, String> body) {
-        Long userId = RequestContext.getUserId();
-        AppUser update = new AppUser();
-        update.setId(userId);
-        if (body.containsKey("nickname")) {
-            update.setNickname(body.get("nickname"));
-        }
-        if (body.containsKey("avatar")) {
-            update.setAvatar(body.get("avatar"));
-        }
-        appUserService.updateById(update);
-        return Result.success();
+    public Result<Map<String, Object>> updateProfile(@RequestBody @Valid AppProfileDTO dto) {
+        return Result.success(appDeviceGroupService.updateCurrentProfile(RequestContext.getUserId(), dto));
     }
 }

+ 16 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppDeviceBindDTO.java

@@ -0,0 +1,16 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+
+/**
+ * App设备绑定请求。
+ */
+@Data
+public class AppDeviceBindDTO {
+
+    /** 设备编号 */
+    @NotBlank(message = "设备编号不能为空")
+    private String deviceCode;
+}

+ 22 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppDeviceSelectDTO.java

@@ -0,0 +1,22 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+
+/**
+ * App当前使用设备选择请求。
+ */
+@Data
+public class AppDeviceSelectDTO {
+
+    /** 设备编号 */
+    @NotBlank(message = "设备编号不能为空")
+    private String deviceCode;
+
+    /** 家庭设备群组ID,公共设备可为空 */
+    private Long groupId;
+
+    /** 当前选择的用户档案ID */
+    private Long profileId;
+}

+ 62 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppGroupMemberDTO.java

@@ -0,0 +1,62 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.Pattern;
+import java.math.BigDecimal;
+
+/**
+ * App家庭群组成员资料请求。
+ */
+@Data
+public class AppGroupMemberDTO {
+
+    /** 姓名 */
+    @NotBlank(message = "姓名不能为空")
+    private String name;
+
+    /** 性别:1=男,2=女 */
+    private Integer gender;
+
+    /** 年龄 */
+    private Integer age;
+
+    /** 手机号,App端可为空 */
+    @Pattern(regexp = "^$|^1[3-9]\\d{9}$", message = "手机号格式不正确")
+    private String phone;
+
+    /** 用户分类ID */
+    private Long userCategoryId;
+
+    private String provinceCode;
+
+    private String cityCode;
+
+    private String districtCode;
+
+    private String address;
+
+    /** 肩宽(cm) */
+    private BigDecimal shoulderWidth;
+
+    /** 身长/坐高(cm) */
+    private BigDecimal bodyHeight;
+
+    /** C7-S4脊柱长度(cm) */
+    private BigDecimal spineLength;
+
+    /** 身高(cm) */
+    private BigDecimal height;
+
+    /** 体重(kg) */
+    private BigDecimal weight;
+
+    private BigDecimal fingerWidth1;
+
+    private BigDecimal fingerWidth15;
+
+    private BigDecimal fingerWidth3;
+
+    private Long acupointTableId;
+}

+ 52 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppProfileDTO.java

@@ -0,0 +1,52 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.Pattern;
+import java.math.BigDecimal;
+
+/**
+ * App当前账号个人档案请求。
+ */
+@Data
+public class AppProfileDTO {
+
+    private String nickname;
+
+    private String avatar;
+
+    private String name;
+
+    private Integer gender;
+
+    private Integer age;
+
+    @Pattern(regexp = "^$|^1[3-9]\\d{9}$", message = "手机号格式不正确")
+    private String phone;
+
+    private String provinceCode;
+
+    private String cityCode;
+
+    private String districtCode;
+
+    private String address;
+
+    private BigDecimal shoulderWidth;
+
+    private BigDecimal bodyHeight;
+
+    private BigDecimal spineLength;
+
+    private BigDecimal height;
+
+    private BigDecimal weight;
+
+    private BigDecimal fingerWidth1;
+
+    private BigDecimal fingerWidth15;
+
+    private BigDecimal fingerWidth3;
+
+    private Long acupointTableId;
+}

+ 3 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/DeviceQueryDTO.java

@@ -17,6 +17,9 @@ public class DeviceQueryDTO {
     /** 绑定用户姓名(模糊搜索,匹配该设备下任意绑定用户) */
     private String boundUserName;
 
+    /** 设备类型:1=家庭设备,2=公共设备 */
+    private Integer deviceType;
+
     /** 在线状态:1=在线,0=离线,null=全部 */
     private Integer onlineStatus;
 

+ 3 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/DeviceSaveDTO.java

@@ -22,6 +22,9 @@ public class DeviceSaveDTO {
     /** 设备型号(必填,AJY-2026 / AJY-2025) */
     private String deviceModel;
 
+    /** 设备类型:1=家庭设备,2=公共设备 */
+    private Integer deviceType;
+
     /** 设备序列号(唯一,必填,最长50字符) */
     private String serialNo;
 

+ 3 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/Device.java

@@ -27,6 +27,9 @@ public class Device {
     /** 设备型号(AJY-2026 / AJY-2025) */
     private String deviceModel;
 
+    /** 设备类型:1=家庭设备,2=公共设备 */
+    private Integer deviceType;
+
     /** 设备序列号(唯一) */
     private String serialNo;
 

+ 41 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/DeviceGroup.java

@@ -0,0 +1,41 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 家庭设备群组实体。
+ */
+@Data
+@TableName("device_group")
+public class DeviceGroup {
+
+    /** 主键ID */
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 家庭设备编号 */
+    private String deviceCode;
+
+    /** 状态:1=正常,0=停用 */
+    private Integer status;
+
+    /** 逻辑删除:0=未删除,1=已删除 */
+    @TableLogic
+    private Integer deleted;
+
+    /** 创建时间 */
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    /** 更新时间 */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 50 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/DeviceGroupMember.java

@@ -0,0 +1,50 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 家庭设备群组成员实体。
+ */
+@Data
+@TableName("device_group_member")
+public class DeviceGroupMember {
+
+    /** 主键ID */
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 群组ID */
+    private Long groupId;
+
+    /** 用户档案ID */
+    private Long profileId;
+
+    /** 关联App账号ID,可为空 */
+    private Long appUserId;
+
+    /** 状态:1=正常,0=移除 */
+    private Integer status;
+
+    /** 加入时间 */
+    private LocalDateTime joinTime;
+
+    /** 逻辑删除:0=未删除,1=已删除 */
+    @TableLogic
+    private Integer deleted;
+
+    /** 创建时间 */
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    /** 更新时间 */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 12 - 0
code/backend/src/main/java/com/aijiuyi/admin/mapper/DeviceGroupMapper.java

@@ -0,0 +1,12 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.DeviceGroup;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 家庭设备群组 Mapper。
+ */
+@Mapper
+public interface DeviceGroupMapper extends BaseMapper<DeviceGroup> {
+}

+ 12 - 0
code/backend/src/main/java/com/aijiuyi/admin/mapper/DeviceGroupMemberMapper.java

@@ -0,0 +1,12 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.DeviceGroupMember;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 家庭设备群组成员 Mapper。
+ */
+@Mapper
+public interface DeviceGroupMemberMapper extends BaseMapper<DeviceGroupMember> {
+}

+ 31 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/AppDeviceGroupService.java

@@ -0,0 +1,31 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.controller.dto.AppDeviceBindDTO;
+import com.aijiuyi.admin.controller.dto.AppDeviceSelectDTO;
+import com.aijiuyi.admin.controller.dto.AppGroupMemberDTO;
+import com.aijiuyi.admin.controller.dto.AppProfileDTO;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * App设备、家庭群组与用户档案服务。
+ */
+public interface AppDeviceGroupService {
+
+    Map<String, Object> bindDevice(Long appUserId, AppDeviceBindDTO dto);
+
+    List<Map<String, Object>> listDevices(Long appUserId);
+
+    Map<String, Object> selectDevice(Long appUserId, AppDeviceSelectDTO dto);
+
+    List<Map<String, Object>> listGroupMembers(Long appUserId, Long groupId);
+
+    Map<String, Object> addGroupMember(Long appUserId, Long groupId, AppGroupMemberDTO dto);
+
+    Map<String, Object> updateGroupMember(Long appUserId, Long groupId, Long profileId, AppGroupMemberDTO dto);
+
+    Map<String, Object> getCurrentProfile(Long appUserId);
+
+    Map<String, Object> updateCurrentProfile(Long appUserId, AppProfileDTO dto);
+}

+ 962 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/impl/AppDeviceGroupServiceImpl.java

@@ -0,0 +1,962 @@
+package com.aijiuyi.admin.service.impl;
+
+import com.aijiuyi.admin.common.constant.ResultCode;
+import com.aijiuyi.admin.common.exception.BusinessException;
+import com.aijiuyi.admin.controller.dto.AppDeviceBindDTO;
+import com.aijiuyi.admin.controller.dto.AppDeviceSelectDTO;
+import com.aijiuyi.admin.controller.dto.AppGroupMemberDTO;
+import com.aijiuyi.admin.controller.dto.AppProfileDTO;
+import com.aijiuyi.admin.entity.AppUser;
+import com.aijiuyi.admin.entity.Device;
+import com.aijiuyi.admin.entity.DeviceGroup;
+import com.aijiuyi.admin.entity.DeviceGroupMember;
+import com.aijiuyi.admin.entity.UserCategory;
+import com.aijiuyi.admin.entity.UserDevice;
+import com.aijiuyi.admin.entity.UserProfile;
+import com.aijiuyi.admin.mapper.AppUserMapper;
+import com.aijiuyi.admin.mapper.DeviceGroupMapper;
+import com.aijiuyi.admin.mapper.DeviceGroupMemberMapper;
+import com.aijiuyi.admin.mapper.DeviceMapper;
+import com.aijiuyi.admin.mapper.UserCategoryMapper;
+import com.aijiuyi.admin.mapper.UserDeviceMapper;
+import com.aijiuyi.admin.mapper.UserProfileMapper;
+import com.aijiuyi.admin.service.AppDeviceGroupService;
+import com.aijiuyi.admin.service.UserAcupointService;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/**
+ * App设备、家庭群组与用户档案服务实现。
+ */
+@Service
+public class AppDeviceGroupServiceImpl implements AppDeviceGroupService {
+
+    private static final int DEVICE_TYPE_FAMILY = 1;
+    private static final int DEVICE_TYPE_PUBLIC = 2;
+    private static final int NORMAL_USER_TYPE = 1;
+    private static final int STATUS_ENABLED = 1;
+    private static final String PHONE_PATTERN = "^1[3-9]\\d{9}$";
+
+    @Autowired
+    private AppUserMapper appUserMapper;
+
+    @Autowired
+    private DeviceMapper deviceMapper;
+
+    @Autowired
+    private DeviceGroupMapper deviceGroupMapper;
+
+    @Autowired
+    private DeviceGroupMemberMapper deviceGroupMemberMapper;
+
+    @Autowired
+    private UserProfileMapper userProfileMapper;
+
+    @Autowired
+    private UserDeviceMapper userDeviceMapper;
+
+    @Autowired
+    private UserCategoryMapper userCategoryMapper;
+
+    @Autowired
+    private UserAcupointService userAcupointService;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> bindDevice(Long appUserId, AppDeviceBindDTO dto) {
+        AppUser appUser = requireAppUser(appUserId);
+        Device device = requireDevice(dto.getDeviceCode());
+        UserProfile accountProfile = ensureAccountProfile(appUser);
+
+        if (isPublicDevice(device)) {
+            ensureUserDevice(accountProfile, device);
+            return buildDeviceContext(device, null, null, accountProfile);
+        }
+
+        DeviceGroup group = ensureDeviceGroup(device);
+        DeviceGroupMember member = findActiveMemberByAppUserId(group.getId(), appUserId);
+        UserProfile profile;
+        if (member != null) {
+            profile = requireProfile(member.getProfileId());
+        } else {
+            member = findActiveMemberByProfileId(group.getId(), accountProfile.getId());
+            if (member == null) {
+                member = createGroupMember(group.getId(), accountProfile.getId(), appUserId);
+            } else if (!Objects.equals(member.getAppUserId(), appUserId)) {
+                DeviceGroupMember update = new DeviceGroupMember();
+                update.setId(member.getId());
+                update.setAppUserId(appUserId);
+                deviceGroupMemberMapper.updateById(update);
+                member.setAppUserId(appUserId);
+            }
+            profile = accountProfile;
+        }
+
+        ensureUserDevice(profile, device);
+        return buildDeviceContext(device, group, member, profile);
+    }
+
+    @Override
+    public List<Map<String, Object>> listDevices(Long appUserId) {
+        requireAppUser(appUserId);
+        Map<String, Map<String, Object>> result = new LinkedHashMap<>();
+
+        List<DeviceGroupMember> familyMembers = deviceGroupMemberMapper.selectList(
+                new LambdaQueryWrapper<DeviceGroupMember>()
+                        .eq(DeviceGroupMember::getAppUserId, appUserId)
+                        .eq(DeviceGroupMember::getStatus, STATUS_ENABLED)
+                        .orderByDesc(DeviceGroupMember::getJoinTime)
+        );
+        for (DeviceGroupMember member : familyMembers) {
+            DeviceGroup group = deviceGroupMapper.selectById(member.getGroupId());
+            if (group == null || !Integer.valueOf(STATUS_ENABLED).equals(group.getStatus())) {
+                continue;
+            }
+            Device device = findDeviceByCode(group.getDeviceCode());
+            if (device == null || !isFamilyDevice(device)) {
+                continue;
+            }
+            UserProfile profile = userProfileMapper.selectById(member.getProfileId());
+            result.put("F:" + device.getDeviceCode(), buildDeviceListItem(device, group, member, profile));
+        }
+
+        List<UserProfile> profiles = userProfileMapper.selectList(
+                new LambdaQueryWrapper<UserProfile>().eq(UserProfile::getUserId, appUserId)
+        );
+        if (!CollectionUtils.isEmpty(profiles)) {
+            List<Long> profileIds = profiles.stream().map(UserProfile::getId).collect(Collectors.toList());
+            List<UserDevice> bindings = userDeviceMapper.selectList(
+                    new LambdaQueryWrapper<UserDevice>()
+                            .in(UserDevice::getUserId, profileIds)
+                            .eq(UserDevice::getStatus, STATUS_ENABLED)
+                            .orderByDesc(UserDevice::getBindTime)
+            );
+            Map<Long, UserProfile> profileMap = profiles.stream()
+                    .collect(Collectors.toMap(UserProfile::getId, p -> p, (a, b) -> a));
+            for (UserDevice binding : bindings) {
+                Device device = findDeviceByCode(binding.getDeviceCode());
+                if (device == null || !isPublicDevice(device)) {
+                    continue;
+                }
+                UserProfile profile = profileMap.get(binding.getUserId());
+                result.put("P:" + device.getDeviceCode(), buildDeviceListItem(device, null, null, profile));
+            }
+        }
+
+        return new ArrayList<>(result.values());
+    }
+
+    @Override
+    public Map<String, Object> selectDevice(Long appUserId, AppDeviceSelectDTO dto) {
+        requireAppUser(appUserId);
+        Device device = requireDevice(dto.getDeviceCode());
+        if (isFamilyDevice(device)) {
+            DeviceGroup group = dto.getGroupId() != null
+                    ? requireGroup(dto.getGroupId())
+                    : requireDeviceGroup(device.getDeviceCode());
+            if (!device.getDeviceCode().equals(group.getDeviceCode())) {
+                throw new BusinessException(ResultCode.FORBIDDEN.getCode(), "无权使用该家庭设备");
+            }
+            DeviceGroupMember currentMember = requireCurrentAccountGroupMember(group.getId(), appUserId);
+            DeviceGroupMember selectedMember = currentMember;
+            if (dto.getProfileId() != null) {
+                selectedMember = findActiveMemberByProfileId(group.getId(), dto.getProfileId());
+                if (selectedMember == null) {
+                    throw new BusinessException(ResultCode.FORBIDDEN.getCode(), "所选成员不属于该群组");
+                }
+            }
+            UserProfile selectedProfile = requireProfile(selectedMember.getProfileId());
+            return buildDeviceContext(device, group, selectedMember, selectedProfile);
+        }
+
+        UserProfile selectedProfile = dto.getProfileId() != null
+                ? requireProfile(dto.getProfileId())
+                : ensureAccountProfile(requireAppUser(appUserId));
+        if (!Objects.equals(selectedProfile.getUserId(), appUserId)) {
+            throw new BusinessException(ResultCode.FORBIDDEN.getCode(), "公共设备只能选择当前账号档案");
+        }
+        if (!hasActiveUserDevice(selectedProfile.getId(), device.getDeviceCode())) {
+            throw new BusinessException(ResultCode.FORBIDDEN.getCode(), "请先绑定设备");
+        }
+        return buildDeviceContext(device, null, null, selectedProfile);
+    }
+
+    @Override
+    public List<Map<String, Object>> listGroupMembers(Long appUserId, Long groupId) {
+        DeviceGroup group = assertGroupAccess(groupId, appUserId);
+        List<DeviceGroupMember> members = deviceGroupMemberMapper.selectList(
+                new LambdaQueryWrapper<DeviceGroupMember>()
+                        .eq(DeviceGroupMember::getGroupId, group.getId())
+                        .eq(DeviceGroupMember::getStatus, STATUS_ENABLED)
+                        .orderByAsc(DeviceGroupMember::getJoinTime)
+        );
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (DeviceGroupMember member : members) {
+            UserProfile profile = userProfileMapper.selectById(member.getProfileId());
+            if (profile != null) {
+                result.add(buildMemberMap(member, profile));
+            }
+        }
+        return result;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> addGroupMember(Long appUserId, Long groupId, AppGroupMemberDTO dto) {
+        DeviceGroup group = assertGroupAccess(groupId, appUserId);
+        validateFingerWidths(dto.getGender(), dto.getFingerWidth1(), dto.getFingerWidth15(), dto.getFingerWidth3());
+        String phone = normalizePhone(dto.getPhone());
+        checkGroupPhoneAvailable(group.getId(), phone, null);
+
+        AppUser linkedAccount = ensureAccountForPhone(phone, dto.getName(), dto.getUserCategoryId());
+        checkGroupAppUserAvailable(group.getId(), linkedAccount == null ? null : linkedAccount.getId(), null);
+
+        UserProfile profile = resolveProfileForNewMember(dto, phone, linkedAccount);
+        DeviceGroupMember member = createGroupMember(group.getId(), profile.getId(), linkedAccount == null ? null : linkedAccount.getId());
+
+        Device device = requireDevice(group.getDeviceCode());
+        ensureUserDevice(profile, device);
+        regenerateIfComplete(profile);
+        return buildMemberMap(member, profile);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> updateGroupMember(Long appUserId, Long groupId, Long profileId, AppGroupMemberDTO dto) {
+        DeviceGroup group = assertGroupAccess(groupId, appUserId);
+        DeviceGroupMember member = findActiveMemberByProfileId(group.getId(), profileId);
+        if (member == null) {
+            throw new BusinessException(ResultCode.PROFILE_NOT_FOUND);
+        }
+        requireProfile(profileId);
+        validateFingerWidths(dto.getGender(), dto.getFingerWidth1(), dto.getFingerWidth15(), dto.getFingerWidth3());
+
+        String phone = normalizePhone(dto.getPhone());
+        checkGroupPhoneAvailable(group.getId(), phone, profileId);
+        AppUser linkedAccount = ensureAccountForPhone(phone, dto.getName(), dto.getUserCategoryId());
+        Long linkedAppUserId = linkedAccount == null ? null : linkedAccount.getId();
+        checkGroupAppUserAvailable(group.getId(), linkedAppUserId, profileId);
+
+        updateProfileFromGroupDto(profileId, dto, phone, linkedAppUserId);
+        updateMemberAppUser(member, linkedAppUserId);
+
+        UserProfile updatedProfile = requireProfile(profileId);
+        Device device = requireDevice(group.getDeviceCode());
+        ensureUserDevice(updatedProfile, device);
+        regenerateIfComplete(updatedProfile);
+        return buildMemberMap(member, updatedProfile);
+    }
+
+    @Override
+    public Map<String, Object> getCurrentProfile(Long appUserId) {
+        AppUser appUser = requireAppUser(appUserId);
+        UserProfile profile = ensureAccountProfile(appUser);
+        return buildCurrentProfileMap(appUser, profile);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> updateCurrentProfile(Long appUserId, AppProfileDTO dto) {
+        AppUser appUser = requireAppUser(appUserId);
+        if (StringUtils.hasText(dto.getNickname()) || StringUtils.hasText(dto.getAvatar())) {
+            AppUser updateUser = new AppUser();
+            updateUser.setId(appUser.getId());
+            if (StringUtils.hasText(dto.getNickname())) {
+                updateUser.setNickname(dto.getNickname().trim());
+                appUser.setNickname(updateUser.getNickname());
+            }
+            if (StringUtils.hasText(dto.getAvatar())) {
+                updateUser.setAvatar(dto.getAvatar().trim());
+                appUser.setAvatar(updateUser.getAvatar());
+            }
+            appUserMapper.updateById(updateUser);
+        }
+
+        UserProfile profile = ensureAccountProfile(appUser);
+        validateFingerWidths(dto.getGender(), dto.getFingerWidth1(), dto.getFingerWidth15(), dto.getFingerWidth3());
+        UserProfile updateProfile = new UserProfile();
+        updateProfile.setId(profile.getId());
+        boolean changed = fillNonNullProfileFields(updateProfile, dto);
+        if (changed) {
+            userProfileMapper.updateById(updateProfile);
+            profile = requireProfile(profile.getId());
+            regenerateIfComplete(profile);
+        }
+        return buildCurrentProfileMap(appUser, profile);
+    }
+
+    private AppUser requireAppUser(Long appUserId) {
+        AppUser appUser = appUserMapper.selectById(appUserId);
+        if (appUser == null) {
+            throw new BusinessException(ResultCode.APP_USER_NOT_FOUND);
+        }
+        if (Integer.valueOf(0).equals(appUser.getStatus())) {
+            throw new BusinessException(ResultCode.APP_USER_DISABLED);
+        }
+        return appUser;
+    }
+
+    private Device requireDevice(String deviceCode) {
+        Device device = findDeviceByCode(deviceCode);
+        if (device == null) {
+            throw new BusinessException(ResultCode.DEVICE_NOT_FOUND);
+        }
+        return device;
+    }
+
+    private Device findDeviceByCode(String deviceCode) {
+        String code = trimToNull(deviceCode);
+        if (code == null) {
+            throw new BusinessException(ResultCode.PARAM_ERROR.getCode(), "设备编号不能为空");
+        }
+        return deviceMapper.selectOne(
+                new LambdaQueryWrapper<Device>()
+                        .eq(Device::getDeviceCode, code)
+                        .last("LIMIT 1")
+        );
+    }
+
+    private UserProfile requireProfile(Long profileId) {
+        UserProfile profile = userProfileMapper.selectById(profileId);
+        if (profile == null) {
+            throw new BusinessException(ResultCode.PROFILE_NOT_FOUND);
+        }
+        return profile;
+    }
+
+    private DeviceGroup requireGroup(Long groupId) {
+        DeviceGroup group = deviceGroupMapper.selectById(groupId);
+        if (group == null || !Integer.valueOf(STATUS_ENABLED).equals(group.getStatus())) {
+            throw new BusinessException(ResultCode.NOT_FOUND.getCode(), "家庭群组不存在");
+        }
+        return group;
+    }
+
+    private DeviceGroup requireDeviceGroup(String deviceCode) {
+        DeviceGroup group = findDeviceGroupByCode(deviceCode);
+        if (group == null) {
+            throw new BusinessException(ResultCode.NOT_FOUND.getCode(), "设备尚未建立家庭群组,请先绑定设备");
+        }
+        return group;
+    }
+
+    private DeviceGroup assertGroupAccess(Long groupId, Long appUserId) {
+        DeviceGroup group = requireGroup(groupId);
+        requireCurrentAccountGroupMember(group.getId(), appUserId);
+        return group;
+    }
+
+    private DeviceGroupMember requireCurrentAccountGroupMember(Long groupId, Long appUserId) {
+        DeviceGroupMember member = findActiveMemberByAppUserId(groupId, appUserId);
+        if (member == null) {
+            throw new BusinessException(ResultCode.FORBIDDEN.getCode(), "无权访问该家庭群组");
+        }
+        return member;
+    }
+
+    private DeviceGroup ensureDeviceGroup(Device device) {
+        DeviceGroup group = findDeviceGroupByCode(device.getDeviceCode());
+        if (group != null) {
+            return group;
+        }
+        group = new DeviceGroup();
+        group.setDeviceCode(device.getDeviceCode());
+        group.setStatus(STATUS_ENABLED);
+        deviceGroupMapper.insert(group);
+        return group;
+    }
+
+    private DeviceGroup findDeviceGroupByCode(String deviceCode) {
+        return deviceGroupMapper.selectOne(
+                new LambdaQueryWrapper<DeviceGroup>()
+                        .eq(DeviceGroup::getDeviceCode, deviceCode)
+                        .eq(DeviceGroup::getStatus, STATUS_ENABLED)
+                        .last("LIMIT 1")
+        );
+    }
+
+    private DeviceGroupMember createGroupMember(Long groupId, Long profileId, Long appUserId) {
+        DeviceGroupMember member = new DeviceGroupMember();
+        member.setGroupId(groupId);
+        member.setProfileId(profileId);
+        member.setAppUserId(appUserId);
+        member.setStatus(STATUS_ENABLED);
+        member.setJoinTime(LocalDateTime.now());
+        deviceGroupMemberMapper.insert(member);
+        return member;
+    }
+
+    private DeviceGroupMember findActiveMemberByProfileId(Long groupId, Long profileId) {
+        return deviceGroupMemberMapper.selectOne(
+                new LambdaQueryWrapper<DeviceGroupMember>()
+                        .eq(DeviceGroupMember::getGroupId, groupId)
+                        .eq(DeviceGroupMember::getProfileId, profileId)
+                        .eq(DeviceGroupMember::getStatus, STATUS_ENABLED)
+                        .last("LIMIT 1")
+        );
+    }
+
+    private DeviceGroupMember findActiveMemberByAppUserId(Long groupId, Long appUserId) {
+        return deviceGroupMemberMapper.selectOne(
+                new LambdaQueryWrapper<DeviceGroupMember>()
+                        .eq(DeviceGroupMember::getGroupId, groupId)
+                        .eq(DeviceGroupMember::getAppUserId, appUserId)
+                        .eq(DeviceGroupMember::getStatus, STATUS_ENABLED)
+                        .last("LIMIT 1")
+        );
+    }
+
+    private UserProfile ensureAccountProfile(AppUser appUser) {
+        UserProfile profile = userProfileMapper.selectOne(
+                new LambdaQueryWrapper<UserProfile>()
+                        .eq(UserProfile::getUserId, appUser.getId())
+                        .orderByAsc(UserProfile::getCreateTime)
+                        .last("LIMIT 1")
+        );
+        if (profile != null) {
+            return profile;
+        }
+
+        String phone = trimToNull(appUser.getPhone());
+        if (phone != null) {
+            profile = userProfileMapper.selectOne(
+                    new LambdaQueryWrapper<UserProfile>()
+                            .eq(UserProfile::getPhone, phone)
+                            .and(w -> w.isNull(UserProfile::getUserId).or().eq(UserProfile::getUserId, appUser.getId()))
+                            .orderByAsc(UserProfile::getCreateTime)
+                            .last("LIMIT 1")
+            );
+            if (profile != null) {
+                UserProfile update = new UserProfile();
+                update.setId(profile.getId());
+                update.setUserId(appUser.getId());
+                userProfileMapper.updateById(update);
+                profile.setUserId(appUser.getId());
+                return profile;
+            }
+        }
+
+        profile = new UserProfile();
+        profile.setUserId(appUser.getId());
+        profile.setPhone(phone);
+        profile.setName(resolveProfileName(appUser.getNickname(), phone, appUser.getId()));
+        profile.setUserType(NORMAL_USER_TYPE);
+        profile.setAddress(appUser.getAddress());
+        userProfileMapper.insert(profile);
+        return profile;
+    }
+
+    private AppUser ensureAccountForPhone(String phone, String name, Long userCategoryId) {
+        if (phone == null) {
+            return null;
+        }
+        validatePhone(phone);
+        AppUser user = appUserMapper.selectOne(
+                new LambdaQueryWrapper<AppUser>()
+                        .eq(AppUser::getPhone, phone)
+                        .last("LIMIT 1")
+        );
+        if (user != null) {
+            if (Integer.valueOf(0).equals(user.getStatus())) {
+                throw new BusinessException(ResultCode.APP_USER_DISABLED);
+            }
+            syncUserCategory(user, userCategoryId);
+            return user;
+        }
+
+        user = new AppUser();
+        user.setPhone(phone);
+        user.setNickname(resolveProfileName(name, phone, null));
+        user.setUserType(NORMAL_USER_TYPE);
+        user.setUserCategoryId(userCategoryId != null ? validateUserCategory(userCategoryId) : getDefaultUserCategoryId());
+        user.setStatus(STATUS_ENABLED);
+        appUserMapper.insert(user);
+        AppUser update = new AppUser();
+        update.setId(user.getId());
+        update.setAppId("AJY" + String.format("%08d", user.getId()));
+        appUserMapper.updateById(update);
+        user.setAppId(update.getAppId());
+        return user;
+    }
+
+    private void syncUserCategory(AppUser user, Long userCategoryId) {
+        if (userCategoryId == null || !Integer.valueOf(NORMAL_USER_TYPE).equals(user.getUserType())) {
+            return;
+        }
+        Long validCategoryId = validateUserCategory(userCategoryId);
+        if (validCategoryId.equals(user.getUserCategoryId())) {
+            return;
+        }
+        AppUser update = new AppUser();
+        update.setId(user.getId());
+        update.setUserCategoryId(validCategoryId);
+        appUserMapper.updateById(update);
+        user.setUserCategoryId(validCategoryId);
+    }
+
+    private Long validateUserCategory(Long userCategoryId) {
+        if (userCategoryId == null) {
+            return null;
+        }
+        long count = userCategoryMapper.selectCount(new LambdaQueryWrapper<UserCategory>()
+                .eq(UserCategory::getId, userCategoryId)
+                .eq(UserCategory::getUserType, NORMAL_USER_TYPE));
+        if (count <= 0) {
+            throw new BusinessException(ResultCode.USER_CATEGORY_NOT_FOUND);
+        }
+        return userCategoryId;
+    }
+
+    private Long getDefaultUserCategoryId() {
+        UserCategory category = userCategoryMapper.selectOne(new LambdaQueryWrapper<UserCategory>()
+                .eq(UserCategory::getUserType, NORMAL_USER_TYPE)
+                .eq(UserCategory::getName, "普通用户")
+                .last("LIMIT 1"));
+        if (category == null) {
+            category = userCategoryMapper.selectOne(new LambdaQueryWrapper<UserCategory>()
+                    .eq(UserCategory::getUserType, NORMAL_USER_TYPE)
+                    .orderByAsc(UserCategory::getId)
+                    .last("LIMIT 1"));
+        }
+        return category != null ? category.getId() : null;
+    }
+
+    private UserProfile buildProfileFromGroupDto(AppGroupMemberDTO dto, String phone, Long appUserId) {
+        UserProfile profile = new UserProfile();
+        profile.setUserId(appUserId);
+        profile.setName(dto.getName().trim());
+        profile.setGender(dto.getGender());
+        profile.setAge(dto.getAge());
+        profile.setPhone(phone);
+        profile.setUserType(NORMAL_USER_TYPE);
+        profile.setProvinceCode(trimToNull(dto.getProvinceCode()));
+        profile.setCityCode(trimToNull(dto.getCityCode()));
+        profile.setDistrictCode(trimToNull(dto.getDistrictCode()));
+        profile.setAddress(trimToNull(dto.getAddress()));
+        profile.setShoulderWidth(dto.getShoulderWidth());
+        profile.setBodyHeight(dto.getBodyHeight());
+        profile.setSpineLength(dto.getSpineLength());
+        profile.setHeight(dto.getHeight());
+        profile.setWeight(dto.getWeight());
+        profile.setFingerWidth1(dto.getFingerWidth1());
+        profile.setFingerWidth15(dto.getFingerWidth15());
+        profile.setFingerWidth3(dto.getFingerWidth3());
+        profile.setAcupointTableId(dto.getAcupointTableId());
+        return profile;
+    }
+
+    private UserProfile resolveProfileForNewMember(AppGroupMemberDTO dto, String phone, AppUser linkedAccount) {
+        if (linkedAccount == null) {
+            UserProfile profile = buildProfileFromGroupDto(dto, phone, null);
+            userProfileMapper.insert(profile);
+            return profile;
+        }
+
+        UserProfile profile = userProfileMapper.selectOne(
+                new LambdaQueryWrapper<UserProfile>()
+                        .eq(UserProfile::getUserId, linkedAccount.getId())
+                        .orderByAsc(UserProfile::getCreateTime)
+                        .last("LIMIT 1")
+        );
+        if (profile == null) {
+            profile = userProfileMapper.selectOne(
+                    new LambdaQueryWrapper<UserProfile>()
+                            .eq(UserProfile::getPhone, phone)
+                            .isNull(UserProfile::getUserId)
+                            .orderByAsc(UserProfile::getCreateTime)
+                            .last("LIMIT 1")
+            );
+        }
+        if (profile == null) {
+            profile = buildProfileFromGroupDto(dto, phone, linkedAccount.getId());
+            userProfileMapper.insert(profile);
+            return profile;
+        }
+
+        updateProfileFromGroupDto(profile.getId(), dto, phone, linkedAccount.getId());
+        return requireProfile(profile.getId());
+    }
+
+    private void updateProfileFromGroupDto(Long profileId, AppGroupMemberDTO dto, String phone, Long appUserId) {
+        LambdaUpdateWrapper<UserProfile> wrapper = new LambdaUpdateWrapper<UserProfile>()
+                .eq(UserProfile::getId, profileId)
+                .set(UserProfile::getUserId, appUserId)
+                .set(UserProfile::getName, dto.getName().trim())
+                .set(UserProfile::getGender, dto.getGender())
+                .set(UserProfile::getAge, dto.getAge())
+                .set(UserProfile::getPhone, phone)
+                .set(UserProfile::getProvinceCode, trimToNull(dto.getProvinceCode()))
+                .set(UserProfile::getCityCode, trimToNull(dto.getCityCode()))
+                .set(UserProfile::getDistrictCode, trimToNull(dto.getDistrictCode()))
+                .set(UserProfile::getAddress, trimToNull(dto.getAddress()))
+                .set(UserProfile::getShoulderWidth, dto.getShoulderWidth())
+                .set(UserProfile::getBodyHeight, dto.getBodyHeight())
+                .set(UserProfile::getSpineLength, dto.getSpineLength())
+                .set(UserProfile::getHeight, dto.getHeight())
+                .set(UserProfile::getWeight, dto.getWeight())
+                .set(UserProfile::getFingerWidth1, dto.getFingerWidth1())
+                .set(UserProfile::getFingerWidth15, dto.getFingerWidth15())
+                .set(UserProfile::getFingerWidth3, dto.getFingerWidth3())
+                .set(UserProfile::getAcupointTableId, dto.getAcupointTableId());
+        userProfileMapper.update(null, wrapper);
+    }
+
+    private boolean fillNonNullProfileFields(UserProfile profile, AppProfileDTO dto) {
+        boolean changed = false;
+        if (StringUtils.hasText(dto.getName())) {
+            profile.setName(dto.getName().trim());
+            changed = true;
+        }
+        if (dto.getGender() != null) {
+            profile.setGender(dto.getGender());
+            changed = true;
+        }
+        if (dto.getAge() != null) {
+            profile.setAge(dto.getAge());
+            changed = true;
+        }
+        String phone = normalizePhone(dto.getPhone());
+        if (phone != null) {
+            profile.setPhone(phone);
+            changed = true;
+        }
+        if (dto.getProvinceCode() != null) {
+            profile.setProvinceCode(trimToNull(dto.getProvinceCode()));
+            changed = true;
+        }
+        if (dto.getCityCode() != null) {
+            profile.setCityCode(trimToNull(dto.getCityCode()));
+            changed = true;
+        }
+        if (dto.getDistrictCode() != null) {
+            profile.setDistrictCode(trimToNull(dto.getDistrictCode()));
+            changed = true;
+        }
+        if (dto.getAddress() != null) {
+            profile.setAddress(trimToNull(dto.getAddress()));
+            changed = true;
+        }
+        if (dto.getShoulderWidth() != null) {
+            profile.setShoulderWidth(dto.getShoulderWidth());
+            changed = true;
+        }
+        if (dto.getBodyHeight() != null) {
+            profile.setBodyHeight(dto.getBodyHeight());
+            changed = true;
+        }
+        if (dto.getSpineLength() != null) {
+            profile.setSpineLength(dto.getSpineLength());
+            changed = true;
+        }
+        if (dto.getHeight() != null) {
+            profile.setHeight(dto.getHeight());
+            changed = true;
+        }
+        if (dto.getWeight() != null) {
+            profile.setWeight(dto.getWeight());
+            changed = true;
+        }
+        if (dto.getFingerWidth1() != null) {
+            profile.setFingerWidth1(dto.getFingerWidth1());
+            changed = true;
+        }
+        if (dto.getFingerWidth15() != null) {
+            profile.setFingerWidth15(dto.getFingerWidth15());
+            changed = true;
+        }
+        if (dto.getFingerWidth3() != null) {
+            profile.setFingerWidth3(dto.getFingerWidth3());
+            changed = true;
+        }
+        if (dto.getAcupointTableId() != null) {
+            profile.setAcupointTableId(dto.getAcupointTableId());
+            changed = true;
+        }
+        return changed;
+    }
+
+    private void updateMemberAppUser(DeviceGroupMember member, Long appUserId) {
+        LambdaUpdateWrapper<DeviceGroupMember> wrapper = new LambdaUpdateWrapper<DeviceGroupMember>()
+                .eq(DeviceGroupMember::getId, member.getId())
+                .set(DeviceGroupMember::getAppUserId, appUserId);
+        deviceGroupMemberMapper.update(null, wrapper);
+        member.setAppUserId(appUserId);
+    }
+
+    private void ensureUserDevice(UserProfile profile, Device device) {
+        UserDevice binding = userDeviceMapper.selectOne(
+                new LambdaQueryWrapper<UserDevice>()
+                        .eq(UserDevice::getUserId, profile.getId())
+                        .eq(UserDevice::getDeviceCode, device.getDeviceCode())
+                        .last("LIMIT 1")
+        );
+        if (binding == null) {
+            binding = new UserDevice();
+            binding.setUserId(profile.getId());
+            binding.setDeviceCode(device.getDeviceCode());
+            binding.setDeviceName(device.getDeviceName());
+            binding.setDeviceModel(device.getDeviceModel());
+            binding.setIsPrimary(0);
+            binding.setOnlineStatus(device.getOnlineStatus());
+            binding.setStatus(STATUS_ENABLED);
+            binding.setBindTime(LocalDateTime.now());
+            userDeviceMapper.insert(binding);
+            return;
+        }
+        LambdaUpdateWrapper<UserDevice> wrapper = new LambdaUpdateWrapper<UserDevice>()
+                .eq(UserDevice::getId, binding.getId())
+                .set(UserDevice::getDeviceName, device.getDeviceName())
+                .set(UserDevice::getDeviceModel, device.getDeviceModel())
+                .set(UserDevice::getOnlineStatus, device.getOnlineStatus())
+                .set(UserDevice::getStatus, STATUS_ENABLED);
+        userDeviceMapper.update(null, wrapper);
+    }
+
+    private boolean hasActiveUserDevice(Long profileId, String deviceCode) {
+        long count = userDeviceMapper.selectCount(
+                new LambdaQueryWrapper<UserDevice>()
+                        .eq(UserDevice::getUserId, profileId)
+                        .eq(UserDevice::getDeviceCode, deviceCode)
+                        .eq(UserDevice::getStatus, STATUS_ENABLED)
+        );
+        return count > 0;
+    }
+
+    private void checkGroupPhoneAvailable(Long groupId, String phone, Long excludeProfileId) {
+        if (phone == null) {
+            return;
+        }
+        List<DeviceGroupMember> members = deviceGroupMemberMapper.selectList(
+                new LambdaQueryWrapper<DeviceGroupMember>()
+                        .eq(DeviceGroupMember::getGroupId, groupId)
+                        .eq(DeviceGroupMember::getStatus, STATUS_ENABLED)
+        );
+        for (DeviceGroupMember member : members) {
+            if (Objects.equals(member.getProfileId(), excludeProfileId)) {
+                continue;
+            }
+            UserProfile profile = userProfileMapper.selectById(member.getProfileId());
+            if (profile != null && phone.equals(profile.getPhone())) {
+                throw new BusinessException(ResultCode.PHONE_PROFILE_EXISTS.getCode(), "同一群组内手机号不能重复");
+            }
+        }
+    }
+
+    private void checkGroupAppUserAvailable(Long groupId, Long appUserId, Long excludeProfileId) {
+        if (appUserId == null) {
+            return;
+        }
+        DeviceGroupMember member = findActiveMemberByAppUserId(groupId, appUserId);
+        if (member != null && !Objects.equals(member.getProfileId(), excludeProfileId)) {
+            throw new BusinessException(ResultCode.PHONE_PROFILE_EXISTS.getCode(), "该手机号对应账号已在群组中");
+        }
+    }
+
+    private void regenerateIfComplete(UserProfile profile) {
+        if (isProfileComplete(profile)) {
+            userAcupointService.generateForUser(profile.getId());
+        }
+    }
+
+    private boolean isProfileComplete(UserProfile profile) {
+        return profile != null
+                && StringUtils.hasText(profile.getName())
+                && profile.getGender() != null
+                && profile.getAge() != null
+                && profile.getShoulderWidth() != null
+                && profile.getBodyHeight() != null
+                && profile.getSpineLength() != null;
+    }
+
+    private void validateFingerWidths(Integer gender, BigDecimal fingerWidth1,
+                                      BigDecimal fingerWidth15, BigDecimal fingerWidth3) {
+        boolean male = Integer.valueOf(1).equals(gender);
+        boolean female = Integer.valueOf(2).equals(gender);
+        if (!male && !female) {
+            return;
+        }
+        checkFingerWidth("1寸", fingerWidth1,
+                male ? new BigDecimal("1.5") : new BigDecimal("1.3"),
+                male ? new BigDecimal("2.8") : new BigDecimal("2.4"));
+        checkFingerWidth("1.5寸", fingerWidth15,
+                male ? new BigDecimal("2.2") : new BigDecimal("2.0"),
+                male ? new BigDecimal("4.0") : new BigDecimal("3.5"));
+        checkFingerWidth("3寸", fingerWidth3,
+                male ? new BigDecimal("4.5") : new BigDecimal("4.0"),
+                male ? new BigDecimal("8.0") : new BigDecimal("7.0"));
+    }
+
+    private void checkFingerWidth(String label, BigDecimal value, BigDecimal min, BigDecimal max) {
+        if (value == null) {
+            return;
+        }
+        if (value.compareTo(min) < 0 || value.compareTo(max) > 0) {
+            throw new BusinessException(ResultCode.FINGER_WIDTH_RANGE_ERROR.getCode(),
+                    label + "指宽应在 " + min.toPlainString() + "~" + max.toPlainString() + " cm 之间");
+        }
+    }
+
+    private Map<String, Object> buildDeviceContext(Device device, DeviceGroup group,
+                                                   DeviceGroupMember member, UserProfile profile) {
+        Map<String, Object> data = buildDeviceListItem(device, group, member, profile);
+        data.put("device", buildDeviceMap(device));
+        data.put("group", group == null ? null : buildGroupMap(group));
+        data.put("member", member == null ? null : buildMemberMap(member, profile));
+        data.put("profile", buildProfileMap(profile));
+        data.put("profileComplete", isProfileComplete(profile));
+        return data;
+    }
+
+    private Map<String, Object> buildDeviceListItem(Device device, DeviceGroup group,
+                                                    DeviceGroupMember member, UserProfile profile) {
+        Map<String, Object> data = buildDeviceMap(device);
+        data.put("groupId", group == null ? null : group.getId());
+        data.put("memberId", member == null ? null : member.getId());
+        data.put("profileId", profile == null ? null : profile.getId());
+        data.put("profileName", profile == null ? null : profile.getName());
+        data.put("profileComplete", isProfileComplete(profile));
+        return data;
+    }
+
+    private Map<String, Object> buildDeviceMap(Device device) {
+        Map<String, Object> data = new HashMap<>(16);
+        data.put("id", device.getId());
+        data.put("deviceId", device.getId());
+        data.put("deviceCode", device.getDeviceCode());
+        data.put("deviceName", device.getDeviceName());
+        data.put("deviceModel", device.getDeviceModel());
+        data.put("deviceType", resolveDeviceType(device));
+        data.put("deviceTypeName", isPublicDevice(device) ? "公共设备" : "家庭设备");
+        data.put("serialNo", device.getSerialNo());
+        data.put("firmwareVersion", device.getFirmwareVersion());
+        data.put("onlineStatus", device.getOnlineStatus());
+        data.put("lastOnlineTime", device.getLastOnlineTime());
+        data.put("address", device.getAddress());
+        return data;
+    }
+
+    private Map<String, Object> buildGroupMap(DeviceGroup group) {
+        Map<String, Object> data = new HashMap<>(8);
+        data.put("id", group.getId());
+        data.put("groupId", group.getId());
+        data.put("deviceCode", group.getDeviceCode());
+        data.put("status", group.getStatus());
+        return data;
+    }
+
+    private Map<String, Object> buildMemberMap(DeviceGroupMember member, UserProfile profile) {
+        Map<String, Object> data = new HashMap<>(32);
+        data.put("memberId", member.getId());
+        data.put("groupId", member.getGroupId());
+        data.put("profileId", profile.getId());
+        data.put("appUserId", member.getAppUserId());
+        data.put("status", member.getStatus());
+        data.put("joinTime", member.getJoinTime());
+        data.put("profileComplete", isProfileComplete(profile));
+        data.putAll(buildProfileMap(profile));
+        data.put("profile", buildProfileMap(profile));
+        return data;
+    }
+
+    private Map<String, Object> buildProfileMap(UserProfile profile) {
+        Map<String, Object> data = new HashMap<>(32);
+        if (profile == null) {
+            return data;
+        }
+        data.put("profileId", profile.getId());
+        data.put("id", profile.getId());
+        data.put("appUserId", profile.getUserId());
+        data.put("name", profile.getName());
+        data.put("gender", profile.getGender());
+        data.put("age", profile.getAge());
+        data.put("phone", profile.getPhone());
+        data.put("userType", profile.getUserType());
+        data.put("provinceCode", profile.getProvinceCode());
+        data.put("cityCode", profile.getCityCode());
+        data.put("districtCode", profile.getDistrictCode());
+        data.put("address", profile.getAddress());
+        data.put("shoulderWidth", profile.getShoulderWidth());
+        data.put("bodyHeight", profile.getBodyHeight());
+        data.put("spineLength", profile.getSpineLength());
+        data.put("height", profile.getHeight());
+        data.put("weight", profile.getWeight());
+        data.put("fingerWidth1", profile.getFingerWidth1());
+        data.put("fingerWidth15", profile.getFingerWidth15());
+        data.put("fingerWidth3", profile.getFingerWidth3());
+        data.put("acupointTableId", profile.getAcupointTableId());
+        data.put("profileComplete", isProfileComplete(profile));
+        return data;
+    }
+
+    private Map<String, Object> buildCurrentProfileMap(AppUser appUser, UserProfile profile) {
+        Map<String, Object> data = buildProfileMap(profile);
+        data.put("userId", appUser.getId());
+        data.put("appUserId", appUser.getId());
+        data.put("nickname", appUser.getNickname());
+        data.put("avatar", appUser.getAvatar());
+        data.put("accountPhone", appUser.getPhone());
+        data.put("phone", profile.getPhone());
+        data.put("appId", appUser.getAppId());
+        data.put("userCategoryId", appUser.getUserCategoryId());
+        return data;
+    }
+
+    private boolean isFamilyDevice(Device device) {
+        return resolveDeviceType(device) == DEVICE_TYPE_FAMILY;
+    }
+
+    private boolean isPublicDevice(Device device) {
+        return resolveDeviceType(device) == DEVICE_TYPE_PUBLIC;
+    }
+
+    private int resolveDeviceType(Device device) {
+        return device.getDeviceType() == null ? DEVICE_TYPE_FAMILY : device.getDeviceType();
+    }
+
+    private String normalizePhone(String phone) {
+        String value = trimToNull(phone);
+        if (value != null) {
+            validatePhone(value);
+        }
+        return value;
+    }
+
+    private void validatePhone(String phone) {
+        if (phone != null && !phone.matches(PHONE_PATTERN)) {
+            throw new BusinessException(ResultCode.PHONE_FORMAT_INVALID);
+        }
+    }
+
+    private String trimToNull(String value) {
+        if (!StringUtils.hasText(value)) {
+            return null;
+        }
+        return value.trim();
+    }
+
+    private String resolveProfileName(String preferredName, String phone, Long fallbackId) {
+        String name = trimToNull(preferredName);
+        if (name != null) {
+            return name;
+        }
+        if (phone != null && phone.length() >= 4) {
+            return "用户" + phone.substring(phone.length() - 4);
+        }
+        return "用户" + (fallbackId == null ? "" : fallbackId);
+    }
+}

+ 12 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/impl/DeviceServiceImpl.java

@@ -162,6 +162,7 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
         for (Device device : list) {
             long count = lambdaQuery().eq(Device::getDeviceCode, device.getDeviceCode()).count();
             if (count == 0) {
+                device.setDeviceType(resolveDeviceType(device.getDeviceType()));
                 device.setOnlineStatus(0);
                 save(device);
             }
@@ -301,6 +302,7 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
         device.setDeviceCode(dto.getDeviceCode());
         device.setDeviceName(dto.getDeviceName());
         device.setDeviceModel(dto.getDeviceModel());
+        device.setDeviceType(resolveDeviceType(dto.getDeviceType()));
         device.setSerialNo(dto.getSerialNo());
         device.setFirmwareVersion(dto.getFirmwareVersion());
         device.setProvinceCode(dto.getProvinceCode());
@@ -312,6 +314,16 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
         return device;
     }
 
+    private Integer resolveDeviceType(Integer deviceType) {
+        if (deviceType == null) {
+            return 1;
+        }
+        if (!Integer.valueOf(1).equals(deviceType) && !Integer.valueOf(2).equals(deviceType)) {
+            throw new BusinessException(ResultCode.PARAM_ERROR.getCode(), "设备类型不合法");
+        }
+        return deviceType;
+    }
+
     /**
      * 为设备批量绑定用户(新增时使用)
      *

+ 4 - 0
code/backend/src/main/resources/mapper/DeviceGroupMapper.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.aijiuyi.admin.mapper.DeviceGroupMapper">
+</mapper>

+ 4 - 0
code/backend/src/main/resources/mapper/DeviceGroupMemberMapper.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.aijiuyi.admin.mapper.DeviceGroupMemberMapper">
+</mapper>

+ 9 - 0
code/backend/src/main/resources/mapper/DeviceMapper.xml

@@ -14,6 +14,7 @@
             d.device_code,
             d.device_name,
             d.device_model,
+            COALESCE(d.device_type, 1) AS device_type,
             d.serial_no,
             d.firmware_version,
             d.online_status,
@@ -41,6 +42,14 @@
         <if test="q.deviceName != null and q.deviceName != ''">
             AND d.device_name LIKE CONCAT('%', #{q.deviceName}, '%')
         </if>
+        <choose>
+            <when test="q.deviceType != null and q.deviceType == 1">
+                AND (d.device_type = 1 OR d.device_type IS NULL)
+            </when>
+            <when test="q.deviceType != null and q.deviceType == 2">
+                AND d.device_type = 2
+            </when>
+        </choose>
         <if test="q.onlineStatus != null">
             AND d.online_status = #{q.onlineStatus}
         </if>

+ 16 - 0
code/backend/src/main/resources/rebel.xml

@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<!--
+  This is the JRebel configuration file. It maps the running application to your IDE workspace, enabling JRebel reloading for this project.
+  Refer to https://manuals.jrebel.com/jrebel/standalone/config.html for more information.
+-->
+<application generated-by="intellij" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://www.zeroturnaround.com" xsi:schemaLocation="http://www.zeroturnaround.com http://update.zeroturnaround.com/jrebel/rebel-2_3.xsd">
+
+	<id>aijiuyi-admin</id>
+
+	<classpath>
+		<dir name="D:/Java_code/aijiuyi/code/backend/target/classes">
+		</dir>
+	</classpath>
+
+</application>

+ 31 - 0
code/backend/src/main/resources/sql/migration_device_group.sql

@@ -0,0 +1,31 @@
+-- 家庭/公共设备、设备群组与群组成员结构迁移
+ALTER TABLE `device`
+    ADD COLUMN `device_type` TINYINT NOT NULL DEFAULT 1 COMMENT '设备类型:1=家庭设备,2=公共设备' AFTER `device_model`;
+
+CREATE TABLE IF NOT EXISTS `device_group` (
+    `id` BIGINT NOT NULL COMMENT '主键ID',
+    `device_code` VARCHAR(64) NOT NULL COMMENT '家庭设备编号',
+    `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1=正常,0=停用',
+    `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0=未删除,1=已删除',
+    `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_device_group_device_code` (`device_code`),
+    KEY `idx_device_group_status` (`status`, `deleted`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭设备群组表';
+
+CREATE TABLE IF NOT EXISTS `device_group_member` (
+    `id` BIGINT NOT NULL COMMENT '主键ID',
+    `group_id` BIGINT NOT NULL COMMENT '群组ID',
+    `profile_id` BIGINT NOT NULL COMMENT '用户档案ID',
+    `app_user_id` BIGINT NULL COMMENT '关联App账号ID,可为空',
+    `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1=正常,0=移除',
+    `join_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间',
+    `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0=未删除,1=已删除',
+    `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_device_group_member_profile` (`group_id`, `profile_id`),
+    KEY `idx_device_group_member_app_user` (`group_id`, `app_user_id`, `status`),
+    KEY `idx_device_group_member_status` (`status`, `deleted`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭设备群组成员表';

+ 1 - 0
code/frontend/src/components/ExcelImport.vue

@@ -19,6 +19,7 @@
       title="数据预览(请确认后导入)"
       width="80%"
       :before-close="handleClose"
+      :close-on-click-modal="false"
     >
       <div class="preview-info">
         <el-tag type="success">共 {{ previewData.length }} 条数据</el-tag>

+ 47 - 0
code/frontend/src/views/device/index.vue

@@ -12,6 +12,12 @@
         <el-form-item label="绑定用户">
           <el-input v-model="query.boundUserName" placeholder="用户姓名模糊搜索" clearable style="width:160px" />
         </el-form-item>
+        <el-form-item label="设备类型">
+          <el-select v-model="query.deviceType" placeholder="全部" clearable style="width:120px">
+            <el-option label="家庭设备" :value="1" />
+            <el-option label="公共设备" :value="2" />
+          </el-select>
+        </el-form-item>
         <el-form-item label="在线状态">
           <el-select v-model="query.onlineStatus" placeholder="全部" clearable style="width:110px">
             <el-option label="在线" :value="1" />
@@ -101,6 +107,13 @@
         <el-table-column prop="deviceModel" label="设备型号" width="90">
           <template #default="{ row }">{{ row.deviceModel || '-' }}</template>
         </el-table-column>
+        <el-table-column label="设备类型" width="95" align="center">
+          <template #default="{ row }">
+            <el-tag :type="row.deviceType === 2 ? 'warning' : 'success'" size="small">
+              {{ formatDeviceType(row.deviceType) }}
+            </el-tag>
+          </template>
+        </el-table-column>
         <el-table-column label="绑定用户数" width="95" align="center">
           <template #default="{ row }">
             <el-tag type="info" size="small">{{ row.boundUserCount || 0 }} 人</el-tag>
@@ -156,6 +169,7 @@
       :title="formMode === 'add' ? '新增设备' : (formMode === 'view' ? '查看设备' : '编辑设备')"
       width="580px"
       destroy-on-close
+      :close-on-click-modal="false"
     >
       <el-form
         :model="deviceForm"
@@ -190,6 +204,17 @@
             <el-option label="AJY-2025" value="AJY-2025" />
           </el-select>
         </el-form-item>
+        <el-form-item label="设备类型" prop="deviceType">
+          <el-select
+            v-model="deviceForm.deviceType"
+            placeholder="请选择设备类型"
+            style="width:100%"
+            :disabled="formMode === 'view'"
+          >
+            <el-option label="家庭设备" :value="1" />
+            <el-option label="公共设备" :value="2" />
+          </el-select>
+        </el-form-item>
         <el-form-item label="设备序列号" prop="serialNo">
           <el-input
             v-model="deviceForm.serialNo"
@@ -308,6 +333,7 @@ const query = reactive({
   deviceCode: '',
   deviceName: '',
   boundUserName: '',
+  deviceType: null,
   onlineStatus: null,
   firmwareVersion: '',
   provinceCode: '',
@@ -427,11 +453,16 @@ function formatBoundUserNames(row) {
     : displayText
 }
 
+function formatDeviceType(deviceType) {
+  return Number(deviceType) === 2 ? '公共设备' : '家庭设备'
+}
+
 /** 导出表头配置 */
 const exportHeaders = [
   { label: '设备编号', prop: 'deviceCode' },
   { label: '设备名称', prop: 'deviceName' },
   { label: '设备型号', prop: 'deviceModel' },
+  { label: '设备类型', prop: 'deviceTypeLabel' },
   { label: '所在地区', prop: 'regionText' },
   { label: '绑定用户数', prop: 'boundUserCount' },
   { label: '绑定用户', prop: 'boundUserNames' },
@@ -455,6 +486,7 @@ async function loadData() {
     // 为导出添加状态标签字段和地区文本
     tableData.value = records.map(r => ({
       ...r,
+      deviceTypeLabel: formatDeviceType(r.deviceType),
       onlineStatusLabel: r.onlineStatus === 1 ? '在线' : '离线',
       regionText: formatRegion(r)
     }))
@@ -480,6 +512,7 @@ function handleReset() {
     deviceCode: '',
     deviceName: '',
     boundUserName: '',
+    deviceType: null,
     onlineStatus: null,
     firmwareVersion: '',
     provinceCode: '',
@@ -508,6 +541,7 @@ const deviceForm = reactive({
   deviceCode: '',
   deviceName: '',
   deviceModel: '',
+  deviceType: 1,
   serialNo: '',
   firmwareVersion: '',
   userIds: [],
@@ -526,6 +560,7 @@ const deviceRules = {
     { max: 100, message: '设备名称不超过100个字符', trigger: 'blur' }
   ],
   deviceModel: [{ required: true, message: '请选择设备型号', trigger: 'change' }],
+  deviceType: [{ required: true, message: '请选择设备类型', trigger: 'change' }],
   serialNo: [
     { required: true, message: '请输入设备序列号', trigger: 'blur' },
     { max: 50, message: '序列号不超过50个字符', trigger: 'blur' }
@@ -561,6 +596,7 @@ async function handleAdd() {
     deviceCode: '',
     deviceName: '',
     deviceModel: '',
+    deviceType: 1,
     serialNo: '',
     firmwareVersion: '',
     userIds: [],
@@ -585,6 +621,7 @@ async function handleView(row) {
   userOptions.value = boundUsers.map(u => ({ id: u.userId, name: u.userName, phone: u.phone }))
   Object.assign(deviceForm, {
     ...row,
+    deviceType: row.deviceType || 1,
     userIds: boundUsers.map(u => u.userId),
     regionCodes: [row.provinceCode, row.cityCode, row.districtCode].filter(Boolean),
     address: row.address || ''
@@ -614,6 +651,7 @@ async function handleEdit(row) {
     deviceCode: row.deviceCode,
     deviceName: row.deviceName,
     deviceModel: row.deviceModel || '',
+    deviceType: row.deviceType || 1,
     serialNo: row.serialNo || '',
     firmwareVersion: row.firmwareVersion || '',
     remark: row.remark || '',
@@ -637,6 +675,7 @@ async function submitForm() {
       deviceCode: deviceForm.deviceCode,
       deviceName: deviceForm.deviceName,
       deviceModel: deviceForm.deviceModel,
+      deviceType: deviceForm.deviceType || 1,
       serialNo: deviceForm.serialNo,
       firmwareVersion: deviceForm.firmwareVersion,
       provinceCode: codes[0] || '',
@@ -703,6 +742,7 @@ async function handleImport(data) {
     deviceCode: row['设备编号'] || row.deviceCode,
     deviceName: row['设备名称'] || row.deviceName,
     deviceModel: row['设备型号'] || row.deviceModel,
+    deviceType: parseDeviceType(row['设备类型'] || row.deviceType),
     serialNo: row['设备序列号'] || row.serialNo,
     firmwareVersion: row['固件版本'] || row.firmwareVersion,
     remark: row['备注'] || row.remark
@@ -710,6 +750,13 @@ async function handleImport(data) {
   await batchImportDevice(list)
 }
 
+function parseDeviceType(value) {
+  if (value === 2 || value === '2' || value === '公共设备') {
+    return 2
+  }
+  return 1
+}
+
 /**
  * 导入成功回调
  */

+ 1 - 1
code/frontend/src/views/device/users.vue

@@ -90,7 +90,7 @@
     </el-card>
 
     <!-- 添加用户弹窗 -->
-    <el-dialog v-model="addUserDialogVisible" title="添加绑定用户" width="520px" destroy-on-close>
+    <el-dialog v-model="addUserDialogVisible" title="添加绑定用户" width="520px" destroy-on-close :close-on-click-modal="false">
       <div class="add-user-tip">从用户档案列表中选择用户与该设备绑定</div>
       <el-input
         v-model="addUserKeyword"

+ 1 - 0
code/frontend/src/views/log/index.vue

@@ -153,6 +153,7 @@
       :title="detailTitle"
       width="640px"
       destroy-on-close
+      :close-on-click-modal="false"
     >
       <el-input
         v-model="detailContent"

+ 1 - 1
code/frontend/src/views/plan/index.vue

@@ -358,7 +358,7 @@
     </el-dialog>
 
     <!-- 方案详情弹窗(只读) -->
-    <el-dialog v-model="detailVisible" title="方案详情" width="700px" destroy-on-close>
+    <el-dialog v-model="detailVisible" title="方案详情" width="700px" destroy-on-close :close-on-click-modal="false">
       <template v-if="detailData">
         <el-descriptions :column="2" border size="small">
           <el-descriptions-item label="方案编码">{{ detailData.planCode }}</el-descriptions-item>

+ 1 - 0
code/frontend/src/views/user/app-user/index.vue

@@ -125,6 +125,7 @@
       :title="dialogTitle"
       width="500px"
       destroy-on-close
+      :close-on-click-modal="false"
     >
       <el-form :model="formData" :rules="formRules" ref="formRef" label-width="90px">
         <el-form-item label="手机号" prop="phone">

+ 1 - 1
code/frontend/src/views/user/device/index.vue

@@ -94,7 +94,7 @@
     </el-card>
 
     <!-- 添加设备弹窗 -->
-    <el-dialog v-model="addDialogVisible" title="添加设备" width="480px" destroy-on-close>
+    <el-dialog v-model="addDialogVisible" title="添加设备" width="480px" destroy-on-close :close-on-click-modal="false">
       <el-form :model="deviceForm" :rules="deviceRules" ref="deviceFormRef" label-width="90px">
         <el-form-item label="设备编号" prop="deviceCode">
           <el-select

+ 1 - 1
code/frontend/src/views/user/plan/index.vue

@@ -76,7 +76,7 @@
     </el-card>
 
     <!-- 方案详情弹窗 -->
-    <el-dialog v-model="detailVisible" title="工艺详情" width="980px" destroy-on-close>
+    <el-dialog v-model="detailVisible" title="工艺详情" width="980px" destroy-on-close :close-on-click-modal="false">
       <template v-if="detailData">
         <el-descriptions :column="2" border size="small">
           <el-descriptions-item label="方案名称">{{ detailData.planName }}</el-descriptions-item>

+ 1 - 0
code/frontend/src/views/user/profile/index.vue

@@ -205,6 +205,7 @@
       :title="dialogTitle"
       width="680px"
       destroy-on-close
+      :close-on-click-modal="false"
     >
       <el-form :model="formData" :rules="formRules" ref="formRef" label-width="100px">
         <el-row :gutter="16">