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

Merge remote-tracking branch 'origin/jiapu_aijiuyi' into codex/merge-master-temp

jiapu 4 месяцев назад
Родитель
Сommit
d52ac159e4
21 измененных файлов с 599 добавлено и 231 удалено
  1. 8 6
      code/backend/src/main/java/com/aijiuyi/admin/common/config/AcupointSchemaInitializer.java
  2. 15 9
      code/backend/src/main/java/com/aijiuyi/admin/common/util/AcupointLocationParser.java
  3. 41 2
      code/backend/src/main/java/com/aijiuyi/admin/common/util/AcupointYReference.java
  4. 3 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AcupointQueryDTO.java
  5. 4 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/UserProfileDTO.java
  6. 2 2
      code/backend/src/main/java/com/aijiuyi/admin/entity/Acupoint.java
  7. 6 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/UserPlan.java
  8. 23 1
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/AcupointServiceImpl.java
  9. 49 22
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/SimulationServiceImpl.java
  10. 14 3
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserAcupointServiceImpl.java
  11. 45 0
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserPlanServiceImpl.java
  12. 34 89
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserProfileServiceImpl.java
  13. 2 2
      code/backend/src/main/resources/sql/acupoint_full_data_import.sql
  14. 2 2
      code/backend/src/main/resources/sql/init.sql
  15. 4 4
      code/backend/src/main/resources/sql/migration_acupoint_auto_parse.sql
  16. 123 0
      code/backend/src/test/java/com/aijiuyi/admin/common/util/AcupointLocationParserTest.java
  17. 1 1
      code/frontend/src/api/acupoint.js
  18. BIN
      code/frontend/src/assets/acupoint-back-realistic.jpg
  19. 18 0
      code/frontend/src/views/acupoint/index.vue
  20. 182 86
      code/frontend/src/views/simulation/index.vue
  21. 23 2
      code/frontend/src/views/user/profile/index.vue

+ 8 - 6
code/backend/src/main/java/com/aijiuyi/admin/common/config/AcupointSchemaInitializer.java

@@ -38,16 +38,18 @@ public class AcupointSchemaInitializer implements CommandLineRunner {
             }
 
             addColumnIfMissing(connection, "parse_status",
-                    "varchar(32) DEFAULT NULL COMMENT 'Acupoint parser status'");
+                    "varchar(32) DEFAULT NULL COMMENT '定位描述解析状态:已解析/待复核/暂不支持'");
             addColumnIfMissing(connection, "parse_confidence",
-                    "varchar(16) DEFAULT NULL COMMENT 'Acupoint parser confidence'");
+                    "varchar(16) DEFAULT NULL COMMENT '定位描述解析置信度:高/中/低'");
             addColumnIfMissing(connection, "parse_message",
-                    "varchar(500) DEFAULT NULL COMMENT 'Acupoint parser message'");
+                    "varchar(500) DEFAULT NULL COMMENT '定位描述解析说明'");
 
             executeUpdate(connection, "UPDATE acupoint SET location_type = 1 WHERE deleted = 0 AND (location_type IS NULL OR location_type <> 1)");
