Просмотр исходного кода

feat: 完善用户脊柱长度校验与模拟展示

jiapu 4 месяцев назад
Родитель
Сommit
23afea132b

+ 4 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/UserProfileDTO.java

@@ -2,6 +2,8 @@ package com.aijiuyi.admin.controller.dto;
 
 import lombok.Data;
 
+import javax.validation.constraints.DecimalMax;
+import javax.validation.constraints.DecimalMin;
 import javax.validation.constraints.NotBlank;
 import javax.validation.constraints.NotNull;
 import javax.validation.constraints.Pattern;
@@ -46,6 +48,8 @@ public class UserProfileDTO {
 
     /** 脊柱长度C7-S4(cm,必填,30-60),用于按医学定位术语计算Y轴坐标 */
     @NotNull(message = "脊柱长度不能为空")
+    @DecimalMin(value = "30.0", message = "C7-S4脊柱长度不能小于30cm")
+    @DecimalMax(value = "60.0", message = "C7-S4脊柱长度不能大于60cm")
     private BigDecimal spineLength;
 
     /** 身高(cm,选填,用于BMI推算指寸) */

+ 34 - 89
code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserProfileServiceImpl.java

@@ -15,6 +15,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.CollectionUtils;
 import org.springframework.util.StringUtils;
 
@@ -27,80 +28,50 @@ import java.util.List;
 @Service
 public class UserProfileServiceImpl extends ServiceImpl<UserProfileMapper, UserProfile> implements UserProfileService {
 
-    /** 用户穴位坐标服务(自动触发生成) */
     @Autowired
     private UserAcupointService userAcupointService;
 
-    /**
-     * 分页查询子用户列表(联表获取绑定设备编号)
-     *
-     * @param queryDTO 查询条件
-     * @return 分页结果
-     */
     @Override
     public IPage<UserProfile> pageList(UserProfileQueryDTO queryDTO) {
         Page<UserProfile> page = new Page<>(queryDTO.getPageNum(), queryDTO.getPageSize());
         return baseMapper.pageWithDevices(page, queryDTO);
     }
 
-    /**
-     * 新增子用户
-     *
-     * @param dto 子用户信息 DTO
-     */
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public void addProfile(UserProfileDTO dto) {
-        // 手机号唯一性校验
         checkPhoneUnique(dto.getPhone(), null);
-        // 指宽范围校验(基于性别)
         validateFingerWidths(dto);
+
         UserProfile profile = buildProfileFromDto(dto);
         save(profile);
         LogUtil.info(UserProfileServiceImpl.class, "新增子用户[{}],手机号[{}]", dto.getName(), dto.getPhone());
-        // 新增子用户后,自动生成穴位坐标(忽略异常,不影响主流程)
-        try {
-            userAcupointService.generateForUser(profile.getId());
-        } catch (Exception e) {
-            LogUtil.error(UserProfileServiceImpl.class,
-                    "新增子用户[" + profile.getId() + "]触发穴位坐标生成失败", e);
-        }
+
+        regenerateUserAcupoints(profile.getId(), "新增");
     }
 
-    /**
-     * 修改子用户
-     *
-     * @param dto 子用户信息 DTO
-     */
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public void updateProfile(UserProfileDTO dto) {
         UserProfile exist = getById(dto.getId());
         if (exist == null) {
             throw new BusinessException(ResultCode.PROFILE_NOT_FOUND);
         }
-        // 如果修改了手机号,校验唯一性
+
         if (StringUtils.hasText(dto.getPhone()) && !dto.getPhone().equals(exist.getPhone())) {
             checkPhoneUnique(dto.getPhone(), dto.getId());
         }
-        // 指宽范围校验(基于性别)
+
         validateFingerWidths(dto);
+
         UserProfile profile = buildProfileFromDto(dto);
         profile.setId(dto.getId());
         updateById(profile);
         LogUtil.info(UserProfileServiceImpl.class, "修改子用户[{}]", dto.getId());
-        // 修改子用户体型数据后,自动重新生成穴位坐标(忽略异常,不影响主流程)
-        try {
-            userAcupointService.generateForUser(dto.getId());
-        } catch (Exception e) {
-            LogUtil.error(UserProfileServiceImpl.class,
-                    "修改子用户[" + dto.getId() + "]触发穴位坐标重新生成失败", e);
-        }
+
+        regenerateUserAcupoints(dto.getId(), "修改");
     }
 
-    /**
-     * 删除子用户(逻辑删除)
-     *
-     * @param id 子用户ID
-     */
     @Override
     public void deleteProfile(Long id) {
         UserProfile exist = getById(id);
@@ -111,11 +82,6 @@ public class UserProfileServiceImpl extends ServiceImpl<UserProfileMapper, UserP
         LogUtil.info(UserProfileServiceImpl.class, "删除子用户[{}]", id);
     }
 
-    /**
-     * 批量删除子用户(逻辑删除)
-     *
-     * @param ids 子用户ID列表
-     */
     @Override
     public void batchDelete(List<Long> ids) {
         if (CollectionUtils.isEmpty(ids)) {
@@ -125,52 +91,43 @@ public class UserProfileServiceImpl extends ServiceImpl<UserProfileMapper, UserP
         LogUtil.info(UserProfileServiceImpl.class, "批量删除子用户,共[{}]条", ids.size());
     }
 
-    /**
-     * 批量导入子用户
-     *
-     * @param list 子用户信息列表
-     */
     @Override
     public void batchImport(List<UserProfileDTO> list) {
         if (CollectionUtils.isEmpty(list)) {
             throw new BusinessException(ResultCode.PARAM_ERROR);
         }
+
         for (UserProfileDTO dto : list) {
-            // 手机号已存在则跳过(幂等处理)
             long count = lambdaQuery().eq(UserProfile::getPhone, dto.getPhone()).count();
             if (count > 0) {
                 continue;
             }
-            // 指宽范围校验(基于性别),不合法则跳过该条记录
+
             try {
                 validateFingerWidths(dto);
             } catch (BusinessException e) {
                 LogUtil.info(UserProfileServiceImpl.class, "批量导入跳过[{}]:指宽数值超出范围", dto.getPhone());
                 continue;
             }
+
             UserProfile profile = buildProfileFromDto(dto);
             save(profile);
         }
+
         LogUtil.info(UserProfileServiceImpl.class, "批量导入子用户,共[{}]条", list.size());
     }
 
     /**
-     * 校验三个指宽字段是否在性别对应的合理范围内
-     * 规格(选填,值为 null 时跳过):
-     *   1寸:男 1.5~2.8 cm / 女 1.3~2.4 cm
-     *   1.5寸:男 2.2~4.0 cm / 女 2.0~3.5 cm
-     *   3寸:男 4.5~8.0 cm / 女 4.0~7.0 cm
-     *
-     * @param dto 请求 DTO
+     * 校验三个指宽字段是否在性别对应的合理范围内。
      */
     private void validateFingerWidths(UserProfileDTO dto) {
         Integer gender = dto.getGender();
         boolean male = Integer.valueOf(1).equals(gender);
         boolean female = Integer.valueOf(2).equals(gender);
         if (!male && !female) {
-            // 性别未知,无法校验,跳过
             return;
         }
+
         checkFingerWidth("1寸", dto.getFingerWidth1(),
                 male ? new BigDecimal("1.5") : new BigDecimal("1.3"),
                 male ? new BigDecimal("2.8") : new BigDecimal("2.4"));
@@ -182,14 +139,6 @@ public class UserProfileServiceImpl extends ServiceImpl<UserProfileMapper, UserP
                 male ? new BigDecimal("8.0") : new BigDecimal("7.0"));
     }
 
-    /**
-     * 校验单个指宽字段范围(选填,为 null 时直接通过)
-     *
-     * @param label 字段名称(用于错误信息)
-     * @param value 字段值
-     * @param min   允许最小值(含)
-     * @param max   允许最大值(含)
-     */
     private void checkFingerWidth(String label, BigDecimal value, BigDecimal min, BigDecimal max) {
         if (value == null) {
             return;
@@ -200,30 +149,33 @@ public class UserProfileServiceImpl extends ServiceImpl<UserProfileMapper, UserP
         }
     }
 
-    /**
-     * 校验手机号唯一性
-     *
-     * @param phone     手机号
-     * @param excludeId 排除的子用户ID(修改时传入,避免自身冲突)
-     */
     private void checkPhoneUnique(String phone, Long excludeId) {
         LambdaQueryWrapper<UserProfile> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(UserProfile::getPhone, phone);
         if (excludeId != null) {
             wrapper.ne(UserProfile::getId, excludeId);
         }
+
         long count = count(wrapper);
         if (count > 0) {
             throw new BusinessException(ResultCode.PHONE_PROFILE_EXISTS);
         }
     }
 
-    /**
-     * 将 DTO 转换为实体对象
-     *
-     * @param dto 请求 DTO
-     * @return UserProfile 实体
-     */
+    private void regenerateUserAcupoints(Long profileId, String action) {
+        try {
+            userAcupointService.generateForUser(profileId);
+        } catch (BusinessException e) {
+            LogUtil.error(UserProfileServiceImpl.class,
+                    action + "子用户[" + profileId + "]后生成穴位坐标失败:" + e.getMessage(), e);
+            throw e;
+        } catch (Exception e) {
+            LogUtil.error(UserProfileServiceImpl.class,
+                    action + "子用户[" + profileId + "]后生成穴位坐标失败", e);
+            throw new BusinessException(ResultCode.USER_ACUPOINT_GENERATE_FAILED);
+        }
+    }
+
     private UserProfile buildProfileFromDto(UserProfileDTO dto) {
         UserProfile profile = new UserProfile();
         profile.setUserId(dto.getUserId());
@@ -243,23 +195,16 @@ public class UserProfileServiceImpl extends ServiceImpl<UserProfileMapper, UserP
         return profile;
     }
 
-    /**
-     * 查询用户档案列表(用于下拉选择,支持姓名/手机号模糊搜索,最多返回200条)
-     *
-     * @param keyword 搜索关键字(姓名或手机号,为空则返回全部)
-     * @return 用户档案列表
-     */
     @Override
     public List<UserProfile> listOptions(String keyword) {
         LambdaQueryWrapper<UserProfile> wrapper = new LambdaQueryWrapper<>();
         wrapper.select(UserProfile::getId, UserProfile::getName, UserProfile::getPhone, UserProfile::getGender);
         if (StringUtils.hasText(keyword)) {
             wrapper.and(w -> w.like(UserProfile::getName, keyword)
-                              .or()
-                              .like(UserProfile::getPhone, keyword));
+                    .or()
+                    .like(UserProfile::getPhone, keyword));
         }
         wrapper.orderByAsc(UserProfile::getName).last("LIMIT 200");
         return list(wrapper);
     }
 }
