Browse Source

feat: 优化模拟测试坐标展示与提示

jiapu 4 months ago
parent
commit
e4891c673f
1 changed files with 150 additions and 19 deletions
  1. 150 19
      code/frontend/src/views/simulation/index.vue

+ 150 - 19
code/frontend/src/views/simulation/index.vue

@@ -26,7 +26,7 @@
                 <el-option
                   v-for="u in userOptions"
                   :key="u.id"
-                  :label="`${u.name || '未命名'} (${u.gender === 1 ? '男' : '女'}, ${u.shoulderWidth || '-'}cm)`"
+                  :label="`${u.name || '未命名'} (${u.gender === 1 ? '男' : '女'}, 肩宽${u.shoulderWidth || '-'}cm)`"
                   :value="u.id"
                 />
               </el-select>
@@ -136,6 +136,16 @@
                 开始模拟测试
               </el-button>
             </el-form-item>
+
+            <el-form-item v-if="showStaleVisualWarning" class="stale-alert-item">
+              <el-alert
+                class="visual-stale-alert"
+                type="warning"
+                show-icon
+                :closable="false"
+                :title="staleVisualWarningText"
+              />
+            </el-form-item>
           </el-form>
         </el-card>
 
@@ -196,9 +206,6 @@
           <div class="body-canvas-wrap">
             <svg viewBox="0 0 400 580" xmlns="http://www.w3.org/2000/svg" class="body-svg">
               <defs>
-                <pattern id="sim-grid" width="20" height="20" patternUnits="userSpaceOnUse">
-                  <path d="M 20 0 L 0 0 0 20" fill="none" stroke="rgba(99,102,241,0.05)" stroke-width="1" />
-                </pattern>
                 <radialGradient id="simWash" cx="50%" cy="18%" r="75%">
                   <stop offset="0%" stop-color="#f8fafc" />
                   <stop offset="55%" stop-color="#f1f5f9" />
@@ -208,6 +215,7 @@
 
               <rect width="100%" height="100%" fill="url(#simWash)" rx="14" />
               <image
+                v-if="showBodyPhoto"
                 :href="bodyBackImage"
                 x="0"
                 y="-10"
@@ -216,7 +224,68 @@
                 preserveAspectRatio="xMidYMid slice"
                 class="body-photo"
               />
-              <rect width="100%" height="100%" fill="url(#sim-grid)" rx="14" opacity="0.35" />
+
+              <g class="coord-plane">
+                <line
+                  v-for="mm in COORD_MINOR_X_LINES"
+                  :key="`minor-x-${mm}`"
+                  :x1="bodyToSvgX(mm)"
+                  y1="12"
+                  :x2="bodyToSvgX(mm)"
+                  y2="568"
+                  class="coord-grid-line minor"
+                />
+                <line
+                  v-for="mm in COORD_MINOR_Y_LINES"
+                  :key="`minor-y-${mm}`"
+                  x1="12"
+                  :y1="bodyToSvgY(mm)"
+                  x2="388"
+                  :y2="bodyToSvgY(mm)"
+                  class="coord-grid-line minor"
+                />
+                <line
+                  v-for="mm in COORD_MAJOR_X_LINES"
+                  :key="`major-x-${mm}`"
+                  :x1="bodyToSvgX(mm)"
+                  y1="12"
+                  :x2="bodyToSvgX(mm)"
+                  y2="568"
+                  class="coord-grid-line major"
+                />
+                <line
+                  v-for="mm in COORD_MAJOR_Y_LINES"
+                  :key="`major-y-${mm}`"
+                  x1="12"
+                  :y1="bodyToSvgY(mm)"
+                  x2="388"
+                  :y2="bodyToSvgY(mm)"
+                  class="coord-grid-line major"
+                />
+                <text
+                  v-for="mm in COORD_X_TICKS_MM"
+                  :key="`x-label-${mm}`"
+                  :x="bodyToSvgX(mm)"
+                  :y="SVG_C7_Y + 18"
+                  text-anchor="middle"
+                  class="coord-tick-label"
+                >
+                  {{ mm }}
+                </text>
+                <text
+                  v-for="mm in COORD_Y_TICKS_MM"
+                  :key="`y-label-${mm}`"
+                  :x="SVG_C7_X - 8"
+                  :y="bodyToSvgY(mm) - 4"
+                  text-anchor="end"
+                  class="coord-tick-label"
+                >
+                  {{ mm }}
+                </text>
+                <text x="382" :y="SVG_C7_Y + 34" text-anchor="end" class="coord-axis-label">X(mm)</text>
+                <text :x="SVG_C7_X + 12" y="42" class="coord-axis-label">Y(mm)</text>
+                <text :x="SVG_C7_X + 12" :y="SVG_C7_Y - 12" class="coord-origin-label">原点 C7</text>
+              </g>
 
               <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" />
               <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" />