-            executeUpdate(connection, "UPDATE acupoint SET parse_status = 'parsed' WHERE deleted = 0 AND parse_status IS NULL AND y_reference_term IS NOT NULL AND normalized_y IS NOT NULL AND (side = 1 OR lateral_offset IS NOT NULL OR acupoint_code IN ('pangguang-31','pangguang-32','pangguang-33','pangguang-34') OR name IN ('上髎','次髎','中髎','下髎'))");
-            executeUpdate(connection, "UPDATE acupoint SET parse_status = 'review_required' WHERE deleted = 0 AND parse_status IS NULL");
-            executeUpdate(connection, "UPDATE acupoint SET parse_confidence = CASE WHEN parse_status = 'parsed' THEN 'medium' ELSE 'low' END WHERE deleted = 0 AND parse_confidence IS NULL");
+            executeUpdate(connection, "UPDATE acupoint SET parse_status = CASE parse_status WHEN 'parsed' THEN '已解析' WHEN 'review_required' THEN '待复核' WHEN 'unsupported' THEN '暂不支持' ELSE parse_status END WHERE deleted = 0 AND parse_status IN ('parsed','review_required','unsupported')");
+            executeUpdate(connection, "UPDATE acupoint SET parse_confidence = CASE parse_confidence WHEN 'high' THEN '高' WHEN 'medium' THEN '中' WHEN 'low' THEN '低' ELSE parse_confidence END WHERE deleted = 0 AND parse_confidence IN ('high','medium','low')");
+            executeUpdate(connection, "UPDATE acupoint SET parse_status = '已解析' WHERE deleted = 0 AND parse_status IS NULL AND y_reference_term IS NOT NULL AND normalized_y IS NOT NULL AND (side = 1 OR lateral_offset IS NOT NULL OR acupoint_code IN ('pangguang-31','pangguang-32','pangguang-33','pangguang-34') OR name IN ('上髎','次髎','中髎','下髎'))");
+            executeUpdate(connection, "UPDATE acupoint SET parse_status = '待复核' WHERE deleted = 0 AND parse_status IS NULL");
+            executeUpdate(connection, "UPDATE acupoint SET parse_confidence = CASE WHEN parse_status = '已解析' THEN '中' ELSE '低' END WHERE deleted = 0 AND parse_confidence IS NULL");
 
             log.info("Acupoint schema initialization completed");
         } catch (SQLException e) {

+ 15 - 9
code/backend/src/main/java/com/aijiuyi/admin/common/util/AcupointLocationParser.java

@@ -13,18 +13,18 @@ import java.util.regex.Pattern;
  */
 public final class AcupointLocationParser {
 
-    public static final String STATUS_PARSED = "parsed";
-    public static final String STATUS_REVIEW_REQUIRED = "review_required";
-    public static final String STATUS_UNSUPPORTED = "unsupported";
+    public static final String STATUS_PARSED = "已解析";
+    public static final String STATUS_REVIEW_REQUIRED = "待复核";
+    public static final String STATUS_UNSUPPORTED = "暂不支持";
 
-    public static final String CONFIDENCE_HIGH = "high";
-    public static final String CONFIDENCE_MEDIUM = "medium";
-    public static final String CONFIDENCE_LOW = "low";
+    public static final String CONFIDENCE_HIGH = "";
+    public static final String CONFIDENCE_MEDIUM = "";
+    public static final String CONFIDENCE_LOW = "";
 
     private static final Pattern MIDLINE_OFFSET =
-            Pattern.compile("(?:前|后|头)?正中线旁开\\s*([0-9]+(?:\\.[0-9]+)?)\\s*寸");
+            Pattern.compile("(?:前|后|头)?正中线旁开\\s*约?\\s*([0-9]+(?:\\.[0-9]+)?)\\s*(?:|(?=凹陷))");
     private static final Pattern GENERAL_OFFSET =
-            Pattern.compile("旁开\\s*([0-9]+(?:\\.[0-9]+)?)\\s*寸");
+            Pattern.compile("旁开\\s*约?\\s*([0-9]+(?:\\.[0-9]+)?)\\s*(?:|(?=凹陷))");
     private static final Pattern UNSUPPORTED_TOUCH =
             Pattern.compile("凹陷|肌腱|骨间|缝中|连线|赤白肉际|脉搏动|鼻唇沟|瞳孔|发际|骨间隙");
     private static final Pattern ABDOMEN_REFERENCE =
@@ -33,6 +33,10 @@ public final class AcupointLocationParser {
     private AcupointLocationParser() {
     }
 
+    public static boolean isParsedStatus(String status) {
+        return STATUS_PARSED.equals(status) || "parsed".equals(status);
+    }
+
     public static ParseResult parse(Acupoint acupoint) {
         String text = mergeText(acupoint);
         ParseResult result = new ParseResult();
@@ -68,7 +72,9 @@ public final class AcupointLocationParser {
         List<String> messages = new ArrayList<String>();
         if (hasY) {
             result.setNormalizedY(yRule.getNormalizedY());
-            result.setLongitudeOffset(yRule.getCunOffset());
+            if (yRule.getNormalizedY() == null) {
+                result.setLongitudeOffset(yRule.getCunOffset());
+            }
             messages.add("已解析Y轴术语:" + yTerm);
         }
         if (hasLateral) {

+ 41 - 2
code/backend/src/main/java/com/aijiuyi/admin/common/util/AcupointYReference.java

@@ -32,6 +32,12 @@ public final class AcupointYReference {
             Pattern.compile("正对第([1-4])骶后孔中?");
     private static final Pattern SACRAL_LEVEL =
             Pattern.compile("横平第([1-4])骶后孔");
+    private static final Pattern SACRAL_HIATUS =
+            Pattern.compile("正对骶管裂孔|骶管裂孔");
+    private static final Pattern COCCYX_UP_CUN =
+            Pattern.compile("尾骨端直上\\s*([0-9]+(?:\\.[0-9]+)?)\\s*寸");
+    private static final Pattern COCCYX_TIP =
+            Pattern.compile("尾骨端");
 
     private static final Map<String, Rule> RULES;
 
@@ -65,6 +71,9 @@ public final class AcupointYReference {
         addSacral(rules, 2, "0.8340");
         addSacral(rules, 3, "0.9170");
         addSacral(rules, 4, "1.0000");
+        addNormalized(rules, "骶管裂孔", "1.0000");
+        addNormalized(rules, "尾骨端", "1.0000");
+        addCombined(rules, "尾骨端直上2寸", "1.0000", "-2.00");
 
         RULES = Collections.unmodifiableMap(rules);
     }
@@ -130,6 +139,19 @@ public final class AcupointYReference {
             return "横平第" + sacralLevel.group(1) + "骶后孔";
         }
 
+        if (SACRAL_HIATUS.matcher(text).find()) {
+            return "骶管裂孔";
+        }
+
+        Matcher coccyxUp = COCCYX_UP_CUN.matcher(text);
+        if (coccyxUp.find()) {
+            return "尾骨端直上" + coccyxUp.group(1) + "寸";
+        }
+
+        if (COCCYX_TIP.matcher(text).find()) {
+            return "尾骨端";
+        }
+
         Matcher thoracic = THORACIC.matcher(text);
         if (thoracic.find()) {
             return "第" + thoracic.group(1) + "胸椎棘突下";
@@ -160,14 +182,24 @@ public final class AcupointYReference {
         }
         if (rule.getNormalizedY() != null) {
             if (rule.getNormalizedY().compareTo(BigDecimal.ZERO) == 0) {
-                return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+                if (rule.getCunOffset() == null || finalCunMm == null) {
+                    return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+                }
+                return rule.getCunOffset().multiply(finalCunMm).setScale(2, RoundingMode.HALF_UP);
             }
             if (spineLengthCm == null || spineLengthCm.compareTo(BigDecimal.ZERO) <= 0) {
                 return null;
             }
-            return spineLengthCm.multiply(BigDecimal.TEN)
+            BigDecimal y = spineLengthCm.multiply(BigDecimal.TEN)
                     .multiply(rule.getNormalizedY())
                     .setScale(2, RoundingMode.HALF_UP);
+            if (rule.getCunOffset() != null) {
+                if (finalCunMm == null) {
+                    return null;
+                }
+                y = y.add(rule.getCunOffset().multiply(finalCunMm)).setScale(2, RoundingMode.HALF_UP);
+            }
+            return y;
         }
         if (rule.getCunOffset() != null && finalCunMm != null) {
             return rule.getCunOffset().multiply(finalCunMm).setScale(2, RoundingMode.HALF_UP);
@@ -185,6 +217,13 @@ public final class AcupointYReference {
         rules.put(term, new Rule(null, value, "Y = " + value.toPlainString() + " × 最终指寸"));
     }
 
+    private static void addCombined(Map<String, Rule> rules, String term, String normalizedY, String cunOffset) {
+        BigDecimal yValue = new BigDecimal(normalizedY);
+        BigDecimal cunValue = new BigDecimal(cunOffset);
+        rules.put(term, new Rule(yValue, cunValue, "Y = 脊柱长度(C7-S4) × "
+                + yValue.toPlainString() + " + " + cunValue.toPlainString() + " × 最终指寸"));
+    }
+
     private static void addSacral(Map<String, Rule> rules, int index, String normalizedY) {
         addNormalized(rules, "横平第" + index + "骶后孔", normalizedY);
         addNormalized(rules, "正对第" + index + "骶后孔中", normalizedY);

+ 3 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AcupointQueryDTO.java

@@ -20,6 +20,9 @@ public class AcupointQueryDTO {
     /** 状态:1=活跃,0=待完善 */
     private Integer status;
 
+    /** 解析状态:已解析、待复核、暂不支持 */
+    private String parseStatus;
+
     /** 当前页码,默认第1页 */
     private Integer pageNum = 1;
 

+ 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推算指寸) */

+ 2 - 2
code/backend/src/main/java/com/aijiuyi/admin/entity/Acupoint.java

@@ -82,10 +82,10 @@ public class Acupoint {
     @TableField(updateStrategy = FieldStrategy.IGNORED)
     private String yReferenceTerm;
 
-    /** 定位描述自动解析状态:parsed=已解析,review_required=待复核,unsupported=暂不支持 */
+    /** 定位描述自动解析状态:已解析、待复核、暂不支持 */
     private String parseStatus;
 
-    /** 定位描述自动解析置信度:high=高,medium=中,low=低 */
+    /** 定位描述自动解析置信度:高、中、低 */
     private String parseConfidence;
 
     /** 定位描述自动解析说明 */

+ 6 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/UserPlan.java

@@ -35,6 +35,12 @@ public class UserPlan {
      */
     private Integer modeType;
 
+    /**
+     * 专业模式功效类型(来自 plan.effect_type,用于前端按功效筛选展示)
+     */
+    @TableField(exist = false)
+    private String effectType;
+
     /** 创作人名称(冗余存储) */
     private String authorName;
 

+ 23 - 1
code/backend/src/main/java/com/aijiuyi/admin/service/impl/AcupointServiceImpl.java

@@ -48,6 +48,28 @@ public class AcupointServiceImpl extends ServiceImpl<AcupointMapper, Acupoint> i
                .eq(StringUtils.hasText(queryDTO.getMeridian()), Acupoint::getMeridian, queryDTO.getMeridian())
                .eq(queryDTO.getLocationType() != null, Acupoint::getLocationType, queryDTO.getLocationType())
                .eq(queryDTO.getStatus() != null, Acupoint::getStatus, queryDTO.getStatus())
+               .and(StringUtils.hasText(queryDTO.getParseStatus()), w -> {
+                   String parseStatus = queryDTO.getParseStatus().trim();
+                   // 兼容前端/历史值:英文状态码与中文库值混用
+                   if ("pending".equalsIgnoreCase(parseStatus) || "待解析".equals(parseStatus)) {
+                       w.and(x -> x.isNull(Acupoint::getParseStatus).or().eq(Acupoint::getParseStatus, ""));
+                       return;
+                   }
+                   if ("parsed".equalsIgnoreCase(parseStatus) || "已解析".equals(parseStatus)) {
+                       w.in(Acupoint::getParseStatus, "已解析", "parsed");
+                       return;
+                   }
+                   if ("review_required".equalsIgnoreCase(parseStatus) || "待复核".equals(parseStatus)) {
+                       w.in(Acupoint::getParseStatus, "待复核", "review_required");
+                       return;
+                   }
+                   if ("unsupported".equalsIgnoreCase(parseStatus) || "暂不支持".equals(parseStatus)) {
+                       w.in(Acupoint::getParseStatus, "暂不支持", "unsupported");
+                       return;
+                   }
+                   // 兜底:按原值精确匹配
+                   w.eq(Acupoint::getParseStatus, parseStatus);
+               })
                .orderByDesc(Acupoint::getCreateTime);
         return page(page, wrapper);
     }
@@ -206,7 +228,7 @@ public class AcupointServiceImpl extends ServiceImpl<AcupointMapper, Acupoint> i
         if (acupoint.getGender() == null) {
             acupoint.setGender(0);
         }
-        acupoint.setStatus(AcupointLocationParser.STATUS_PARSED.equals(parsed.getParseStatus()) ? 1 : 0);
+        acupoint.setStatus(AcupointLocationParser.isParsedStatus(parsed.getParseStatus()) ? 1 : 0);
         if (acupoint.getDeleted() == null) {
             acupoint.setDeleted(0);
         }

+ 49 - 22
code/backend/src/main/java/com/aijiuyi/admin/service/impl/SimulationServiceImpl.java

@@ -94,29 +94,14 @@ public class SimulationServiceImpl implements SimulationService {
 
         List<SimulationResponse.StepData> stepDataList = new ArrayList<>();
         for (PlanStep step : planSteps) {
-            SimulationResponse.StepData sd = new SimulationResponse.StepData();
-            sd.setStepOrder(step.getStepOrder());
-            sd.setAcupointId(step.getAcupointId());
-            sd.setAcupointName(step.getAcupointName());
-            sd.setTemperature(step.getTemperature());
-            sd.setDuration(step.getDuration());
-            sd.setTechniqueId(step.getTechniqueId());
-            sd.setTechniqueName(step.getTechniqueName());
-
-            // 将步骤的 side(int) 映射为穴位表中的 acupointSide(string) 进行匹配
-            UserAcupoint matched = matchAcupoint(acupointMap, step);
-            if (matched != null) {
-                sd.setOffsetX(matched.getCoordinateX());
-                sd.setOffsetY(matched.getCoordinateY());
-                sd.setAcupointSide(matched.getAcupointSide());
-                sd.setConfidence(matched.getConfidence());
-            } else {
-                sd.setOffsetX(null);
-                sd.setOffsetY(null);
-                sd.setAcupointSide(sideIntToString(step.getSide()));
-                sd.setConfidence("none");
+            // 双侧穴位:输出两条(左/右),与“用户方案管理”的坐标展示保持一致
+            if (step.getSide() != null && step.getSide() == 4) {
+                stepDataList.add(buildStepData(acupointMap, step, "left"));
+                stepDataList.add(buildStepData(acupointMap, step, "right"));
+                continue;
             }
-            stepDataList.add(sd);
+
+            stepDataList.add(buildStepData(acupointMap, step, sideIntToString(step.getSide())));
         }
         schemeData.setSteps(stepDataList);
         resp.setScheme(schemeData);
@@ -124,6 +109,48 @@ public class SimulationServiceImpl implements SimulationService {
         return resp;
     }
 
+    private SimulationResponse.StepData buildStepData(Map<String, UserAcupoint> acupointMap, PlanStep step, String targetSide) {
+        SimulationResponse.StepData sd = new SimulationResponse.StepData();
+        sd.setStepOrder(step.getStepOrder());
+        sd.setAcupointId(step.getAcupointId());
+        sd.setAcupointName(step.getAcupointName());
+        sd.setTemperature(step.getTemperature());
+        sd.setDuration(step.getDuration());
+        sd.setTechniqueId(step.getTechniqueId());
+        sd.setTechniqueName(step.getTechniqueName());
+        sd.setAcupointSide(targetSide);
+
+        if (step.getAcupointId() == null) {
+            sd.setOffsetX(null);
+            sd.setOffsetY(null);
+            sd.setConfidence("none");
+            return sd;
+        }
+
+        UserAcupoint matched = acupointMap.get(step.getAcupointId() + ":" + targetSide);
+        if (matched != null) {
+            sd.setOffsetX(matched.getCoordinateX());
+            sd.setOffsetY(matched.getCoordinateY());
+            sd.setAcupointSide(matched.getAcupointSide());
+            sd.setConfidence(matched.getConfidence());
+            return sd;
+        }
+
+        // 兜底:没有该侧坐标时,用中心坐标作为回退(仍保留侧别用于前端识别数据缺失)
+        matched = acupointMap.get(step.getAcupointId() + ":center");
+        if (matched != null) {
+            sd.setOffsetX(matched.getCoordinateX());
+            sd.setOffsetY(matched.getCoordinateY());
+            sd.setConfidence(matched.getConfidence());
+            return sd;
+        }
+
+        sd.setOffsetX(null);
+        sd.setOffsetY(null);
+        sd.setConfidence("none");
+        return sd;
+    }
+
     /**
      * 匹配穴位坐标记录:优先按 acupointId + side 精确匹配,回退到同穴位任意侧
      */

+ 14 - 3
code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserAcupointServiceImpl.java

@@ -181,7 +181,7 @@ public class UserAcupointServiceImpl extends ServiceImpl<UserAcupointMapper, Use
         if (acupoint == null) {
             return false;
         }
-        if (AcupointLocationParser.STATUS_PARSED.equals(acupoint.getParseStatus())) {
+        if (AcupointLocationParser.isParsedStatus(acupoint.getParseStatus())) {
             return true;
         }
         if (acupoint.getParseStatus() != null && acupoint.getParseStatus().trim().length() > 0) {
@@ -438,15 +438,26 @@ public class UserAcupointServiceImpl extends ServiceImpl<UserAcupointMapper, Use
     }
 
     private String combineConfidence(String profileConfidence, String parseConfidence) {
-        if ("low".equals(profileConfidence) || "low".equals(parseConfidence)) {
+        int confidenceRank = Math.min(confidenceRank(profileConfidence), confidenceRank(parseConfidence));
+        if (confidenceRank <= 1) {
             return "low";
         }
-        if ("medium".equals(profileConfidence) || "medium".equals(parseConfidence)) {
+        if (confidenceRank == 2) {
             return "medium";
         }
         return "high";
     }
 
+    private int confidenceRank(String confidence) {
+        if ("low".equals(confidence) || "低".equals(confidence)) {
+            return 1;
+        }
+        if ("medium".equals(confidence) || "中".equals(confidence)) {
+            return 2;
+        }
+        return 3;
+    }
+
     private UserAcupoint buildRecord(Long profileId, Acupoint acupoint, String side,
                                       BigDecimal coordinateX, BigDecimal coordinateY,
                                       String confidence) {

+ 45 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserPlanServiceImpl.java

@@ -29,6 +29,7 @@ import java.util.Comparator;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.HashMap;
 import java.util.stream.Collectors;
 
 @Service
@@ -58,6 +59,8 @@ public class UserPlanServiceImpl extends ServiceImpl<UserPlanMapper, UserPlan> i
                 .peek(this::normalizeCreator)
                 .collect(Collectors.toList());
 
+        fillEffectType(allPlans);
+
         allPlans.sort(Comparator.comparing(
                 UserPlan::getModeType,
                 Comparator.nullsLast(Comparator.naturalOrder())
@@ -97,6 +100,7 @@ public class UserPlanServiceImpl extends ServiceImpl<UserPlanMapper, UserPlan> i
             throw new BusinessException(ResultCode.USER_PLAN_NOT_FOUND);
         }
         normalizeCreator(userPlan);
+        fillEffectType(userPlan);
         List<PlanStep> steps = getPlanSteps(userPlan.getPlanId());
         userPlan.setStepCount(steps.size());
         userPlan.setStepsJson(JSON.toJSONString(enrichStepsWithUserCoordinates(userPlan.getUserId(), steps)));
@@ -180,6 +184,7 @@ public class UserPlanServiceImpl extends ServiceImpl<UserPlanMapper, UserPlan> i
         userPlan.setPlanCode(plan.getPlanCode());
         userPlan.setPlanName(plan.getName());
         userPlan.setModeType(plan.getModeType());
+        userPlan.setEffectType(plan.getEffectType());
         userPlan.setAuthorName(normalizeCreatorName(plan.getAuthorName()));
         userPlan.setStepCount(planSteps.size());
         userPlan.setUseCount(0);
@@ -187,6 +192,46 @@ public class UserPlanServiceImpl extends ServiceImpl<UserPlanMapper, UserPlan> i
         return userPlan;
     }
 
+    private void fillEffectType(UserPlan userPlan) {
+        if (userPlan == null || userPlan.getPlanId() == null) {
+            return;
+        }
+        Plan plan = planMapper.selectById(userPlan.getPlanId());
+        if (plan != null) {
+            userPlan.setEffectType(plan.getEffectType());
+        }
+    }
+
+    private void fillEffectType(List<UserPlan> userPlans) {
+        if (userPlans == null || userPlans.isEmpty()) {
+            return;
+        }
+        List<Long> planIds = userPlans.stream()
+                .map(UserPlan::getPlanId)
+                .filter(id -> id != null)
+                .distinct()
+                .collect(Collectors.toList());
+        if (planIds.isEmpty()) {
+            return;
+        }
+        List<Plan> plans = planMapper.selectBatchIds(planIds);
+        Map<Long, Plan> planMap = new HashMap<>();
+        for (Plan plan : plans) {
+            if (plan != null && plan.getId() != null) {
+                planMap.put(plan.getId(), plan);
+            }
+        }
+        for (UserPlan up : userPlans) {
+            if (up == null || up.getPlanId() == null) {
+                continue;
+            }
+            Plan p = planMap.get(up.getPlanId());
+            if (p != null) {
+                up.setEffectType(p.getEffectType());
+            }
+        }
+    }
+
     private List<PlanStep> getPlanSteps(Long planId) {
         if (planId == null) {
             return new ArrayList<>();

+ 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);
     }
 }
-

+ 2 - 2
code/backend/src/main/resources/sql/acupoint_full_data_import.sql

@@ -37,10 +37,10 @@ SET @ddl = IF((SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEM
     'ALTER TABLE `acupoint` ADD COLUMN `y_reference_term` VARCHAR(100) DEFAULT NULL COMMENT ''Y轴医学定位术语'' AFTER `normalized_y`', 'SELECT 1');
 PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
 SET @ddl = IF((SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'acupoint' AND COLUMN_NAME = 'parse_status') = 0,
-    'ALTER TABLE `acupoint` ADD COLUMN `parse_status` VARCHAR(20) DEFAULT NULL COMMENT ''定位描述解析状态:parsed/review_required/unsupported'' AFTER `y_reference_term`', 'SELECT 1');
+    'ALTER TABLE `acupoint` ADD COLUMN `parse_status` VARCHAR(20) DEFAULT NULL COMMENT ''定位描述解析状态:已解析/待复核/暂不支持'' AFTER `y_reference_term`', 'SELECT 1');
 PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
 SET @ddl = IF((SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'acupoint' AND COLUMN_NAME = 'parse_confidence') = 0,
-    'ALTER TABLE `acupoint` ADD COLUMN `parse_confidence` VARCHAR(10) DEFAULT NULL COMMENT ''定位描述解析置信度:high/medium/low'' AFTER `parse_status`', 'SELECT 1');
+    'ALTER TABLE `acupoint` ADD COLUMN `parse_confidence` VARCHAR(10) DEFAULT NULL COMMENT ''定位描述解析置信度:高/中/低'' AFTER `parse_status`', 'SELECT 1');
 PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
 SET @ddl = IF((SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'acupoint' AND COLUMN_NAME = 'parse_message') = 0,
     'ALTER TABLE `acupoint` ADD COLUMN `parse_message` VARCHAR(500) DEFAULT NULL COMMENT ''定位描述解析说明'' AFTER `parse_confidence`', 'SELECT 1');

+ 2 - 2
code/backend/src/main/resources/sql/init.sql

@@ -155,8 +155,8 @@ CREATE TABLE IF NOT EXISTS `acupoint` (
     `lateral_offset`    DECIMAL(6,2)           COMMENT '相对大椎穴横向偏移(寸,双侧穴位填写)',
     `normalized_y`      DECIMAL(6,4)           COMMENT 'Panjabi归一化Y位置系数(0~1,基于C7-S4脊柱长度)',
     `y_reference_term`  VARCHAR(100)           COMMENT 'Y轴医学定位术语',
-    `parse_status`      VARCHAR(20)            COMMENT '定位描述解析状态:parsed/review_required/unsupported',
-    `parse_confidence`  VARCHAR(10)            COMMENT '定位描述解析置信度:high/medium/low',
+    `parse_status`      VARCHAR(20)            COMMENT '定位描述解析状态:已解析/待复核/暂不支持',
+    `parse_confidence`  VARCHAR(10)            COMMENT '定位描述解析置信度:高/中/低',
     `parse_message`     VARCHAR(500)           COMMENT '定位描述解析说明',
     `side`              TINYINT                COMMENT '侧别:1=中心,2=左侧,3=右侧,4=双侧',
     `finger_width_type` TINYINT                COMMENT '指寸类型:1=一寸,2=1.5寸,3=三寸',

+ 4 - 4
code/backend/src/main/resources/sql/migration_acupoint_auto_parse.sql

@@ -2,11 +2,11 @@
 -- 说明:旧版固定坐标字段不再参与业务;为兼容已有库,本迁移不删除旧列。
 
 SET @ddl = IF((SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'acupoint' AND COLUMN_NAME = 'parse_status') = 0,
-    'ALTER TABLE `acupoint` ADD COLUMN `parse_status` VARCHAR(20) DEFAULT NULL COMMENT ''定位描述解析状态:parsed/review_required/unsupported'' AFTER `y_reference_term`', 'SELECT 1');
+    'ALTER TABLE `acupoint` ADD COLUMN `parse_status` VARCHAR(20) DEFAULT NULL COMMENT ''定位描述解析状态:已解析/待复核/暂不支持'' AFTER `y_reference_term`', 'SELECT 1');
 PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
 
 SET @ddl = IF((SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'acupoint' AND COLUMN_NAME = 'parse_confidence') = 0,
-    'ALTER TABLE `acupoint` ADD COLUMN `parse_confidence` VARCHAR(10) DEFAULT NULL COMMENT ''定位描述解析置信度:high/medium/low'' AFTER `parse_status`', 'SELECT 1');
+    'ALTER TABLE `acupoint` ADD COLUMN `parse_confidence` VARCHAR(10) DEFAULT NULL COMMENT ''定位描述解析置信度:高/中/低'' AFTER `parse_status`', 'SELECT 1');
 PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
 
 SET @ddl = IF((SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'acupoint' AND COLUMN_NAME = 'parse_message') = 0,
@@ -19,9 +19,9 @@ SET `location_type` = 1,
     `status` = CASE WHEN (`y_reference_term` IS NOT NULL OR `normalized_y` IS NOT NULL OR `longitude_offset` IS NOT NULL)
         AND (`side` IN (1) OR `lateral_offset` IS NOT NULL OR `acupoint_code` IN ('pangguang-31','pangguang-32','pangguang-33','pangguang-34') OR `name` IN ('上髎','次髎','中髎','下髎')) THEN `status` ELSE 0 END,
     `parse_status` = CASE WHEN (`y_reference_term` IS NOT NULL OR `normalized_y` IS NOT NULL OR `longitude_offset` IS NOT NULL)
-        AND (`side` IN (1) OR `lateral_offset` IS NOT NULL OR `acupoint_code` IN ('pangguang-31','pangguang-32','pangguang-33','pangguang-34') OR `name` IN ('上髎','次髎','中髎','下髎')) THEN 'parsed' ELSE 'review_required' END,
+        AND (`side` IN (1) OR `lateral_offset` IS NOT NULL OR `acupoint_code` IN ('pangguang-31','pangguang-32','pangguang-33','pangguang-34') OR `name` IN ('上髎','次髎','中髎','下髎')) THEN '已解析' ELSE '待复核' END,
     `parse_confidence` = CASE WHEN (`y_reference_term` IS NOT NULL OR `normalized_y` IS NOT NULL OR `longitude_offset` IS NOT NULL)
-        AND (`side` IN (1) OR `lateral_offset` IS NOT NULL OR `acupoint_code` IN ('pangguang-31','pangguang-32','pangguang-33','pangguang-34') OR `name` IN ('上髎','次髎','中髎','下髎')) THEN 'medium' ELSE 'low' END,
+        AND (`side` IN (1) OR `lateral_offset` IS NOT NULL OR `acupoint_code` IN ('pangguang-31','pangguang-32','pangguang-33','pangguang-34') OR `name` IN ('上髎','次髎','中髎','下髎')) THEN '中' ELSE '低' END,
     `parse_message` = CASE WHEN (`y_reference_term` IS NOT NULL OR `normalized_y` IS NOT NULL OR `longitude_offset` IS NOT NULL)
         AND (`side` IN (1) OR `lateral_offset` IS NOT NULL OR `acupoint_code` IN ('pangguang-31','pangguang-32','pangguang-33','pangguang-34') OR `name` IN ('上髎','次髎','中髎','下髎'))
         THEN '历史数据已有Y轴参数,按相对坐标继续使用'

+ 123 - 0
code/backend/src/test/java/com/aijiuyi/admin/common/util/AcupointLocationParserTest.java

@@ -0,0 +1,123 @@
+package com.aijiuyi.admin.common.util;
+
+import com.aijiuyi.admin.entity.Acupoint;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class AcupointLocationParserTest {
+
+    @Test
+    void parsesPreviouslyUnsupportedBackReferences() {
+        assertParsed(acupoint("会阳", "双穴", "足太阳膀胱经", "骶区",
+                "在骶区,尾骨端旁开0.5寸。"));
+        assertParsed(acupoint("腰俞", "单穴", "督脉", "骶区",
+                "在骶区,正对骶管裂孔,后正中线上。"));
+        assertParsed(acupoint("腰眼", "双穴", "经外奇穴", "腰部",
+                "在腰区,横平第4腰椎棘突下,后正中线旁开约3.5凹陷中。"));
+        assertParsed(acupoint("腰奇", "单穴", "经外奇穴", "骶区",
+                "在骶区,尾骨端直上2寸,骶角之间凹陷中。"));
+    }
+
+    @Test
+    void calculatesCombinedCoccyxReference() {
+        BigDecimal y = AcupointYReference.calculateY(
+                "尾骨端直上2寸", new BigDecimal("60"), new BigDecimal("25"));
+        assertEquals(new BigDecimal("550.00"), y);
+    }
+
+    @Test
+    void parsesAllBackAcupointsFromDataModel() {
+        String[][] rows = new String[][]{
+                {"肩外俞", "双穴", "手太阳小肠经", "背部", "在脊柱区,第1胸椎棘突下,后正中线旁开3寸。"},
+                {"肩中俞", "双穴", "手太阳小肠经", "背部", "在脊柱区,第7颈椎棘突下,后正中线旁开2寸。"},
+                {"大杼", "双穴", "足太阳膀胱经", "颈后部", "在脊柱区,第1胸椎棘突下,后正中线旁开1.5寸。"},
+                {"风门", "双穴", "足太阳膀胱经", "背部", "在脊柱区,第2胸椎棘突下,后正中线旁开1.5寸。"},
+                {"肺俞", "双穴", "足太阳膀胱经", "背部", "在脊柱区,第3胸椎棘突下,后正中线旁开1.5寸。"},
+                {"厥阴俞", "双穴", "足太阳膀胱经", "背部", "在脊柱区,第4胸椎棘突下,后正中线旁开1.5寸。"},
+                {"心俞", "双穴", "足太阳膀胱经", "背部", "在脊柱区,第5胸椎棘突下,后正中线旁开1.5寸。"},
+                {"督俞", "双穴", "足太阳膀胱经", "背部", "在脊柱区,第6胸椎棘突下,后正中线旁开1.5寸。"},
+                {"膈俞", "双穴", "足太阳膀胱经", "背部", "在脊柱区,第7胸椎棘突下,后正中线旁开1.5寸。"},
+                {"肝俞", "双穴", "足太阳膀胱经", "背部", "在脊柱区,第9胸椎棘突下,后正中线旁开1.5寸。"},
+                {"胆俞", "双穴", "足太阳膀胱经", "背部", "在脊柱区,第10胸椎棘突下,后正中线旁开1.5寸。"},
+                {"脾俞", "双穴", "足太阳膀胱经", "背部", "在脊柱区,第11胸椎棘突下,后正中线旁开1.5寸"},
+                {"胃俞", "双穴", "足太阳膀胱经", "背部", "在脊柱区,第12胸椎棘突下,后正中线旁开1.5寸。"},
+                {"三焦俞", "双穴", "足太阳膀胱经", "腰部", "在脊柱区,第1腰椎棘突下,后正中线旁开1.5寸。"},
+                {"肾俞", "双穴", "足太阳膀胱经", "腰部", "在脊柱区,第2腰椎棘突下,后正中线旁开1.5寸。"},
+                {"气海俞", "双穴", "足太阳膀胱经", "腰部", "在脊柱区,第3腰椎棘突下,后正中线旁开1.5寸。"},
+                {"大肠俞", "双穴", "足太阳膀胱经", "腰部", "在脊柱区,第4腰椎棘突下,后正中线旁开1.5寸。"},
+                {"关元俞", "双穴", "足太阳膀胱经", "腰部", "在脊柱区,第5腰椎棘突下,后正中线旁开1.5寸。"},
+                {"小肠俞", "双穴", "足太阳膀胱经", "骶区", "在骶区,横平第1骶后孔,骶正中嵴旁开1.5寸。"},
+                {"膀胱俞", "双穴", "足太阳膀胱经", "骶区", "在骶区,横平第2骶后孔,骶正中嵴旁开1.5寸。"},
+                {"中膂俞", "双穴", "足太阳膀胱经", "骶区", "在骶区,横平第3骶后孔,骶正中嵴旁开1.5寸。"},
+                {"白环俞", "双穴", "足太阳膀胱经", "骶区", "在骶区,横平第4骶后孔,骶正中嵴旁开1.5寸。"},
+                {"上髎", "双穴", "足太阳膀胱经", "骶区", "在骶区,正对第1骶后孔中。"},
+                {"次髎", "双穴", "足太阳膀胱经", "骶区", "在骶区,正对第2骶后孔中。"},
+                {"中髎", "双穴", "足太阳膀胱经", "骶区", "在骶区,正对第3骶后孔中。"},
+                {"下髎", "双穴", "足太阳膀胱经", "骶区", "在骶区,正对第4骶后孔中。"},
+                {"会阳", "双穴", "足太阳膀胱经", "骶区", "在骶区,尾骨端旁开0.5寸。"},
+                {"附分", "双穴", "足太阳膀胱经", "背部", "在脊柱区,第2胸椎棘突下,后正中线旁开3寸。"},
+                {"魄户", "双穴", "足太阳膀胱经", "背部", "在脊柱区,第3胸椎棘突下,后正中线旁开3寸。"},
+                {"膏肓", "双穴", "足太阳膀胱经", "背部", "在脊柱区,第4胸椎棘突下,后正中线旁开3寸。"},
+                {"神堂", "双穴", "足太阳膀胱经", "背部", "在脊柱区,第5胸椎棘突下,后正中线旁开3寸。"},
+                {"譩譆", "双穴", "足太阳膀胱经", "背部", "在脊柱区,第6胸椎棘突下,后正中线旁开3寸。"},
+                {"膈关", "双穴", "足太阳膀胱经", "背部", "在脊柱区,第7胸椎棘突下,后正中线旁开3寸。"},
+                {"魂门", "双穴", "足太阳膀胱经", "背部", "在脊柱区,第9胸椎棘突下,后正中线旁开3寸。"},
+                {"阳纲", "双穴", "足太阳膀胱经", "背部", "在脊柱区,第10胸椎棘突下,后正中线旁开3寸。"},
+                {"意舍", "双穴", "足太阳膀胱经", "背部", "在脊柱区,第11胸椎棘突下,后正中线旁开3寸。"},
+                {"胃仓", "双穴", "足太阳膀胱经", "背部", "在脊柱区,第12胸椎棘突下,后正中线旁开3寸。"},
+                {"盲门", "双穴", "足太阳膀胱经", "腰部", "在腰区,第1腰椎棘突下,后正中线旁开3寸。"},
+                {"志室", "双穴", "足太阳膀胱经", "腰部", "在腰区,第2腰椎棘突下,后正中线旁开3寸。"},
+                {"胞肓", "双穴", "足太阳膀胱经", "骶区", "在骶区,横平第2骶后孔,骶正中嵴旁开3寸。"},
+                {"秩边", "双穴", "足太阳膀胱经", "骶区", "在骶区,横平第4骶后孔,骶正中嵴旁开3寸。"},
+                {"腰俞", "单穴", "督脉", "骶区", "在骶区,正对骶管裂孔,后正中线上。"},
+                {"腰阳关", "单穴", "督脉", "腰部", "在脊柱区,第4腰椎棘突下凹陷中,后正中线上。"},
+                {"命门", "单穴", "督脉", "腰部", "在脊柱区,第2腰椎棘突下凹陷中,后正中线上。"},
+                {"悬枢", "单穴", "督脉", "腰部", "在脊柱区,第1腰椎棘突下凹陷中,后正中线上。"},
+                {"脊中", "单穴", "督脉", "背部", "在脊柱区,第11胸椎棘突下凹陷中,后正中线上。"},
+                {"中枢", "单穴", "督脉", "背部", "在脊柱区,第10胸椎棘突下凹陷中,后正中线上。"},
+                {"筋缩", "单穴", "督脉", "背部", "在脊柱区,第9胸椎棘突下凹陷中,后正中线上。"},
+                {"至阳", "单穴", "督脉", "背部", "在脊柱区,第7胸椎棘突下凹陷中,后正中线上。"},
+                {"灵台", "单穴", "督脉", "背部", "在脊柱区,第6胸椎棘突下凹陷中,后正中线上。"},
+                {"神道", "单穴", "督脉", "背部", "在脊柱区,第5胸椎棘突下凹陷中,后正中线上。"},
+                {"身柱", "单穴", "督脉", "背部", "在脊柱区,第3胸椎棘突下凹陷中,后正中线上。"},
+                {"陶道", "单穴", "督脉", "背部", "在脊柱区,第1胸椎棘突下凹陷中,后正中线上。"},
+                {"大椎", "单穴", "督脉", "背部", "在脊柱区,第7颈椎棘突下凹陷中,后正中线上。"},
+                {"定喘", "双穴", "经外奇穴", "背部", "在脊柱区,横平第7颈椎棘突下,后正中线旁开0.5寸。"},
+                {"夹脊", "双穴", "经外奇穴", "背部", "在脊柱区,第1胸椎至第5腰椎棘突下两侧,后正中线旁开0.5寸,一侧17穴。"},
+                {"胃脘下俞", "双穴", "经外奇穴", "背部", "在脊柱区,横平第8胸椎棘突下,后正中线旁开1.5寸。"},
+                {"痞根", "双穴", "经外奇穴", "腰部", "在腰区,横平第1腰椎棘突下,后正中线旁开3.5寸。"},
+                {"下极俞", "单穴", "经外奇穴", "腰部", "在腰区,第3腰椎棘突下。"},
+                {"腰宜", "双穴", "经外奇穴", "腰部", "在腰区,横平第4腰椎棘突下,后正中线旁开3寸。"},
+                {"腰眼", "双穴", "经外奇穴", "腰部", "在腰区,横平第4腰椎棘突下,后正中线旁开约3.5凹陷中。"},
+                {"十七椎", "单穴", "经外奇穴", "腰部", "在腰区,第5腰椎棘突下凹陷中。"},
+                {"腰奇", "单穴", "经外奇穴", "骶区", "在骶区,尾骨端直上2寸,骶角之间凹陷中。"}
+        };
+
+        for (String[] row : rows) {
+            assertParsed(acupoint(row[0], row[1], row[2], row[3], row[4]));
+        }
+    }
+
+    private void assertParsed(Acupoint acupoint) {
+        AcupointLocationParser.ParseResult result = AcupointLocationParser.parse(acupoint);
+        assertEquals(AcupointLocationParser.STATUS_PARSED, result.getParseStatus(),
+                acupoint.getName() + " should be parsed: " + result.getParseMessage());
+        assertNotNull(result.getYReferenceTerm(), acupoint.getName() + " should have a Y reference");
+    }
+
+    private Acupoint acupoint(String name, String singleDouble, String meridian,
+                              String bodyRegion, String locationDescription) {
+        Acupoint acupoint = new Acupoint();
+        acupoint.setName(name);
+        acupoint.setSingleDouble(singleDouble);
+        acupoint.setMeridian(meridian);
+        acupoint.setBodyPart("背部");
+        acupoint.setBodyRegion(bodyRegion);
+        acupoint.setLocationDescription(locationDescription);
+        return acupoint;
+    }
+}

+ 1 - 1
code/frontend/src/api/acupoint.js

@@ -22,7 +22,7 @@ export function getYReferenceOptions() {
 
 /**
  * 分页查询穴位列表
- * @param {Object} params 查询参数:name/meridian/status/pageNum/pageSize
+ * @param {Object} params 查询参数:name/meridian/status/parseStatus/pageNum/pageSize
  */
 export function getAcupointPage(params) {
   return request.get('/acupoint/page', { params })

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


+ 18 - 0
code/frontend/src/views/acupoint/index.vue

@@ -22,6 +22,13 @@
             <el-option label="待完善" :value="0" />
           </el-select>
         </el-form-item>
+        <el-form-item label="解析状态">
+          <el-select v-model="queryForm.parseStatus" placeholder="全部" clearable style="width:130px">
+            <el-option label="已解析" value="已解析" />
+            <el-option label="待复核" value="待复核" />
+            <el-option label="暂不支持" value="暂不支持" />
+          </el-select>
+        </el-form-item>
         <el-form-item>
           <el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
           <el-button :icon="Refresh" @click="handleReset">重置</el-button>
@@ -314,6 +321,7 @@ const queryForm = reactive({
   name: '',
   meridian: '',
   status: null,
+  parseStatus: '',
   pageNum: 1,
   pageSize: 10
 })
@@ -423,6 +431,9 @@ async function loadData() {
 
 function parseStatusLabel(status) {
   const map = {
+    '已解析': '已解析',
+    '待复核': '待复核',
+    '暂不支持': '暂不支持',
     parsed: '已解析',
     review_required: '待复核',
     unsupported: '暂不支持'
@@ -432,6 +443,9 @@ function parseStatusLabel(status) {
 
 function parseStatusTagType(status) {
   const map = {
+    '已解析': 'success',
+    '待复核': 'warning',
+    '暂不支持': 'danger',
     parsed: 'success',
     review_required: 'warning',
     unsupported: 'danger'
@@ -441,6 +455,9 @@ function parseStatusTagType(status) {
 
 function parseConfidenceLabel(confidence) {
   const map = {
+    '高': '高',
+    '中': '中',
+    '低': '低',
     high: '高',
     medium: '中',
     low: '低'
@@ -463,6 +480,7 @@ function handleReset() {
   queryForm.name = ''
   queryForm.meridian = ''
   queryForm.status = null
+  queryForm.parseStatus = ''
   queryForm.pageNum = 1
   loadData()
 }

+ 182 - 86
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,71 +204,42 @@
                   <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" />
-
-              <line class="axis-line" x1="200" y1="12" x2="200" y2="568" stroke="rgba(148,163,184,0.4)" stroke-width="1" stroke-dasharray="4 6" />
-              <line class="axis-line" x1="12" y1="100" x2="388" y2="100" 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="200" y1="92" x2="200" y2="390" stroke="rgba(99,102,241,0.35)" stroke-width="2" stroke-dasharray="8 6" stroke-linecap="round" />
+              <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(200, 100)">
+              <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" />
@@ -278,8 +254,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="92" font-size="10" fill="#94a3b8" font-family="system-ui, sans-serif">患者左 (&minus;X)</text>
-              <text x="386" y="92" text-anchor="end" 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">
@@ -288,16 +264,6 @@
                 <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>
-                <text
-                  :x="m.labelX" :y="m.y + 4"
-                  :text-anchor="m.anchor"
-                  font-size="10" font-weight="700" :fill="m.color"
-                  font-family="system-ui, sans-serif"
-                  paint-order="stroke fill"
-                  stroke="rgba(255,255,255,0.92)" stroke-width="3" stroke-linejoin="round"
-                >
-                  {{ m.name }}
-                </text>
                 <!-- 步骤连接线 -->
                 <line
                   v-if="idx > 0"
@@ -338,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 -->
@@ -356,11 +331,12 @@
 </template>
 
 <script setup>
-import { computed, onMounted, ref, reactive } from 'vue'
+import { computed, onMounted, ref, reactive, watch } from 'vue'
 import { ElMessage } from 'element-plus'
 import { getProfilePage } from '@/api/user'
-import { getPlanPage } from '@/api/plan'
+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)
@@ -370,6 +346,7 @@ 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)
@@ -381,6 +358,7 @@ const bodyForm = reactive({
   age: '',
   shoulderWidth: '',
   bodyHeight: '',
+  spineLength: '',
   fingerWidth1: '',
   fingerWidth15: '',
   fingerWidth3: '',
@@ -389,9 +367,16 @@ const bodyForm = reactive({
 
 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 = allPlans.value.filter((p) => p.modeType === selectedMode.value)
+  let list = planSource.value.filter((p) => p.modeType === selectedMode.value)
   if (selectedMode.value === 2 && selectedEfficacy.value) {
     list = list.filter((p) => p.effectType === selectedEfficacy.value)
   }
@@ -410,22 +395,46 @@ const pushDataJson = computed(() => {
   catch { return String(pushData.value) }
 })
 
-// SVG coordinate mapping: C7 大椎穴 at (200, 100), 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 = 100
-const SCALE = 0.38
+// 参考经络示意图:大椎位于颈后下缘、后正中线处,视觉上略低于头颈连接点
+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) * 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 = {
@@ -453,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
@@ -501,10 +515,36 @@ async function loadPlans() {
   }
 }
 
+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)
@@ -513,10 +553,20 @@ 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 ?? ''
   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() {
@@ -525,15 +575,14 @@ function onModeChange() {
   pushData.value = null
 
   if (selectedMode.value === 1) {
-    const oneClick = allPlans.value.find((p) => p.modeType === 1)
+    const oneClick = planSource.value.find((p) => p.modeType === 1)
     if (oneClick) selectedPlanId.value = oneClick.id
   }
 }
 
 function onEfficacyChange() {
+  // 方案联动由 watch 统一处理,避免异步加载用户方案时错过自动选中
   selectedPlanId.value = null
-  const match = filteredPlans.value[0]
-  if (match) selectedPlanId.value = match.id
 }
 
 async function runSimulation() {
@@ -565,6 +614,23 @@ 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>
@@ -658,6 +724,11 @@ onMounted(() => {
   width: 100%;
   max-width: 420px;
   border-radius: 14px;
+  overflow: hidden;
+  background: #f8fafc;
+}
+.body-photo {
+  pointer-events: none;
 }
 
 .marker-legend {
@@ -749,6 +820,31 @@ onMounted(() => {
   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() {