Ver código fonte

Merge remote-tracking branch 'origin/dev'

liaoxg 3 meses atrás
pai
commit
33f6b8d534

+ 12 - 0
code/ajyApp/pages.json

@@ -131,6 +131,18 @@
 				}
 			}
 		},
+		{
+			"path": "pages/device/acupointEdit/acupointEdit",
+			"style": {
+				"navigationStyle": "custom",
+				"navigationBarTitleText": "",
+				"backgroundColor": "#389588",
+				"app-plus": {
+					"backgroundColor": "#389588",
+					"background": "#389588"
+				}
+			}
+		},
 		{
 			"path": "pages/device/bodyParams/bodyParams",
 			"style": {

+ 813 - 0
code/ajyApp/pages/device/acupointEdit/acupointEdit.vue

@@ -0,0 +1,813 @@
+<template>
+	<view class="page">
+		<!-- 自定义导航栏 -->
+		<view class="status-bar" :style="{height: statusBarHeight + 'px'}"></view>
+		<view class="nav-bar">
+			<view class="nav-back" @click="goBack">
+				<image src="/static/public/back.png" mode="aspectFill" class="back-icon"></image>
+			</view>
+			<text class="nav-title">穴位定位</text>
+			<view class="nav-right">
+				<text class="nav-count">{{acupointList.length}}穴位</text>
+			</view>
+		</view>
+
+		<!-- 人体模型 + 穴位标注 -->
+		<scroll-view class="body-scroll" :scroll-y="!isLandscape" :show-scrollbar="false" :scroll-top="scrollTop" @scroll="onScroll" scroll-with-animation>
+			<view class="body-content">
+				<view class="body-wrapper">
+					<!-- 中线参考 -->
+					<view class="midline"></view>
+
+					<!-- 人体背部剪影 -->
+					<image
+						src="/static/device/body-back-silhouette.svg"
+						mode="aspectFit"
+						class="body-img"
+						@load="onImageLoad"
+					></image>
+
+					<!-- 穴位点 -->
+					<view
+						v-for="(point, index) in positionedAcupoints"
+						:key="point.id"
+						class="acupoint-dot"
+						:class="{
+							'acupoint-active': selectedIndex === index
+						}"
+						:style="{
+							left: point.screenX + 'px',
+							top: point.screenY + 'px'
+						}"
+						@click="onAcupointTap(index)"
+					>
+						<view class="dot-inner"></view>
+					</view>
+				</view>
+			</view>
+		</scroll-view>
+
+		<!-- 穴位列表 -->
+		<view class="list-section">
+			<view class="list-header">
+				<text class="list-title">穴位清单</text>
+			</view>
+			<scroll-view class="list-scroll" :scroll-y="!isLandscape" :show-scrollbar="false">
+				<view class="list-grid">
+					<view
+						v-for="(point, index) in acupointList"
+						:key="point.id"
+						class="list-item"
+						:class="{
+							'list-item-active': selectedIndex === index,
+							'list-item-full': point.isSingle
+						}"
+						@click="onListItemTap(index)"
+					>
+						<view class="item-index" :class="{'item-index-active': selectedIndex === index}">
+							<text class="index-text">{{index + 1}}</text>
+						</view>
+						<text class="item-name">{{point.acupointName}}</text>
+					</view>
+				</view>
+			</scroll-view>
+		</view>
+
+		<!-- 编辑弹窗 -->
+		<view class="popup-mask" v-if="showPopup" @click="closePopup">
+			<view class="popup-content" @click.stop>
+				<view class="popup-header">
+					<text class="popup-title">编辑穴位参数</text>
+					<text class="popup-acupoint-name">{{ currentEditAcupoint?.acupointName }}</text>
+				</view>
+
+				<view class="popup-body">
+					<!-- 侧别 -->
+					<view class="form-group">
+						<text class="form-label">侧别</text>
+						<view class="tags-row">
+							<view 
+								class="tag-item" 
+								v-for="item in sideOptions" 
+								:key="item"
+								:class="{'tag-item-active': editForm.side === item}"
+								@click="editForm.side = item"
+							>
+								{{item}}
+							</view>
+						</view>
+					</view>
+
+					<!-- 手法 -->
+					<view class="form-group">
+						<text class="form-label">手法选择</text>
+						<view class="tags-row">
+							<view 
+								class="tag-item" 
+								v-for="item in methodOptions" 
+								:key="item"
+								:class="{'tag-item-active': editForm.method === item}"
+								@click="editForm.method = item"
+							>
+								{{item}}
+							</view>
+						</view>
+					</view>
+
+					<!-- 温度与时间 -->
+					<view class="form-row-2">
+						<view class="form-group half-width">
+							<text class="form-label">温度 (°C)</text>
+							<view class="stepper">
+								<view class="stepper-btn" @click="editForm.temperature > 30 && editForm.temperature--">-</view>
+								<input class="stepper-input" type="number" v-model="editForm.temperature" />
+								<view class="stepper-btn" @click="editForm.temperature < 60 && editForm.temperature++">+</view>
+							</view>
+						</view>
+						
+						<view class="form-group half-width">
+							<text class="form-label">时间 (分钟)</text>
+							<view class="stepper">
+								<view class="stepper-btn" @click="editForm.duration > 1 && editForm.duration--">-</view>
+								<input class="stepper-input" type="number" v-model="editForm.duration" />
+								<view class="stepper-btn" @click="editForm.duration < 60 && editForm.duration++">+</view>
+							</view>
+						</view>
+					</view>
+				</view>
+
+				<view class="popup-footer">
+					<view class="btn btn-cancel" @click="closePopup">取消</view>
+					<view class="btn btn-confirm" @click="saveEdit">确定</view>
+				</view>
+			</view>
+		</view>
+
+		<!-- 加载提示 -->
+		<view class="loading-mask" v-if="loading">
+			<view class="loading-card">
+				<view class="loading-spinner"></view>
+				<text class="loading-text">加载中...</text>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+import { getBackAcupoints } from '@/utils/api/acupoint.js'
+
+export default {
+	data() {
+		return {
+			isLandscape: false,
+			statusBarHeight: 44,
+			loading: false,
+			acupointList: [],
+			selectedIndex: -1,
+			imageReady: false,
+			// SVG viewBox: 0 0 300 400, 一体式实心剪影
+			containerWidth: 345,
+			containerHeight: 460,
+			// 大椎穴(0,0)在容器中的位置
+			// 颈根/斜方肌起点(C7)在SVG中 y=78 -> 按比例放大1.15倍
+			originX: 172.5,
+			originY: 89.7,
+			// 毫米到像素缩放比
+			// 0.5 * 1.15 = 0.575
+			mmToPixel: 0.575,
+			
+			// 弹窗表单
+			showPopup: false,
+			editForm: {
+				side: '',
+				method: '温和灸',
+				temperature: 45,
+				duration: 15
+			},
+			sideOptions: ['双侧', '中心', '左侧', '右侧'],
+			methodOptions: ['温和灸', '雀啄灸', '回旋灸'],
+			
+			scrollTop: 0,
+			oldScrollTop: 0
+		}
+	},
+	computed: {
+		/** 当前正在编辑的穴位 */
+		currentEditAcupoint() {
+			if (this.selectedIndex >= 0 && this.acupointList[this.selectedIndex]) {
+				return this.acupointList[this.selectedIndex]
+			}
+			return null
+		},
+		/** 计算屏幕坐标后的穴位列表 */
+		positionedAcupoints() {
+			if (!this.imageReady || this.acupointList.length === 0) return []
+
+			return this.acupointList.map(point => {
+				// coordinateX: 向右为正 (mm), coordinateY: 向下为正 (mm)
+				// 大椎穴为(0,0)原点
+				const screenX = this.originX + (point.coordinateX * this.mmToPixel)
+				const screenY = this.originY + (point.coordinateY * this.mmToPixel)
+				return {
+					...point,
+					screenX: Math.max(8, Math.min(this.containerWidth - 8, screenX)),
+					screenY: Math.max(8, Math.min(this.containerHeight - 8, screenY))
+				}
+			})
+		}
+	},
+	onLoad() {
+		const sysInfo = uni.getSystemInfoSync()
+		this.statusBarHeight = sysInfo.statusBarHeight || 44
+		this.checkOrientation(sysInfo)
+		this.loadAcupoints()
+	},
+	onResize(res) {
+		this.checkOrientation(res.size || uni.getSystemInfoSync())
+	},
+	methods: {
+		checkOrientation(size) {
+			if (size && size.windowWidth && size.windowHeight) {
+				this.isLandscape = size.windowWidth > size.windowHeight
+			}
+		},
+		goBack() {
+			uni.navigateBack()
+		},
+
+		onScroll(e) {
+			this.oldScrollTop = e.detail.scrollTop
+		},
+
+		/** 图片加载完成 */
+		onImageLoad() {
+			this.imageReady = true
+		},
+
+		/** 加载穴位数据 */
+		async loadAcupoints() {
+			this.loading = true
+			try {
+				const data = await getBackAcupoints(4)
+				if (Array.isArray(data)) {
+					// 按名称分组并计算平均 Y 坐标,以处理对称穴位
+					const nameMap = new Map()
+					data.forEach(point => {
+						if (!nameMap.has(point.acupointName)) {
+							nameMap.set(point.acupointName, {
+								name: point.acupointName,
+								points: [],
+								sumY: 0
+							})
+						}
+						const group = nameMap.get(point.acupointName)
+						group.points.push(point)
+						group.sumY += point.coordinateY
+					})
+					
+					const groups = Array.from(nameMap.values())
+					groups.forEach(group => {
+						group.avgY = group.sumY / group.points.length
+						// 同名穴位按 X 坐标从左到右排序
+						group.points.sort((a, b) => a.coordinateX - b.coordinateX)
+					})
+					
+					// 组之间按平均 Y 坐标从上到下排序
+					groups.sort((a, b) => a.avgY - b.avgY)
+					
+					// 重新打平为一维数组,并标记是否为单穴(用于占满整行)
+					const sortedData = []
+					groups.forEach(group => {
+						const isSingle = group.points.length === 1
+						group.points.forEach(p => {
+							p.isSingle = isSingle
+						})
+						sortedData.push(...group.points)
+					})
+					
+					this.acupointList = sortedData
+				} else {
+					this.acupointList = []
+				}
+			} catch (e) {
+				console.error('加载穴位数据失败:', e)
+				uni.showToast({ title: '穴位数据加载失败', icon: 'none' })
+				this.acupointList = []
+			} finally {
+				this.loading = false
+				// 确保图片加载后穴位可显示
+				this.$nextTick(() => {
+					if (!this.imageReady) {
+						this.imageReady = true
+					}
+				})
+			}
+		},
+
+		/** 点击穴位点 */
+		onAcupointTap(index) {
+			this.openPopup(index)
+		},
+
+		/** 点击列表项 */
+		onListItemTap(index) {
+			this.openPopup(index)
+		},
+
+		/** 打开编辑弹窗 */
+		openPopup(index) {
+			this.selectedIndex = index
+			const point = this.acupointList[index]
+			// 初始化表单数据
+			this.editForm = {
+				side: point.customSide || this.sideLabel(point.acupointSide) || '中心',
+				method: point.method || '温和灸',
+				temperature: point.temperature || 45,
+				duration: point.duration || 15
+			}
+			this.showPopup = true
+
+			this.scrollToAcupoint()
+		},
+
+		/** 滚动到选中的穴位 */
+		scrollToAcupoint() {
+			this.$nextTick(() => {
+				const query = uni.createSelectorQuery().in(this)
+				query.select('.body-scroll').boundingClientRect()
+				query.select('.acupoint-active').boundingClientRect()
+				query.exec(res => {
+					const scrollRes = res[0]
+					const dotRes = res[1]
+					
+					if (scrollRes && dotRes) {
+						const relativeTop = dotRes.top - scrollRes.top
+						const offset = relativeTop - (scrollRes.height / 2) + (dotRes.height / 2)
+						
+						if (Math.abs(offset) > 5) {
+							let newScrollTop = this.oldScrollTop + offset
+							if (newScrollTop < 0) newScrollTop = 0
+							
+							this.scrollTop = this.oldScrollTop
+							this.$nextTick(() => {
+								this.scrollTop = newScrollTop
+							})
+						}
+					}
+				})
+			})
+		},
+
+		/** 关闭弹窗 */
+		closePopup() {
+			this.showPopup = false
+		},
+
+		/** 保存编辑结果 */
+		saveEdit() {
+			if (this.selectedIndex >= 0) {
+				const point = this.acupointList[this.selectedIndex]
+				// 更新穴位列表中的对应数据(使用Vue的响应式需要特殊处理,但这里我们直接改属性,若无法触发视图更新可用$set或splice,但简单修改通常有效)
+				point.customSide = this.editForm.side
+				point.method = this.editForm.method
+				point.temperature = this.editForm.temperature
+				point.duration = this.editForm.duration
+			}
+			this.closePopup()
+		},
+
+		/** 侧别标签 */
+		sideLabel(side) {
+			const map = { left: '左侧', right: '右侧', center: '中心' }
+			return map[side] || side
+		},
+
+		/** 置信度标签 */
+		confidenceLabel(confidence) {
+			const map = { high: '高', medium: '中', low: '低' }
+			return map[confidence] || confidence
+		}
+	}
+}
+</script>
+
+<style scoped>
+/* 隐藏滚动条 */
+::-webkit-scrollbar {
+	display: none;
+	width: 0 !important;
+	height: 0 !important;
+	-webkit-appearance: none;
+	background: transparent;
+}
+
+.page {
+	display: flex;
+	flex-direction: column;
+	height: 100vh;
+	overflow: hidden;
+	background: linear-gradient(to bottom, #389588, rgba(56,149,136,0.3) 40%, #F8F8F8 60%);
+}
+.status-bar {
+	background-color: transparent;
+}
+
+/* 导航栏 */
+.nav-bar {
+	display: flex;
+	flex-direction: row;
+	justify-content: space-between;
+	align-items: center;
+	height: 44px;
+	padding: 0 15px;
+}
+.nav-back {
+	width: 25px;
+	height: 25px;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+}
+.back-icon {
+	width: 25px;
+	height: 25px;
+}
+.nav-title {
+	font-size: 16px;
+	font-weight: bold;
+	color: #FFFFFF;
+}
+.nav-right {
+	width: 60px;
+	display: flex;
+	justify-content: flex-end;
+}
+.nav-count {
+	font-size: 12px;
+	color: rgba(255,255,255,0.8);
+}
+
+/* 人体模型容器 */
+.body-scroll {
+	flex: 1;
+	min-height: 0;
+	width: 100%;
+}
+.body-content {
+	display: flex;
+	justify-content: center;
+	align-items: flex-start; /* 改为顶部对齐,防止高度不足时头部被居中截断 */
+	padding: 20px 10px; /* 增加顶部间距,保护头部不贴边 */
+	min-height: 100%;
+	box-sizing: border-box;
+}
+.body-wrapper {
+	position: relative;
+	width: 345px;
+	height: 460px;
+	margin: auto 0; /* 空间充足时保持垂直居中,空间不足时贴顶 */
+	/* Removed bubble-like styling to keep it clean */
+}
+
+/* 中线 */
+.midline {
+	position: absolute;
+	left: 50%;
+	top: 60px;
+	bottom: 20px;
+	width: 1px;
+	background: transparent;
+	border-left: 1px dashed rgba(255, 255, 255, 0.4);
+	z-index: 2;
+	pointer-events: none;
+}
+
+
+.body-img {
+	position: relative;
+	width: 345px;
+	height: 460px;
+	z-index: 3;
+	/* Added a soft drop shadow to make the human shape stand out nicely */
+	filter: drop-shadow(0px 15px 25px rgba(0, 0, 0, 0.12));
+}
+
+/* 穴位点 */
+.acupoint-dot {
+	position: absolute;
+	width: 30px;
+	height: 30px;
+	margin-left: -15px;
+	margin-top: -15px;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	z-index: 10;
+}
+.dot-inner {
+	width: 8px;
+	height: 8px;
+	border-radius: 50%;
+	background: #FFFFFF;
+	border: 1.5px solid #389588;
+	box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
+	transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
+	z-index: 1;
+}
+
+/* 选中态 */
+.acupoint-active .dot-inner {
+	width: 14px;
+	height: 14px;
+	background: #F65252;
+	border: 2px solid #FFFFFF;
+	box-shadow: 0 4px 12px rgba(246, 82, 82, 0.4);
+	transform: scale(1.1);
+}
+
+/* 穴位列表 */
+.list-section {
+	background: #FFFFFF;
+	border-radius: 28px 28px 0 0;
+	padding: 20px 12px 0;
+	max-height: 40vh;
+	display: flex;
+	flex-direction: column;
+	box-shadow: 0 -10px 30px rgba(0, 0, 0, 0.06);
+	box-sizing: border-box;
+}
+.list-header {
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	margin-bottom: 16px;
+}
+.list-title {
+	font-size: 15px;
+	color: #333333;
+	font-weight: 600;
+}
+.list-scroll {
+	flex: 1;
+	overflow-y: auto;
+}
+.list-grid {
+	display: flex;
+	flex-direction: row;
+	flex-wrap: wrap;
+	gap: 10px;
+	padding: 4px 8px 20px 8px;
+	box-sizing: border-box;
+}
+.list-item {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	padding: 10px 12px;
+	border-radius: 12px;
+	background: #F8FAFA;
+	border: 1px solid rgba(56, 149, 136, 0.05);
+	transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
+	width: calc(50% - 5px);
+	box-sizing: border-box;
+}
+.list-item-active {
+	background: rgba(56, 149, 136, 0.08);
+	border-color: rgba(56, 149, 136, 0.3);
+	box-shadow: 0 4px 12px rgba(56, 149, 136, 0.1);
+	transform: scale(1);
+}
+.list-item-full {
+	margin-right: 50%;
+}
+.item-index {
+	width: 24px;
+	height: 24px;
+	border-radius: 8px;
+	background: rgba(56, 149, 136, 0.1);
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	margin-right: 8px;
+	transition: all 0.25s;
+	flex-shrink: 0;
+}
+.item-index-active {
+	background: rgba(246, 82, 82, 0.1);
+}
+.index-text {
+	font-size: 11px;
+	font-weight: 600;
+	color: #389588;
+}
+.item-index-active .index-text {
+	color: #F65252;
+}
+.item-name {
+	font-size: 13px;
+	color: #545F71;
+	font-weight: 500;
+	line-height: 1.3;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+
+/* 加载遮罩 */
+.loading-mask {
+	position: fixed;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	background: rgba(0, 0, 0, 0.4);
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	z-index: 100;
+}
+.loading-card {
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	background: #FFFFFF;
+	border-radius: 16px;
+	padding: 28px 36px;
+	box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15);
+}
+@keyframes spin {
+	0% { transform: rotate(0deg); }
+	100% { transform: rotate(360deg); }
+}
+.loading-spinner {
+	width: 32px;
+	height: 32px;
+	border: 2px solid #EEF1F4;
+	border-top-color: #389588;
+	border-radius: 50%;
+	animation: spin 0.8s linear infinite;
+	margin-bottom: 14px;
+}
+.loading-text {
+	color: #545F71;
+	font-size: 13px;
+	font-weight: 500;
+}
+
+/* 弹窗样式 */
+.popup-mask {
+	position: fixed;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	background: rgba(0, 0, 0, 0.5);
+	z-index: 200;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	animation: fadeIn 0.3s ease;
+}
+@keyframes fadeIn {
+	from { opacity: 0; }
+	to { opacity: 1; }
+}
+.popup-content {
+	width: 320px;
+	background: #FFFFFF;
+	border-radius: 20px;
+	overflow: hidden;
+	box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
+	animation: slideUp 0.3s ease;
+}
+@keyframes slideUp {
+	from { transform: translateY(20px); opacity: 0; }
+	to { transform: translateY(0); opacity: 1; }
+}
+.popup-header {
+	padding: 20px 24px 16px;
+	border-bottom: 1px solid #F0F2F5;
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+}
+.popup-title {
+	font-size: 16px;
+	font-weight: 600;
+	color: #333333;
+}
+.popup-acupoint-name {
+	font-size: 15px;
+	color: #389588;
+	font-weight: 600;
+	background: rgba(56, 149, 136, 0.1);
+	padding: 4px 10px;
+	border-radius: 6px;
+}
+.popup-body {
+	padding: 20px 24px;
+}
+.form-group {
+	margin-bottom: 20px;
+}
+.form-group:last-child {
+	margin-bottom: 0;
+}
+.form-row-2 {
+	display: flex;
+	justify-content: space-between;
+	gap: 16px;
+}
+.half-width {
+	flex: 1;
+}
+.form-label {
+	font-size: 13px;
+	color: #545F71;
+	margin-bottom: 10px;
+	display: block;
+	font-weight: 500;
+}
+.tags-row {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 10px;
+}
+.tag-item {
+	padding: 8px 14px;
+	background: #F4F6F8;
+	border-radius: 8px;
+	font-size: 13px;
+	color: #545F71;
+	border: 1px solid transparent;
+	transition: all 0.2s;
+}
+.tag-item-active {
+	background: rgba(56, 149, 136, 0.1);
+	color: #389588;
+	border-color: #389588;
+	font-weight: 500;
+}
+.stepper {
+	display: flex;
+	align-items: center;
+	background: #F4F6F8;
+	border-radius: 8px;
+	overflow: hidden;
+}
+.stepper-btn {
+	width: 36px;
+	height: 36px;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	font-size: 18px;
+	color: #545F71;
+	background: #EEF1F4;
+}
+.stepper-btn:active {
+	background: #E2E6EA;
+}
+.stepper-input {
+	flex: 1;
+	height: 36px;
+	text-align: center;
+	font-size: 14px;
+	color: #333333;
+	font-weight: 500;
+}
+.popup-footer {
+	display: flex;
+	border-top: 1px solid #F0F2F5;
+}
+.btn {
+	flex: 1;
+	height: 50px;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	font-size: 15px;
+	font-weight: 500;
+}
+.btn-cancel {
+	color: #9BA5B7;
+	background: #FFFFFF;
+}
+.btn-confirm {
+	color: #FFFFFF;
+	background: #389588;
+}
+
+/* 参数展示 */
+.item-params {
+	display: flex;
+	align-items: center;
+}
+.param-text {
+	font-size: 11px;
+	color: #389588;
+	background: rgba(56, 149, 136, 0.1);
+	padding: 2px 6px;
+	border-radius: 4px;
+}
+</style>

+ 4 - 36
code/ajyApp/pages/device/bodyParams/bodyParams.nvue

@@ -12,6 +12,7 @@
 			</view>
 			<text class="tipTxt">请录入体形参数,用于精准定位穴位</text>
 			<scroll-view class="formArea" scroll-y="true">
+				<view class="formInner">
 				<!-- 用户姓名 -->
 				<view class="formItem">
 					<text class="formLabel">姓名</text>
@@ -49,15 +50,6 @@
 						<input class="formInput" v-model="form.shoulderHeight" type="digit" placeholder="例如:135" placeholder-class="pholder" />
 						<text class="unitTxt">cm</text>
 					</view>
-					<view class="sliderWrap">
-						<slider :value="Number(form.shoulderHeight) || 100" :min="50" :max="250" :step="1" 
-							activeColor="#389588" backgroundColor="#E8E8E8" block-size="20"
-							@change="onShoulderHeightChange" />
-						<view class="sliderRange">
-							<text class="rangeTxt">50cm</text>
-							<text class="rangeTxt">250cm</text>
-						</view>
-					</view>
 				</view>
 
 				<!-- 肩宽 -->
@@ -67,15 +59,6 @@
 						<input class="formInput" v-model="form.shoulderWidth" type="digit" placeholder="例如:42" placeholder-class="pholder" />
 						<text class="unitTxt">cm</text>
 					</view>
-					<view class="sliderWrap">
-						<slider :value="Number(form.shoulderWidth) || 40" :min="20" :max="80" :step="1"
-							activeColor="#389588" backgroundColor="#E8E8E8" block-size="20"
-							@change="onShoulderWidthChange" />
-						<view class="sliderRange">
-							<text class="rangeTxt">20cm</text>
-							<text class="rangeTxt">80cm</text>
-						</view>
-					</view>
 				</view>
 
 				<!-- 体重 -->
@@ -93,6 +76,7 @@
 				</view>
 
 				<view class="bottomSpace"></view>
+				</view>
 			</scroll-view>
 		</view>
 		<ay-toast ref="ayToast" />
@@ -147,12 +131,6 @@ export default {
 				this.form.weight = user.weight ? String(user.weight) : ''
 			}
 		},