-

BIN
code/frontend/src/assets/acupoint-back-realistic.jpg


+ 108 - 62
code/frontend/src/views/simulation/index.vue

@@ -50,16 +50,21 @@
               </el-col>
             </el-row>
             <el-row :gutter="12">
-              <el-col :span="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="12">
+              <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">
@@ -199,68 +204,39 @@
                   <stop offset="55%" stop-color="#f1f5f9" />
                   <stop offset="100%" stop-color="#e8eef5" />
                 </radialGradient>
-                <linearGradient id="simBodyGrad" x1="0%" y1="0%" x2="100%" y2="100%">
-                  <stop offset="0%" stop-color="#fdf8f5" />
-                  <stop offset="35%" stop-color="#f5ebe3" />
-                  <stop offset="100%" stop-color="#e8ddd4" />
-                </linearGradient>
-                <linearGradient id="simStroke" x1="0%" y1="0%" x2="0%" y2="100%">
-                  <stop offset="0%" stop-color="#c9b8a8" />
-                  <stop offset="100%" stop-color="#a8988a" />
-                </linearGradient>
-                <filter id="simShadow" x="-25%" y="-15%" width="150%" height="130%">
-                  <feDropShadow dx="0" dy="6" stdDeviation="8" flood-color="#5c4a3d" flood-opacity="0.10" />
-                </filter>
               </defs>
 
               <rect width="100%" height="100%" fill="url(#simWash)" rx="14" />
