acupointEdit.vue 25 KB

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