Bladeren bron

自定义穴位页面搭建

liyuliangjiazai 3 maanden geleden
bovenliggende
commit
3ef48d124f

+ 10 - 0
code/ajyApp/pages.json

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

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

@@ -0,0 +1,753 @@
+<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>
+
+		<!-- 人体模型 + 穴位标注 -->
+		<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>
+				</view>
+			</view>
+		</view>
+
+		<!-- 穴位列表 -->
+		<view class="list-section">
+			<view class="list-header">
+				<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="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>
+					</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 {
+			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: ['温和灸', '雀啄灸', '回旋灸']
+		}
+	},
+	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.loadAcupoints()
+	},
+	methods: {
+		goBack() {
+			uni.navigateBack()
+		},
+
+		/** 图片加载完成 */
+		onImageLoad() {
+			this.imageReady = true
+		},
+
+		/** 加载穴位数据 */
+		async loadAcupoints() {
+			this.loading = true
+			try {
+				const data = await getBackAcupoints(4)
+				if (Array.isArray(data)) {
+					this.acupointList = data
+				} 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
+		},
+
+		/** 关闭弹窗 */
+		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>
+.page {
+	display: flex;
+	flex-direction: column;
+	height: 100vh;
+	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-container {
+	flex: 1;
+	display: flex;
+	justify-content: center;
+	align-items: flex-start; /* 改为顶部对齐,防止高度不足时头部被居中截断 */
+	padding: 20px 10px; /* 增加顶部间距,保护头部不贴边 */
+	overflow-y: auto; /* 允许垂直滚动,适配屏幕高度不足的情况 */
+	min-height: 0;
+}
+.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 20px 0;
+	max-height: 40vh;
+	display: flex;
+	flex-direction: column;
+	box-shadow: 0 -10px 30px rgba(0, 0, 0, 0.06);
+}
+.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;
+	padding-bottom: 20px;
+}
+.list-item {
+	display: flex;
+	flex-direction: row;
+	justify-content: space-between;
+	align-items: center;
+	padding: 14px 16px;
+	margin-bottom: 10px;
+	border-radius: 16px;
+	background: #F8FAFA;
+	border: 1px solid rgba(56, 149, 136, 0.05);
+	transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
+}
+.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);
+}
+.item-left {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+}
+.item-index {
+	width: 28px;
+	height: 28px;
+	border-radius: 10px;
+	background: rgba(56, 149, 136, 0.1);
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	margin-right: 12px;
+	transition: all 0.25s;
+}
+.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-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;
+}
+
+/* 加载遮罩 */
+.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>

+ 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
 		},
 

+ 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`)
+}