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

feat: 完善穴位定位与用户方案功能

jiapu 5 месяцев назад
Родитель
Сommit
3d337c58db

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

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

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

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

+ 74 - 24
code/frontend/src/views/simulation/index.vue

@@ -216,8 +216,8 @@
               <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" />
+              <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">
@@ -260,10 +260,10 @@
                 <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" />
+              <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" />
 
               <!-- 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 +278,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 +288,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"
@@ -356,10 +346,10 @@
 </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'
 
 const selectedUserId = ref(null)
@@ -370,6 +360,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)
@@ -389,9 +380,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,9 +408,10 @@ const pushDataJson = computed(() => {
   catch { return String(pushData.value) }
 })
 
-// SVG coordinate mapping: C7 大椎穴 at (200, 100), scale 0.38 px/mm
+// SVG coordinate mapping: C7 大椎穴 at (SVG_C7_X, SVG_C7_Y), scale 0.38 px/mm
 const SVG_C7_X = 200
-const SVG_C7_Y = 100
+// 参考经络示意图:大椎位于颈后下缘、后正中线处,视觉上略低于头颈连接点
+const SVG_C7_Y = 112
 const SCALE = 0.38
 
 function bodyToSvg(offsetXmm, offsetYmm) {
@@ -501,10 +500,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)
@@ -517,6 +542,15 @@ function onUserSelect(userId) {
   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 +559,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 +598,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>