-		onShoulderHeightChange(e) {
-			this.form.shoulderHeight = String(e.detail.value)
-		},
-		onShoulderWidthChange(e) {
-			this.form.shoulderWidth = String(e.detail.value)
-		},
 		/** 校验并保存 */
 		onSave() {
 			// 校验
@@ -289,6 +267,8 @@ export default {
 }
 .formArea {
 	flex: 1;
+}
+.formInner {
 	padding-left: 30rpx;
 	padding-right: 30rpx;
 }
@@ -340,18 +320,6 @@ export default {
 .genderTxt {
 	font-size: 28rpx;
 }
-.sliderWrap {
-	margin-top: 20rpx;
-}
-.sliderRange {
-	flex-direction: row;
-	justify-content: space-between;
-	margin-top: 4rpx;
-}
-.rangeTxt {
-	font-size: 22rpx;
-	color: #9BA5B7;
-}
 .saveBtn {
 	background-color: #389588;
 	border-radius: 44rpx;

+ 7 - 0
code/ajyApp/pages/device/deviceInfo/mixins/acupoint-mixin.js

@@ -18,6 +18,13 @@ export default {
 	},
 	methods: {
 		picModeChange(value) {
+			if (value === 2) {
+				// 穴位编辑跳转到独立vue页面(支持更灵活的坐标渲染)
+				uni.navigateTo({
+					url: '/pages/device/acupointEdit/acupointEdit'
+				})
+				return
+			}
 			this.picMode = value
 		},
 

+ 141 - 1
code/ajyApp/pages/my/info/info.nvue

@@ -20,13 +20,48 @@
 					<image class="item-arrow" src="/static/my/arrowright.png" mode="aspectFill"></image>
 				</view>
 			</view>
+			<view class="info-item" @click="editGender">
+				<text class="item-label">性别</text>
+				<view class="item-right">
+					<text class="item-value">{{gender === 1 ? '男' : '女'}}</text>
+					<image class="item-arrow" src="/static/my/arrowright.png" mode="aspectFill"></image>
+				</view>
+			</view>
+			<view class="info-item" @click="editNumberField('age')">
+				<text class="item-label">年龄</text>
+				<view class="item-right">
+					<text class="item-value">{{age ? age + '岁' : '去设置'}}</text>
+					<image class="item-arrow" src="/static/my/arrowright.png" mode="aspectFill"></image>
+				</view>
+			</view>
+			<view class="info-item" @click="editNumberField('shoulderHeight')">
+				<text class="item-label">肩高</text>
+				<view class="item-right">
+					<text class="item-value">{{shoulderHeight ? shoulderHeight + 'cm' : '去设置'}}</text>
+					<image class="item-arrow" src="/static/my/arrowright.png" mode="aspectFill"></image>
+				</view>
+			</view>
+			<view class="info-item" @click="editNumberField('shoulderWidth')">
+				<text class="item-label">肩宽</text>
+				<view class="item-right">
+					<text class="item-value">{{shoulderWidth ? shoulderWidth + 'cm' : '去设置'}}</text>
+					<image class="item-arrow" src="/static/my/arrowright.png" mode="aspectFill"></image>
+				</view>
+			</view>
+			<view class="info-item" @click="editNumberField('weight')">
+				<text class="item-label">体重</text>
+				<view class="item-right">
+					<text class="item-value">{{weight ? weight + 'kg' : '去设置'}}</text>
+					<image class="item-arrow" src="/static/my/arrowright.png" mode="aspectFill"></image>
+				</view>
+			</view>
 			<view class="info-item">
 				<text class="item-label">手机号</text>
 				<view class="item-right">
 					<text class="item-value">{{phone}}</text>
 				</view>
 			</view>
-			<view class="info-item">
+			<view class="info-item" style="border-bottom-width: 0;">
 				<text class="item-label">AppId</text>
 				<view class="item-right">
 					<text class="item-value">{{appid}}</text>
@@ -54,6 +89,27 @@
 				</view>
 			</view>
 		</view>
+		<!-- 数字输入弹窗 -->
+		<view class="nickname-overlay" v-if="showNumberPopup" @click="closeNumberPopup">
+			<view class="nickname-dialog" @click.stop="" :style="{marginBottom: keyboardHeight + 'px'}">
+				<text class="dialog-title">修改{{ getNumberFieldLabel() }}</text>
+				<view class="input-box">
+					<input class="nickname-input" v-model="numberInput" :type="currentEditField === 'age' ? 'number' : 'digit'" :placeholder="'请输入' + getNumberFieldLabel()" placeholder-class="input-placeholder"
+						:adjust-position="false" :focus="showNumberPopup"
+						@keyboardheightchange="onKeyboardHeight" />
+				</view>
+				<view class="dialog-btns">
+					<view class="dialog-btn" @click="closeNumberPopup">
+						<text class="dialog-cancel">取消</text>
+					</view>
+					<view class="dialog-divider"></view>
+					<view class="dialog-btn" @click="saveNumberField">
+						<text class="dialog-confirm">确定</text>
+					</view>
+				</view>
+			</view>
+		</view>
+
 		<ay-toast ref="ayToast" />
 	</view>
 </template>
@@ -72,8 +128,17 @@ export default {
 			nickname: '用户',
 			phone: '',
 			appid: '',
+			gender: 1, // 1男 2女
+			age: '',
+			shoulderHeight: '',
+			shoulderWidth: '',
+			weight: '',
 			showNicknamePopup: false,
+			showGenderPopup: false,
+			showNumberPopup: false,
 			nicknameInput: '',
+			numberInput: '',
+			currentEditField: '', // 'age' | 'shoulderHeight' | 'shoulderWidth' | 'weight'
 			keyboardHeight: 0
 		}
 	},
@@ -86,10 +151,17 @@ export default {
 			const nickname = uni.getStorageSync('nickname') || '用户'
 			const appid = uni.getStorageSync('appId') || ''
 			const avatar = uni.getStorageSync('avatar') || '/static/144x144.png'
+			
 			this.phone = phone
 			this.nickname = nickname
 			this.appid = appid
 			this.avatar = avatar
+			
+			this.gender = uni.getStorageSync('gender') || 1
+			this.age = uni.getStorageSync('age') || ''
+			this.shoulderHeight = uni.getStorageSync('shoulderHeight') || ''
+			this.shoulderWidth = uni.getStorageSync('shoulderWidth') || ''
+			this.weight = uni.getStorageSync('weight') || ''
 		},
 		changeAvatar() {
 			uni.chooseImage({
@@ -145,6 +217,74 @@ export default {
 			} catch (e) {
 				// 错误已由 http 层统一提示
 			}
+		},
+		editGender() {
+			uni.showActionSheet({
+				itemList: ['男', '女'],
+				success: (res) => {
+					const val = res.tapIndex === 0 ? 1 : 2
+					this.gender = val
+					uni.setStorageSync('gender', val)
+					// TODO: 如果后端支持,在这里调用 updateProfile({ gender: val })
+					this.$refs.ayToast.success('修改成功')
+				}
+			})
+		},
+		editNumberField(field) {
+			this.currentEditField = field
+			this.numberInput = String(this[field] || '')
+			this.showNumberPopup = true
+		},
+		closeNumberPopup() {
+			this.showNumberPopup = false
+			this.keyboardHeight = 0
+		},
+		getNumberFieldLabel() {
+			const map = {
+				age: '年龄',
+				shoulderHeight: '肩高',
+				shoulderWidth: '肩宽',
+				weight: '体重'
+			}
+			return map[this.currentEditField] || ''
+		},
+		saveNumberField() {
+			const val = this.numberInput.trim()
+			if (!val) {
+				this.$refs.ayToast.error(`请输入${this.getNumberFieldLabel()}`)
+				return
+			}
+			
+			const num = Number(val)
+			if (isNaN(num) || num <= 0) {
+				this.$refs.ayToast.error('请输入有效的数字')
+				return
+			}
+			
+			if (this.currentEditField === 'shoulderHeight' && (num < 50 || num > 250)) {
+				this.$refs.ayToast.error('肩高范围为50-250cm')
+				return
+			}
+			if (this.currentEditField === 'shoulderWidth' && (num < 20 || num > 80)) {
+				this.$refs.ayToast.error('肩宽范围为20-80cm')
+				return
+			}
+			if (this.currentEditField === 'weight' && (num < 10 || num > 300)) {
+				this.$refs.ayToast.error('体重范围为10-300kg')
+				return
+			}
+			if (this.currentEditField === 'age' && (num < 1 || num > 150)) {
+				this.$refs.ayToast.error('年龄范围为1-150岁')
+				return
+			}
+			
+			this[this.currentEditField] = num
+			uni.setStorageSync(this.currentEditField, num)
+			// TODO: 如果后端支持,在这里调用 updateProfile({ [this.currentEditField]: num })
+			
+			this.showNumberPopup = false
+			this.keyboardHeight = 0
+			this.$refs.ayToast.success('修改成功')
 		}
 	},
 	onShow() {

+ 90 - 0
code/ajyApp/static/device/body-back-silhouette.svg

@@ -0,0 +1,90 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 400" width="100%" height="100%">
+  <defs>
+    <!-- Glow filter -->
+    <filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
+      <feGaussianBlur stdDeviation="3" result="blur" />
+      <feComposite in="SourceGraphic" in2="blur" operator="over" />
+    </filter>
+    
+    <!-- Body base gradient -->
+    <linearGradient id="bodyFill" x1="0%" y1="0%" x2="0%" y2="100%">
+      <stop offset="0%" stop-color="#389588" stop-opacity="0.12" />
+      <stop offset="50%" stop-color="#389588" stop-opacity="0.04" />
+      <stop offset="100%" stop-color="#389588" stop-opacity="0" />
+    </linearGradient>
+
+    <!-- Outline gradient -->
+    <linearGradient id="bodyOutline" x1="0%" y1="0%" x2="0%" y2="100%">
+      <stop offset="0%" stop-color="#389588" stop-opacity="0.8" />
+      <stop offset="50%" stop-color="#389588" stop-opacity="0.4" />
+      <stop offset="100%" stop-color="#389588" stop-opacity="0.05" />
+    </linearGradient>
+
+    <!-- Tech lines gradient -->
+    <linearGradient id="techLines" x1="0%" y1="0%" x2="0%" y2="100%">
+      <stop offset="0%" stop-color="#389588" stop-opacity="0.6" />
+      <stop offset="100%" stop-color="#389588" stop-opacity="0" />
+    </linearGradient>
+  </defs>
+
+  <!-- Body Silhouette Base Path -->
+  <path d="
+    M 150, 15
+    C 165, 15   172, 22   172, 35
+    C 172, 48   166, 60   162, 65
+    C 160, 68   163, 73   170, 78
+    C 190, 85   215, 95   230, 110
+    C 245, 125  252, 140  255, 160
+    C 258, 180  262, 220  264, 260
+    C 265, 280  265, 300  266, 330
+    C 267, 360  268, 380  268, 400
+    L 242, 400
+    C 242, 370  240, 340  236, 300
+    C 232, 260  228, 230  220, 200
+    C 215, 180  210, 160  204, 145
+    C 204, 150  205, 160  206, 175
+    C 208, 200  210, 230  206, 260
+    C 202, 290  198, 320  202, 360
+    C 204, 380  205, 390  206, 400
+    L 94, 400
+    C 95, 390   96, 380   98, 360
+    C 102, 320  98, 290   94, 260
+    C 90, 230   92, 200   94, 175
+    C 95, 160   96, 150   96, 145
+    C 90, 160   85, 180   80, 200
+    C 72, 230   68, 260   64, 300
+    C 60, 340   58, 370   58, 400
+    L 32, 400
+    C 32, 380   33, 360   34, 330
+    C 35, 300   35, 280   36, 260
+    C 38, 220   42, 180   45, 160
+    C 48, 140   55, 125   70, 110
+    C 85, 95    110, 85   130, 78
+    C 137, 73   140, 68   138, 65
+    C 134, 60   128, 48   128, 35
+    C 128, 22   135, 15   150, 15
+    Z
+  " fill="url(#bodyFill)" stroke="url(#bodyOutline)" stroke-width="1.5" filter="url(#glow)"/>
+
+  <!-- Inner tech lines -->
+  <g fill="none" stroke="url(#techLines)" stroke-width="1" opacity="0.8">
+    <!-- Spine Line -->
+    <line x1="150" y1="78" x2="150" y2="400" stroke-dasharray="8 4" stroke-width="1.5" />
+    
+    <!-- Scapula (Shoulder Blades) -->
+    <!-- Left -->
+    <path d="M 125, 110 L 105, 120 L 100, 150 L 120, 175 Z" opacity="0.4" stroke-dasharray="2 2" />
+    <!-- Right -->
+    <path d="M 175, 110 L 195, 120 L 200, 150 L 180, 175 Z" opacity="0.4" stroke-dasharray="2 2" />
+
+    <!-- Back Contours -->
+    <path d="M 140, 190 C 120, 230 120, 280 135, 330" opacity="0.3" />
+    <path d="M 160, 190 C 180, 230 180, 280 165, 330" opacity="0.3" />
+
+    <!-- Horizontal scanning lines -->
+    <line x1="120" y1="200" x2="180" y2="200" opacity="0.2" />
+    <line x1="110" y1="250" x2="190" y2="250" opacity="0.2" />
+    <line x1="115" y1="300" x2="185" y2="300" opacity="0.2" />
+    <line x1="125" y1="350" x2="175" y2="350" opacity="0.2" />
+  </g>
+</svg>

+ 13 - 0
code/ajyApp/utils/api/acupoint.js

@@ -0,0 +1,13 @@
+/**
+ * 穴位模块 API
+ */
+import http from '@/utils/http'
+
+/**
+ * 获取用户背部产品穴位坐标列表
+ * @param {number} profileId - 子用户档案 ID (user_profile.id)
+ * @returns {Promise<Array<{id, userId, acupointId, acupointCode, acupointName, acupointSide, bodyPart, productAcupoint, coordinateX, coordinateY, coordinateZ, confidence}>>}
+ */
+export function getBackAcupoints(profileId) {
+	return http.get(`/user/acupoint/${profileId}/product-back-coordinates`)
+}

+ 2 - 2
code/ajyApp/utils/http/config.js

@@ -11,9 +11,9 @@ baseUrl = '/api' // H5 走代理
 // #endif
 
 // #ifdef APP-PLUS || MP-WEIXIN || MP-ALIPAY
-baseUrl = 'http://192.168.1.4:18888/api' // TODO: 替换为实际接口域名
+baseUrl = 'http://110.177.148.159:18888/api' // TODO: 替换为实际接口域名
 // #endif
-// http://ajy.iwintrue.com/api
+ // baseUrl = 'http://ajy.iwintrue.com/api'
 export default {
   // 接口基础地址
   baseUrl,

+ 37 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/AppUserDeviceController.java

@@ -0,0 +1,37 @@
+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.AppUserDeviceBindDTO;
+import com.aijiuyi.admin.entity.UserDevice;
+import com.aijiuyi.admin.service.UserDeviceService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import javax.validation.Valid;
+
+/**
+ * App 端用户设备 Controller
+ */
+@RestController
+@RequestMapping("/app/user/device")
+public class AppUserDeviceController {
+
+    @Autowired
+    private UserDeviceService userDeviceService;
+
+    /**
+     * 按设备编号绑定设备。
+     *
+     * @param dto 绑定请求,userId 为用户档案 ID,deviceCode 为设备编号
+     * @return 新增的绑定记录
+     */
+    @PostMapping("/bind-by-code")
+    @Log(value = "App按设备编号绑定设备", module = "App设备", operationType = OperationType.INSERT)
+    public Result<UserDevice> bindByCode(@RequestBody @Valid AppUserDeviceBindDTO dto) {
+        Long appUserId = RequestContext.getUserId();
+        return Result.success(userDeviceService.bindAppDeviceByCode(dto.getUserId(), dto.getDeviceCode(), appUserId));
+    }
+}

+ 23 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppUserDeviceBindDTO.java

@@ -0,0 +1,23 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Size;
+
+/**
+ * App 端按设备编号绑定用户设备请求 DTO
+ */
+@Data
+public class AppUserDeviceBindDTO {
+
+    /** 用户档案 ID,对应 user_profile.id */
+    @NotNull(message = "用户ID不能为空")
+    private Long userId;
+
+    /** 设备编号,对应 device.device_code */
+    @NotBlank(message = "设备编号不能为空")
+    @Size(max = 100, message = "设备编号不能超过100个字符")
+    private String deviceCode;
+}

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

@@ -0,0 +1,48 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 用户穴位坐标查询返回 DTO
+ */
+@Data
+public class UserAcupointCoordinateDTO {
+
+    /** 用户穴位坐标记录ID */
+    private Long id;
+
+    /** 子用户ID,对应 user_profile.id */
+    private Long userId;
+
+    /** 穴位ID */
+    private Long acupointId;
+
+    /** 穴位编码 */
+    private String acupointCode;
+
+    /** 穴位名称 */
+    private String acupointName;
+
+    /** 侧别:center=中心,left=左侧,right=右侧 */
+    private String acupointSide;
+
+    /** 身体部位 */
+    private String bodyPart;
+
+    /** 是否产品穴位:1=是,0=否 */
+    private Integer productAcupoint;
+
+    /** X坐标,单位 mm */
+    private BigDecimal coordinateX;
+
+    /** Y坐标,单位 mm */
+    private BigDecimal coordinateY;
+
+    /** Z坐标,单位 mm */
+    private BigDecimal coordinateZ;
+
+    /** 坐标置信度 */
+    private String confidence;
+}