@@ -254,8 +323,8 @@
 
               <text x="208" y="24" font-size="10" fill="#94a3b8" font-family="system-ui, sans-serif">&uarr; 头侧</text>
               <text x="208" y="570" font-size="10" fill="#94a3b8" font-family="system-ui, sans-serif">&darr; 尾侧</text>
-              <text x="14" :y="SVG_C7_Y - 8" font-size="10" fill="#94a3b8" font-family="system-ui, sans-serif">患者左 (&minus;X)</text>
-              <text x="386" text-anchor="end" :y="SVG_C7_Y - 8" font-size="10" fill="#94a3b8" font-family="system-ui, sans-serif">患者右 (+X)</text>
+              <text x="14" :y="SVG_C7_Y - 8" font-size="10" fill="#94a3b8" font-family="system-ui, sans-serif">左 (&minus;X)</text>
+              <text x="386" text-anchor="end" :y="SVG_C7_Y - 8" font-size="10" fill="#94a3b8" font-family="system-ui, sans-serif">右 (+X)</text>
 
               <!-- 穴位标记 -->
               <g v-for="(m, idx) in markers" :key="idx">
@@ -273,12 +342,6 @@
                 />
               </g>
 
-              <!-- 空状态 -->
-              <g v-if="!markers.length">
-                <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" />
-                <text x="200" y="282" text-anchor="middle" font-size="13" fill="#475569" font-weight="600" font-family="system-ui, sans-serif">等待模拟</text>
-                <text x="200" y="300" text-anchor="middle" font-size="11" fill="#94a3b8" font-family="system-ui, sans-serif">选择用户和方案后点击「开始模拟测试」</text>
-              </g>
             </svg>
           </div>
 
@@ -306,7 +369,7 @@
           </div>
 
           <div v-if="unmappedSteps.length" class="visual-note">
-            <div class="visual-note-title">以下穴位未叠加到后背图</div>
+            <div class="visual-note-title">以下穴位未叠加到坐标系</div>
             <div class="visual-note-text">
               <span v-for="s in unmappedSteps" :key="s.stepOrder" class="visual-note-item">
                 {{ s.stepOrder }}. {{ s.acupointName }}
@@ -383,6 +446,20 @@ const filteredPlans = computed(() => {
   return list
 })
 
