|
|
@@ -13,37 +13,39 @@
|
|
|
</view>
|
|
|
|
|
|
<!-- 人体模型 + 穴位标注 -->
|
|
|
- <view class="body-container">
|
|
|
- <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>
|
|
|
+ <scroll-view class="body-scroll" scroll-y :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>
|
|
|
- </view>
|
|
|
+ </scroll-view>
|
|
|
|
|
|
<!-- 穴位列表 -->
|
|
|
<view class="list-section">
|
|
|
@@ -51,31 +53,21 @@
|
|
|
<text class="list-title">穴位清单</text>
|
|
|
</view>
|
|
|
<scroll-view class="list-scroll" scroll-y>
|
|
|
- <view
|
|
|
- v-for="(point, index) in acupointList"
|
|
|
- :key="point.id"
|
|
|
- class="list-item"
|
|
|
- :class="{'list-item-active': selectedIndex === index}"
|
|
|
- @click="onListItemTap(index)"
|
|
|
- >
|
|
|
- <view class="item-left">
|
|
|
+ <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>
|
|
|
- <view class="item-info">
|
|
|
- <text class="item-name">{{point.acupointName}}</text>
|
|
|
- <text class="item-code">{{point.acupointCode}}</text>
|
|
|
- </view>
|
|
|
- </view>
|
|
|
- <view class="item-right">
|
|
|
- <text class="item-side" v-if="point.customSide || point.acupointSide !== 'center'">{{ point.customSide || sideLabel(point.acupointSide) }}</text>
|
|
|
- <view class="item-params" v-if="point.method">
|
|
|
- <text class="param-text">{{point.method}} {{point.temperature}}°C {{point.duration}}min</text>
|
|
|
- </view>
|
|
|
- <view class="item-coord-box" v-else>
|
|
|
- <text class="item-coord">{{point.coordinateX}},{{point.coordinateY}}</text>
|
|
|
- <text class="item-unit">mm</text>
|
|
|
- </view>
|
|
|
+ <text class="item-name">{{point.acupointName}}</text>
|
|
|
</view>
|
|
|
</view>
|
|
|
</scroll-view>
|
|
|
@@ -192,7 +184,10 @@ export default {
|
|
|
duration: 15
|
|
|
},
|
|
|
sideOptions: ['双侧', '中心', '左侧', '右侧'],
|
|
|
- methodOptions: ['温和灸', '雀啄灸', '回旋灸']
|
|
|
+ methodOptions: ['温和灸', '雀啄灸', '回旋灸'],
|
|
|
+
|
|
|
+ scrollTop: 0,
|
|
|
+ oldScrollTop: 0
|
|
|
}
|
|
|
},
|
|
|
computed: {
|
|
|
@@ -230,6 +225,10 @@ export default {
|
|
|
uni.navigateBack()
|
|
|
},
|
|
|
|
|
|
+ onScroll(e) {
|
|
|
+ this.oldScrollTop = e.detail.scrollTop
|
|
|
+ },
|
|
|
+
|
|
|
/** 图片加载完成 */
|
|
|
onImageLoad() {
|
|
|
this.imageReady = true
|
|
|
@@ -241,7 +240,42 @@ export default {
|
|
|
try {
|
|
|
const data = await getBackAcupoints(4)
|
|
|
if (Array.isArray(data)) {
|
|
|
- this.acupointList = 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 = []
|
|
|
}
|
|
|
@@ -282,6 +316,36 @@ export default {
|
|
|
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
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ })
|
|
|
},
|
|
|
|
|
|
/** 关闭弹窗 */
|
|
|
@@ -364,14 +428,18 @@ export default {
|
|
|
}
|
|
|
|
|
|
/* 人体模型容器 */
|
|
|
-.body-container {
|
|
|
+.body-scroll {
|
|
|
flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+.body-content {
|
|
|
display: flex;
|
|
|
justify-content: center;
|
|
|
align-items: flex-start; /* 改为顶部对齐,防止高度不足时头部被居中截断 */
|
|
|
padding: 20px 10px; /* 增加顶部间距,保护头部不贴边 */
|
|
|
- overflow-y: auto; /* 允许垂直滚动,适配屏幕高度不足的情况 */
|
|
|
- min-height: 0;
|
|
|
+ min-height: 100%;
|
|
|
+ box-sizing: border-box;
|
|
|
}
|
|
|
.body-wrapper {
|
|
|
position: relative;
|
|
|
@@ -441,7 +509,7 @@ export default {
|
|
|
.list-section {
|
|
|
background: #FFFFFF;
|
|
|
border-radius: 28px 28px 0 0;
|
|
|
- padding: 20px 20px 0;
|
|
|
+ padding: 20px 12px 0;
|
|
|
max-height: 40vh;
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
@@ -461,41 +529,46 @@ export default {
|
|
|
.list-scroll {
|
|
|
flex: 1;
|
|
|
overflow-y: auto;
|
|
|
- padding-bottom: 20px;
|
|
|
+ padding: 4px 8px 20px 8px;
|
|
|
+}
|
|
|
+.list-grid {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: row;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 10px;
|
|
|
}
|
|
|
.list-item {
|
|
|
display: flex;
|
|
|
flex-direction: row;
|
|
|
- justify-content: space-between;
|
|
|
align-items: center;
|
|
|
- padding: 14px 16px;
|
|
|
- margin-bottom: 10px;
|
|
|
- border-radius: 16px;
|
|
|
+ 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.02);
|
|
|
+ transform: scale(1);
|
|
|
}
|
|
|
-.item-left {
|
|
|
- display: flex;
|
|
|
- flex-direction: row;
|
|
|
- align-items: center;
|
|
|
+.list-item-full {
|
|
|
+ margin-right: 50%;
|
|
|
}
|
|
|
.item-index {
|
|
|
- width: 28px;
|
|
|
- height: 28px;
|
|
|
- border-radius: 10px;
|
|
|
+ width: 24px;
|
|
|
+ height: 24px;
|
|
|
+ border-radius: 8px;
|
|
|
background: rgba(56, 149, 136, 0.1);
|
|
|
display: flex;
|
|
|
justify-content: center;
|
|
|
align-items: center;
|
|
|
- margin-right: 12px;
|
|
|
+ margin-right: 8px;
|
|
|
transition: all 0.25s;
|
|
|
+ flex-shrink: 0;
|
|
|
}
|
|
|
.item-index-active {
|
|
|
background: rgba(246, 82, 82, 0.1);
|
|
|
@@ -508,49 +581,14 @@ export default {
|
|
|
.item-index-active .index-text {
|
|
|
color: #F65252;
|
|
|
}
|
|
|
-.item-info {
|
|
|
- display: flex;
|
|
|
- flex-direction: column;
|
|
|
-}
|
|
|
.item-name {
|
|
|
font-size: 13px;
|
|
|
color: #545F71;
|
|
|
font-weight: 500;
|
|
|
line-height: 1.3;
|
|
|
-}
|
|
|
-.item-code {
|
|
|
- font-size: 10px;
|
|
|
- color: #9BA5B7;
|
|
|
- margin-top: 2px;
|
|
|
- font-family: monospace;
|
|
|
-}
|
|
|
-.item-right {
|
|
|
- display: flex;
|
|
|
- flex-direction: row;
|
|
|
- align-items: center;
|
|
|
- gap: 8px;
|
|
|
-}
|
|
|
-.item-side {
|
|
|
- font-size: 10px;
|
|
|
- color: #389588;
|
|
|
- padding: 2px 6px;
|
|
|
- border-radius: 4px;
|
|
|
- background: rgba(56, 149, 136, 0.08);
|
|
|
-}
|
|
|
-.item-coord-box {
|
|
|
- display: flex;
|
|
|
- flex-direction: row;
|
|
|
- align-items: baseline;
|
|
|
-}
|
|
|
-.item-coord {
|
|
|
- font-size: 11px;
|
|
|
- color: #9BA5B7;
|
|
|
- font-family: monospace;
|
|
|
-}
|
|
|
-.item-unit {
|
|
|
- font-size: 9px;
|
|
|
- color: #c0c0c0;
|
|
|
- margin-left: 2px;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
}
|
|
|
|
|
|
/* 加载遮罩 */
|