| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888 |
- <template>
- <div class="simulation-page">
- <div class="page-header">
- <span class="page-title">方案模拟测试</span>
- <el-tag type="info" effect="plain">模拟小程序选择方案后,系统对穴位的计算结果</el-tag>
- </div>
- <div class="sim-layout">
- <!-- 左侧:配置面板 -->
- <div class="sim-config">
- <el-card shadow="never" class="config-card">
- <template #header><span class="card-title">模拟配置</span></template>
- <el-form label-width="90px" class="config-form">
- <!-- 用户选择 -->
- <el-form-item label="选择用户">
- <el-select
- v-model="selectedUserId"
- filterable
- clearable
- placeholder="选择已有用户(自动填充体型数据)"
- style="width: 100%"
- :loading="usersLoading"
- @change="onUserSelect"
- >
- <el-option
- v-for="u in userOptions"
- :key="u.id"
- :label="`${u.name || '未命名'} (${u.gender === 1 ? '男' : '女'}, ${u.shoulderWidth || '-'}cm)`"
- :value="u.id"
- />
- </el-select>
- </el-form-item>
- <el-divider content-position="left">体型参数</el-divider>
- <el-row :gutter="12">
- <el-col :span="12">
- <el-form-item label="性别">
- <el-select v-model="bodyForm.gender" style="width: 100%" disabled>
- <el-option label="男" :value="1" />
- <el-option label="女" :value="2" />
- </el-select>
- </el-form-item>
- </el-col>
- <el-col :span="12">
- <el-form-item label="年龄">
- <el-input v-model="bodyForm.age" disabled />
- </el-form-item>
- </el-col>
- </el-row>
- <el-row :gutter="12">
- <el-col :span="8">
- <el-form-item label="肩宽(cm)">
- <el-input v-model="bodyForm.shoulderWidth" disabled />
- </el-form-item>
- </el-col>
- <el-col :span="8">
- <el-form-item label="身长(cm)">
- <el-input v-model="bodyForm.bodyHeight" disabled />
- </el-form-item>
- </el-col>
- <el-col :span="8">
- <el-form-item label="脊柱长(cm)">
- <el-input v-model="bodyForm.spineLength" disabled />
- </el-form-item>
- </el-col>
- </el-row>
- <el-row :gutter="12">
- <el-col :span="8">
- <el-form-item label="一寸">
- <el-input v-model="bodyForm.fingerWidth1" disabled placeholder="-" />
- </el-form-item>
- </el-col>
- <el-col :span="8">
- <el-form-item label="1.5寸">
- <el-input v-model="bodyForm.fingerWidth15" disabled placeholder="-" />
- </el-form-item>
- </el-col>
- <el-col :span="8">
- <el-form-item label="三寸">
- <el-input v-model="bodyForm.fingerWidth3" disabled placeholder="-" />
- </el-form-item>
- </el-col>
- </el-row>
- <div v-if="bodyForm.acupointTableId !== null" class="status-badge">
- <el-tag :type="bodyForm.acupointTableId ? 'success' : 'warning'" size="small">
- 穴位表: {{ bodyForm.acupointTableId ? '已生成' : '未生成' }}
- </el-tag>
- </div>
- <el-divider content-position="left">选择模式与方案</el-divider>
- <el-form-item label="艾灸模式">
- <el-select v-model="selectedMode" placeholder="选择模式" style="width: 100%" @change="onModeChange">
- <el-option label="一键艾灸(默认)" :value="1" />
- <el-option label="专业模式(8种功效)" :value="2" />
- <el-option label="自定义模式" :value="3" />
- <el-option label="延年圣手模式(专家方案)" :value="4" />
- </el-select>
- </el-form-item>
- <el-form-item v-if="selectedMode === 2" label="功效类型">
- <el-select v-model="selectedEfficacy" placeholder="选择功效" style="width: 100%" @change="onEfficacyChange">
- <el-option v-for="e in efficacyTypes" :key="e" :label="e" :value="e" />
- </el-select>
- </el-form-item>
- <el-form-item label="选择方案">
- <el-select
- v-model="selectedPlanId"
- filterable
- placeholder="选择具体方案"
- style="width: 100%"
- :loading="plansLoading"
- :disabled="!filteredPlans.length"
- >
- <el-option
- v-for="p in filteredPlans"
- :key="p.id"
- :label="`${p.name} (${p.effectType || modeLabel(p.modeType)})`"
- :value="p.id"
- />
- </el-select>
- </el-form-item>
- <el-form-item>
- <el-button
- type="primary"
- size="large"
- :loading="simulating"
- :disabled="!canSimulate"
- @click="runSimulation"
- style="width: 100%"
- >
- 开始模拟测试
- </el-button>
- </el-form-item>
- </el-form>
- </el-card>
- <!-- 方案步骤结果 -->
- <el-card v-if="pushData" shadow="never" class="result-card steps-card">
- <template #header>
- <div class="card-header-row">
- <span class="card-title">模拟结果 — 治疗步骤</span>
- <el-tag effect="plain" size="small">
- 总时长: {{ totalMinutes }} 分钟 / {{ pushData.scheme?.steps?.length || 0 }} 步
- </el-tag>
- </div>
- </template>
- <el-table :data="pushData.scheme?.steps || []" class="modern-table" size="small" stripe>
- <el-table-column prop="stepOrder" label="#" width="45" align="center" />
- <el-table-column prop="acupointName" label="穴位" width="100" />
- <el-table-column label="侧别" width="70" align="center">
- <template #default="{ row }">{{ sideLabel(row.acupointSide) }}</template>
- </el-table-column>
- <el-table-column label="偏移X(mm)" width="95" align="right">
- <template #default="{ row }">
- <span class="num-cell">{{ fmtNum(row.offsetX) }}</span>
- </template>
- </el-table-column>
- <el-table-column label="偏移Y(mm)" width="95" align="right">
- <template #default="{ row }">
- <span class="num-cell">{{ fmtNum(row.offsetY) }}</span>
- </template>
- </el-table-column>
- <el-table-column prop="temperature" label="温度°C" width="75" align="center" />
- <el-table-column label="时长" width="70" align="center">
- <template #default="{ row }">{{ row.duration }}分</template>
- </el-table-column>
- <el-table-column prop="techniqueName" label="手法" min-width="80" />
- <el-table-column label="置信度" width="75" align="center">
- <template #default="{ row }">
- <el-tag :type="confidenceType(row.confidence)" size="small">
- {{ confidenceLabel(row.confidence) }}
- </el-tag>
- </template>
- </el-table-column>
- </el-table>
- </el-card>
- </div>
- <!-- 右侧:可视化 -->
- <div class="sim-visual">
- <el-card shadow="never" class="visual-card">
- <template #header>
- <div class="card-header-row">
- <span class="card-title">穴位可视化</span>
- <span v-if="pushData" class="visual-sub">
- {{ pushData.userName }} · {{ modeLabel(pushData.modeType) }} · {{ pushData.scheme?.planName }}
- </span>
- </div>
- </template>
- <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" />
- <stop offset="100%" stop-color="#e8eef5" />
- </radialGradient>
- </defs>
- <rect width="100%" height="100%" fill="url(#simWash)" rx="14" />
- <image
- :href="bodyBackImage"
- x="0"
- y="-10"
- width="400"
- height="600"
- preserveAspectRatio="xMidYMid slice"
- class="body-photo"
- />
- <rect width="100%" height="100%" fill="url(#sim-grid)" rx="14" opacity="0.35" />
- <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" />
- <!-- 背部中线参考 -->
- <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" />
- <line
- :x1="SVG_C7_X + 18"
- :y1="SVG_C7_Y"
- :x2="SVG_C7_X + 18"
- :y2="currentSpineEndY"
- stroke="rgba(14,165,233,0.9)"
- stroke-width="3"
- stroke-linecap="round"
- />
- <circle :cx="SVG_C7_X + 18" :cy="currentSpineEndY" r="5" fill="#0ea5e9" stroke="#fff" stroke-width="2" />
- <text :x="SVG_C7_X + 28" :y="currentSpineEndY - 10" font-size="10" fill="#0369a1" font-weight="700" font-family="system-ui, sans-serif">
- {{ fmtNum(currentSpineLengthCm) }} cm
- </text>
- <text :x="SVG_C7_X + 28" :y="currentSpineEndY + 6" font-size="10" fill="#0369a1" font-family="system-ui, sans-serif">脊柱终点</text>
- <!-- C7 大椎穴标记 -->
- <g :transform="`translate(${SVG_C7_X}, ${SVG_C7_Y})`">
- <circle r="14" fill="none" stroke="#e85d5d" stroke-width="1" opacity="0.3">
- <animate attributeName="r" values="8;16;8" dur="2.5s" repeatCount="indefinite" />
- <animate attributeName="opacity" values="0.5;0;0.5" dur="2.5s" repeatCount="indefinite" />
- </circle>
- <circle r="5" fill="#fff" stroke="#e85d5d" stroke-width="2" />
- <line x1="-11" y1="0" x2="-5" y2="0" stroke="#e85d5d" stroke-width="1.8" stroke-linecap="round" />
- <line x1="5" y1="0" x2="11" y2="0" stroke="#e85d5d" stroke-width="1.8" stroke-linecap="round" />
- <line x1="0" y1="-11" x2="0" y2="-5" stroke="#e85d5d" stroke-width="1.8" stroke-linecap="round" />
- <line x1="0" y1="5" x2="0" y2="11" stroke="#e85d5d" stroke-width="1.8" stroke-linecap="round" />
- <text x="16" y="4" font-size="11" fill="#c24141" font-weight="700" font-family="system-ui, sans-serif">C7 大椎穴</text>
- </g>
- <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>
- <!-- 穴位标记 -->
- <g v-for="(m, idx) in markers" :key="idx">
- <circle :cx="m.x" :cy="m.y" r="12" :fill="m.color" opacity="0.18" />
- <circle :cx="m.x" :cy="m.y" r="6" :fill="m.color" stroke="#fff" stroke-width="2" />
- <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">
- {{ m.num }}
- </text>
- <!-- 步骤连接线 -->
- <line
- v-if="idx > 0"
- :x1="markers[idx-1].x" :y1="markers[idx-1].y"
- :x2="m.x" :y2="m.y"
- stroke="rgba(16,185,129,0.25)" stroke-width="1.5" stroke-dasharray="4 4"
- />
- </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>
- <!-- 穴位图例 -->
- <div v-if="markers.length" class="marker-legend">
- <div class="legend-header">
- <span class="legend-title">穴位定位结果</span>
- <div class="legend-chips">
- <span class="chip"><i class="dot dot-step" />步骤顺序</span>
- <span class="chip"><i class="dot dot-coord" />坐标偏移(mm)</span>
- </div>
- </div>
- <div class="legend-cards">
- <div v-for="(m, idx) in markers" :key="idx" class="legend-item" :style="{ borderLeftColor: m.color }">
- <div class="legend-num" :style="{ background: m.color }">{{ m.num }}</div>
- <div class="legend-info">
- <div class="legend-name">{{ m.name }} <span v-if="m.side !== 'center'" class="side-tag">{{ sideLabel(m.side) }}</span></div>
- <div class="legend-detail">
- 偏移: ({{ fmtNum(m.rawOffsetX) }}, {{ fmtNum(m.rawOffsetY) }}) mm ·
- {{ m.technique }} · {{ m.temp }}°C · {{ m.mins }}分钟
- </div>
- </div>
- </div>
- </div>
- </div>
- <div v-if="unmappedSteps.length" class="visual-note">
- <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 }}
- </span>
- </div>
- </div>
- </el-card>
- <!-- 推送数据 JSON -->
- <el-card v-if="pushData" shadow="never" class="json-card">
- <template #header>
- <div class="card-header-row">
- <span class="card-title">推送数据预览(设备接收格式)</span>
- <el-button size="small" @click="copyJson">复制 JSON</el-button>
- </div>
- </template>
- <pre class="json-block">{{ pushDataJson }}</pre>
- </el-card>
- </div>
- </div>
- </div>
- </template>
- <script setup>
- import { computed, onMounted, ref, reactive, watch } from 'vue'
- import { ElMessage } from 'element-plus'
- import { getProfilePage } from '@/api/user'
- import { getPlanPage, getUserPlanPage } from '@/api/plan'
- import { getSimulationData } from '@/api/simulation'
- import bodyBackImage from '@/assets/acupoint-back-realistic.jpg'
- const selectedUserId = ref(null)
- const selectedMode = ref(null)
- const selectedEfficacy = ref('')
- const selectedPlanId = ref(null)
- const userOptions = ref([])
- const usersLoading = ref(false)
- const allPlans = ref([])
- const userPlans = ref([])
- const plansLoading = ref(false)
- const simulating = ref(false)
- const pushData = ref(null)
- const efficacyTypes = ['驱寒', '祛湿', '祛风', '化瘀', '活血', '化痰', '养颜', '扶阳']
- const bodyForm = reactive({
- gender: null,
- age: '',
- shoulderWidth: '',
- bodyHeight: '',
- spineLength: '',
- fingerWidth1: '',
- fingerWidth15: '',
- fingerWidth3: '',
- acupointTableId: null,
- })
- const canSimulate = computed(() => selectedUserId.value && selectedPlanId.value)
- const planSource = computed(() => {
- // 专业模式:要求与“用户方案列表”保持一致,优先使用 user-plan 里该用户对应功效的方案
- if (selectedMode.value === 2) return userPlans.value
- // 其他模式暂仍使用全局方案列表
- return allPlans.value
- })
- const filteredPlans = computed(() => {
- if (!selectedMode.value) return []
- let list = planSource.value.filter((p) => p.modeType === selectedMode.value)
- if (selectedMode.value === 2 && selectedEfficacy.value) {
- list = list.filter((p) => p.effectType === selectedEfficacy.value)
- }
- return list
- })
- const totalMinutes = computed(() => {
- const steps = pushData.value?.scheme?.steps || []
- return steps.reduce((sum, s) => sum + (s.duration || 0), 0)
- })
- const pushDataJson = computed(() => {
- if (!pushData.value) return ''
- try { return JSON.stringify(pushData.value, null, 2) }
- catch { return String(pushData.value) }
- })
- // SVG coordinate mapping: align mm offsets to the realistic back photo.
- const SVG_C7_X = 200
- // 参考经络示意图:大椎位于颈后下缘、后正中线处,视觉上略低于头颈连接点
- const SVG_C7_Y = 112
- 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 currentSpineLengthMm = computed(() => {
- const spineLengthCm = Number(pushData.value?.bodyData?.spineLength ?? bodyForm.spineLength)
- return Number.isFinite(spineLengthCm) && spineLengthCm > 0
- ? spineLengthCm * 10
- : DEFAULT_SPINE_LENGTH_MM
- })
- const currentSpineLengthCm = computed(() => currentSpineLengthMm.value / 10)
- const visualScale = (SVG_S4_Y - SVG_C7_Y) / MAX_VISUAL_SPINE_LENGTH_MM
- const currentSpineEndY = computed(() =>
- Math.min(SVG_S4_Y, SVG_C7_Y + currentSpineLengthMm.value * visualScale)
- )
- function bodyToSvg(offsetXmm, offsetYmm) {
- return {
- x: SVG_C7_X + Number(offsetXmm) * visualScale,
- y: 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
- const isZeroFallback = Number(step.offsetX) === 0 && Number(step.offsetY) === 0 && step.acupointName !== '大椎穴'
- return !isZeroFallback
- }
- const markers = computed(() => {
- const steps = pushData.value?.scheme?.steps || []
- return steps
- .filter(isBackVisualStep)
- .map((s, idx) => {
- const { x, y } = bodyToSvg(s.offsetX, s.offsetY)
- const techniqueColors = {
- '温和灸': '#10b981',
- '雀啄灸': '#f59e0b',
- '回旋灸': '#6366f1',
- '麻雀灸': '#ec4899',
- '一键艾灸': '#3b82f6',
- }
- const color = techniqueColors[s.techniqueName] || '#10b981'
- const onRight = x > SVG_C7_X
- return {
- num: s.stepOrder || idx + 1,
- x, y, color,
- name: s.acupointName,
- side: s.acupointSide,
- labelX: onRight ? x - 14 : x + 14,
- anchor: onRight ? 'end' : 'start',
- rawOffsetX: s.offsetX,
- rawOffsetY: s.offsetY,
- technique: s.techniqueName,
- temp: s.temperature,
- mins: s.duration || 0,
- }
- })
- })
- const unmappedSteps = computed(() => {
- const steps = pushData.value?.scheme?.steps || []
- return steps.filter((s) => !isBackVisualStep(s))
- })
- function modeLabel(modeType) {
- const map = { 1: '一键艾灸', 2: '专业模式', 3: '自定义模式', 4: '延年圣手' }
- return map[modeType] || modeType
- }
- function sideLabel(side) {
- const map = { center: '中心', left: '左侧', right: '右侧' }
- return map[side] || side || '-'
- }
- function confidenceLabel(c) {
- const map = { high: '高', medium: '中', low: '低', none: '无' }
- return map[c] || c || '-'
- }
- function confidenceType(c) {
- const map = { high: 'success', medium: 'warning', low: 'danger', none: 'info' }
- return map[c] || 'info'
- }
- function fmtNum(v) {
- return v != null ? Number(v).toFixed(1) : '-'
- }
- async function loadUsers() {
- usersLoading.value = true
- try {
- const res = await getProfilePage({ pageNum: 1, pageSize: 500 })
- userOptions.value = Array.isArray(res.data?.records) ? res.data.records : []
- } catch {
- userOptions.value = []
- } finally {
- usersLoading.value = false
- }
- }
- async function loadPlans() {
- plansLoading.value = true
- try {
- const res = await getPlanPage({ pageNum: 1, pageSize: 500, status: 1 })
- allPlans.value = Array.isArray(res.data?.records) ? res.data.records : []
- } catch {
- allPlans.value = []
- } finally {
- plansLoading.value = false
- }
- }
- async function loadUserPlans(userId) {
- if (!userId) {
- userPlans.value = []
- return
- }
- try {
- // user-plan 接口自身会确保“必需方案”(一键+8功效)存在并去重
- const res = await getUserPlanPage({ userId, pageNum: 1, pageSize: 200 })
- const records = Array.isArray(res.data?.records) ? res.data.records : []
- // 这里用于下拉展示与选择 planId,因此把 userPlan 结构“平铺”为 plan 结构
- userPlans.value = records
- .filter((r) => r && r.planId)
- .map((r) => ({
- id: r.planId,
- name: r.planName,
- planCode: r.planCode,
- modeType: r.modeType,
- effectType: r.effectType,
- _userPlanId: r.id,
- }))
- } catch {
- userPlans.value = []
- }
- }
- function onUserSelect(userId) {
- pushData.value = null
- if (!userId) {
- Object.keys(bodyForm).forEach((k) => (bodyForm[k] = k === 'acupointTableId' ? null : ''))
- userPlans.value = []
- return
- }
- const u = userOptions.value.find((x) => x.id === userId)
- if (!u) return
- bodyForm.gender = u.gender ?? null
- bodyForm.age = u.age ?? ''
- bodyForm.shoulderWidth = u.shoulderWidth ?? ''
- bodyForm.bodyHeight = u.bodyHeight ?? ''
- bodyForm.spineLength = u.spineLength ?? ''
- bodyForm.fingerWidth1 = u.fingerWidth1 ?? ''
- bodyForm.fingerWidth15 = u.fingerWidth15 ?? ''
- bodyForm.fingerWidth3 = u.fingerWidth3 ?? ''
- bodyForm.acupointTableId = u.acupointTableId ?? null
- // 同步加载该用户的“用户方案列表”,确保专业模式选择与其一致
- loadUserPlans(userId).finally(() => {
- // 若当前处于专业模式且已选功效,自动对齐默认方案
- if (selectedMode.value === 2 && selectedEfficacy.value) {
- const match = filteredPlans.value[0]
- selectedPlanId.value = match ? match.id : null
- }
- })
- }
- function onModeChange() {
- selectedPlanId.value = null
- selectedEfficacy.value = ''
- pushData.value = null
- if (selectedMode.value === 1) {
- const oneClick = planSource.value.find((p) => p.modeType === 1)
- if (oneClick) selectedPlanId.value = oneClick.id
- }
- }
- function onEfficacyChange() {
- // 方案联动由 watch 统一处理,避免异步加载用户方案时错过自动选中
- selectedPlanId.value = null
- }
- async function runSimulation() {
- if (!selectedUserId.value || !selectedPlanId.value) {
- ElMessage.warning('请先选择用户和方案')
- return
- }
- simulating.value = true
- try {
- const res = await getSimulationData(selectedUserId.value, selectedPlanId.value)
- pushData.value = res.data
- ElMessage.success('模拟完成,查看右侧可视化结果')
- } catch (e) {
- ElMessage.error('模拟失败: ' + (e.message || '未知错误'))
- } finally {
- simulating.value = false
- }
- }
- function copyJson() {
- navigator.clipboard.writeText(pushDataJson.value).then(
- () => ElMessage.success('已复制'),
- () => ElMessage.error('复制失败')
- )
- }
- onMounted(() => {
- loadUsers()
- loadPlans()
- })
- // 进入专业模式时,确保 userPlans 已加载(否则功效筛选拿不到方案)
- watch([selectedMode, selectedUserId], async ([mode, uid]) => {
- if (mode === 2 && uid) {
- await loadUserPlans(uid)
- }
- })
- // 专业模式:功效变化或用户方案加载完成后,自动选中该功效对应方案
- watch([selectedMode, selectedEfficacy, filteredPlans], ([mode, eff, list]) => {
- if (mode !== 2) return
- if (!eff) return
- if (selectedPlanId.value) return
- if (Array.isArray(list) && list.length) {
- selectedPlanId.value = list[0].id
- }
- })
- </script>
- <style scoped>
- .simulation-page {
- max-width: 1600px;
- margin: 0 auto;
- }
- .page-header {
- display: flex;
- align-items: center;
- gap: 16px;
- margin-bottom: 20px;
- }
- .page-title {
- font-size: 18px;
- font-weight: 600;
- color: #334155;
- }
- .sim-layout {
- display: flex;
- gap: 24px;
- align-items: flex-start;
- }
- .sim-config {
- flex: 0 0 480px;
- min-width: 380px;
- display: flex;
- flex-direction: column;
- gap: 16px;
- }
- .sim-visual {
- flex: 1;
- min-width: 420px;
- display: flex;
- flex-direction: column;
- gap: 16px;
- }
- .config-card,
- .result-card,
- .visual-card,
- .json-card {
- border: none;
- border-radius: 14px;
- box-shadow: 0 2px 12px rgba(0, 0, 0, 0.03);
- }
- .card-title {
- font-weight: 600;
- color: #334155;
- font-size: 15px;
- }
- .card-header-row {
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
- .config-form :deep(.el-form-item) {
- margin-bottom: 14px;
- }
- .config-form :deep(.el-form-item__label) {
- font-size: 13px;
- color: #64748b;
- }
- .status-badge {
- text-align: right;
- margin-bottom: 4px;
- }
- .steps-card :deep(.el-table) {
- font-size: 13px;
- }
- .num-cell {
- font-family: ui-monospace, 'Cascadia Code', monospace;
- font-size: 12px;
- }
- .body-canvas-wrap {
- display: flex;
- justify-content: center;
- padding: 8px;
- background: linear-gradient(145deg, #fff 0%, #f4f6f9 100%);
- border-radius: 18px;
- box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 12px 40px -12px rgba(15, 23, 42, 0.10);
- border: 1px solid rgba(255, 255, 255, 0.8);
- }
- .body-svg {
- display: block;
- width: 100%;
- max-width: 420px;
- border-radius: 14px;
- overflow: hidden;
- background: #f8fafc;
- }
- .body-photo {
- pointer-events: none;
- }
- .marker-legend {
- margin-top: 16px;
- }
- .legend-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 12px;
- }
- .legend-title {
- font-size: 14px;
- font-weight: 700;
- color: #1e293b;
- }
- .legend-chips {
- display: flex;
- gap: 8px;
- }
- .chip {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- font-size: 11px;
- color: #64748b;
- padding: 3px 10px;
- border-radius: 999px;
- background: #f1f5f9;
- border: 1px solid #e2e8f0;
- }
- .dot {
- width: 7px;
- height: 7px;
- border-radius: 50%;
- display: inline-block;
- }
- .dot-step { background: #10b981; }
- .dot-coord { background: #6366f1; }
- .legend-cards {
- display: flex;
- flex-direction: column;
- gap: 8px;
- }
- .legend-item {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 10px 14px;
- background: #fafbfc;
- border-radius: 10px;
- border: 1px solid #eef0f4;
- border-left: 4px solid;
- transition: background 0.15s;
- }
- .legend-item:hover {
- background: #f1f5f9;
- }
- .legend-num {
- width: 26px;
- height: 26px;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- color: #fff;
- font-size: 12px;
- font-weight: 700;
- flex-shrink: 0;
- }
- .legend-info {
- flex: 1;
- min-width: 0;
- }
- .legend-name {
- font-size: 13px;
- font-weight: 600;
- color: #1e293b;
- }
- .side-tag {
- font-size: 11px;
- font-weight: 400;
- color: #94a3b8;
- margin-left: 4px;
- }
- .legend-detail {
- font-size: 11px;
- color: #64748b;
- margin-top: 2px;
- }
- .visual-note {
- margin-top: 12px;
- padding: 10px 12px;
- border: 1px solid #fde68a;
- border-radius: 8px;
- background: #fffbeb;
- }
- .visual-note-title {
- font-size: 13px;
- font-weight: 700;
- color: #92400e;
- }
- .visual-note-text {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- margin-top: 6px;
- }
- .visual-note-item {
- font-size: 12px;
- color: #a16207;
- padding: 2px 8px;
- border-radius: 8px;
- background: #fef3c7;
- }
- .json-block {
- background: #1e1e1e;
- color: #d4d4d4;
- padding: 16px;
- border-radius: 10px;
- overflow: auto;
- max-height: 400px;
- font-size: 12px;
- line-height: 1.5;
- font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
- }
- .modern-table {
- border-radius: 8px;
- overflow: hidden;
- }
- .modern-table :deep(th.el-table__cell) {
- background-color: #f8fafc;
- color: #475569;
- font-weight: 600;
- font-size: 12px;
- }
- .visual-sub {
- font-size: 13px;
- color: #64748b;
- font-weight: 400;
- }
- @media (max-width: 1100px) {
- .sim-layout {
- flex-direction: column;
- }
- .sim-config {
- flex: 1 1 auto;
- min-width: 0;
- max-width: 100%;
- }
- }
- </style>
|