acupointEdit.vue 24 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024
  1. <template>
  2. <view class="page">
  3. <!-- 自定义导航栏 -->
  4. <view class="status-bar" :style="{height: statusBarHeight + 'px'}"></view>
  5. <view class="nav-bar">
  6. <view class="nav-back" @click="goBack">
  7. <image src="/static/public/back.png" mode="aspectFill" class="back-icon"></image>
  8. </view>
  9. <text class="nav-title">{{pageMode === 'edit' ? '编辑方案' : '新增方案'}}</text>
  10. <view class="nav-right">
  11. <text class="nav-save-btn" @click="savePlan">保存</text>
  12. </view>
  13. </view>
  14. <!-- 人体模型 + 穴位标注 -->
  15. <scroll-view class="body-scroll" :scroll-y="!isLandscape" :show-scrollbar="false" :scroll-top="scrollTop" @scroll="onScroll" scroll-with-animation>
  16. <view class="body-content">
  17. <view class="body-wrapper">
  18. <!-- 中线参考 -->
  19. <view class="midline"></view>
  20. <!-- 人体背部剪影 -->
  21. <image
  22. src="/static/device/body-back-silhouette.svg"
  23. mode="aspectFit"
  24. class="body-img"
  25. @load="onImageLoad"
  26. ></image>
  27. <!-- 穴位点 -->
  28. <view
  29. v-for="(point, index) in positionedAcupoints"
  30. :key="point.id"
  31. class="acupoint-dot"
  32. :class="{
  33. 'acupoint-active': selectedIndex === index,
  34. 'acupoint-edited': selectedIndex !== index && (point.customSide || point._inPlan)
  35. }"
  36. :style="{
  37. left: point.screenX + 'px',
  38. top: point.screenY + 'px'
  39. }"
  40. @click="onAcupointTap(index)"
  41. >
  42. <view class="dot-inner"></view>
  43. </view>
  44. </view>
  45. </view>
  46. </scroll-view>
  47. <!-- 穴位列表 -->
  48. <view class="list-section">
  49. <view class="list-header">
  50. <text class="list-title">穴位清单</text>
  51. </view>
  52. <scroll-view class="list-scroll" :scroll-y="!isLandscape" :show-scrollbar="false">
  53. <view class="list-grid">
  54. <view
  55. v-for="(point, index) in acupointList"
  56. :key="point.id"
  57. class="list-item"
  58. :class="{
  59. 'list-item-active': selectedIndex === index,
  60. 'list-item-edited': selectedIndex !== index && (point.customSide || point._inPlan),
  61. 'list-item-full': point.isSingle
  62. }"
  63. @click="onListItemTap(index)"
  64. >
  65. <view class="item-index" :class="{'item-index-active': selectedIndex === index || point.customSide || point._inPlan}">
  66. <text class="index-text">{{index + 1}}</text>
  67. </view>
  68. <text class="item-name">{{point.acupointName}}</text>
  69. </view>
  70. </view>
  71. </scroll-view>
  72. </view>
  73. <!-- 编辑弹窗 -->
  74. <view class="popup-mask" v-if="showPopup" @click="closePopup">
  75. <view class="popup-content" @click.stop>
  76. <view class="popup-header">
  77. <text class="popup-title">编辑穴位参数</text>
  78. <text class="popup-acupoint-name">{{ currentEditAcupoint?.acupointName }}</text>
  79. </view>
  80. <view class="popup-body">
  81. <!-- 侧别 -->
  82. <view class="form-group">
  83. <text class="form-label">侧别</text>
  84. <view class="tags-row">
  85. <view
  86. class="tag-item"
  87. v-for="item in sideOptions"
  88. :key="item"
  89. :class="{'tag-item-active': editForm.side === item}"
  90. @click="editForm.side = item"
  91. >
  92. {{item}}
  93. </view>
  94. </view>
  95. </view>
  96. <!-- 手法 -->
  97. <view class="form-group">
  98. <text class="form-label">手法选择</text>
  99. <view class="tags-row">
  100. <view
  101. class="tag-item"
  102. v-for="item in methodOptions"
  103. :key="item"
  104. :class="{'tag-item-active': editForm.method === item}"
  105. @click="editForm.method = item"
  106. >
  107. {{item}}
  108. </view>
  109. </view>
  110. </view>
  111. <!-- 温度与时间 -->
  112. <view class="form-row-2">
  113. <view class="form-group half-width">
  114. <text class="form-label">温度 (°C)</text>
  115. <view class="stepper">
  116. <view class="stepper-btn" @click="editForm.temperature > 30 && editForm.temperature--">-</view>
  117. <input class="stepper-input" type="number" v-model="editForm.temperature" />
  118. <view class="stepper-btn" @click="editForm.temperature < 60 && editForm.temperature++">+</view>
  119. </view>
  120. </view>
  121. <view class="form-group half-width">
  122. <text class="form-label">时间 (分钟)</text>
  123. <view class="stepper">
  124. <view class="stepper-btn" @click="editForm.duration > 1 && editForm.duration--">-</view>
  125. <input class="stepper-input" type="number" v-model="editForm.duration" />
  126. <view class="stepper-btn" @click="editForm.duration < 60 && editForm.duration++">+</view>
  127. </view>
  128. </view>
  129. </view>
  130. </view>
  131. <view class="popup-footer">
  132. <view class="btn btn-cancel" @click="closePopup">取消</view>
  133. <view class="btn btn-confirm" @click="saveEdit">确定</view>
  134. </view>
  135. </view>
  136. </view>
  137. <!-- 加载提示 -->
  138. <view class="loading-mask" v-if="loading">
  139. <view class="loading-card">
  140. <view class="loading-spinner"></view>
  141. <text class="loading-text">加载中...</text>
  142. </view>
  143. </view>
  144. <!-- 新增方案名称弹窗 -->
  145. <view class="popup-mask" v-if="showNamePopup" @click="showNamePopup = false">
  146. <view class="popup-content" @click.stop>
  147. <view class="popup-header">
  148. <text class="popup-title">保存方案</text>
  149. </view>
  150. <view class="popup-body">
  151. <view class="form-group">
  152. <text class="form-label">方案名称</text>
  153. <input class="name-popup-input" v-model="planName" placeholder="请输入方案名称" maxlength="100" focus />
  154. </view>
  155. </view>
  156. <view class="popup-footer">
  157. <view class="btn btn-cancel" @click="showNamePopup = false">取消</view>
  158. <view class="btn btn-confirm" @click="confirmSavePlan">确认保存</view>
  159. </view>
  160. </view>
  161. </view>
  162. <ay-toast ref="ayToast" />
  163. </view>
  164. </template>
  165. <script>
  166. import { getBackAcupoints } from '@/utils/api/acupoint.js'
  167. import { createCustomPlan, updateCustomPlan, getCustomPlanDetail } from '@/utils/api/care.js'
  168. import ayToast from '@/components/ay-toast/ay-toast.nvue'
  169. export default {
  170. components: { ayToast },
  171. data() {
  172. return {
  173. isLandscape: false,
  174. statusBarHeight: 44,
  175. loading: false,
  176. acupointList: [],
  177. selectedIndex: -1,
  178. imageReady: false,
  179. // SVG viewBox: 0 0 300 400, 一体式实心剪影
  180. containerWidth: 345,
  181. containerHeight: 460,
  182. // 大椚穴(0,0)在容器中的位置
  183. // 颈根/斜方肌起点(C7)在SVG中 y=78 -> 按比例放大1.15倍
  184. originX: 172.5,
  185. originY: 89.7,
  186. // 毫米到像素缩放比
  187. // 0.5 * 1.15 = 0.575
  188. mmToPixel: 0.575,
  189. // 弹窗表单
  190. showPopup: false,
  191. editForm: {
  192. side: '',
  193. method: '温和灸',
  194. temperature: 45,
  195. duration: 15
  196. },
  197. sideOptions: ['双侧', '中心', '左侧', '右侧'],
  198. methodOptions: ['温和灸', '雀啄灸', '回旋灸'],
  199. scrollTop: 0,
  200. oldScrollTop: 0,
  201. // 方案相关
  202. pageMode: 'create', // 'create' | 'edit'
  203. planId: null,
  204. planName: '',
  205. planData: null, // 编辑模式下的原始方案数据
  206. saving: false,
  207. showNamePopup: false
  208. }
  209. },
  210. computed: {
  211. /** 当前正在编辑的穴位 */
  212. currentEditAcupoint() {
  213. if (this.selectedIndex >= 0 && this.acupointList[this.selectedIndex]) {
  214. return this.acupointList[this.selectedIndex]
  215. }
  216. return null
  217. },
  218. /** 计算屏幕坐标后的穴位列表 */
  219. positionedAcupoints() {
  220. if (!this.imageReady || this.acupointList.length === 0) return []
  221. return this.acupointList.map(point => {
  222. // coordinateX: 向右为正 (mm), coordinateY: 向下为正 (mm)
  223. // 大椎穴为(0,0)原点
  224. const screenX = this.originX + (point.coordinateX * this.mmToPixel)
  225. const screenY = this.originY + (point.coordinateY * this.mmToPixel)
  226. return {
  227. ...point,
  228. screenX: Math.max(8, Math.min(this.containerWidth - 8, screenX)),
  229. screenY: Math.max(8, Math.min(this.containerHeight - 8, screenY))
  230. }
  231. })
  232. }
  233. },
  234. onLoad(options) {
  235. const sysInfo = uni.getSystemInfoSync()
  236. this.statusBarHeight = sysInfo.statusBarHeight || 44
  237. this.profileId = decodeURIComponent(options.profileId || '')
  238. this.pageMode = options.mode || 'create'
  239. this.planId = options.planId || null
  240. this.checkOrientation(sysInfo)
  241. this.loadAcupoints()
  242. },
  243. onResize(res) {
  244. this.checkOrientation(res.size || uni.getSystemInfoSync())
  245. },
  246. methods: {
  247. checkOrientation(size) {
  248. if (size && size.windowWidth && size.windowHeight) {
  249. this.isLandscape = size.windowWidth > size.windowHeight
  250. }
  251. },
  252. goBack() {
  253. uni.navigateBack()
  254. },
  255. onScroll(e) {
  256. this.oldScrollTop = e.detail.scrollTop
  257. },
  258. /** 图片加载完成 */
  259. onImageLoad() {
  260. this.imageReady = true
  261. },
  262. /** 加载穴位数据 */
  263. async loadAcupoints() {
  264. this.loading = true
  265. try {
  266. const data = await getBackAcupoints(this.profileId)
  267. if (Array.isArray(data)) {
  268. // 按名称分组并计算平均 Y 坐标,以处理对称穴位
  269. const nameMap = new Map()
  270. data.forEach(point => {
  271. if (!nameMap.has(point.acupointName)) {
  272. nameMap.set(point.acupointName, {
  273. name: point.acupointName,
  274. points: [],
  275. sumY: 0
  276. })
  277. }
  278. const group = nameMap.get(point.acupointName)
  279. group.points.push(point)
  280. group.sumY += point.coordinateY
  281. })
  282. const groups = Array.from(nameMap.values())
  283. groups.forEach(group => {
  284. group.avgY = group.sumY / group.points.length
  285. // 同名穴位按 X 坐标从左到右排序
  286. group.points.sort((a, b) => a.coordinateX - b.coordinateX)
  287. })
  288. // 组之间按平均 Y 坐标从上到下排序
  289. groups.sort((a, b) => a.avgY - b.avgY)
  290. // 重新打平为一维数组,并标记是否为单穴(用于占满整行)
  291. const sortedData = []
  292. groups.forEach(group => {
  293. const isSingle = group.points.length === 1
  294. group.points.forEach(p => {
  295. p.isSingle = isSingle
  296. })
  297. sortedData.push(...group.points)
  298. })
  299. this.acupointList = sortedData
  300. } else {
  301. this.acupointList = []
  302. }
  303. } catch (e) {
  304. console.error('加载穴位数据失败:', e)
  305. this.$refs.ayToast.error('穴位数据加载失败')
  306. this.acupointList = []
  307. } finally {
  308. this.loading = false
  309. // 确保图片加载后穴位可显示
  310. this.$nextTick(() => {
  311. if (!this.imageReady) {
  312. this.imageReady = true
  313. }
  314. })
  315. }
  316. // 穴位数据加载完成后,编辑模式下加载方案详情并回填
  317. if (this.pageMode === 'edit' && this.planId && this.acupointList.length > 0) {
  318. this.loadPlanDetail()
  319. }
  320. },
  321. /** 点击穴位点 */
  322. onAcupointTap(index) {
  323. this.openPopup(index)
  324. },
  325. /** 点击列表项 */
  326. onListItemTap(index) {
  327. this.openPopup(index)
  328. },
  329. /** 打开编辑弹窗 */
  330. openPopup(index) {
  331. this.selectedIndex = index
  332. const point = this.acupointList[index]
  333. // 初始化表单数据
  334. this.editForm = {
  335. side: point.customSide || this.sideLabel(point.acupointSide) || '中心',
  336. method: point.method || '温和灸',
  337. temperature: point.temperature || 45,
  338. duration: point.duration || 15
  339. }
  340. this.showPopup = true
  341. this.scrollToAcupoint()
  342. },
  343. /** 滚动到选中的穴位 */
  344. scrollToAcupoint() {
  345. this.$nextTick(() => {
  346. const query = uni.createSelectorQuery().in(this)
  347. query.select('.body-scroll').boundingClientRect()
  348. query.select('.acupoint-active').boundingClientRect()
  349. query.exec(res => {
  350. const scrollRes = res[0]
  351. const dotRes = res[1]
  352. if (scrollRes && dotRes) {
  353. const relativeTop = dotRes.top - scrollRes.top
  354. const offset = relativeTop - (scrollRes.height / 2) + (dotRes.height / 2)
  355. if (Math.abs(offset) > 5) {
  356. let newScrollTop = this.oldScrollTop + offset
  357. if (newScrollTop < 0) newScrollTop = 0
  358. this.scrollTop = this.oldScrollTop
  359. this.$nextTick(() => {
  360. this.scrollTop = newScrollTop
  361. })
  362. }
  363. }
  364. })
  365. })
  366. },
  367. /** 关闭弹窗 */
  368. closePopup() {
  369. this.showPopup = false
  370. },
  371. /** 保存编辑结果 */
  372. saveEdit() {
  373. if (this.selectedIndex >= 0) {
  374. const point = this.acupointList[this.selectedIndex]
  375. // 更新穴位列表中的对应数据
  376. point.customSide = this.editForm.side
  377. point.method = this.editForm.method
  378. point.temperature = this.editForm.temperature
  379. point.duration = this.editForm.duration
  380. }
  381. this.closePopup()
  382. },
  383. /** 加载方案详情(编辑模式) */
  384. async loadPlanDetail() {
  385. try {
  386. const res = await getCustomPlanDetail(this.planId)
  387. if (res) {
  388. this.planData = res
  389. this.planName = res.name || ''
  390. // 穴位列表已加载完成,直接回填方案步骤参数
  391. if (res.steps && res.steps.length > 0) {
  392. this.applyStepsToAcupoints(res.steps)
  393. }
  394. }
  395. } catch (e) {
  396. console.error('加载方案详情失败:', e)
  397. this.$refs.ayToast.error('加载方案失败')
  398. }
  399. },
  400. /** 将方案步骤参数回填到穴位列表 */
  401. applyStepsToAcupoints(steps) {
  402. const sideMap = { 1: '中心', 2: '左侧', 3: '右侧', 4: '双侧' }
  403. const techniqueMap = { '2': '温和灸', '4': '回旋灸', '3': '雀啄灸' }
  404. steps.forEach(step => {
  405. const point = this.acupointList.find(p => p.acupointId === step.acupointId)
  406. if (point) {
  407. point.customSide = sideMap[step.side] || '中心'
  408. point.method = techniqueMap[step.techniqueId] || '温和灸'
  409. point.temperature = step.temperature || 45
  410. point.duration = step.duration || 15
  411. point._inPlan = true
  412. }
  413. })
  414. },
  415. /** 点击保存按钮 */
  416. savePlan() {
  417. if (this.saving) return
  418. const steps = this.buildSteps()
  419. if (steps.length === 0) {
  420. this.$refs.ayToast.show('请至少编辑一个穴位参数')
  421. return
  422. }
  423. if (this.pageMode === 'edit') {
  424. // 编辑模式直接保存
  425. this.doSavePlan()
  426. } else {
  427. // 新增模式弹出名称输入框
  428. this.showNamePopup = true
  429. }
  430. },
  431. /** 弹窗确认保存(新增模式) */
  432. confirmSavePlan() {
  433. if (!this.planName.trim()) {
  434. this.$refs.ayToast.show('请输入方案名称')
  435. return
  436. }
  437. this.showNamePopup = false
  438. this.doSavePlan()
  439. },
  440. /** 执行保存方案 */
  441. async doSavePlan() {
  442. if (this.saving) return
  443. if (!this.planName.trim()) {
  444. this.$refs.ayToast.show('请输入方案名称')
  445. return
  446. }
  447. this.saving = true
  448. try {
  449. const sideMap = { '中心': 1, '左侧': 2, '右侧': 3, '双侧': 4 }
  450. const techniqueMap = { '温和灸': '2', '回旋灸': '4', '雀啄灸': '3' }
  451. const steps = this.buildSteps()
  452. const planData = {
  453. profileId: this.profileId,
  454. name: this.planName.trim(),
  455. status: 1,
  456. steps: steps.map(point => ({
  457. acupointId: point.acupointId,
  458. side: sideMap[point.customSide] || 1,
  459. temperature: Number(point.temperature) || 45,
  460. duration: Number(point.duration) || 15,
  461. techniqueId: techniqueMap[point.method] || '2'
  462. }))
  463. }
  464. if (this.pageMode === 'edit' && this.planId) {
  465. planData.id = this.planId
  466. await updateCustomPlan(planData)
  467. this.$refs.ayToast.success('修改成功')
  468. } else {
  469. await createCustomPlan(planData)
  470. this.$refs.ayToast.success('新增成功')
  471. }
  472. setTimeout(() => {
  473. uni.navigateBack()
  474. }, 1000)
  475. } catch (e) {
  476. console.error('保存方案失败:', e)
  477. this.$refs.ayToast.error(e.message || '保存失败')
  478. } finally {
  479. this.saving = false
  480. }
  481. },
  482. /** 构建方案步骤(筛选已编辑的穴位) */
  483. buildSteps() {
  484. return this.acupointList.filter(point => point.customSide || point._inPlan)
  485. },
  486. /** 侧别标签 */
  487. sideLabel(side) {
  488. const map = { left: '左侧', right: '右侧', center: '中心' }
  489. return map[side] || side
  490. },
  491. /** 置信度标签 */
  492. confidenceLabel(confidence) {
  493. const map = { high: '高', medium: '中', low: '低' }
  494. return map[confidence] || confidence
  495. }
  496. }
  497. }
  498. </script>
  499. <style scoped>
  500. /* 隐藏滚动条 */
  501. ::-webkit-scrollbar {
  502. display: none;
  503. width: 0 !important;
  504. height: 0 !important;
  505. -webkit-appearance: none;
  506. background: transparent;
  507. }
  508. .page {
  509. display: flex;
  510. flex-direction: column;
  511. height: 100vh;
  512. overflow: hidden;
  513. background: linear-gradient(to bottom, #389588, rgba(56,149,136,0.3) 40%, #F8F8F8 60%);
  514. }
  515. .status-bar {
  516. background-color: transparent;
  517. }
  518. /* 导航栏 */
  519. .nav-bar {
  520. display: flex;
  521. flex-direction: row;
  522. justify-content: space-between;
  523. align-items: center;
  524. height: 44px;
  525. padding: 0 15px;
  526. }
  527. .nav-back {
  528. width: 25px;
  529. height: 25px;
  530. display: flex;
  531. justify-content: center;
  532. align-items: center;
  533. }
  534. .back-icon {
  535. width: 25px;
  536. height: 25px;
  537. }
  538. .nav-title {
  539. font-size: 16px;
  540. font-weight: bold;
  541. color: #FFFFFF;
  542. }
  543. .nav-right {
  544. width: 60px;
  545. display: flex;
  546. justify-content: flex-end;
  547. }
  548. .nav-count {
  549. font-size: 12px;
  550. color: rgba(255,255,255,0.8);
  551. }
  552. .nav-save-btn {
  553. font-size: 14px;
  554. color: #FFFFFF;
  555. background: rgba(255,255,255,0.2);
  556. padding: 5px 14px;
  557. border-radius: 14px;
  558. font-weight: 500;
  559. }
  560. /* 方案名称输入 */
  561. .plan-name-section {
  562. display: flex;
  563. flex-direction: row;
  564. align-items: center;
  565. margin: 0 15px 10px;
  566. background: rgba(255,255,255,0.9);
  567. border-radius: 12px;
  568. padding: 10px 14px;
  569. }
  570. .plan-name-label {
  571. font-size: 13px;
  572. color: #545F71;
  573. font-weight: 500;
  574. margin-right: 10px;
  575. flex-shrink: 0;
  576. }
  577. .plan-name-input {
  578. flex: 1;
  579. font-size: 14px;
  580. color: #333333;
  581. height: 32px;
  582. }
  583. /* 名称弹窗输入框 */
  584. .name-popup-input {
  585. width: 100%;
  586. height: 40px;
  587. border: 1px solid #E8ECF0;
  588. border-radius: 8px;
  589. padding: 0 12px;
  590. font-size: 14px;
  591. color: #333333;
  592. box-sizing: border-box;
  593. }
  594. /* 人体模型容器 */
  595. .body-scroll {
  596. flex: 1;
  597. min-height: 0;
  598. width: 100%;
  599. }
  600. .body-content {
  601. display: flex;
  602. justify-content: center;
  603. align-items: flex-start; /* 改为顶部对齐,防止高度不足时头部被居中截断 */
  604. padding: 20px 10px; /* 增加顶部间距,保护头部不贴边 */
  605. min-height: 100%;
  606. box-sizing: border-box;
  607. }
  608. .body-wrapper {
  609. position: relative;
  610. width: 345px;
  611. height: 460px;
  612. margin: auto 0; /* 空间充足时保持垂直居中,空间不足时贴顶 */
  613. /* Removed bubble-like styling to keep it clean */
  614. }
  615. /* 中线 */
  616. .midline {
  617. position: absolute;
  618. left: 50%;
  619. top: 60px;
  620. bottom: 20px;
  621. width: 1px;
  622. background: transparent;
  623. border-left: 1px dashed rgba(255, 255, 255, 0.4);
  624. z-index: 2;
  625. pointer-events: none;
  626. }
  627. .body-img {
  628. position: relative;
  629. width: 345px;
  630. height: 460px;
  631. z-index: 3;
  632. /* Added a soft drop shadow to make the human shape stand out nicely */
  633. filter: drop-shadow(0px 15px 25px rgba(0, 0, 0, 0.12));
  634. }
  635. /* 穴位点 */
  636. .acupoint-dot {
  637. position: absolute;
  638. width: 30px;
  639. height: 30px;
  640. margin-left: -15px;
  641. margin-top: -15px;
  642. display: flex;
  643. justify-content: center;
  644. align-items: center;
  645. z-index: 10;
  646. }
  647. .dot-inner {
  648. width: 8px;
  649. height: 8px;
  650. border-radius: 50%;
  651. background: #FFFFFF;
  652. border: 1.5px solid #389588;
  653. box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
  654. transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
  655. z-index: 1;
  656. }
  657. /* 选中态 */
  658. .acupoint-active .dot-inner {
  659. width: 14px;
  660. height: 14px;
  661. background: #F65252;
  662. border: 2px solid #FFFFFF;
  663. box-shadow: 0 4px 12px rgba(246, 82, 82, 0.4);
  664. transform: scale(1.1);
  665. }
  666. /* 已编辑态 */
  667. .acupoint-edited .dot-inner {
  668. width: 11px;
  669. height: 11px;
  670. background: #F65252;
  671. border: 1.5px solid #FFFFFF;
  672. box-shadow: 0 2px 8px rgba(246, 82, 82, 0.3);
  673. }
  674. /* 穴位列表 */
  675. .list-section {
  676. background: #FFFFFF;
  677. border-radius: 28px 28px 0 0;
  678. padding: 20px 12px 0;
  679. max-height: 40vh;
  680. display: flex;
  681. flex-direction: column;
  682. box-shadow: 0 -10px 30px rgba(0, 0, 0, 0.06);
  683. box-sizing: border-box;
  684. }
  685. .list-header {
  686. display: flex;
  687. flex-direction: column;
  688. align-items: center;
  689. margin-bottom: 16px;
  690. }
  691. .list-title {
  692. font-size: 15px;
  693. color: #333333;
  694. font-weight: 600;
  695. }
  696. .list-scroll {
  697. flex: 1;
  698. overflow-y: auto;
  699. }
  700. .list-grid {
  701. display: flex;
  702. flex-direction: row;
  703. flex-wrap: wrap;
  704. gap: 10px;
  705. padding: 4px 8px 20px 8px;
  706. box-sizing: border-box;
  707. }
  708. .list-item {
  709. display: flex;
  710. flex-direction: row;
  711. align-items: center;
  712. padding: 10px 12px;
  713. border-radius: 12px;
  714. background: #F8FAFA;
  715. border: 1px solid rgba(56, 149, 136, 0.05);
  716. transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
  717. width: calc(50% - 5px);
  718. box-sizing: border-box;
  719. }
  720. .list-item-active {
  721. background: rgba(56, 149, 136, 0.08);
  722. border-color: rgba(56, 149, 136, 0.3);
  723. box-shadow: 0 4px 12px rgba(56, 149, 136, 0.1);
  724. transform: scale(1);
  725. }
  726. .list-item-edited {
  727. background: rgba(246, 82, 82, 0.05);
  728. border-color: rgba(246, 82, 82, 0.2);
  729. }
  730. .list-item-full {
  731. margin-right: 50%;
  732. }
  733. .item-index {
  734. width: 24px;
  735. height: 24px;
  736. border-radius: 8px;
  737. background: rgba(56, 149, 136, 0.1);
  738. display: flex;
  739. justify-content: center;
  740. align-items: center;
  741. margin-right: 8px;
  742. transition: all 0.25s;
  743. flex-shrink: 0;
  744. }
  745. .item-index-active {
  746. background: rgba(246, 82, 82, 0.1);
  747. }
  748. .index-text {
  749. font-size: 11px;
  750. font-weight: 600;
  751. color: #389588;
  752. }
  753. .item-index-active .index-text {
  754. color: #F65252;
  755. }
  756. .item-name {
  757. font-size: 13px;
  758. color: #545F71;
  759. font-weight: 500;
  760. line-height: 1.3;
  761. overflow: hidden;
  762. text-overflow: ellipsis;
  763. white-space: nowrap;
  764. }
  765. /* 加载遮罩 */
  766. .loading-mask {
  767. position: fixed;
  768. top: 0;
  769. left: 0;
  770. right: 0;
  771. bottom: 0;
  772. background: rgba(0, 0, 0, 0.4);
  773. display: flex;
  774. justify-content: center;
  775. align-items: center;
  776. z-index: 100;
  777. }
  778. .loading-card {
  779. display: flex;
  780. flex-direction: column;
  781. align-items: center;
  782. background: #FFFFFF;
  783. border-radius: 16px;
  784. padding: 28px 36px;
  785. box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15);
  786. }
  787. @keyframes spin {
  788. 0% { transform: rotate(0deg); }
  789. 100% { transform: rotate(360deg); }
  790. }
  791. .loading-spinner {
  792. width: 32px;
  793. height: 32px;
  794. border: 2px solid #EEF1F4;
  795. border-top-color: #389588;
  796. border-radius: 50%;
  797. animation: spin 0.8s linear infinite;
  798. margin-bottom: 14px;
  799. }
  800. .loading-text {
  801. color: #545F71;
  802. font-size: 13px;
  803. font-weight: 500;
  804. }
  805. /* 弹窗样式 */
  806. .popup-mask {
  807. position: fixed;
  808. top: 0;
  809. left: 0;
  810. right: 0;
  811. bottom: 0;
  812. background: rgba(0, 0, 0, 0.5);
  813. z-index: 200;
  814. display: flex;
  815. justify-content: center;
  816. align-items: center;
  817. animation: fadeIn 0.3s ease;
  818. }
  819. @keyframes fadeIn {
  820. from { opacity: 0; }
  821. to { opacity: 1; }
  822. }
  823. .popup-content {
  824. width: 320px;
  825. background: #FFFFFF;
  826. border-radius: 20px;
  827. overflow: hidden;
  828. box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
  829. animation: slideUp 0.3s ease;
  830. }
  831. @keyframes slideUp {
  832. from { transform: translateY(20px); opacity: 0; }
  833. to { transform: translateY(0); opacity: 1; }
  834. }
  835. .popup-header {
  836. padding: 20px 24px 16px;
  837. border-bottom: 1px solid #F0F2F5;
  838. display: flex;
  839. justify-content: space-between;
  840. align-items: center;
  841. }
  842. .popup-title {
  843. font-size: 16px;
  844. font-weight: 600;
  845. color: #333333;
  846. }
  847. .popup-acupoint-name {
  848. font-size: 15px;
  849. color: #389588;
  850. font-weight: 600;
  851. background: rgba(56, 149, 136, 0.1);
  852. padding: 4px 10px;
  853. border-radius: 6px;
  854. }
  855. .popup-body {
  856. padding: 20px 24px;
  857. }
  858. .form-group {
  859. margin-bottom: 20px;
  860. }
  861. .form-group:last-child {
  862. margin-bottom: 0;
  863. }
  864. .form-row-2 {
  865. display: flex;
  866. justify-content: space-between;
  867. gap: 16px;
  868. }
  869. .half-width {
  870. flex: 1;
  871. }
  872. .form-label {
  873. font-size: 13px;
  874. color: #545F71;
  875. margin-bottom: 10px;
  876. display: block;
  877. font-weight: 500;
  878. }
  879. .tags-row {
  880. display: flex;
  881. flex-wrap: wrap;
  882. gap: 10px;
  883. }
  884. .tag-item {
  885. padding: 8px 14px;
  886. background: #F4F6F8;
  887. border-radius: 8px;
  888. font-size: 13px;
  889. color: #545F71;
  890. border: 1px solid transparent;
  891. transition: all 0.2s;
  892. }
  893. .tag-item-active {
  894. background: rgba(56, 149, 136, 0.1);
  895. color: #389588;
  896. border-color: #389588;
  897. font-weight: 500;
  898. }
  899. .stepper {
  900. display: flex;
  901. align-items: center;
  902. background: #F4F6F8;
  903. border-radius: 8px;
  904. overflow: hidden;
  905. }
  906. .stepper-btn {
  907. width: 36px;
  908. height: 36px;
  909. display: flex;
  910. justify-content: center;
  911. align-items: center;
  912. font-size: 18px;
  913. color: #545F71;
  914. background: #EEF1F4;
  915. }
  916. .stepper-btn:active {
  917. background: #E2E6EA;
  918. }
  919. .stepper-input {
  920. flex: 1;
  921. height: 36px;
  922. text-align: center;
  923. font-size: 14px;
  924. color: #333333;
  925. font-weight: 500;
  926. }
  927. .popup-footer {
  928. display: flex;
  929. border-top: 1px solid #F0F2F5;
  930. }
  931. .btn {
  932. flex: 1;
  933. height: 50px;
  934. display: flex;
  935. justify-content: center;
  936. align-items: center;
  937. font-size: 15px;
  938. font-weight: 500;
  939. }
  940. .btn-cancel {
  941. color: #9BA5B7;
  942. background: #FFFFFF;
  943. }
  944. .btn-confirm {
  945. color: #FFFFFF;
  946. background: #389588;
  947. }
  948. /* 参数展示 */
  949. .item-params {
  950. display: flex;
  951. align-items: center;
  952. }
  953. .param-text {
  954. font-size: 11px;
  955. color: #389588;
  956. background: rgba(56, 149, 136, 0.1);
  957. padding: 2px 6px;
  958. border-radius: 4px;
  959. }
  960. </style>