Преглед изворни кода

merge: 合并dev分支 - App端设备绑定、背部产品穴位、查询优化

变更内容:
- AcupointService: 新增身体部位选项 listBodyPartOptions()
- AcupointServiceImpl: 新增 bodyPart、productAcupoint 查询过滤
- UserAcupointService: 新增背部产品穴位列表查询
- UserDeviceService: 新增 App 端按设备编号绑定 bindAppDeviceByCode()
- APP: 背部产品穴位接口、蓝牙重连、子方案显示逻辑

保留 openspec/ 参考目录
liaoxg пре 3 месеци
родитељ
комит
1805363b75
20 измењених фајлова са 942 додато и 73 уклоњено
  1. 169 3
      code/ajyApp/pages/device/deviceInfo/deviceInfo.nvue
  2. 12 18
      code/ajyApp/pages/device/deviceInfo/mixins/ble-mixin.js
  3. 107 0
      code/ajyApp/pages/device/deviceInfo/mixins/plan-mixin.js
  4. 58 28
      code/ajyApp/pages/device/userManage/userManage.nvue
  5. 29 0
      code/ajyApp/utils/api/care.js
  6. 23 14
      code/ajyApp/utils/ble/BleManager.js
  7. 300 0
      code/app对接接口说明.md
  8. 12 1
      code/backend/src/main/java/com/aijiuyi/admin/controller/AcupointController.java
  9. 13 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/UserAcupointController.java
  10. 6 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AcupointQueryDTO.java
  11. 9 0
      code/backend/src/main/java/com/aijiuyi/admin/mapper/UserAcupointMapper.java
  12. 7 0
      code/backend/src/main/java/com/aijiuyi/admin/service/AcupointService.java
  13. 10 0
      code/backend/src/main/java/com/aijiuyi/admin/service/UserAcupointService.java
  14. 10 0
      code/backend/src/main/java/com/aijiuyi/admin/service/UserDeviceService.java
  15. 17 0
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/AcupointServiceImpl.java
  16. 13 0
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserAcupointServiceImpl.java
  17. 64 0
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserDeviceServiceImpl.java
  18. 25 0
      code/backend/src/main/resources/mapper/UserAcupointMapper.xml
  19. 8 1
      code/frontend/src/api/acupoint.js
  20. 50 8
      code/frontend/src/views/acupoint/index.vue

+ 169 - 3
code/ajyApp/pages/device/deviceInfo/deviceInfo.nvue

@@ -47,6 +47,20 @@
 					</view>
 				</view>
 
+				<!-- 方案选择 -->
+				<view class="planSection" v-if="modeType != 0 && picMode == 1">
+					<text class="planSectionTitle">选择方案</text>
+					<view class="planCard" @click="showPlanDrawer">
+						<view class="planCardLeft">
+							<text class="planCardName">{{selectedPlan ? selectedPlan.name : '点击选择方案'}}</text>
+							<text class="planCardDesc" v-if="selectedPlan && selectedPlan.description">{{selectedPlan.description}}</text>
+						</view>
+						<view class="planCardRight">
+							<text class="planCardArrow">›</text>
+						</view>
+					</view>
+				</view>
+
 				<!-- 其他功能 -->
 				<view class="otherSetting" v-if="modeType != 0 && picMode == 1">
 					<text class="otherTxt">其他功能</text>
@@ -241,6 +255,36 @@
 			</view>
 		</customPopup>
 
+		<!-- 方案选择弹框 -->
+		<customPopup :isShow="isShowPlanDrawer" @closePop="isShowPlanDrawer=false">
+			<text class="planDrawerTitle">选择方案</text>
+			<text class="planDrawerSub">{{modelist[modeType]}} - 可用方案</text>
+			<scroll-view class="planDrawerList" scroll-y="true">
+				<view class="planDrawerEmpty" v-if="planList.length === 0 && !planLoading">
+					<text class="planEmptyTxt">暂无可用方案</text>
+				</view>
+				<view class="planDrawerEmpty" v-if="planLoading">
+					<text class="planEmptyTxt">加载中...</text>
+				</view>
+				<view class="planItem" :class="selectedPlan && selectedPlan.id === item.id ? 'planItemActive' : ''"
+					v-for="(item, index) in planList" :key="index" @click="selectPlan(item)">
+					<view class="planItemInfo">
+						<text class="planItemName">{{item.name}}</text>
+						<text class="planItemDesc" v-if="item.description">{{item.description}}</text>
+						<view class="planItemMeta" v-if="item.steps || item.duration">
+							<text class="planMetaTxt" v-if="item.steps">{{item.steps}}步</text>
+							<text class="planMetaTxt" v-if="item.duration">约{{item.duration}}分钟</text>
+						</view>
+					</view>
+					<view class="planItemCheck">
+						<view class="checkboxCustom">
+							<view v-if="selectedPlan && selectedPlan.id === item.id" class="checkboxCircle"></view>
+						</view>
+					</view>
+				</view>
+			</scroll-view>
+		</customPopup>
+
 		<!-- 时间配置器 -->
 		<customPopup :isShow="isShowTimeDrawer" @closePop="isShowTimeDrawer=false">
 			<text class="aijiuTime">选择灸疗时间</text>
@@ -260,11 +304,11 @@
 		</customPopup>
 
 		<!-- 重连弹框 -->
-		<view class="maskbg" v-if="reconnectDrawer">
+		<view class="maskbg" v-if="reconnectDrawer" @click.stop="">
 			<view class="maskbox">
 				<text class="maskTxt">蓝牙连接断开,第{{reconnectCount}}次重连中...</text>
 			</view>
-		</view>
+		</view>	
 
 		<ay-toast ref="ayToast" />
 	</view>
