|
|
@@ -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">↑ 头侧</text>
|
|
|
<text x="208" y="570" font-size="10" fill="#94a3b8" font-family="system-ui, sans-serif">↓ 尾侧</text>
|
|
|
- <text x="14" :y="SVG_C7_Y - 8" font-size="10" fill="#94a3b8" font-family="system-ui, sans-serif">患者左 (−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">左 (−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 {
|