-              <rect width="100%" height="100%" fill="url(#sim-grid)" rx="14" opacity="0.85" />
+              <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" />
 
-              <!-- 人体轮廓 -->
-              <g filter="url(#simShadow)" stroke="url(#simStroke)" stroke-width="1.35" stroke-linejoin="round" stroke-linecap="round">
-                <path d="
-                  M 200 28
-                  C 186 28, 174 38, 172 56
-                  C 170 70, 174 86, 178 96
-                  C 180 102, 162 108, 138 114
-                  C 114 124, 98 136, 88 150
-                  C 80 168, 76 192, 74 220
-                  C 72 252, 68 288, 64 318
-                  C 60 340, 54 352, 62 358
-                  C 68 364, 84 356, 96 332
-                  C 108 306, 112 272, 116 238
-                  C 120 208, 124 186, 128 192
-                  C 134 204, 136 238, 138 278
-                  C 142 330, 146 374, 148 408
-                  C 152 454, 144 510, 138 548
-                  C 134 568, 130 578, 144 582
-                  C 160 586, 170 582, 176 556
-                  C 182 514, 190 452, 200 424
-                  C 210 452, 218 514, 224 556
-                  C 230 582, 240 586, 256 582
-                  C 270 578, 266 568, 262 548
-                  C 256 510, 248 454, 252 408
-                  C 254 374, 258 330, 262 278
-                  C 264 238, 266 204, 272 192
-                  C 276 186, 280 208, 284 238
-                  C 288 272, 292 306, 304 332
-                  C 316 356, 332 364, 338 358
-                  C 346 352, 340 340, 336 318
-                  C 332 288, 328 252, 326 220
-                  C 324 192, 320 168, 312 150
-                  C 304 136, 286 124, 262 114
-                  C 238 108, 220 102, 222 96
-                  C 226 86, 230 70, 228 56
-                  C 226 38, 214 28, 200 28 Z" fill="url(#simBodyGrad)" />
-                <path d="M 148 126 C 136 134, 124 158, 132 192 C 136 208, 152 212, 158 196 C 164 174, 162 142, 148 126 Z" fill="none" stroke="rgba(92,74,61,0.18)" stroke-width="1.1" />
-                <path d="M 252 126 C 264 134, 276 158, 268 192 C 264 208, 248 212, 242 196 C 236 174, 238 142, 252 126 Z" fill="none" stroke="rgba(92,74,61,0.18)" stroke-width="1.1" />
-                <path d="M 148 372 C 170 402, 186 412, 200 406 C 214 412, 230 402, 252 372" fill="none" stroke="rgba(92,74,61,0.14)" stroke-width="1.1" />
-              </g>
-
-              <line :x1="SVG_C7_X" y1="92" :x2="SVG_C7_X" y2="390" stroke="rgba(99,102,241,0.35)" stroke-width="2" stroke-dasharray="8 6" stroke-linecap="round" />
+              <!-- 背部中线参考 -->
+              <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})`">
@@ -328,6 +304,15 @@
               </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 -->
@@ -351,6 +336,7 @@ 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)
@@ -372,6 +358,7 @@ const bodyForm = reactive({
   age: '',
   shoulderWidth: '',
   bodyHeight: '',
+  spineLength: '',
   fingerWidth1: '',
   fingerWidth15: '',
   fingerWidth3: '',
@@ -408,23 +395,46 @@ const pushDataJson = computed(() => {
   catch { return String(pushData.value) }
 })
 
-// SVG coordinate mapping: C7 大椎穴 at (SVG_C7_X, SVG_C7_Y), scale 0.38 px/mm
+// SVG coordinate mapping: align mm offsets to the realistic back photo.
 const SVG_C7_X = 200
 // 参考经络示意图:大椎位于颈后下缘、后正中线处,视觉上略低于头颈连接点
 const SVG_C7_Y = 112
-const SCALE = 0.38
+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) * SCALE,
-    y: SVG_C7_Y + Number(offsetYmm) * SCALE,
+    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((s) => s.offsetX != null && s.offsetY != null)
+    .filter(isBackVisualStep)
     .map((s, idx) => {
       const { x, y } = bodyToSvg(s.offsetX, s.offsetY)
       const techniqueColors = {
@@ -452,6 +462,11 @@ const markers = computed(() => {
     })
 })
 
+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
@@ -538,6 +553,7 @@ function onUserSelect(userId) {
   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 ?? ''
@@ -708,6 +724,11 @@ watch([selectedMode, selectedEfficacy, filteredPlans], ([mode, eff, list]) => {
   width: 100%;
   max-width: 420px;
   border-radius: 14px;
+  overflow: hidden;
+  background: #f8fafc;
+}
+.body-photo {
+  pointer-events: none;
 }
 
 .marker-legend {
@@ -799,6 +820,31 @@ watch([selectedMode, selectedEfficacy, filteredPlans], ([mode, eff, list]) => {
   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;

+ 23 - 2
code/frontend/src/views/user/profile/index.vue

@@ -180,7 +180,7 @@
           </el-col>
           <el-col :span="12">
             <el-form-item label="脊柱长(cm)" prop="spineLength">
-              <el-input-number v-model="formData.spineLength" :min="30" :max="60" :precision="1" style="width:100%" />
+              <el-input-number v-model="formData.spineLength" :step="0.1" :precision="1" style="width:100%" />
             </el-form-item>
           </el-col>
           <el-col :span="12">
@@ -372,6 +372,9 @@ const formData = reactive({
   fingerWidth3: null
 })
 
+const SPINE_LENGTH_MIN = 30
+const SPINE_LENGTH_MAX = 60
+
 // 各指宽字段在不同性别下的有效范围
 const fingerLimits = computed(() => {
   const male = formData.gender === 1
@@ -403,6 +406,20 @@ function makeFingerValidator(field) {
   }
 }
 
+function validateSpineLength(rule, value, callback) {
+  if (value === null || value === undefined || value === '') {
+    return callback(new Error('请输入C7-S4脊柱长度'))
+  }
+  const numericValue = Number(value)
+  if (!Number.isFinite(numericValue)) {
+    return callback(new Error('脊柱长度格式不正确'))
+  }
+  if (numericValue < SPINE_LENGTH_MIN || numericValue > SPINE_LENGTH_MAX) {
+    return callback(new Error(`C7-S4脊柱长度需在${SPINE_LENGTH_MIN}-${SPINE_LENGTH_MAX}cm之间`))
+  }
+  callback()
+}
+
 const formRules = {
   name: [
     { required: true, message: '请输入姓名', trigger: 'blur' },
@@ -419,7 +436,11 @@ const formRules = {
   spineLength: [{ required: true, message: '请输入C7-S4脊柱长度', trigger: 'blur' }],
   fingerWidth1: [{ validator: makeFingerValidator('fw1'), trigger: 'change' }],
   fingerWidth15: [{ validator: makeFingerValidator('fw15'), trigger: 'change' }],
-  fingerWidth3: [{ validator: makeFingerValidator('fw3'), trigger: 'change' }]
+  fingerWidth3: [{ validator: makeFingerValidator('fw3'), trigger: 'change' }],
+  spineLength: [
+    { required: true, message: '请输入C7-S4脊柱长度', trigger: 'blur' },
+    { validator: validateSpineLength, trigger: 'change' }
+  ]
 }
 
 function handleAdd() {