@@ -277,6 +321,7 @@ import lSliderRange from '@/components/l-sliderRange/l-sliderRange.nvue'
 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'
 
 export default {
@@ -285,7 +330,7 @@ export default {
 		ayToast,
 		lSliderRange
 	},
-	mixins: [bleMixin, audioMixin, acupointMixin],
+	mixins: [bleMixin, audioMixin, acupointMixin, planMixin],
 	data() {
 		return {
 			statusBarHeight: 44,
@@ -798,6 +843,127 @@ export default {
 .empty {
 	height: 180rpx;
 }
+/* 方案选择区 */
+.planSection {
+	margin-left: 38rpx;
+	margin-right: 38rpx;
+	margin-top: 20rpx;
+	background-color: rgba(255,255,255,0.9);
+	border-radius: 16rpx;
+	padding: 30rpx;
+}
+.planSectionTitle {
+	font-size: 28rpx;
+	color: #9BA5B7;
+	margin-bottom: 16rpx;
+}
+.planCard {
+	flex-direction: row;
+	justify-content: space-between;
+	align-items: center;
+	background-color: #389588;
+	border-radius: 40rpx;
+	padding-left: 30rpx;
+	padding-right: 24rpx;
+	padding-top: 20rpx;
+	padding-bottom: 20rpx;
+}
+.planCardLeft {
+	flex: 1;
+}
+.planCardName {
+	font-size: 30rpx;
+	font-weight: bold;
+	color: #FFFFFF;
+}
+.planCardDesc {
+	font-size: 22rpx;
+	color: rgba(255,255,255,0.7);
+	margin-top: 6rpx;
+}
+.planCardRight {
+	width: 40rpx;
+	align-items: center;
+}
+.planCardArrow {
+	font-size: 40rpx;
+	color: #FFFFFF;
+}
+/* 方案选择弹框 */
+.planDrawerTitle {
+	font-size: 32rpx;
+	color: #545F71;
+	font-weight: bold;
+	text-align: center;
+}
+.planDrawerSub {
+	font-size: 24rpx;
+	color: #9BA5B7;
+	text-align: center;
+	margin-top: 8rpx;
+	margin-bottom: 24rpx;
+}
+.planDrawerList {
+	max-height: 600rpx;
+}
+.planDrawerEmpty {
+	align-items: center;
+	padding-top: 60rpx;
+	padding-bottom: 60rpx;
+}
+.planEmptyTxt {
+	font-size: 28rpx;
+	color: #9BA5B7;
+}
+.planItem {
+	flex-direction: row;
+	justify-content: space-between;
+	align-items: center;
+	padding-left: 20rpx;
+	padding-right: 20rpx;
+	padding-top: 24rpx;
+	padding-bottom: 24rpx;
+	border-bottom-width: 1px;
+	border-bottom-style: solid;
+	border-bottom-color: #EEF1F4;
+	border-radius: 12rpx;
+}
+.planItemActive {
+	background-color: #F0F8F7;
+}
+.planItemInfo {
+	flex: 1;
+	margin-right: 20rpx;
+}
+.planItemName {
+	font-size: 30rpx;
+	color: #545F71;
+	font-weight: bold;
+}
+.planItemDesc {
+	font-size: 24rpx;
+	color: #9BA5B7;
+	margin-top: 8rpx;
+}
+.planItemMeta {
+	flex-direction: row;
+	margin-top: 10rpx;
+}
+.planMetaTxt {
+	font-size: 22rpx;
+	color: #389588;
+	margin-right: 20rpx;
+	background-color: rgba(56,149,136,0.1);
+	padding-left: 12rpx;
+	padding-right: 12rpx;
+	padding-top: 4rpx;
+	padding-bottom: 4rpx;
+	border-radius: 8rpx;
+}
+.planItemCheck {
+	width: 50rpx;
+	align-items: center;
+}
 /* 弹框样式 */
 .tip1 {
 	font-size: 28rpx;

+ 12 - 18
code/ajyApp/pages/device/deviceInfo/mixins/ble-mixin.js

@@ -47,23 +47,30 @@ export default {
 			this._unbinders = [
 				bleManager.on('state', (s) => {
 					this.linked = (s === BLE_STATE.READY_COMM)
-					if (s === BLE_STATE.DISCONNECTED) {
-						this.linked = false
-						this._handleBleDisconnect()
-					}
 				}),
 				bleManager.on('disconnected', (info) => {
 					this.linked = false
 					if (!info.manual) {
-						this._handleBleDisconnect()
+						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)
 				}),
@@ -94,19 +101,6 @@ export default {
 			}
 		},
 
-		_handleBleDisconnect() {
-			this.reconnectCount++
-			if (this.reconnectCount >= 4) {
-				this.reconnectDrawer = false
-				this.$refs.ayToast.error('连接失败,请返回重试')
-				setTimeout(() => {
-					uni.navigateBack()
-				}, 1500)
-			} else {
-				this.reconnectDrawer = true
-			}
-		},
-
 		// ========== 设备上报数据解析 ==========
 		_handleGroup1(data) {
 			// 参数组1: 设备状态

+ 107 - 0
code/ajyApp/pages/device/deviceInfo/mixins/plan-mixin.js

@@ -0,0 +1,107 @@
+/**
+ * 方案管理 mixin
+ * 负责:方案列表加载、子方案选择、模式切换时刷新方案
+ */
+import { getCareList, getCareDetail } from '@/utils/api/care.js'
+
+export default {
+	data() {
+		return {
+			planList: [], // 当前模式下的方案列表
+			selectedPlan: null, // 当前选中的方案 { id, name, description }
+			isShowPlanDrawer: false, // 方案选择弹窗
+			planLoading: false
+		}
+	},
+	methods: {
+		/**
+		 * 切换模式 - 覆盖原始changeMode,加入方案加载逻辑
+		 */
+		changeMode(mode) {
+			this.isShowDrawer2 = false
+			if (this.modeType === mode) return
+			this.modeType = mode
+			// 切换模式时重置方案选择
+			this.selectedPlan = null
+			this.planList = []
+			// 非休闲模式加载对应方案列表
+			if (mode !== 0) {
+				this.loadPlanList(mode)
+			}
+			// 停止音频(休闲模式专属)
+			if (mode !== 0 && this._stopAudioOnModeChange) {
+				this._stopAudioOnModeChange()
+			}
+		},
+
+		/**
+		 * 加载指定模式的方案列表
+		 * @param {Number} mode - 1专业 2自定义 3专家
+		 */
+		async loadPlanList(mode) {
+			this.planLoading = true
+			try {
+				// modeType映射为后端模式标识
+				const modeMap = {
+					1: 'PROFESSIONAL',
+					2: 'CUSTOM',
+					3: 'EXPERT'
+				}
+				const res = await getCareList({ modeType: modeMap[mode] || '' })
+				if (res && res.records) {
+					this.planList = res.records.map(item => ({
+						id: item.id,
+						name: item.title || item.name || '未命名方案',
+						description: item.description || item.remark || '',
+						steps: item.stepCount || item.steps || 0,
+						duration: item.totalDuration || 0
+					}))
+				} else {
+					this.planList = []
+				}
+			} catch (e) {
+				console.error('加载方案列表失败', e)
+				this.planList = []
+			}
+			this.planLoading = false
+		},
+
+		/** 打开方案选择弹窗 */
+		showPlanDrawer() {
+			if (this.planList.length === 0 && this.modeType !== 0) {
+				this.loadPlanList(this.modeType)
+			}
+			this.isShowPlanDrawer = true
+		},
+
+		/** 选择方案 */
+		selectPlan(plan) {
+			this.selectedPlan = plan
+			this.isShowPlanDrawer = false
+			// 选中方案后,可在此触发方案数据加载到curCase
+			this._applyPlanToCase(plan)
+		},
+
+		/**
+		 * 将方案数据应用到当前穴位配置
+		 * TODO: 后续对接方案详情接口,获取具体步骤和穴位坐标
+		 */
+		async _applyPlanToCase(plan) {
+			if (!plan || !plan.id) return
+			try {
+				const res = await getCareDetail(plan.id)
+				if (res && res.steps) {
+					this.curCase = res.steps.map(step => ({
+						id: step.acupointId || step.id,
+						time: step.duration || 15,
+						_x: step.x || 0,
+						_y: step.y || 0
+					}))
+					this.curCaseIndex = 0
+				}
+			} catch (e) {
+				console.error('加载方案详情失败', e)
+			}
+		}
+	}
+}

+ 58 - 28
code/ajyApp/pages/device/userManage/userManage.nvue

@@ -14,32 +14,37 @@
 			</view>
 			<text class="tipTxt">最多可添加10个用户,点击卡片切换用户</text>
 			<scroll-view class="userList" scroll-y="true">
-				<view class="userCard" :class="{'curUserCard': item.id === currentUserId}" 
+				<view class="listInner">
+				<view class="cardWrap" :class="{'curCardWrap': item.id === currentUserId}"
 					v-for="(item, index) in userList" :key="item.id" @click="switchUser(item)">
-					<view class="userInfo">
-						<view class="avatarWrap">
-							<text class="avatarTxt">{{item.name ? item.name.substring(0,1) : '用'}}</text>
-						</view>
-						<view class="infoDetail">
-							<view class="nameRow">
-								<text class="userName">{{item.name}}</text>
-								<text class="userGender">{{item.gender === 1 ? '男' : '女'}}</text>
-								<text class="userAge" v-if="item.age">{{item.age}}岁</text>
+					<view class="userCard">
+						<view class="userInfo">
+							<view class="avatarWrap">
+								<text class="avatarTxt">{{item.name ? item.name.substring(0,1) : '用'}}</text>
 							</view>
-							<view class="bodyRow">
-								<text class="bodyTxt">肩高:{{item.shoulderHeight || '--'}}cm</text>
-								<text class="bodyTxt">肩宽:{{item.shoulderWidth || '--'}}cm</text>
-								<text class="bodyTxt">体重:{{item.weight || '--'}}kg</text>
+							<view class="infoDetail">
+								<view class="nameRow">
+									<text class="userName">{{item.name}}</text>
+									<text class="userGender">{{item.gender === 1 ? '男' : '女'}}</text>
+									<text class="userAge" v-if="item.age">{{item.age}}岁</text>
+								</view>
+								<view class="bodyRow">
+									<text class="bodyTxt">肩高:{{item.shoulderHeight || '--'}}cm</text>
+									<text class="bodyTxt">肩宽:{{item.shoulderWidth || '--'}}cm</text>
+									<text class="bodyTxt">体重:{{item.weight || '--'}}kg</text>
+								</view>
 							</view>
 						</view>
+						<view class="cardBtns">
+							<text class="fingerBtn" @click.stop="enrollFingerprint(item)">录入指纹</text>
+							<text class="editBtn" @click.stop="editUser(item)">编辑</text>
+							<text class="deleteBtn" @click.stop="confirmDelete(item)">删除</text>
+						</view>
+						<view class="curTag" v-if="item.id === currentUserId">
+							<text class="curTagTxt">当前</text>
+						</view>
 					</view>
-					<view class="cardBtns">
-						<text class="editBtn" @click.stop="editUser(item)">编辑</text>
-						<text class="deleteBtn" @click.stop="confirmDelete(item)">删除</text>
-					</view>
-					<view class="curTag" v-if="item.id === currentUserId">
-						<text class="curTagTxt">当前</text>
-					</view>
+				</view>
 				</view>
 			</scroll-view>
 		</view>
@@ -139,6 +144,11 @@ export default {
 			this.deleteTarget = item
 			this.showDeletePopup = true
 		},
+		/** 录入指纹 */
+		enrollFingerprint(item) {
+			this.$refs.ayToast.loading("指纹录入中", { mask: false })
+
+		},
 		/** 执行删除 */
 		doDelete() {
 			if (!this.deleteTarget) return
@@ -222,21 +232,28 @@ export default {
 }
 .userList {
 	flex: 1;
+}
+.listInner {
 	padding-left: 30rpx;
 	padding-right: 30rpx;
+	padding-top: 20rpx;
+	padding-bottom: 30rpx;
+}
+.cardWrap {
+	border-radius: 20rpx;
+	overflow: hidden;
+	margin-bottom: 24rpx;
+	background-color: #389588;
+}
+.curCardWrap {
+
+	border-radius: 20rpx;
 }
 .userCard {
 	background-color: #FFFFFF;
 	border-radius: 20rpx;
 	padding: 30rpx;
-	margin-bottom: 24rpx;
 	position: relative;
-	overflow: hidden;
-}
-.curUserCard {
-	border-width: 2px;
-	border-style: solid;
-	border-color: #389588;
 }
 .userInfo {
 	flex-direction: row;
@@ -292,6 +309,17 @@ export default {
 	justify-content: flex-end;
 	margin-top: 20rpx;
 }
+.fingerBtn {
+	font-size: 26rpx;
+	color: #E6A23C;
+	padding-left: 24rpx;
+	padding-right: 24rpx;
+	padding-top: 10rpx;
+	padding-bottom: 10rpx;
+	border-radius: 24rpx;
+	background-color: #FDF6EC;
+	margin-right: 20rpx;
+}
 .editBtn {
 	font-size: 26rpx;
 	color: #389588;
@@ -323,6 +351,8 @@ export default {
 	padding-top: 4rpx;
 	padding-bottom: 4rpx;
 	border-bottom-left-radius: 12rpx;
+	border-top-right-radius: 18rpx;
+
 }
 .curTagTxt {
 	font-size: 20rpx;

+ 29 - 0
code/ajyApp/utils/api/care.js

@@ -0,0 +1,29 @@
+/**
+ * 调理方案模块 API
+ */
+import http from '@/utils/http'
+
+/**
+ * 获取调理方案列表(分页)
+ * @param {Object} params - { modeType, page, size }
+ * @param {string} params.modeType - 模式标识: PROFESSIONAL / CUSTOM / EXPERT
+ * @param {number} [params.page=0] - 页码
+ * @param {number} [params.size=100] - 每页数量
+ * @returns {Promise<{records, total}>}
+ */
+export function getCareList(params = {}) {
+	return http.post('/userCare/careList', {
+		page: params.page || 0,
+		size: params.size || 100,
+		modeType: params.modeType || ''
+	})
+}
+
+/**
+ * 获取调理方案详情
+ * @param {string|number} id - 方案ID
+ * @returns {Promise<{steps: Array}>}
+ */
+export function getCareDetail(id) {
+	return http.get('/userCare/careDetail', { data: { id } })
+}

+ 23 - 14
code/ajyApp/utils/ble/BleManager.js

@@ -325,21 +325,30 @@ class BleManager extends EventEmitter {
     this.emit('disconnected', { manual: false, reason })
     this._parser.reset()
     // 尝试自动重连
-    if (this.config.autoReconnect && device && this._reconnectCount < this.config.maxReconnect) {
-      this._reconnectCount++
-      const delay = Math.min(1000 * this._reconnectCount, 5000)
-      logger.warn(`reconnect in ${delay}ms (${this._reconnectCount}/${this.config.maxReconnect})`)
-      this._reconnectTimer = setTimeout(async () => {
-        try {
-          await this.init()
-          await this.connect(device.deviceId)
-          this.emit('reconnected', this._device)
-        } catch (e) {
-          logger.error('reconnect fail', e)
-          this._handleDisconnected(reason)
-        }
-      }, delay)
+    this._attemptReconnect(device, reason)
+  }
+
+  _attemptReconnect(device, reason) {
+    if (!this.config.autoReconnect || !device || this._reconnectCount >= this.config.maxReconnect) {
+      if (this._reconnectCount >= this.config.maxReconnect) {
+        this.emit('reconnectFailed')
+      }
+      return
     }
+    this._reconnectCount++
+    const delay = Math.min(1000 * this._reconnectCount, 5000)
+    logger.warn(`reconnect in ${delay}ms (${this._reconnectCount}/${this.config.maxReconnect})`)
+    this.emit('reconnecting', { count: this._reconnectCount, max: this.config.maxReconnect })
+    this._reconnectTimer = setTimeout(async () => {
+      try {
+        await this.init()
+        await this.connect(device.deviceId)
+        this.emit('reconnected', this._device)
+      } catch (e) {
+        logger.error('reconnect fail', e)
+        this._attemptReconnect(device, reason)
+      }
+    }, delay)
   }
 
   _clearReconnect() {

+ 300 - 0
code/app对接接口说明.md

@@ -0,0 +1,300 @@
+# APP 对接接口说明
+
+## 1. 文档说明
+
+本文档用于统一维护 APP 对接后台接口说明。
+
+- 服务前缀:`/api`
+- 本地开发示例地址:`http://localhost:18888`
+- 鉴权方式:除登录等白名单接口外,请求头需携带后台登录后的 Token
+- 通用返回结构:`code`、`message`、`data`
+
+## 2. 接口目录
+
+| 序号 | 接口名称 | 方法 | URI | 说明 |
+| --- | --- | --- | --- | --- |
+| 1 | 查询用户背部产品穴位坐标列表 | `GET` | `/api/user/acupoint/{profileId}/product-back-coordinates` | 查询指定子用户身体部位为背部且产品穴位为是的穴位坐标 |
+| 2 | 查询用户拥有的设备列表 | `GET` | `/api/user/device/page` | 按 `userId` 查询用户当前已绑定设备,`status` 固定为 `1` |
+| 3 | 用户手动输入设备编号绑定设备 | `POST` | `/api/app/user/device/bind-by-code` | APP 端按设备编号绑定设备,编号不存在返回错误提示 |
+
+## 3. 接口详情
+
+### 3.1 查询用户背部产品穴位坐标列表
+
+#### 3.1.1 接口用途
+
+用于查询指定子用户的穴位坐标列表,接口固定筛选身体部位为`背部`,且产品穴位为`是`的穴位。
+
+当前场景:APP 根据用户档案 ID 获取该用户已计算出的背部产品穴位坐标,用于设备推送、坐标展示或调理方案匹配。
+
+#### 3.1.2 请求信息
+
+- 请求方式:`GET`
+- URI:`/api/user/acupoint/{profileId}/product-back-coordinates`
+- 完整示例:`http://localhost:18888/api/user/acupoint/123/product-back-coordinates`
+
+#### 3.1.3 请求参数
+
+| 参数名 | 类型 | 是否必填 | 说明 | 示例 |
+| --- | --- | --- | --- | --- |
+| `profileId` | `number` | 是 | 子用户 ID,对应 `user_profile.id`,通过路径参数传递 | `123` |
+
+#### 3.1.4 调用示例
+
+```http
+GET /api/user/acupoint/123/product-back-coordinates HTTP/1.1
+Host: localhost:18888
+Authorization: Bearer <token>
+```
+
+```bash
+curl -X GET "http://localhost:18888/api/user/acupoint/123/product-back-coordinates" \
+  -H "Authorization: Bearer <token>"
+```
+
+#### 3.1.5 返回示例
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": [
+    {
+      "id": 192837465,
+      "userId": 123,
+      "acupointId": 371,
+      "acupointCode": "dumai-14",
+      "acupointName": "大椎",
+      "acupointSide": "center",
+      "bodyPart": "背部",
+      "productAcupoint": 1,
+      "coordinateX": 0.00,
+      "coordinateY": 0.00,
+      "coordinateZ": 0.00,
+      "confidence": "high"
+    },
+    {
+      "id": 192837466,
+      "userId": 123,
+      "acupointId": 362,
+      "acupointCode": "dumai-04",
+      "acupointName": "命门",
+      "acupointSide": "center",
+      "bodyPart": "背部",
+      "productAcupoint": 1,
+      "coordinateX": 0.00,
+      "coordinateY": 245.30,
+      "coordinateZ": 0.00,
+      "confidence": "medium"
+    }
+  ]
+}
+```
+
+#### 3.1.6 字段说明
+
+| 字段名 | 类型 | 说明 |
+| --- | --- | --- |
+| `id` | `number` | 用户穴位坐标记录 ID,对应 `user_acupoint.id` |
+| `userId` | `number` | 子用户 ID,对应 `user_profile.id` |
+| `acupointId` | `number` | 穴位 ID,对应 `acupoint.id` |
+| `acupointCode` | `string` | 穴位编码 |
+| `acupointName` | `string` | 穴位名称 |
+| `acupointSide` | `string` | 坐标侧别:`center=中心`,`left=左侧`,`right=右侧` |
+| `bodyPart` | `string` | 身体部位,本接口固定返回 `背部` |
+| `productAcupoint` | `number` | 是否产品穴位,本接口固定返回 `1` |
+| `coordinateX` | `number` | X 坐标,单位 mm,相对大椎穴中心,向右为正 |
+| `coordinateY` | `number` | Y 坐标,单位 mm,相对大椎穴中心,向下为正 |
+| `coordinateZ` | `number` | Z 坐标,单位 mm,当前体表坐标为 `0` |
+| `confidence` | `string` | 坐标置信度:`high`、`medium`、`low` |
+
+#### 3.1.7 备注
+
+- 本接口不需要传 `bodyPart` 和 `productAcupoint`,后端固定按 `bodyPart=背部`、`productAcupoint=1` 查询。
+- 本接口只返回启用且未删除的穴位坐标,即 `acupoint.status=1` 且 `acupoint.deleted=0`。
+- 如果该子用户尚未生成穴位坐标表,后端会先自动生成用户穴位坐标,再返回查询结果。
+- `profileId` 使用子用户档案 ID,即 `user_profile.id`,不是后台管理员 ID。
+
+### 3.2 查询用户拥有的设备列表
+
+#### 3.2.1 接口用途
+
+用于查询指定用户当前拥有的设备列表。
+
+当前场景:APP 根据用户档案 ID 查询该用户已绑定且未解绑的设备,用于展示用户可使用的设备。
+
+#### 3.2.2 请求信息
+
+- 请求方式:`GET`
+- URI:`/api/user/device/page`
+- 完整示例:`http://localhost:18888/api/user/device/page`
+
+#### 3.2.3 请求参数
+
+| 参数名 | 类型 | 是否必填 | 说明 | 示例 |
+| --- | --- | --- | --- | --- |
+| `userId` | `number` | 是 | 用户档案 ID,对应 `user_profile.id` | `123` |
+| `status` | `number` | 是 | 绑定状态,固定传 `1`,表示只查询当前已绑定设备 | `1` |
+| `pageNum` | `number` | 否 | 页码,默认 `1` | `1` |
+| `pageSize` | `number` | 否 | 每页条数,默认 `10` | `10` |
+
+#### 3.2.4 调用示例
+
+```http
+GET /api/user/device/page?userId=123&status=1&pageNum=1&pageSize=10 HTTP/1.1
+Host: localhost:18888
+Authorization: Bearer <token>
+```
+
+```bash
+curl -X GET "http://localhost:18888/api/user/device/page?userId=123&status=1&pageNum=1&pageSize=10" \
+  -H "Authorization: Bearer <token>"
+```
+
+#### 3.2.5 返回示例
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": {
+    "records": [
+      {
+        "id": 192837465,
+        "userId": 123,
+        "deviceCode": "AJY-0001",
+        "deviceName": "艾灸椅一号",
+        "deviceModel": "AJY-2026",
+        "isPrimary": 1,
+        "onlineStatus": 0,
+        "status": 1,
+        "bindTime": "2026-06-02 10:30:00",
+        "createTime": "2026-06-02 10:30:00",
+        "updateTime": "2026-06-02 10:30:00"
+      }
+    ],
+    "total": 1,
+    "size": 10,
+    "current": 1,
+    "pages": 1
+  }
+}
+```
+
+#### 3.2.6 字段说明
+
+| 字段名 | 类型 | 说明 |
+| --- | --- | --- |
+| `id` | `number` | 用户设备绑定记录 ID,对应 `user_device.id` |
+| `userId` | `number` | 用户档案 ID,对应 `user_profile.id` |
+| `deviceCode` | `string` | 设备编号 |
+| `deviceName` | `string` | 设备名称 |
+| `deviceModel` | `string` | 设备型号 |
+| `isPrimary` | `number` | 是否主设备:`1=是`,`0=否` |
+| `onlineStatus` | `number` | 在线状态:`1=在线`,`0=离线` |
+| `status` | `number` | 绑定状态:`1=已绑定`,`0=已解绑` |
+| `bindTime` | `string` | 绑定时间 |
+
+#### 3.2.7 备注
+
+- APP 查询用户拥有的设备列表时,`status` 固定传 `1`,不查询已解绑设备。
+- `userId` 使用用户档案 ID,即 `user_profile.id`,不是后台管理员 ID。
+- 返回数据来自用户设备绑定记录表 `user_device`,用于展示该用户当前已绑定设备。
+
+### 3.3 用户手动输入设备编号绑定设备
+
+#### 3.3.1 接口用途
+
+用于 APP 端让用户手动输入设备编号后绑定设备。
+
+后端会先根据设备编号查询设备主表 `device`:
+
+- 如果设备编号存在,则自动带出设备名称、设备型号等信息,并新增用户设备绑定记录。
+- 如果设备编号不存在,则返回错误码和错误信息,APP 可直接弹窗提示。
+
+#### 3.3.2 请求信息
+
+- 请求方式:`POST`
+- URI:`/api/app/user/device/bind-by-code`
+- 完整示例:`http://localhost:18888/api/app/user/device/bind-by-code`
+
+#### 3.3.3 请求参数
+
+请求体格式:`application/json`
+
+| 参数名 | 类型 | 是否必填 | 说明 | 示例 |
+| --- | --- | --- | --- | --- |
+| `userId` | `number` | 是 | 用户档案 ID,对应 `user_profile.id` | `123` |
+| `deviceCode` | `string` | 是 | 用户手动输入的设备编号,对应 `device.device_code` | `AJY-0001` |
+
+#### 3.3.4 调用示例
+
+```http
+POST /api/app/user/device/bind-by-code HTTP/1.1
+Host: localhost:18888
+Authorization: Bearer <token>
+Content-Type: application/json
+
+{
+  "userId": 123,
+  "deviceCode": "AJY-0001"
+}
+```
+
+```bash
+curl -X POST "http://localhost:18888/api/app/user/device/bind-by-code" \
+  -H "Authorization: Bearer <token>" \
+  -H "Content-Type: application/json" \
+  -d "{\"userId\":123,\"deviceCode\":\"AJY-0001\"}"
+```
+
+#### 3.3.5 成功返回示例
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": {
+    "id": 192837466,
+    "userId": 123,
+    "deviceCode": "AJY-0001",
+    "deviceName": "艾灸椅一号",
+    "deviceModel": "AJY-2026",
+    "isPrimary": 0,
+    "onlineStatus": 0,
+    "status": 1,
+    "bindTime": "2026-06-02 10:35:00",
+    "createTime": "2026-06-02 10:35:00",
+    "updateTime": "2026-06-02 10:35:00"
+  }
+}
+```
+
+#### 3.3.6 失败返回示例
+
+设备编号不存在:
+
+```json
+{
+  "code": 1300,
+  "message": "设备不存在",
+  "data": null
+}
+```
+
+该用户已绑定此设备:
+
+```json
+{
+  "code": 1304,
+  "message": "该用户已绑定此设备",
+  "data": null
+}
+```
+
+#### 3.3.7 备注
+
+- APP 端只需要传 `userId` 和 `deviceCode`,不需要传 `deviceName`、`deviceModel`。
+- 后端会用 `deviceCode` 查询设备主表,存在时自动写入 `deviceName`、`deviceModel`。
+- 绑定成功后,绑定记录默认 `status=1`、`isPrimary=0`。
+- APP 可根据失败响应中的 `message` 直接弹窗,例如设备编号不存在时提示“设备不存在”。

+ 12 - 1
code/backend/src/main/java/com/aijiuyi/admin/controller/AcupointController.java

@@ -53,7 +53,18 @@ public class AcupointController {
 		return Result.success(acupointService.listYReferenceOptions());
 	}
 
-	/**
+/**
+     * 获取身体部位筛选选项
+     *
+     * @return 身体部位列表
+     */
+    @GetMapping("/options/body-parts")
+    @Log(value = "获取穴位身体部位选项", module = "穴位管理", operationType = OperationType.QUERY)
+    public Result<List<String>> bodyPartOptions() {
+        return Result.success(acupointService.listBodyPartOptions());
+    }
+
+    /**
      * 分页查询穴位列表
      *
      * @param queryDTO 查询条件

+ 13 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/UserAcupointController.java

@@ -3,6 +3,7 @@ package com.aijiuyi.admin.controller;
 import com.aijiuyi.admin.common.annotation.Log;
 import com.aijiuyi.admin.common.entity.Result;
 import com.aijiuyi.admin.common.enums.OperationType;
+import com.aijiuyi.admin.controller.dto.UserAcupointCoordinateDTO;
 import com.aijiuyi.admin.entity.UserAcupoint;
 import com.aijiuyi.admin.service.UserAcupointService;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -45,4 +46,16 @@ public class UserAcupointController {
     public Result<List<UserAcupoint>> list(@PathVariable Long profileId) {
         return Result.success(userAcupointService.listByUser(profileId));
     }
+
+    /**
+     * 查询指定子用户的背部产品穴位坐标列表
+     *
+     * @param profileId 子用户ID
+     * @return 背部产品穴位坐标列表
+     */
+    @GetMapping("/{profileId}/product-back-coordinates")
+    @Log(value = "查询用户背部产品穴位坐标", module = "用户穴位管理", operationType = OperationType.QUERY)
+    public Result<List<UserAcupointCoordinateDTO>> productBackCoordinates(@PathVariable Long profileId) {
+        return Result.success(userAcupointService.listBackProductCoordinatesByUser(profileId));
+    }
 }

+ 6 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AcupointQueryDTO.java

@@ -14,6 +14,12 @@ public class AcupointQueryDTO {
     /** 所属经络 */
     private String meridian;
 
+    /** 身体部位 */
+    private String bodyPart;
+
+    /** 是否产品穴位:1=是,0=否 */
+    private Integer productAcupoint;
+
     /** 定位方式:当前固定为相对定位,保留字段兼容旧查询参数 */
     private Integer locationType;
 

+ 9 - 0
code/backend/src/main/java/com/aijiuyi/admin/mapper/UserAcupointMapper.java

@@ -1,5 +1,6 @@
 package com.aijiuyi.admin.mapper;
 
+import com.aijiuyi.admin.controller.dto.UserAcupointCoordinateDTO;
 import com.aijiuyi.admin.entity.UserAcupoint;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import org.apache.ibatis.annotations.Mapper;
@@ -21,6 +22,14 @@ public interface UserAcupointMapper extends BaseMapper<UserAcupoint> {
      */
     List<UserAcupoint> listByUserId(@Param("userId") Long userId);
 
+    /**
+     * 查询指定子用户的背部产品穴位坐标列表
+     *
+     * @param userId 子用户ID(user_profile.id)
+     * @return 背部产品穴位坐标列表
+     */
+    List<UserAcupointCoordinateDTO> listBackProductCoordinatesByUserId(@Param("userId") Long userId);
+
     /**
      * 删除指定子用户的所有穴位坐标记录(物理删除,用于重新生成前清空)
      *

+ 7 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/AcupointService.java

@@ -36,6 +36,13 @@ public interface AcupointService extends IService<Acupoint> {
      */
     List<YReferenceOptionDTO> listYReferenceOptions();
 
+    /**
+     * 获取已有穴位身体部位选项
+     *
+     * @return 身体部位列表
+     */
+    List<String> listBodyPartOptions();
+
     /**
      * 新增穴位
      *

+ 10 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/UserAcupointService.java

@@ -1,5 +1,6 @@
 package com.aijiuyi.admin.service;
 
+import com.aijiuyi.admin.controller.dto.UserAcupointCoordinateDTO;
 import com.aijiuyi.admin.entity.UserAcupoint;
 import com.baomidou.mybatisplus.extension.service.IService;
 
@@ -34,4 +35,13 @@ public interface UserAcupointService extends IService<UserAcupoint> {
      * @return 穴位坐标列表
      */
     List<UserAcupoint> listByUser(Long profileId);
+
+    /**
+     * 查询指定子用户的背部产品穴位坐标列表。
+     * 如果用户尚未生成穴位坐标表,会先自动生成。
+     *
+     * @param profileId 子用户ID
+     * @return 背部产品穴位坐标列表
+     */
+    List<UserAcupointCoordinateDTO> listBackProductCoordinatesByUser(Long profileId);
 }

+ 10 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/UserDeviceService.java

@@ -27,6 +27,16 @@ public interface UserDeviceService extends IService<UserDevice> {
      */
     void bindDevice(UserDevice device);
 
+    /**
+     * App 端按设备编号绑定设备。
+     *
+     * @param userId     用户档案ID(user_profile.id)
+     * @param deviceCode 设备编号
+     * @param appUserId  当前登录 App 用户ID(app_user.id)
+     * @return 新增的绑定记录
+     */
+    UserDevice bindAppDeviceByCode(Long userId, String deviceCode, Long appUserId);
+
     /**
      * 解绑设备
      *

+ 17 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/impl/AcupointServiceImpl.java

@@ -55,6 +55,8 @@ public class AcupointServiceImpl extends ServiceImpl<AcupointMapper, Acupoint> i
         LambdaQueryWrapper<Acupoint> wrapper = new LambdaQueryWrapper<>();
         wrapper.like(StringUtils.hasText(queryDTO.getName()), Acupoint::getName, queryDTO.getName())
                .eq(StringUtils.hasText(queryDTO.getMeridian()), Acupoint::getMeridian, queryDTO.getMeridian())
+               .eq(StringUtils.hasText(queryDTO.getBodyPart()), Acupoint::getBodyPart, queryDTO.getBodyPart())
+               .eq(queryDTO.getProductAcupoint() != null, Acupoint::getProductAcupoint, queryDTO.getProductAcupoint())
                .eq(queryDTO.getLocationType() != null, Acupoint::getLocationType, queryDTO.getLocationType())
                .eq(queryDTO.getStatus() != null, Acupoint::getStatus, queryDTO.getStatus())
                .and(StringUtils.hasText(queryDTO.getParseStatus()), w -> {
@@ -88,6 +90,21 @@ public class AcupointServiceImpl extends ServiceImpl<AcupointMapper, Acupoint> i
         return AcupointYReference.listOptions();
     }
 
+    @Override
+    public List<String> listBodyPartOptions() {
+        return lambdaQuery()
+                .select(Acupoint::getBodyPart)
+                .isNotNull(Acupoint::getBodyPart)
+                .ne(Acupoint::getBodyPart, "")
+                .groupBy(Acupoint::getBodyPart)
+                .orderByAsc(Acupoint::getBodyPart)
+                .list()
+                .stream()
+                .map(Acupoint::getBodyPart)
+                .filter(StringUtils::hasText)
+                .collect(java.util.stream.Collectors.toList());
+    }
+
     /**
      * 新增穴位,校验编码和名称唯一性
      *

+ 13 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserAcupointServiceImpl.java

@@ -5,6 +5,7 @@ import com.aijiuyi.admin.common.exception.BusinessException;
 import com.aijiuyi.admin.common.util.AcupointLocationParser;
 import com.aijiuyi.admin.common.util.AcupointYReference;
 import com.aijiuyi.admin.common.util.LogUtil;
+import com.aijiuyi.admin.controller.dto.UserAcupointCoordinateDTO;
 import com.aijiuyi.admin.entity.Acupoint;
 import com.aijiuyi.admin.entity.UserAcupoint;
 import com.aijiuyi.admin.entity.UserProfile;
@@ -155,6 +156,18 @@ public class UserAcupointServiceImpl extends ServiceImpl<UserAcupointMapper, Use
         return baseMapper.listByUserId(profileId);
     }
 
+    @Override
+    public List<UserAcupointCoordinateDTO> listBackProductCoordinatesByUser(Long profileId) {
+        UserProfile profile = userProfileMapper.selectById(profileId);
+        if (profile == null) {
+            throw new BusinessException(ResultCode.PROFILE_NOT_FOUND);
+        }
+        if (profile.getAcupointTableId() == null) {
+            generateForUser(profileId);
+        }
+        return baseMapper.listBackProductCoordinatesByUserId(profileId);
+    }
+
     // ==================== 核心算法(3.0) ====================
 
     /**

+ 64 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserDeviceServiceImpl.java

@@ -4,18 +4,24 @@ import com.aijiuyi.admin.common.constant.ResultCode;
 import com.aijiuyi.admin.common.exception.BusinessException;
 import com.aijiuyi.admin.common.util.LogUtil;
 import com.aijiuyi.admin.controller.dto.UserDeviceQueryDTO;
+import com.aijiuyi.admin.entity.Device;
 import com.aijiuyi.admin.entity.UserDevice;
+import com.aijiuyi.admin.entity.UserProfile;
+import com.aijiuyi.admin.mapper.DeviceMapper;
 import com.aijiuyi.admin.mapper.UserDeviceMapper;
+import com.aijiuyi.admin.mapper.UserProfileMapper;
 import com.aijiuyi.admin.service.UserDeviceService;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.StringUtils;
 
+import java.time.LocalDateTime;
 import java.util.List;
 
 /**
@@ -24,6 +30,12 @@ import java.util.List;
 @Service
 public class UserDeviceServiceImpl extends ServiceImpl<UserDeviceMapper, UserDevice> implements UserDeviceService {
 
+    @Autowired
+    private DeviceMapper deviceMapper;
+
+    @Autowired
+    private UserProfileMapper userProfileMapper;
+
     /**
      * 分页查询设备列表
      *
@@ -66,6 +78,58 @@ public class UserDeviceServiceImpl extends ServiceImpl<UserDeviceMapper, UserDev
         LogUtil.info(UserDeviceServiceImpl.class, "子用户[{}]绑定设备[{}]", device.getUserId(), device.getDeviceCode());
     }
 
+    /**
+     * App 端按设备编号绑定设备。
+     *
+     * @param userId     用户档案ID
+     * @param deviceCode 设备编号
+     * @param appUserId  当前登录 App 用户ID
+     * @return 新增的绑定记录
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public UserDevice bindAppDeviceByCode(Long userId, String deviceCode, Long appUserId) {
+        UserProfile profile = userProfileMapper.selectById(userId);
+        if (profile == null) {
+            throw new BusinessException(ResultCode.PROFILE_NOT_FOUND);
+        }
+        if (appUserId != null && !appUserId.equals(profile.getUserId())) {
+            throw new BusinessException(ResultCode.FORBIDDEN);
+        }
+
+        String normalizedDeviceCode = deviceCode.trim();
+        Device device = deviceMapper.selectOne(new LambdaQueryWrapper<Device>()
+                .eq(Device::getDeviceCode, normalizedDeviceCode)
+                .last("LIMIT 1"));
+        if (device == null) {
+            throw new BusinessException(ResultCode.DEVICE_NOT_FOUND);
+        }
+
+        long count = lambdaQuery()
+                .eq(UserDevice::getUserId, userId)
+                .eq(UserDevice::getDeviceCode, normalizedDeviceCode)
+                .eq(UserDevice::getStatus, 1)
+                .count();
+        if (count > 0) {
+            throw new BusinessException(ResultCode.DEVICE_USER_ALREADY_BOUND);
+        }
+
+        UserDevice userDevice = new UserDevice();
+        userDevice.setUserId(userId);
+        userDevice.setDeviceCode(device.getDeviceCode());
+        userDevice.setDeviceName(device.getDeviceName());
+        userDevice.setDeviceModel(device.getDeviceModel());
+        userDevice.setStatus(1);
+        userDevice.setIsPrimary(0);
+        userDevice.setOnlineStatus(device.getOnlineStatus() != null ? device.getOnlineStatus() : 0);
+        userDevice.setBindTime(LocalDateTime.now());
+        save(userDevice);
+
+        LogUtil.info(UserDeviceServiceImpl.class,
+                "App用户[{}]为子用户[{}]绑定设备[{}]", appUserId, userId, device.getDeviceCode());
+        return userDevice;
+    }
+
     /**
      * 解绑设备(设置状态为0)
      *

+ 25 - 0
code/backend/src/main/resources/mapper/UserAcupointMapper.xml

@@ -11,6 +11,31 @@
         ORDER BY acupoint_id, acupoint_side
     </select>
 
+    <!-- 查询指定子用户的背部产品穴位坐标列表 -->
+    <select id="listBackProductCoordinatesByUserId" resultType="com.aijiuyi.admin.controller.dto.UserAcupointCoordinateDTO">
+        SELECT
+            ua.id,
+            ua.user_id,
+            ua.acupoint_id,
+            a.acupoint_code,
+            ua.acupoint_name,
+            ua.acupoint_side,
+            a.body_part,
+            a.product_acupoint,
+            ua.coordinate_x,
+            ua.coordinate_y,
+            ua.coordinate_z,
+            ua.confidence
+        FROM user_acupoint ua
+        INNER JOIN acupoint a ON a.id = ua.acupoint_id
+        WHERE ua.user_id = #{userId}
+          AND a.body_part = '背部'
+          AND a.product_acupoint = 1
+          AND a.status = 1
+          AND a.deleted = 0
+        ORDER BY ua.acupoint_id, ua.acupoint_side
+    </select>
+
     <!-- 删除指定子用户的所有穴位坐标记录(物理删除) -->
     <delete id="deleteByUserId">
         DELETE FROM user_acupoint

+ 8 - 1
code/frontend/src/api/acupoint.js

@@ -20,9 +20,16 @@ export function getYReferenceOptions() {
   return request.get('/acupoint/y-reference-options')
 }
 
+/**
+ * 获取身体部位筛选选项
+ */
+export function getBodyPartOptions() {
+  return request.get('/acupoint/options/body-parts')
+}
+
 /**
  * 分页查询穴位列表
- * @param {Object} params 查询参数:name/meridian/status/parseStatus/pageNum/pageSize
+ * @param {Object} params 查询参数:name/meridian/bodyPart/productAcupoint/status/parseStatus/pageNum/pageSize
  */
 export function getAcupointPage(params) {
   return request.get('/acupoint/page', { params })

+ 50 - 8
code/frontend/src/views/acupoint/index.vue

@@ -16,6 +16,22 @@
             <el-option label="其他" value="其他" />
           </el-select>
         </el-form-item>
+        <el-form-item label="身体部位">
+          <el-select v-model="queryForm.bodyPart" placeholder="全部" clearable filterable style="width:140px">
+            <el-option
+              v-for="item in bodyPartOptions"
+              :key="item"
+              :label="item"
+              :value="item"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="产品穴位">
+          <el-select v-model="queryForm.productAcupoint" placeholder="全部" clearable style="width:120px">
+            <el-option label="是" :value="1" />
+            <el-option label="否" :value="0" />
+          </el-select>
+        </el-form-item>
         <el-form-item label="状态">
           <el-select v-model="queryForm.status" placeholder="全部" clearable style="width:110px">
             <el-option label="活跃" :value="1" />
@@ -314,6 +330,7 @@ import { parsePageData } from '@/utils/pagination'
 import {
   getAcupointPage,
   exportAcupoint,
+  getBodyPartOptions,
   addAcupoint,
   updateAcupoint,
   deleteAcupoint,
@@ -325,6 +342,8 @@ import {
 const queryForm = reactive({
   name: '',
   meridian: '',
+  bodyPart: '',
+  productAcupoint: null,
   status: null,
   parseStatus: '',
   pageNum: 1,
@@ -335,6 +354,7 @@ const tableData = ref([])
 const total = ref(0)
 const loading = ref(false)
 const selectedIds = ref([])
+const bodyPartOptions = ref([])
 
 // 弹窗
 const dialogVisible = ref(false)
@@ -410,13 +430,29 @@ const exportHeaders = [
   { label: '状态', prop: 'statusLabel' }
 ]
 
+function buildQueryParams(includePage = true) {
+  const params = {
+    name: queryForm.name,
+    meridian: queryForm.meridian,
+    bodyPart: queryForm.bodyPart,
+    productAcupoint: queryForm.productAcupoint,
+    status: queryForm.status,
+    parseStatus: queryForm.parseStatus
+  }
+  if (includePage) {
+    params.pageNum = queryForm.pageNum
+    params.pageSize = queryForm.pageSize
+  }
+  return params
+}
+
 /**
  * 加载穴位列表
  */
 async function loadData() {
   loading.value = true
   try {
-    const res = await getAcupointPage(queryForm)
+    const res = await getAcupointPage(buildQueryParams())
     const { records, total: pageTotal } = parsePageData(res.data)
     // 添加辅助显示字段
     tableData.value = records.map(formatAcupointRow)
@@ -426,6 +462,15 @@ async function loadData() {
   }
 }
 
+async function loadBodyPartOptions() {
+  try {
+    const res = await getBodyPartOptions()
+    bodyPartOptions.value = res.data || []
+  } catch {
+    bodyPartOptions.value = []
+  }
+}
+
 function formatAcupointRow(item) {
   return {
     ...item,
@@ -442,13 +487,7 @@ function formatAcupointRow(item) {
  * 获取导出数据(全量,传入当前筛选条件)
  */
 async function fetchExportData() {
-  const params = {
-    name: queryForm.name,
-    meridian: queryForm.meridian,
-    status: queryForm.status,
-    parseStatus: queryForm.parseStatus
-  }
-  const res = await exportAcupoint(params)
+  const res = await exportAcupoint(buildQueryParams(false))
   const list = res.data || []
   return list.map(formatAcupointRow)
 }
@@ -503,6 +542,8 @@ function handleSearch() {
 function handleReset() {
   queryForm.name = ''
   queryForm.meridian = ''
+  queryForm.bodyPart = ''
+  queryForm.productAcupoint = null
   queryForm.status = null
   queryForm.parseStatus = ''
   queryForm.pageNum = 1
@@ -649,6 +690,7 @@ async function handleImport(data) {
 }
 
 onMounted(() => {
+  loadBodyPartOptions()
   loadData()
 })
 </script>