index.vue 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888
  1. <template>
  2. <div class="simulation-page">
  3. <div class="page-header">
  4. <span class="page-title">方案模拟测试</span>
  5. <el-tag type="info" effect="plain">模拟小程序选择方案后,系统对穴位的计算结果</el-tag>
  6. </div>
  7. <div class="sim-layout">
  8. <!-- 左侧:配置面板 -->
  9. <div class="sim-config">
  10. <el-card shadow="never" class="config-card">
  11. <template #header><span class="card-title">模拟配置</span></template>
  12. <el-form label-width="90px" class="config-form">
  13. <!-- 用户选择 -->
  14. <el-form-item label="选择用户">
  15. <el-select
  16. v-model="selectedUserId"
  17. filterable
  18. clearable
  19. placeholder="选择已有用户(自动填充体型数据)"
  20. style="width: 100%"
  21. :loading="usersLoading"
  22. @change="onUserSelect"
  23. >
  24. <el-option
  25. v-for="u in userOptions"
  26. :key="u.id"
  27. :label="`${u.name || '未命名'} (${u.gender === 1 ? '男' : '女'}, ${u.shoulderWidth || '-'}cm)`"
  28. :value="u.id"
  29. />
  30. </el-select>
  31. </el-form-item>
  32. <el-divider content-position="left">体型参数</el-divider>
  33. <el-row :gutter="12">
  34. <el-col :span="12">
  35. <el-form-item label="性别">
  36. <el-select v-model="bodyForm.gender" style="width: 100%" disabled>
  37. <el-option label="男" :value="1" />
  38. <el-option label="女" :value="2" />
  39. </el-select>
  40. </el-form-item>
  41. </el-col>
  42. <el-col :span="12">
  43. <el-form-item label="年龄">
  44. <el-input v-model="bodyForm.age" disabled />
  45. </el-form-item>
  46. </el-col>
  47. </el-row>
  48. <el-row :gutter="12">
  49. <el-col :span="8">
  50. <el-form-item label="肩宽(cm)">
  51. <el-input v-model="bodyForm.shoulderWidth" disabled />
  52. </el-form-item>
  53. </el-col>
  54. <el-col :span="8">
  55. <el-form-item label="身长(cm)">
  56. <el-input v-model="bodyForm.bodyHeight" disabled />
  57. </el-form-item>
  58. </el-col>
  59. <el-col :span="8">
  60. <el-form-item label="脊柱长(cm)">
  61. <el-input v-model="bodyForm.spineLength" disabled />
  62. </el-form-item>
  63. </el-col>
  64. </el-row>
  65. <el-row :gutter="12">
  66. <el-col :span="8">
  67. <el-form-item label="一寸">
  68. <el-input v-model="bodyForm.fingerWidth1" disabled placeholder="-" />
  69. </el-form-item>
  70. </el-col>
  71. <el-col :span="8">
  72. <el-form-item label="1.5寸">
  73. <el-input v-model="bodyForm.fingerWidth15" disabled placeholder="-" />
  74. </el-form-item>
  75. </el-col>
  76. <el-col :span="8">
  77. <el-form-item label="三寸">
  78. <el-input v-model="bodyForm.fingerWidth3" disabled placeholder="-" />
  79. </el-form-item>
  80. </el-col>
  81. </el-row>
  82. <div v-if="bodyForm.acupointTableId !== null" class="status-badge">
  83. <el-tag :type="bodyForm.acupointTableId ? 'success' : 'warning'" size="small">
  84. 穴位表: {{ bodyForm.acupointTableId ? '已生成' : '未生成' }}
  85. </el-tag>
  86. </div>
  87. <el-divider content-position="left">选择模式与方案</el-divider>
  88. <el-form-item label="艾灸模式">
  89. <el-select v-model="selectedMode" placeholder="选择模式" style="width: 100%" @change="onModeChange">
  90. <el-option label="一键艾灸(默认)" :value="1" />
  91. <el-option label="专业模式(8种功效)" :value="2" />
  92. <el-option label="自定义模式" :value="3" />
  93. <el-option label="延年圣手模式(专家方案)" :value="4" />
  94. </el-select>
  95. </el-form-item>
  96. <el-form-item v-if="selectedMode === 2" label="功效类型">
  97. <el-select v-model="selectedEfficacy" placeholder="选择功效" style="width: 100%" @change="onEfficacyChange">
  98. <el-option v-for="e in efficacyTypes" :key="e" :label="e" :value="e" />
  99. </el-select>
  100. </el-form-item>
  101. <el-form-item label="选择方案">
  102. <el-select
  103. v-model="selectedPlanId"
  104. filterable
  105. placeholder="选择具体方案"
  106. style="width: 100%"
  107. :loading="plansLoading"
  108. :disabled="!filteredPlans.length"
  109. >
  110. <el-option
  111. v-for="p in filteredPlans"
  112. :key="p.id"
  113. :label="`${p.name} (${p.effectType || modeLabel(p.modeType)})`"
  114. :value="p.id"
  115. />
  116. </el-select>
  117. </el-form-item>
  118. <el-form-item>
  119. <el-button
  120. type="primary"
  121. size="large"
  122. :loading="simulating"
  123. :disabled="!canSimulate"
  124. @click="runSimulation"
  125. style="width: 100%"
  126. >
  127. 开始模拟测试
  128. </el-button>
  129. </el-form-item>
  130. </el-form>
  131. </el-card>
  132. <!-- 方案步骤结果 -->
  133. <el-card v-if="pushData" shadow="never" class="result-card steps-card">
  134. <template #header>
  135. <div class="card-header-row">
  136. <span class="card-title">模拟结果 — 治疗步骤</span>
  137. <el-tag effect="plain" size="small">
  138. 总时长: {{ totalMinutes }} 分钟 / {{ pushData.scheme?.steps?.length || 0 }} 步
  139. </el-tag>
  140. </div>
  141. </template>
  142. <el-table :data="pushData.scheme?.steps || []" class="modern-table" size="small" stripe>
  143. <el-table-column prop="stepOrder" label="#" width="45" align="center" />
  144. <el-table-column prop="acupointName" label="穴位" width="100" />
  145. <el-table-column label="侧别" width="70" align="center">
  146. <template #default="{ row }">{{ sideLabel(row.acupointSide) }}</template>
  147. </el-table-column>
  148. <el-table-column label="偏移X(mm)" width="95" align="right">
  149. <template #default="{ row }">
  150. <span class="num-cell">{{ fmtNum(row.offsetX) }}</span>
  151. </template>
  152. </el-table-column>
  153. <el-table-column label="偏移Y(mm)" width="95" align="right">
  154. <template #default="{ row }">
  155. <span class="num-cell">{{ fmtNum(row.offsetY) }}</span>
  156. </template>
  157. </el-table-column>
  158. <el-table-column prop="temperature" label="温度°C" width="75" align="center" />
  159. <el-table-column label="时长" width="70" align="center">
  160. <template #default="{ row }">{{ row.duration }}分</template>
  161. </el-table-column>
  162. <el-table-column prop="techniqueName" label="手法" min-width="80" />
  163. <el-table-column label="置信度" width="75" align="center">
  164. <template #default="{ row }">
  165. <el-tag :type="confidenceType(row.confidence)" size="small">
  166. {{ confidenceLabel(row.confidence) }}
  167. </el-tag>
  168. </template>
  169. </el-table-column>
  170. </el-table>
  171. </el-card>
  172. </div>
  173. <!-- 右侧:可视化 -->
  174. <div class="sim-visual">
  175. <el-card shadow="never" class="visual-card">
  176. <template #header>
  177. <div class="card-header-row">
  178. <span class="card-title">穴位可视化</span>
  179. <span v-if="pushData" class="visual-sub">
  180. {{ pushData.userName }} · {{ modeLabel(pushData.modeType) }} · {{ pushData.scheme?.planName }}
  181. </span>
  182. </div>
  183. </template>
  184. <div class="body-canvas-wrap">
  185. <svg viewBox="0 0 400 580" xmlns="http://www.w3.org/2000/svg" class="body-svg">
  186. <defs>
  187. <pattern id="sim-grid" width="20" height="20" patternUnits="userSpaceOnUse">
  188. <path d="M 20 0 L 0 0 0 20" fill="none" stroke="rgba(99,102,241,0.05)" stroke-width="1" />
  189. </pattern>
  190. <radialGradient id="simWash" cx="50%" cy="18%" r="75%">
  191. <stop offset="0%" stop-color="#f8fafc" />
  192. <stop offset="55%" stop-color="#f1f5f9" />
  193. <stop offset="100%" stop-color="#e8eef5" />
  194. </radialGradient>
  195. </defs>
  196. <rect width="100%" height="100%" fill="url(#simWash)" rx="14" />
  197. <image
  198. :href="bodyBackImage"
  199. x="0"
  200. y="-10"
  201. width="400"
  202. height="600"
  203. preserveAspectRatio="xMidYMid slice"
  204. class="body-photo"
  205. />
  206. <rect width="100%" height="100%" fill="url(#sim-grid)" rx="14" opacity="0.35" />
  207. <line class="axis-line" :x1="SVG_C7_X" y1="12" :x2="SVG_C7_X" y2="568" stroke="rgba(148,163,184,0.4)" stroke-width="1" stroke-dasharray="4 6" />
  208. <line class="axis-line" x1="12" :y1="SVG_C7_Y" x2="388" :y2="SVG_C7_Y" stroke="rgba(148,163,184,0.4)" stroke-width="1" stroke-dasharray="5 5" />
  209. <!-- 背部中线参考 -->
  210. <line :x1="SVG_C7_X" :y1="SVG_C7_Y - 20" :x2="SVG_C7_X" :y2="SVG_S4_Y" stroke="rgba(99,102,241,0.35)" stroke-width="2" stroke-dasharray="8 6" stroke-linecap="round" />
  211. <line
  212. :x1="SVG_C7_X + 18"
  213. :y1="SVG_C7_Y"
  214. :x2="SVG_C7_X + 18"
  215. :y2="currentSpineEndY"
  216. stroke="rgba(14,165,233,0.9)"
  217. stroke-width="3"
  218. stroke-linecap="round"
  219. />
  220. <circle :cx="SVG_C7_X + 18" :cy="currentSpineEndY" r="5" fill="#0ea5e9" stroke="#fff" stroke-width="2" />
  221. <text :x="SVG_C7_X + 28" :y="currentSpineEndY - 10" font-size="10" fill="#0369a1" font-weight="700" font-family="system-ui, sans-serif">
  222. {{ fmtNum(currentSpineLengthCm) }} cm
  223. </text>
  224. <text :x="SVG_C7_X + 28" :y="currentSpineEndY + 6" font-size="10" fill="#0369a1" font-family="system-ui, sans-serif">脊柱终点</text>
  225. <!-- C7 大椎穴标记 -->
  226. <g :transform="`translate(${SVG_C7_X}, ${SVG_C7_Y})`">
  227. <circle r="14" fill="none" stroke="#e85d5d" stroke-width="1" opacity="0.3">
  228. <animate attributeName="r" values="8;16;8" dur="2.5s" repeatCount="indefinite" />
  229. <animate attributeName="opacity" values="0.5;0;0.5" dur="2.5s" repeatCount="indefinite" />
  230. </circle>
  231. <circle r="5" fill="#fff" stroke="#e85d5d" stroke-width="2" />
  232. <line x1="-11" y1="0" x2="-5" y2="0" stroke="#e85d5d" stroke-width="1.8" stroke-linecap="round" />
  233. <line x1="5" y1="0" x2="11" y2="0" stroke="#e85d5d" stroke-width="1.8" stroke-linecap="round" />
  234. <line x1="0" y1="-11" x2="0" y2="-5" stroke="#e85d5d" stroke-width="1.8" stroke-linecap="round" />
  235. <line x1="0" y1="5" x2="0" y2="11" stroke="#e85d5d" stroke-width="1.8" stroke-linecap="round" />
  236. <text x="16" y="4" font-size="11" fill="#c24141" font-weight="700" font-family="system-ui, sans-serif">C7 大椎穴</text>
  237. </g>
  238. <text x="208" y="24" font-size="10" fill="#94a3b8" font-family="system-ui, sans-serif">&uarr; 头侧</text>
  239. <text x="208" y="570" font-size="10" fill="#94a3b8" font-family="system-ui, sans-serif">&darr; 尾侧</text>
  240. <text x="14" :y="SVG_C7_Y - 8" font-size="10" fill="#94a3b8" font-family="system-ui, sans-serif">患者左 (&minus;X)</text>
  241. <text x="386" text-anchor="end" :y="SVG_C7_Y - 8" font-size="10" fill="#94a3b8" font-family="system-ui, sans-serif">患者右 (+X)</text>
  242. <!-- 穴位标记 -->
  243. <g v-for="(m, idx) in markers" :key="idx">
  244. <circle :cx="m.x" :cy="m.y" r="12" :fill="m.color" opacity="0.18" />
  245. <circle :cx="m.x" :cy="m.y" r="6" :fill="m.color" stroke="#fff" stroke-width="2" />
  246. <text :x="m.x" :y="m.y + 4" text-anchor="middle" font-size="8" font-weight="800" fill="#fff" font-family="system-ui, sans-serif" style="pointer-events:none">
  247. {{ m.num }}
  248. </text>
  249. <!-- 步骤连接线 -->
  250. <line
  251. v-if="idx > 0"
  252. :x1="markers[idx-1].x" :y1="markers[idx-1].y"
  253. :x2="m.x" :y2="m.y"
  254. stroke="rgba(16,185,129,0.25)" stroke-width="1.5" stroke-dasharray="4 4"
  255. />
  256. </g>
  257. <!-- 空状态 -->
  258. <g v-if="!markers.length">
  259. <rect x="80" y="260" width="240" height="52" rx="26" fill="rgba(255,255,255,0.72)" stroke="rgba(148,163,184,0.35)" stroke-width="1" />
  260. <text x="200" y="282" text-anchor="middle" font-size="13" fill="#475569" font-weight="600" font-family="system-ui, sans-serif">等待模拟</text>
  261. <text x="200" y="300" text-anchor="middle" font-size="11" fill="#94a3b8" font-family="system-ui, sans-serif">选择用户和方案后点击「开始模拟测试」</text>
  262. </g>
  263. </svg>
  264. </div>
  265. <!-- 穴位图例 -->
  266. <div v-if="markers.length" class="marker-legend">
  267. <div class="legend-header">
  268. <span class="legend-title">穴位定位结果</span>
  269. <div class="legend-chips">
  270. <span class="chip"><i class="dot dot-step" />步骤顺序</span>
  271. <span class="chip"><i class="dot dot-coord" />坐标偏移(mm)</span>
  272. </div>
  273. </div>
  274. <div class="legend-cards">
  275. <div v-for="(m, idx) in markers" :key="idx" class="legend-item" :style="{ borderLeftColor: m.color }">
  276. <div class="legend-num" :style="{ background: m.color }">{{ m.num }}</div>
  277. <div class="legend-info">
  278. <div class="legend-name">{{ m.name }} <span v-if="m.side !== 'center'" class="side-tag">{{ sideLabel(m.side) }}</span></div>
  279. <div class="legend-detail">
  280. 偏移: ({{ fmtNum(m.rawOffsetX) }}, {{ fmtNum(m.rawOffsetY) }}) mm ·
  281. {{ m.technique }} · {{ m.temp }}°C · {{ m.mins }}分钟
  282. </div>
  283. </div>
  284. </div>
  285. </div>
  286. </div>
  287. <div v-if="unmappedSteps.length" class="visual-note">
  288. <div class="visual-note-title">以下穴位未叠加到后背图</div>
  289. <div class="visual-note-text">
  290. <span v-for="s in unmappedSteps" :key="s.stepOrder" class="visual-note-item">
  291. {{ s.stepOrder }}. {{ s.acupointName }}
  292. </span>
  293. </div>
  294. </div>
  295. </el-card>
  296. <!-- 推送数据 JSON -->
  297. <el-card v-if="pushData" shadow="never" class="json-card">
  298. <template #header>
  299. <div class="card-header-row">
  300. <span class="card-title">推送数据预览(设备接收格式)</span>
  301. <el-button size="small" @click="copyJson">复制 JSON</el-button>
  302. </div>
  303. </template>
  304. <pre class="json-block">{{ pushDataJson }}</pre>
  305. </el-card>
  306. </div>
  307. </div>
  308. </div>
  309. </template>
  310. <script setup>
  311. import { computed, onMounted, ref, reactive, watch } from 'vue'
  312. import { ElMessage } from 'element-plus'
  313. import { getProfilePage } from '@/api/user'
  314. import { getPlanPage, getUserPlanPage } from '@/api/plan'
  315. import { getSimulationData } from '@/api/simulation'
  316. import bodyBackImage from '@/assets/acupoint-back-realistic.jpg'
  317. const selectedUserId = ref(null)
  318. const selectedMode = ref(null)
  319. const selectedEfficacy = ref('')
  320. const selectedPlanId = ref(null)
  321. const userOptions = ref([])
  322. const usersLoading = ref(false)
  323. const allPlans = ref([])
  324. const userPlans = ref([])
  325. const plansLoading = ref(false)
  326. const simulating = ref(false)
  327. const pushData = ref(null)
  328. const efficacyTypes = ['驱寒', '祛湿', '祛风', '化瘀', '活血', '化痰', '养颜', '扶阳']
  329. const bodyForm = reactive({
  330. gender: null,
  331. age: '',
  332. shoulderWidth: '',
  333. bodyHeight: '',
  334. spineLength: '',
  335. fingerWidth1: '',
  336. fingerWidth15: '',
  337. fingerWidth3: '',
  338. acupointTableId: null,
  339. })
  340. const canSimulate = computed(() => selectedUserId.value && selectedPlanId.value)
  341. const planSource = computed(() => {
  342. // 专业模式:要求与“用户方案列表”保持一致,优先使用 user-plan 里该用户对应功效的方案
  343. if (selectedMode.value === 2) return userPlans.value
  344. // 其他模式暂仍使用全局方案列表
  345. return allPlans.value
  346. })
  347. const filteredPlans = computed(() => {
  348. if (!selectedMode.value) return []
  349. let list = planSource.value.filter((p) => p.modeType === selectedMode.value)
  350. if (selectedMode.value === 2 && selectedEfficacy.value) {
  351. list = list.filter((p) => p.effectType === selectedEfficacy.value)
  352. }
  353. return list
  354. })
  355. const totalMinutes = computed(() => {
  356. const steps = pushData.value?.scheme?.steps || []
  357. return steps.reduce((sum, s) => sum + (s.duration || 0), 0)
  358. })
  359. const pushDataJson = computed(() => {
  360. if (!pushData.value) return ''
  361. try { return JSON.stringify(pushData.value, null, 2) }
  362. catch { return String(pushData.value) }
  363. })
  364. // SVG coordinate mapping: align mm offsets to the realistic back photo.
  365. const SVG_C7_X = 200
  366. // 参考经络示意图:大椎位于颈后下缘、后正中线处,视觉上略低于头颈连接点
  367. const SVG_C7_Y = 112
  368. const SVG_S4_Y = 520
  369. const DEFAULT_SPINE_LENGTH_MM = 450
  370. const MAX_VISUAL_SPINE_LENGTH_MM = 600
  371. const FRONT_ACUPOINT_NAMES = new Set(['中脘穴', '气海穴', '关元穴'])
  372. const currentSpineLengthMm = computed(() => {
  373. const spineLengthCm = Number(pushData.value?.bodyData?.spineLength ?? bodyForm.spineLength)
  374. return Number.isFinite(spineLengthCm) && spineLengthCm > 0
  375. ? spineLengthCm * 10
  376. : DEFAULT_SPINE_LENGTH_MM
  377. })
  378. const currentSpineLengthCm = computed(() => currentSpineLengthMm.value / 10)
  379. const visualScale = (SVG_S4_Y - SVG_C7_Y) / MAX_VISUAL_SPINE_LENGTH_MM
  380. const currentSpineEndY = computed(() =>
  381. Math.min(SVG_S4_Y, SVG_C7_Y + currentSpineLengthMm.value * visualScale)
  382. )
  383. function bodyToSvg(offsetXmm, offsetYmm) {
  384. return {
  385. x: SVG_C7_X + Number(offsetXmm) * visualScale,
  386. y: SVG_C7_Y + Number(offsetYmm) * visualScale,
  387. }
  388. }
  389. function isBackVisualStep(step) {
  390. if (step.offsetX == null || step.offsetY == null) return false
  391. if (FRONT_ACUPOINT_NAMES.has(step.acupointName)) return false
  392. const isZeroFallback = Number(step.offsetX) === 0 && Number(step.offsetY) === 0 && step.acupointName !== '大椎穴'
  393. return !isZeroFallback
  394. }
  395. const markers = computed(() => {
  396. const steps = pushData.value?.scheme?.steps || []
  397. return steps
  398. .filter(isBackVisualStep)
  399. .map((s, idx) => {
  400. const { x, y } = bodyToSvg(s.offsetX, s.offsetY)
  401. const techniqueColors = {
  402. '温和灸': '#10b981',
  403. '雀啄灸': '#f59e0b',
  404. '回旋灸': '#6366f1',
  405. '麻雀灸': '#ec4899',
  406. '一键艾灸': '#3b82f6',
  407. }
  408. const color = techniqueColors[s.techniqueName] || '#10b981'
  409. const onRight = x > SVG_C7_X
  410. return {
  411. num: s.stepOrder || idx + 1,
  412. x, y, color,
  413. name: s.acupointName,
  414. side: s.acupointSide,
  415. labelX: onRight ? x - 14 : x + 14,
  416. anchor: onRight ? 'end' : 'start',
  417. rawOffsetX: s.offsetX,
  418. rawOffsetY: s.offsetY,
  419. technique: s.techniqueName,
  420. temp: s.temperature,
  421. mins: s.duration || 0,
  422. }
  423. })
  424. })
  425. const unmappedSteps = computed(() => {
  426. const steps = pushData.value?.scheme?.steps || []
  427. return steps.filter((s) => !isBackVisualStep(s))
  428. })
  429. function modeLabel(modeType) {
  430. const map = { 1: '一键艾灸', 2: '专业模式', 3: '自定义模式', 4: '延年圣手' }
  431. return map[modeType] || modeType
  432. }
  433. function sideLabel(side) {
  434. const map = { center: '中心', left: '左侧', right: '右侧' }
  435. return map[side] || side || '-'
  436. }
  437. function confidenceLabel(c) {
  438. const map = { high: '高', medium: '中', low: '低', none: '无' }
  439. return map[c] || c || '-'
  440. }
  441. function confidenceType(c) {
  442. const map = { high: 'success', medium: 'warning', low: 'danger', none: 'info' }
  443. return map[c] || 'info'
  444. }
  445. function fmtNum(v) {
  446. return v != null ? Number(v).toFixed(1) : '-'
  447. }
  448. async function loadUsers() {
  449. usersLoading.value = true
  450. try {
  451. const res = await getProfilePage({ pageNum: 1, pageSize: 500 })
  452. userOptions.value = Array.isArray(res.data?.records) ? res.data.records : []
  453. } catch {
  454. userOptions.value = []
  455. } finally {
  456. usersLoading.value = false
  457. }
  458. }
  459. async function loadPlans() {
  460. plansLoading.value = true
  461. try {
  462. const res = await getPlanPage({ pageNum: 1, pageSize: 500, status: 1 })
  463. allPlans.value = Array.isArray(res.data?.records) ? res.data.records : []
  464. } catch {
  465. allPlans.value = []
  466. } finally {
  467. plansLoading.value = false
  468. }
  469. }
  470. async function loadUserPlans(userId) {
  471. if (!userId) {
  472. userPlans.value = []
  473. return
  474. }
  475. try {
  476. // user-plan 接口自身会确保“必需方案”(一键+8功效)存在并去重
  477. const res = await getUserPlanPage({ userId, pageNum: 1, pageSize: 200 })
  478. const records = Array.isArray(res.data?.records) ? res.data.records : []
  479. // 这里用于下拉展示与选择 planId,因此把 userPlan 结构“平铺”为 plan 结构
  480. userPlans.value = records
  481. .filter((r) => r && r.planId)
  482. .map((r) => ({
  483. id: r.planId,
  484. name: r.planName,
  485. planCode: r.planCode,
  486. modeType: r.modeType,
  487. effectType: r.effectType,
  488. _userPlanId: r.id,
  489. }))
  490. } catch {
  491. userPlans.value = []
  492. }
  493. }
  494. function onUserSelect(userId) {
  495. pushData.value = null
  496. if (!userId) {
  497. Object.keys(bodyForm).forEach((k) => (bodyForm[k] = k === 'acupointTableId' ? null : ''))
  498. userPlans.value = []
  499. return
  500. }
  501. const u = userOptions.value.find((x) => x.id === userId)
  502. if (!u) return
  503. bodyForm.gender = u.gender ?? null
  504. bodyForm.age = u.age ?? ''
  505. bodyForm.shoulderWidth = u.shoulderWidth ?? ''
  506. bodyForm.bodyHeight = u.bodyHeight ?? ''
  507. bodyForm.spineLength = u.spineLength ?? ''
  508. bodyForm.fingerWidth1 = u.fingerWidth1 ?? ''
  509. bodyForm.fingerWidth15 = u.fingerWidth15 ?? ''
  510. bodyForm.fingerWidth3 = u.fingerWidth3 ?? ''
  511. bodyForm.acupointTableId = u.acupointTableId ?? null
  512. // 同步加载该用户的“用户方案列表”,确保专业模式选择与其一致
  513. loadUserPlans(userId).finally(() => {
  514. // 若当前处于专业模式且已选功效,自动对齐默认方案
  515. if (selectedMode.value === 2 && selectedEfficacy.value) {
  516. const match = filteredPlans.value[0]
  517. selectedPlanId.value = match ? match.id : null
  518. }
  519. })
  520. }
  521. function onModeChange() {
  522. selectedPlanId.value = null
  523. selectedEfficacy.value = ''
  524. pushData.value = null
  525. if (selectedMode.value === 1) {
  526. const oneClick = planSource.value.find((p) => p.modeType === 1)
  527. if (oneClick) selectedPlanId.value = oneClick.id
  528. }
  529. }
  530. function onEfficacyChange() {
  531. // 方案联动由 watch 统一处理,避免异步加载用户方案时错过自动选中
  532. selectedPlanId.value = null
  533. }
  534. async function runSimulation() {
  535. if (!selectedUserId.value || !selectedPlanId.value) {
  536. ElMessage.warning('请先选择用户和方案')
  537. return
  538. }
  539. simulating.value = true
  540. try {
  541. const res = await getSimulationData(selectedUserId.value, selectedPlanId.value)
  542. pushData.value = res.data
  543. ElMessage.success('模拟完成,查看右侧可视化结果')
  544. } catch (e) {
  545. ElMessage.error('模拟失败: ' + (e.message || '未知错误'))
  546. } finally {
  547. simulating.value = false
  548. }
  549. }
  550. function copyJson() {
  551. navigator.clipboard.writeText(pushDataJson.value).then(
  552. () => ElMessage.success('已复制'),
  553. () => ElMessage.error('复制失败')
  554. )
  555. }
  556. onMounted(() => {
  557. loadUsers()
  558. loadPlans()
  559. })
  560. // 进入专业模式时,确保 userPlans 已加载(否则功效筛选拿不到方案)
  561. watch([selectedMode, selectedUserId], async ([mode, uid]) => {
  562. if (mode === 2 && uid) {
  563. await loadUserPlans(uid)
  564. }
  565. })
  566. // 专业模式:功效变化或用户方案加载完成后,自动选中该功效对应方案
  567. watch([selectedMode, selectedEfficacy, filteredPlans], ([mode, eff, list]) => {
  568. if (mode !== 2) return
  569. if (!eff) return
  570. if (selectedPlanId.value) return
  571. if (Array.isArray(list) && list.length) {
  572. selectedPlanId.value = list[0].id
  573. }
  574. })
  575. </script>
  576. <style scoped>
  577. .simulation-page {
  578. max-width: 1600px;
  579. margin: 0 auto;
  580. }
  581. .page-header {
  582. display: flex;
  583. align-items: center;
  584. gap: 16px;
  585. margin-bottom: 20px;
  586. }
  587. .page-title {
  588. font-size: 18px;
  589. font-weight: 600;
  590. color: #334155;
  591. }
  592. .sim-layout {
  593. display: flex;
  594. gap: 24px;
  595. align-items: flex-start;
  596. }
  597. .sim-config {
  598. flex: 0 0 480px;
  599. min-width: 380px;
  600. display: flex;
  601. flex-direction: column;
  602. gap: 16px;
  603. }
  604. .sim-visual {
  605. flex: 1;
  606. min-width: 420px;
  607. display: flex;
  608. flex-direction: column;
  609. gap: 16px;
  610. }
  611. .config-card,
  612. .result-card,
  613. .visual-card,
  614. .json-card {
  615. border: none;
  616. border-radius: 14px;
  617. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.03);
  618. }
  619. .card-title {
  620. font-weight: 600;
  621. color: #334155;
  622. font-size: 15px;
  623. }
  624. .card-header-row {
  625. display: flex;
  626. justify-content: space-between;
  627. align-items: center;
  628. }
  629. .config-form :deep(.el-form-item) {
  630. margin-bottom: 14px;
  631. }
  632. .config-form :deep(.el-form-item__label) {
  633. font-size: 13px;
  634. color: #64748b;
  635. }
  636. .status-badge {
  637. text-align: right;
  638. margin-bottom: 4px;
  639. }
  640. .steps-card :deep(.el-table) {
  641. font-size: 13px;
  642. }
  643. .num-cell {
  644. font-family: ui-monospace, 'Cascadia Code', monospace;
  645. font-size: 12px;
  646. }
  647. .body-canvas-wrap {
  648. display: flex;
  649. justify-content: center;
  650. padding: 8px;
  651. background: linear-gradient(145deg, #fff 0%, #f4f6f9 100%);
  652. border-radius: 18px;
  653. box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 12px 40px -12px rgba(15, 23, 42, 0.10);
  654. border: 1px solid rgba(255, 255, 255, 0.8);
  655. }
  656. .body-svg {
  657. display: block;
  658. width: 100%;
  659. max-width: 420px;
  660. border-radius: 14px;
  661. overflow: hidden;
  662. background: #f8fafc;
  663. }
  664. .body-photo {
  665. pointer-events: none;
  666. }
  667. .marker-legend {
  668. margin-top: 16px;
  669. }
  670. .legend-header {
  671. display: flex;
  672. justify-content: space-between;
  673. align-items: center;
  674. margin-bottom: 12px;
  675. }
  676. .legend-title {
  677. font-size: 14px;
  678. font-weight: 700;
  679. color: #1e293b;
  680. }
  681. .legend-chips {
  682. display: flex;
  683. gap: 8px;
  684. }
  685. .chip {
  686. display: inline-flex;
  687. align-items: center;
  688. gap: 5px;
  689. font-size: 11px;
  690. color: #64748b;
  691. padding: 3px 10px;
  692. border-radius: 999px;
  693. background: #f1f5f9;
  694. border: 1px solid #e2e8f0;
  695. }
  696. .dot {
  697. width: 7px;
  698. height: 7px;
  699. border-radius: 50%;
  700. display: inline-block;
  701. }
  702. .dot-step { background: #10b981; }
  703. .dot-coord { background: #6366f1; }
  704. .legend-cards {
  705. display: flex;
  706. flex-direction: column;
  707. gap: 8px;
  708. }
  709. .legend-item {
  710. display: flex;
  711. align-items: center;
  712. gap: 12px;
  713. padding: 10px 14px;
  714. background: #fafbfc;
  715. border-radius: 10px;
  716. border: 1px solid #eef0f4;
  717. border-left: 4px solid;
  718. transition: background 0.15s;
  719. }
  720. .legend-item:hover {
  721. background: #f1f5f9;
  722. }
  723. .legend-num {
  724. width: 26px;
  725. height: 26px;
  726. border-radius: 50%;
  727. display: flex;
  728. align-items: center;
  729. justify-content: center;
  730. color: #fff;
  731. font-size: 12px;
  732. font-weight: 700;
  733. flex-shrink: 0;
  734. }
  735. .legend-info {
  736. flex: 1;
  737. min-width: 0;
  738. }
  739. .legend-name {
  740. font-size: 13px;
  741. font-weight: 600;
  742. color: #1e293b;
  743. }
  744. .side-tag {
  745. font-size: 11px;
  746. font-weight: 400;
  747. color: #94a3b8;
  748. margin-left: 4px;
  749. }
  750. .legend-detail {
  751. font-size: 11px;
  752. color: #64748b;
  753. margin-top: 2px;
  754. }
  755. .visual-note {
  756. margin-top: 12px;
  757. padding: 10px 12px;
  758. border: 1px solid #fde68a;
  759. border-radius: 8px;
  760. background: #fffbeb;
  761. }
  762. .visual-note-title {
  763. font-size: 13px;
  764. font-weight: 700;
  765. color: #92400e;
  766. }
  767. .visual-note-text {
  768. display: flex;
  769. flex-wrap: wrap;
  770. gap: 6px;
  771. margin-top: 6px;
  772. }
  773. .visual-note-item {
  774. font-size: 12px;
  775. color: #a16207;
  776. padding: 2px 8px;
  777. border-radius: 8px;
  778. background: #fef3c7;
  779. }
  780. .json-block {
  781. background: #1e1e1e;
  782. color: #d4d4d4;
  783. padding: 16px;
  784. border-radius: 10px;
  785. overflow: auto;
  786. max-height: 400px;
  787. font-size: 12px;
  788. line-height: 1.5;
  789. font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
  790. }
  791. .modern-table {
  792. border-radius: 8px;
  793. overflow: hidden;
  794. }
  795. .modern-table :deep(th.el-table__cell) {
  796. background-color: #f8fafc;
  797. color: #475569;
  798. font-weight: 600;
  799. font-size: 12px;
  800. }
  801. .visual-sub {
  802. font-size: 13px;
  803. color: #64748b;
  804. font-weight: 400;
  805. }
  806. @media (max-width: 1100px) {
  807. .sim-layout {
  808. flex-direction: column;
  809. }
  810. .sim-config {
  811. flex: 1 1 auto;
  812. min-width: 0;
  813. max-width: 100%;
  814. }
  815. }
  816. </style>