+const showStaleVisualWarning = computed(() =>
+  Boolean(pushData.value && selectedUserId.value && String(pushData.value.userId) !== String(selectedUserId.value))
+)
+
+const displayUserName = computed(() => {
+  if (!pushData.value) return ''
+  if (pushData.value.userName) return pushData.value.userName
+  const user = userOptions.value.find((u) => String(u.id) === String(pushData.value.userId))
+  return user?.name || '上一位'
+})
+
+const staleVisualWarningText = computed(() =>
+  `当前展示的是「${displayUserName.value}」用户数据,请点击「开始模拟测试」获取当前用户坐标展示`
+)
 
 const totalMinutes = computed(() => {
   const steps = pushData.value?.scheme?.steps || []
@@ -395,7 +472,10 @@ const pushDataJson = computed(() => {
   catch { return String(pushData.value) }
 })
 
-// SVG coordinate mapping: align mm offsets to the realistic back photo.
+// 保留人体图片接入点,后续需要恢复图片展示时只需打开此开关。
+const showBodyPhoto = false
+
+// SVG coordinate mapping: use C7 as the coordinate origin, X to patient right and Y to tail side.
 const SVG_C7_X = 200
 // 参考经络示意图:大椎位于颈后下缘、后正中线处,视觉上略低于头颈连接点
 const SVG_C7_Y = 112
@@ -403,6 +483,12 @@ const SVG_S4_Y = 520
 const DEFAULT_SPINE_LENGTH_MM = 450
 const MAX_VISUAL_SPINE_LENGTH_MM = 600
 const FRONT_ACUPOINT_NAMES = new Set(['中脘穴', '气海穴', '关元穴'])
+const COORD_MINOR_X_LINES = Array.from({ length: 23 }, (_, i) => -275 + i * 25).filter((mm) => mm !== 0)
+const COORD_MINOR_Y_LINES = Array.from({ length: 32 }, (_, i) => -125 + i * 25).filter((mm) => mm !== 0)
+const COORD_MAJOR_X_LINES = [-250, -200, -150, -100, -50, 50, 100, 150, 200, 250]
+const COORD_MAJOR_Y_LINES = [-100, 100, 200, 300, 400, 500, 600]
+const COORD_X_TICKS_MM = [-250, -200, -150, -100, -50, 50, 100, 150, 200, 250]
+const COORD_Y_TICKS_MM = [-100, 100, 200, 300, 400, 500, 600]
 
 const currentSpineLengthMm = computed(() => {
   const spineLengthCm = Number(pushData.value?.bodyData?.spineLength ?? bodyForm.spineLength)
@@ -419,11 +505,19 @@ const currentSpineEndY = computed(() =>
 
 function bodyToSvg(offsetXmm, offsetYmm) {
   return {
-    x: SVG_C7_X + Number(offsetXmm) * visualScale,
-    y: SVG_C7_Y + Number(offsetYmm) * visualScale,
+    x: bodyToSvgX(offsetXmm),
+    y: bodyToSvgY(offsetYmm),
   }
 }
 
+function bodyToSvgX(offsetXmm) {
+  return SVG_C7_X + Number(offsetXmm) * visualScale
+}
+
+function bodyToSvgY(offsetYmm) {
+  return SVG_C7_Y + Number(offsetYmm) * visualScale
+}
+
 function isBackVisualStep(step) {
   if (step.offsetX == null || step.offsetY == null) return false
   if (FRONT_ACUPOINT_NAMES.has(step.acupointName)) return false
@@ -541,10 +635,10 @@ async function loadUserPlans(userId) {
 }
 
 function onUserSelect(userId) {
-  pushData.value = null
   if (!userId) {
     Object.keys(bodyForm).forEach((k) => (bodyForm[k] = k === 'acupointTableId' ? null : ''))
     userPlans.value = []
+    pushData.value = null
     return
   }
   const u = userOptions.value.find((x) => x.id === userId)
@@ -594,7 +688,10 @@ async function runSimulation() {
   simulating.value = true
   try {
     const res = await getSimulationData(selectedUserId.value, selectedPlanId.value)
-    pushData.value = res.data
+    pushData.value = {
+      ...(res.data || {}),
+      userId: res.data?.userId ?? selectedUserId.value,
+    }
     ElMessage.success('模拟完成,查看右侧可视化结果')
   } catch (e) {
     ElMessage.error('模拟失败: ' + (e.message || '未知错误'))
@@ -710,6 +807,13 @@ watch([selectedMode, selectedEfficacy, filteredPlans], ([mode, eff, list]) => {
   font-size: 12px;
 }
 
+.stale-alert-item {
+  margin-top: -6px;
+}
+.visual-stale-alert {
+  width: 100%;
+}
+
 .body-canvas-wrap {
   display: flex;
   justify-content: center;
@@ -729,6 +833,33 @@ watch([selectedMode, selectedEfficacy, filteredPlans], ([mode, eff, list]) => {
 }
 .body-photo {
   pointer-events: none;
+  opacity: 0.52;
+}
+.coord-plane {
+  pointer-events: none;
+}
+.coord-grid-line {
+  vector-effect: non-scaling-stroke;
+}
+.coord-grid-line.minor {
+  stroke: rgba(148, 163, 184, 0.18);
+  stroke-width: 1;
+}
+.coord-grid-line.major {
+  stroke: rgba(100, 116, 139, 0.26);
+  stroke-width: 1;
+}
+.coord-tick-label,
+.coord-axis-label,
+.coord-origin-label {
+  fill: #64748b;
+  font-family: system-ui, sans-serif;
+  font-size: 9px;
+}
+.coord-axis-label,
+.coord-origin-label {
+  fill: #334155;
+  font-weight: 700;
 }
 
 .marker-legend {