| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814 |
- <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(options) {
- const sysInfo = uni.getSystemInfoSync()
- this.statusBarHeight = sysInfo.statusBarHeight || 44
- this.profileId = decodeURIComponent(options.profileId || '')
- 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(this.profileId)
- 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>
|