Procházet zdrojové kódy

Merge branch 'refs/heads/jiapu_aijiuyi'

jiapu před 3 měsíci
rodič
revize
8c85868dae

+ 2 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/constant/ResultCode.java

@@ -114,6 +114,8 @@ public enum ResultCode {
     USER_PLAN_NOT_FOUND(1705, "用户方案不存在"),
     /** 方案步骤至少保留1个 */
     PLAN_STEP_MIN_ERROR(1706, "方案步骤至少保留1个"),
+    /** 系统预置方案仅允许查看和编辑 */
+    PLAN_SYSTEM_PRESET_FORBIDDEN(1707, "系统预置方案仅允许查看和编辑"),
 
     // ======================== 管理员管理相关 1800-1899 ========================
     /** 管理员不存在 */

+ 1 - 1
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/PlanQueryDTO.java

@@ -14,7 +14,7 @@ public class PlanQueryDTO {
     /** 模式类型:1=一键艾灸,2=专业模式,3=自定义模式,4=延年圣手 */
     private Integer modeType;
 
-    /** 功效类型:驱寒/祛湿/祛风/化瘀/活血/化痰/养颜/扶阳 */
+    /** 作用类型:驱寒/祛湿/祛风/化瘀/活血/化痰/养颜/扶阳 */
     private String effectType;
 
     /** 创作人名称(模糊搜索) */

+ 1 - 1
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/PlanSaveDTO.java

@@ -23,7 +23,7 @@ public class PlanSaveDTO {
     /** 模式类型:1=一键艾灸,2=专业模式,3=自定义模式,4=延年圣手 */
     private Integer modeType;
 
-    /** 功效类型:驱寒/祛湿/祛风/化瘀/活血/化痰/养颜/扶阳 */
+    /** 作用类型:驱寒/祛湿/祛风/化瘀/活血/化痰/养颜/扶阳 */
     private String effectType;
 
     /** 适用症状(逗号分隔) */

+ 1 - 1
code/backend/src/main/java/com/aijiuyi/admin/entity/Plan.java

@@ -30,7 +30,7 @@ public class Plan {
     private Integer modeType;
 
     /**
-     * 功效类型:驱寒/祛湿/祛风/化瘀/活血/化痰/养颜/扶阳
+     * 作用类型:驱寒/祛湿/祛风/化瘀/活血/化痰/养颜/扶阳
      */
     private String effectType;
 

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

@@ -36,7 +36,7 @@ public class UserPlan {
     private Integer modeType;
 
     /**
-     * 专业模式功效类型(来自 plan.effect_type,用于前端按功效筛选展示)
+     * 专业模式作用类型(来自 plan.effect_type,用于前端按作用筛选展示)
      */
     @TableField(exist = false)
     private String effectType;

+ 87 - 4
code/backend/src/main/java/com/aijiuyi/admin/service/impl/PlanServiceImpl.java

@@ -14,7 +14,6 @@ import com.aijiuyi.admin.mapper.PlanStepMapper;
 import com.aijiuyi.admin.service.AppUserService;
 import com.aijiuyi.admin.service.PlanService;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
@@ -35,6 +34,12 @@ import java.util.UUID;
 @Service
 public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements PlanService {
 
+    private static final String SYSTEM_ONE_CLICK_PLAN_CODE = "S001";
+    private static final int MODE_TYPE_ONE_CLICK = 1;
+    private static final int MODE_TYPE_PROFESSIONAL = 2;
+    private static final int MODE_TYPE_CUSTOM = 3;
+    private static final int MODE_TYPE_MASTER = 4;
+
     @Autowired
     private PlanStepMapper planStepMapper;
 
@@ -112,6 +117,7 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
         String planCode = "S" + UUID.randomUUID().toString().replace("-", "").substring(0, 6).toUpperCase();
         Plan plan = new Plan();
         BeanUtils.copyProperties(dto, plan);
+        applyAddPlanModeRules(plan);
         fillCurrentAuthor(plan);
         plan.setPlanCode(planCode);
         plan.setStatus(0); // 草稿
@@ -134,6 +140,10 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
         if (plan == null) {
             throw new BusinessException(ResultCode.PLAN_NOT_FOUND);
         }
+        boolean systemPresetPlan = isSystemPresetPlan(plan);
+        String originalPlanCode = plan.getPlanCode();
+        String originalName = plan.getName();
+        Integer originalStatus = plan.getStatus();
         // 方案名称唯一性校验(排除自身)
         long nameCount = lambdaQuery()
                 .eq(Plan::getName, dto.getName())
@@ -143,6 +153,7 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
             throw new BusinessException(ResultCode.PLAN_NAME_EXISTS);
         }
         BeanUtils.copyProperties(dto, plan);
+        applyUpdatePlanModeRules(plan, originalPlanCode, originalName, originalStatus, systemPresetPlan);
         fillCurrentAuthor(plan);
         updateById(plan);
         // 删除旧步骤(逻辑删除)
@@ -190,6 +201,71 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
         return 1;
     }
 
+    private void applyAddPlanModeRules(Plan plan) {
+        if (!Integer.valueOf(MODE_TYPE_CUSTOM).equals(plan.getModeType())
+                && !Integer.valueOf(MODE_TYPE_MASTER).equals(plan.getModeType())) {
+            throw new BusinessException(ResultCode.PARAM_ERROR);
+        }
+        plan.setEffectType("");
+    }
+
+    private void applyUpdatePlanModeRules(Plan plan, String originalPlanCode, String originalName, Integer originalStatus, boolean systemPresetPlan) {
+        if (systemPresetPlan) {
+            plan.setPlanCode(originalPlanCode);
+            plan.setName(originalName);
+            plan.setStatus(originalStatus);
+            if (SYSTEM_ONE_CLICK_PLAN_CODE.equals(originalPlanCode)) {
+                plan.setModeType(MODE_TYPE_ONE_CLICK);
+                plan.setEffectType("");
+                return;
+            }
+            plan.setModeType(MODE_TYPE_PROFESSIONAL);
+            plan.setEffectType(getProfessionalPresetEffectType(originalPlanCode));
+            return;
+        }
+        if (!Integer.valueOf(MODE_TYPE_PROFESSIONAL).equals(plan.getModeType())) {
+            plan.setEffectType("");
+        }
+    }
+
+    private boolean isSystemPresetPlan(Plan plan) {
+        return plan != null
+                && StringUtils.hasText(plan.getPlanCode())
+                && plan.getPlanCode().matches("S00[1-9]");
+    }
+
+    private String getProfessionalPresetEffectType(String planCode) {
+        if (!StringUtils.hasText(planCode)) {
+            return "";
+        }
+        switch (planCode) {
+            case "S002":
+                return "驱寒";
+            case "S003":
+                return "祛湿";
+            case "S004":
+                return "祛风";
+            case "S005":
+                return "化瘀";
+            case "S006":
+                return "活血";
+            case "S007":
+                return "化痰";
+            case "S008":
+                return "养颜";
+            case "S009":
+                return "扶阳";
+            default:
+                return "";
+        }
+    }
+
+    private void checkSystemPresetPlanOperation(Plan plan) {
+        if (isSystemPresetPlan(plan)) {
+            throw new BusinessException(ResultCode.PLAN_SYSTEM_PRESET_FORBIDDEN);
+        }
+    }
+
     /**
      * 删除方案(逻辑删除,仅草稿状态可删)
      *
@@ -202,7 +278,8 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
         if (plan == null) {
             throw new BusinessException(ResultCode.PLAN_NOT_FOUND);
         }
-        if (plan.getStatus() != 0) {
+        checkSystemPresetPlanOperation(plan);
+        if (!Integer.valueOf(0).equals(plan.getStatus())) {
             throw new BusinessException(ResultCode.PLAN_CANNOT_DELETE);
         }
         removeById(id);
@@ -239,6 +316,7 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
         if (plan == null) {
             throw new BusinessException(ResultCode.PLAN_NOT_FOUND);
         }
+        checkSystemPresetPlanOperation(plan);
         lambdaUpdate().eq(Plan::getId, id).set(Plan::getStatus, 1).update();
         LogUtil.info(PlanServiceImpl.class, "发布方案[{}]", id);
     }
@@ -254,7 +332,9 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
         if (ids == null || ids.isEmpty()) {
             throw new BusinessException(ResultCode.PARAM_ERROR);
         }
-        update(new LambdaUpdateWrapper<Plan>().in(Plan::getId, ids).set(Plan::getStatus, 1));
+        for (Long id : ids) {
+            publishPlan(id);
+        }
         LogUtil.info(PlanServiceImpl.class, "批量发布方案,共[{}]条", ids.size());
     }
 
@@ -269,6 +349,7 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
         if (plan == null) {
             throw new BusinessException(ResultCode.PLAN_NOT_FOUND);
         }
+        checkSystemPresetPlanOperation(plan);
         lambdaUpdate().eq(Plan::getId, id).set(Plan::getStatus, 2).update();
         LogUtil.info(PlanServiceImpl.class, "下架方案[{}]", id);
     }
@@ -284,7 +365,9 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
         if (ids == null || ids.isEmpty()) {
             throw new BusinessException(ResultCode.PARAM_ERROR);
         }
-        update(new LambdaUpdateWrapper<Plan>().in(Plan::getId, ids).set(Plan::getStatus, 2));
+        for (Long id : ids) {
+            offshelfPlan(id);
+        }
         LogUtil.info(PlanServiceImpl.class, "批量下架方案,共[{}]条", ids.size());
     }
 

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

@@ -284,7 +284,7 @@ CREATE TABLE IF NOT EXISTS `plan` (
     `plan_code`        VARCHAR(50)   NOT NULL COMMENT '方案编码(唯一标识,如 S001)',
     `name`             VARCHAR(100)  NOT NULL COMMENT '方案名称(最长100字符)',
     `mode_type`        TINYINT       NOT NULL COMMENT '模式类型:1=一键艾灸,2=专业模式,3=自定义模式,4=延年圣手',
-    `effect_type`      VARCHAR(20)            COMMENT '功效类型:驱寒/祛湿/祛风/化瘀/活血/化痰/养颜/扶阳',
+    `effect_type`      VARCHAR(20)            COMMENT '作用类型:驱寒/祛湿/祛风/化瘀/活血/化痰/养颜/扶阳',
     `symptoms`         VARCHAR(500)           COMMENT '适用症状(逗号分隔:怕冷,感冒,咳嗽,...)',
     `applicable_gender` TINYINT      NOT NULL DEFAULT 0 COMMENT '适用人群:0=不限,1=仅男,2=仅女',
     `age_min`          INT           NOT NULL DEFAULT 1 COMMENT '适用年龄最小值(岁)',

+ 1 - 1
code/frontend/src/router/index.js

@@ -85,7 +85,7 @@ const routes = [
         path: 'moxibustion',
         name: 'Moxibustion',
         component: () => import('@/views/moxibustion/index.vue'),
-        meta: { requiresAuth: true, title: '艾灸手法管理', icon: 'Promotion' }
+        meta: { requiresAuth: true, title: '艾灸手法管理', icon: 'Promotion', hidden: true }
       },
       {
         path: 'plan',

+ 5 - 3
code/frontend/src/views/admin/index.vue

@@ -197,7 +197,7 @@
       </div>
       <el-checkbox-group v-model="selectedRoutes" class="permission-checkboxes">
         <el-row>
-          <el-col v-for="item in allMenuRoutes" :key="item.path" :span="12">
+          <el-col v-for="item in visibleMenuRoutes" :key="item.path" :span="12">
             <el-checkbox :label="item.path" style="margin-bottom: 10px;">
               {{ item.title }}
             </el-checkbox>
@@ -215,7 +215,7 @@
 </template>
 
 <script setup>
-import { ref, reactive, onMounted } from 'vue'
+import { ref, reactive, computed, onMounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { Search, Refresh, Plus, Delete } from '@element-plus/icons-vue'
 import { adminApi } from '@/api'
@@ -226,7 +226,7 @@ const allMenuRoutes = [
   { path: '/user/profile', title: '用户管理' },
   { path: '/acupoint', title: '穴位管理' },
   { path: '/device', title: '设备管理' },
-  { path: '/moxibustion', title: '艾灸手法管理' },
+  { path: '/moxibustion', title: '艾灸手法管理', hidden: true },
   { path: '/plan', title: '方案管理' },
   { path: '/simulation', title: '方案模拟测试' },
   { path: '/admin', title: '管理员管理' },
@@ -234,6 +234,8 @@ const allMenuRoutes = [
   { path: '/log', title: '日志管理' },
 ]
 
+const visibleMenuRoutes = computed(() => allMenuRoutes.filter(item => !item.hidden))
+
 // ======================== 列表 ========================
 const loading = ref(false)
 const tableData = ref([])

+ 205 - 38
code/frontend/src/views/plan/index.vue

@@ -14,7 +14,7 @@
             <el-option label="延年圣手" :value="4" />
           </el-select>
         </el-form-item>
-        <el-form-item label="功效类型">
+        <el-form-item label="作用类型">
           <el-select v-model="queryForm.effectType" placeholder="全部" clearable style="width:110px">
             <el-option v-for="e in effectOptions" :key="e" :label="e" :value="e" />
           </el-select>
@@ -81,7 +81,7 @@
         stripe
         @selection-change="handleSelectionChange"
       >
-        <el-table-column type="selection" width="50" align="center" />
+        <el-table-column type="selection" width="50" align="center" :selectable="isRowSelectable" />
         <el-table-column type="index" label="序号" width="60" align="center" />
         <el-table-column prop="planCode" label="方案ID" width="100" />
         <el-table-column prop="name" label="方案名称" min-width="140" show-overflow-tooltip />
@@ -90,7 +90,7 @@
             <el-tag :type="modeTypeTag(row.modeType)" size="small">{{ modeTypeLabel(row.modeType) }}</el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="effectType" label="功效类型" width="90" align="center">
+        <el-table-column prop="effectType" label="作用类型" width="90" align="center">
           <template #default="{ row }">{{ row.effectType || '-' }}</template>
         </el-table-column>
         <el-table-column prop="authorName" label="创作人" width="100">
@@ -108,9 +108,11 @@
           <template #default="{ row }">
             <el-button size="small" type="primary" link @click="handleView(row)">查看</el-button>
             <el-button size="small" type="warning" link @click="handleEdit(row)">编辑</el-button>
-            <el-button v-if="row.status === 0 || row.status === 2" size="small" type="success" link @click="handlePublish(row)">发布</el-button>
-            <el-button v-if="row.status === 1" size="small" type="info" link @click="handleOffshelf(row)">下架</el-button>
-            <el-button v-if="row.status === 0" size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
+            <template v-if="!isSystemPresetPlan(row)">
+              <el-button v-if="row.status === 0 || row.status === 2" size="small" type="success" link @click="handlePublish(row)">发布</el-button>
+              <el-button v-if="row.status === 1" size="small" type="info" link @click="handleOffshelf(row)">下架</el-button>
+              <el-button v-if="row.status === 0" size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
+            </template>
           </template>
         </el-table-column>
       </el-table>
@@ -146,24 +148,43 @@
         <el-row :gutter="20">
           <el-col :span="12">
             <el-form-item label="方案名称" prop="name">
-              <el-input v-model="formData.name" placeholder="请输入方案名称" maxlength="100" />
+              <el-input
+                v-model="formData.name"
+                placeholder="请输入方案名称"
+                maxlength="100"
+                :disabled="isPlanNameLocked"
+              />
             </el-form-item>
           </el-col>
           <el-col :span="12">
             <el-form-item label="模式类型" prop="modeType">
-              <el-select v-model="formData.modeType" placeholder="请选择模式类型" style="width:100%">
-                <el-option label="一键艾灸" :value="1" />
-                <el-option label="专业模式" :value="2" />
-                <el-option label="自定义模式" :value="3" />
-                <el-option label="延年圣手" :value="4" />
+              <el-select
+                v-model="formData.modeType"
+                placeholder="请选择模式类型"
+                style="width:100%"
+                :disabled="isModeTypeLocked"
+                @change="handleModeTypeChange"
+              >
+                <el-option
+                  v-for="item in editableModeTypeOptions"
+                  :key="item.value"
+                  :label="item.label"
+                  :value="item.value"
+                />
               </el-select>
             </el-form-item>
           </el-col>
         </el-row>
         <el-row :gutter="20">
-          <el-col :span="12">
-            <el-form-item label="功效类型" prop="effectType">
-              <el-select v-model="formData.effectType" placeholder="请选择功效" clearable style="width:100%">
+          <el-col v-if="showEffectTypeField" :span="12">
+            <el-form-item label="作用类型" prop="effectType">
+              <el-select
+                v-model="formData.effectType"
+                placeholder="请选择作用"
+                :clearable="!isEffectTypeLocked"
+                :disabled="isEffectTypeLocked"
+                style="width:100%"
+              >
                 <el-option v-for="e in effectOptions" :key="e" :label="e" :value="e" />
               </el-select>
             </el-form-item>
@@ -212,19 +233,40 @@
           <el-input v-model="formData.description" type="textarea" :rows="2" maxlength="200" show-word-limit />
         </el-form-item>
 
-        <!-- 治疗步骤 -->
-        <el-divider>治疗步骤</el-divider>
+        <!-- 灸方参数 -->
+        <el-divider>灸方参数</el-divider>
         <div v-for="(step, index) in formData.steps" :key="index" class="step-row">
           <div class="step-header">
-            <span class="step-num">步骤 {{ index + 1 }}</span>
-            <el-button
-              v-if="dialogMode !== 'view'"
-              size="small"
-              type="danger"
-              link
-              :disabled="formData.steps.length <= 1"
-              @click="removeStep(index)"
-            >删除</el-button>
+            <span class="step-num">参数 {{ index + 1 }}</span>
+            <div v-if="dialogMode !== 'view'" class="step-actions">
+              <el-tooltip content="上移参数" placement="top">
+                <el-button
+                  size="small"
+                  :icon="ArrowUp"
+                  link
+                  :disabled="index === 0"
+                  aria-label="上移参数"
+                  @click="moveStep(index, -1)"
+                />
+              </el-tooltip>
+              <el-tooltip content="下移参数" placement="top">
+                <el-button
+                  size="small"
+                  :icon="ArrowDown"
+                  link
+                  :disabled="index === formData.steps.length - 1"
+                  aria-label="下移参数"
+                  @click="moveStep(index, 1)"
+                />
+              </el-tooltip>
+              <el-button
+                size="small"
+                type="danger"
+                link
+                :disabled="formData.steps.length <= 1"
+                @click="removeStep(index)"
+              >删除</el-button>
+            </div>
           </div>
           <el-row :gutter="12">
             <el-col :span="8">
@@ -299,14 +341,16 @@
           size="small"
           @click="addStep"
           style="margin-top:4px"
-        >+ 添加步骤</el-button>
+        >+ 添加参数</el-button>
       </el-form>
 
       <template #footer>
         <el-button @click="dialogVisible = false">{{ dialogMode === 'view' ? '关闭' : '取消' }}</el-button>
         <template v-if="dialogMode !== 'view'">
-          <el-button :loading="submitLoading" @click="handleSubmit(0)">保存为草稿</el-button>
-          <el-button type="primary" :loading="submitLoading" @click="handleSubmit(1)">发布方案</el-button>
+          <el-button v-if="!isSystemPresetPlan(formData)" :loading="submitLoading" @click="handleSubmit(0)">保存为草稿</el-button>
+          <el-button type="primary" :loading="submitLoading" @click="handleSubmit(isSystemPresetPlan(formData) ? formData.status : 1)">
+            {{ isSystemPresetPlan(formData) ? '保存修改' : '发布方案' }}
+          </el-button>
         </template>
       </template>
     </el-dialog>
@@ -318,7 +362,7 @@
           <el-descriptions-item label="方案编码">{{ detailData.planCode }}</el-descriptions-item>
           <el-descriptions-item label="方案名称">{{ detailData.name }}</el-descriptions-item>
           <el-descriptions-item label="模式类型">{{ modeTypeLabel(detailData.modeType) }}</el-descriptions-item>
-          <el-descriptions-item label="功效类型">{{ detailData.effectType || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="作用类型">{{ detailData.effectType || '-' }}</el-descriptions-item>
           <el-descriptions-item label="创作人">{{ detailData.authorName || 'ADMIN' }}</el-descriptions-item>
           <el-descriptions-item label="状态">{{ statusLabel(detailData.status) }}</el-descriptions-item>
           <el-descriptions-item label="适用人群">{{ ['不限', '仅男', '仅女'][detailData.applicableGender] || '-' }}</el-descriptions-item>
@@ -331,9 +375,9 @@
           <el-descriptions-item label="更新时间">{{ formatDateTime(detailData.updateTime) }}</el-descriptions-item>
         </el-descriptions>
 
-        <el-divider>治疗步骤</el-divider>
+        <el-divider>灸方参数</el-divider>
         <el-table :data="detailData.steps || []" border size="small">
-          <el-table-column prop="stepOrder" label="步骤" width="60" align="center" />
+          <el-table-column prop="stepOrder" label="参数" width="60" align="center" />
           <el-table-column prop="acupointName" label="穴位" width="100" />
           <el-table-column label="侧别" width="80" align="center">
             <template #default="{ row }">{{ ['', '中心', '左侧', '右侧', '双侧'][row.side] || '-' }}</template>
@@ -354,9 +398,9 @@
 </template>
 
 <script setup>
-import { ref, reactive, onMounted } from 'vue'
+import { ref, reactive, computed, onMounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
-import { Search, Refresh, Plus, Delete } from '@element-plus/icons-vue'
+import { Search, Refresh, Plus, Delete, ArrowUp, ArrowDown } from '@element-plus/icons-vue'
 import {
   getPlanPage,
   getPlanDetail,
@@ -375,8 +419,26 @@ import { useUserStore } from '@/store/user'
 import { formatDateTime } from '@/utils/date'
 // ============ 枚举常量 ============
 const effectOptions = ['驱寒', '祛湿', '祛风', '化瘀', '活血', '化痰', '养颜', '扶阳']
+const modeTypeOptions = [
+  { label: '一键艾灸', value: 1 },
+  { label: '专业模式', value: 2 },
+  { label: '自定义模式', value: 3 },
+  { label: '延年圣手', value: 4 }
+]
+const addModeTypeOptions = modeTypeOptions.filter(item => [3, 4].includes(item.value))
 const defaultSymptomsOptions = ['怕冷', '感冒', '咳嗽', '痰多', '疼痛', '腰酸', '失眠', '疲劳', '皮肤暗沉', '手脚冰凉']
 const symptomsOptions = ref([...defaultSymptomsOptions])
+const systemPresetPlanCodes = Array.from({ length: 9 }, (_, index) => `S00${index + 1}`)
+const professionalPresetEffectMap = {
+  S002: '驱寒',
+  S003: '祛湿',
+  S004: '祛风',
+  S005: '化瘀',
+  S006: '活血',
+  S007: '化痰',
+  S008: '养颜',
+  S009: '扶阳'
+}
 const userStore = useUserStore()
 
 // ============ 选项列表(用于选择框) ============
@@ -427,6 +489,26 @@ function statusTag(val) {
   return { 0: 'info', 1: 'success', 2: 'warning' }[val] ?? ''
 }
 
+function isSystemPresetPlan(plan) {
+  return systemPresetPlanCodes.includes(plan?.planCode)
+}
+
+function isOneClickPresetPlan(plan) {
+  return plan?.planCode === 'S001'
+}
+
+function isProfessionalPresetPlan(plan) {
+  return Boolean(professionalPresetEffectMap[plan?.planCode])
+}
+
+function getProfessionalPresetEffect(plan) {
+  return professionalPresetEffectMap[plan?.planCode] || ''
+}
+
+function isRowSelectable(row) {
+  return !isSystemPresetPlan(row)
+}
+
 // ============ 查询 ============
 const queryForm = reactive({
   name: '',
@@ -514,8 +596,9 @@ const defaultStep = () => ({
 
 const defaultForm = () => ({
   id: null,
+  planCode: '',
   name: '',
-  modeType: 1,
+  modeType: 3,
   effectType: '',
   symptomsList: [],
   symptoms: '',
@@ -525,6 +608,7 @@ const defaultForm = () => ({
   authorId: getCurrentAuthorId(),
   authorName: getCurrentAuthorName(),
   authorType: getCurrentAuthorType(),
+  status: 0,
   description: '',
   steps: [defaultStep()]
 })
@@ -533,7 +617,19 @@ const formData = reactive(defaultForm())
 
 const formRules = {
   name: [{ required: true, message: '请输入方案名称', trigger: 'blur' }],
-  modeType: [{ required: true, message: '请选择模式类型', trigger: 'change' }]
+  modeType: [{ required: true, message: '请选择模式类型', trigger: 'change' }],
+  effectType: [
+    {
+      validator: (_rule, value, callback) => {
+        if (formData.modeType === 2 && !value) {
+          callback(new Error('请选择作用类型'))
+          return
+        }
+        callback()
+      },
+      trigger: 'change'
+    }
+  ]
 }
 
 const stepRules = {
@@ -612,19 +708,77 @@ function applyCurrentAuthor() {
   formData.authorType = getCurrentAuthorType()
 }
 
+const editableModeTypeOptions = computed(() => {
+  if (dialogMode.value === 'add') {
+    return addModeTypeOptions
+  }
+  if (isOneClickPresetPlan(formData)) {
+    return modeTypeOptions.filter(item => item.value === 1)
+  }
+  if (isProfessionalPresetPlan(formData)) {
+    return modeTypeOptions.filter(item => item.value === 2)
+  }
+  return modeTypeOptions
+})
+
+const isModeTypeLocked = computed(() => (
+  dialogMode.value === 'edit' && (isOneClickPresetPlan(formData) || isProfessionalPresetPlan(formData))
+))
+
+const isPlanNameLocked = computed(() => dialogMode.value === 'edit' && isSystemPresetPlan(formData))
+
+const isEffectTypeLocked = computed(() => dialogMode.value === 'edit' && isProfessionalPresetPlan(formData))
+
+const showEffectTypeField = computed(() => formData.modeType === 2 && !isOneClickPresetPlan(formData))
+
+function applyPlanModeRules() {
+  if (dialogMode.value === 'add' && ![3, 4].includes(formData.modeType)) {
+    formData.modeType = 3
+  }
+  if (isOneClickPresetPlan(formData)) {
+    formData.modeType = 1
+    formData.effectType = ''
+    return
+  }
+  if (isProfessionalPresetPlan(formData)) {
+    formData.modeType = 2
+    formData.effectType = getProfessionalPresetEffect(formData)
+    return
+  }
+  if (formData.modeType !== 2) {
+    formData.effectType = ''
+  }
+}
+
+function handleModeTypeChange() {
+  applyPlanModeRules()
+}
+
 /**
- * 添加一个治疗步骤
+ * 添加一个灸方参数
  */
 function addStep() {
   formData.steps.push(defaultStep())
 }
 
 /**
- * 删除某个治疗步骤
+ * 调整灸方参数顺序
+ */
+function moveStep(index, direction) {
+  const targetIndex = index + direction
+  if (targetIndex < 0 || targetIndex >= formData.steps.length) return
+  const [step] = formData.steps.splice(index, 1)
+  formData.steps.splice(targetIndex, 0, step)
+  formRef.value?.clearValidate?.()
+}
+
+/**
+ * 删除某个灸方参数
  */
 function removeStep(index) {
   if (formData.steps.length <= 1) return
   formData.steps.splice(index, 1)
+  formRef.value?.clearValidate?.()
 }
 
 /**
@@ -637,6 +791,7 @@ function handleAdd() {
   applyCurrentAuthor()
   dialogTitle.value = '新增方案'
   dialogMode.value = 'add'
+  applyPlanModeRules()
   dialogVisible.value = true
 }
 
@@ -657,6 +812,7 @@ async function handleEdit(row) {
   applyCurrentAuthor()
   dialogTitle.value = '编辑方案'
   dialogMode.value = 'edit'
+  applyPlanModeRules()
   dialogVisible.value = true
 }
 
@@ -664,6 +820,7 @@ async function handleEdit(row) {
  * 提交保存(0=草稿,1=发布)
  */
 async function handleSubmit(publishFlag) {
+  applyPlanModeRules()
   await formRef.value.validate()
   applyCurrentAuthor()
   submitLoading.value = true
@@ -840,6 +997,16 @@ onMounted(() => {
       color: #409eff;
       font-size: 13px;
     }
+
+    .step-actions {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+
+      :deep(.el-button + .el-button) {
+        margin-left: 0;
+      }
+    }
   }
 }
 </style>

+ 8 - 8
code/frontend/src/views/simulation/index.vue

@@ -94,14 +94,14 @@
             <el-form-item label="艾灸模式">
               <el-select v-model="selectedMode" placeholder="选择模式" style="width: 100%" @change="onModeChange">
                 <el-option label="一键艾灸(默认)" :value="1" />
-                <el-option label="专业模式(8种功效)" :value="2" />
+                <el-option label="专业模式(8种作用)" :value="2" />
                 <el-option label="自定义模式" :value="3" />
                 <el-option label="延年圣手模式(专家方案)" :value="4" />
               </el-select>
             </el-form-item>
 
-            <el-form-item v-if="selectedMode === 2" label="功效类型">
-              <el-select v-model="selectedEfficacy" placeholder="选择功效" style="width: 100%" @change="onEfficacyChange">
+            <el-form-item v-if="selectedMode === 2" label="作用类型">
+              <el-select v-model="selectedEfficacy" placeholder="选择作用" style="width: 100%" @change="onEfficacyChange">
                 <el-option v-for="e in efficacyTypes" :key="e" :label="e" :value="e" />
               </el-select>
             </el-form-item>
@@ -463,7 +463,7 @@ const bodyForm = reactive({
 const canSimulate = computed(() => selectedUserId.value && selectedPlanId.value)
 
 const planSource = computed(() => {
-  // 专业模式:要求与“用户方案列表”保持一致,优先使用 user-plan 里该用户对应功效的方案
+  // 专业模式:要求与“用户方案列表”保持一致,优先使用 user-plan 里该用户对应作用的方案
   if (selectedMode.value === 2) return userPlans.value
   // 其他模式暂仍使用全局方案列表
   return allPlans.value
@@ -663,7 +663,7 @@ async function loadUserPlans(userId) {
     return
   }
   try {
-    // user-plan 接口自身会确保“必需方案”(一键+8功效)存在并去重
+    // 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 结构
@@ -703,7 +703,7 @@ function onUserSelect(userId) {
 
   // 同步加载该用户的“用户方案列表”,确保专业模式选择与其一致
   loadUserPlans(userId).finally(() => {
-    // 若当前处于专业模式且已选功效,自动对齐默认方案
+    // 若当前处于专业模式且已选作用,自动对齐默认方案
     if (selectedMode.value === 2 && selectedEfficacy.value) {
       const match = filteredPlans.value[0]
       selectedPlanId.value = match ? match.id : null
@@ -760,14 +760,14 @@ onMounted(() => {
   loadPlans()
 })
 
-// 进入专业模式时,确保 userPlans 已加载(否则功效筛选拿不到方案)
+// 进入专业模式时,确保 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

+ 1 - 1
code/技术文档.md

@@ -389,7 +389,7 @@ code/
 | `plan_code` | VARCHAR(50) | NOT NULL | UNIQUE `uk_plan_code` | 方案编码 |
 | `name` | VARCHAR(100) | NOT NULL | - | 方案名称 |
 | `mode_type` | TINYINT | NOT NULL | - | 模式类型:1=一键艾灸,2=专业模式,3=自定义模式,4=延年圣手 |
-| `effect_type` | VARCHAR(20) | - | - | 功效类型:驱寒、祛湿、祛风、化瘀、活血、化痰、养颜、扶阳 |
+| `effect_type` | VARCHAR(20) | - | - | 作用类型:驱寒、祛湿、祛风、化瘀、活血、化痰、养颜、扶阳 |
 | `symptoms` | VARCHAR(500) | - | - | 适用症状,逗号分隔 |
 | `applicable_gender` | TINYINT | NOT NULL, DEFAULT 0 | - | 适用人群:0=不限,1=仅男,2=仅女 |
 | `age_min` | INT | NOT NULL, DEFAULT 1 | - | 适用年龄最小值 |

+ 5 - 5
code/方案管理和用户方案管理.md

@@ -16,7 +16,7 @@
 
 #### 方案列表
 
-| 多选框 | 序号 | 方案ID | 方案名称 | 模式类型 | 功效类型 | 创作人 | 步骤数 | 状态 | 创建时间 | 操作 |
+| 多选框 | 序号 | 方案ID | 方案名称 | 模式类型 | 作用类型 | 创作人 | 步骤数 | 状态 | 创建时间 | 操作 |
 |--------|------|--------|----------|----------|----------|--------|--------|------|----------|------|
 | ☐ | 1 | S001 | 一键艾灸(默认) | 一键艾灸 | - | ADMIN | 5 | 已发布 | 2026-01-01 | 查看/编辑 |
 | ☐ | 2 | S002 | 春季祛湿方案 | 专业模式 | 祛湿 | ADMIN | 4 | 已发布 | 2026-03-20 | 查看/编辑/下架 |
@@ -37,7 +37,7 @@
 |----------|------|------|
 | 方案名称 | 文本输入 | 支持模糊搜索 |
 | 模式类型 | 下拉选择 | 全部 / 一键艾灸 / 专业模式 / 自定义模式 / 延年圣手 |
-| 功效类型 | 下拉选择 | 全部 / 驱寒 / 祛湿 / 祛风 / 化瘀 / 活血 / 化痰 / 养颜 / 扶阳 |
+| 作用类型 | 下拉选择 | 全部 / 驱寒 / 祛湿 / 祛风 / 化瘀 / 活血 / 化痰 / 养颜 / 扶阳 |
 | 创作人 | 文本输入 | 支持模糊搜索,可搜索ADMIN或用户/专家姓名 |
 | 状态 | 下拉选择 | 全部 / 已发布 / 草稿 / 已下架 |
 | 创建时间范围 | 日期区间 | 开始日期 ~ 结束日期 |
@@ -63,7 +63,7 @@
 | 模板类型 | 说明 |
 |----------|------|
 | 一键艾灸 | 系统默认工艺,预设督脉5穴,可编辑参数 |
-| 专业模式 | 选择功效类型,系统预设对应穴位组合,可调整 |
+| 专业模式 | 选择作用类型,系统预设对应穴位组合,可调整 |
 | 自定义模式 | 自由选择穴位组合,自定义参数 |
 | 延年圣手 | 专家方案模板,可设置发布范围 |
 
@@ -72,7 +72,7 @@
 | 字段名 | 类型 | 必填 | 验证规则 | 说明 |
 |--------|------|------|----------|------|
 | 方案名称 | 文本 | 是 | 最长100字符 | - |
-| 功效类型 | 下拉选择 | 是 | 专业模式时必选 | 驱寒/祛湿/祛风/化瘀/活血/化痰/养颜/扶阳 |
+| 作用类型 | 下拉选择 | 是 | 专业模式时必选 | 驱寒/祛湿/祛风/化瘀/活血/化痰/养颜/扶阳 |
 | 适用症状 | 多选标签 | 是 | 至少选1项 | 怕冷/感冒/咳嗽/痰多/疼痛/腰酸/失眠/疲劳/皮肤暗沉/手脚冰凉/其他 |
 | 适用人群 | 单选 | 是 | 不限 / 仅男 / 仅女 | - |
 | 适用年龄范围 | 数字区间 | 是 | 最小1岁,最大120岁 | - |
@@ -115,7 +115,7 @@
 | 方案ID | S001 |
 | 方案名称 | 一键艾灸(默认) |
 | 模式类型 | 一键艾灸 |
-| 功效类型 | - |
+| 作用类型 | - |
 | 创作人 | ADMIN |
 | 状态 | 已发布 |
 | 创建时间 | 2026-01-01 10:00:00 |

+ 7 - 7
code/智能穴位定位系统设计文档.md

@@ -220,7 +220,7 @@
 
 **基本信息模板**:
 - 方案名称(必填)
-- 功效类型(必填,8选1)
+- 作用类型(必填,8选1)
 - 适用症状(必填,多选)
 - 适用人群(必填,不限/仅男/仅女)
 - 适用年龄(必填,范围)
@@ -458,7 +458,7 @@ interface ExpertSchemePush {
   expertId: string;              // 专家ID(由专家端系统分配)
   expertName: string;            // 专家名称
   schemeName: string;            // 方案名称
-  efficacyType: string;          // 功效类型
+  efficacyType: string;          // 作用类型
   symptoms: string[];            // 适用症状
   targetGender: 'male' | 'female' | 'all';
   ageRange: { min: number; max: number };
@@ -1374,7 +1374,7 @@ interface SchemeStep {
 
 #### 方案列表
 
-| 多选框 | 序号 | 方案ID | 方案名称 | 模式类型 | 功效类型 | 创作人 | 步骤数 | 状态 | 创建时间 | 操作 |
+| 多选框 | 序号 | 方案ID | 方案名称 | 模式类型 | 作用类型 | 创作人 | 步骤数 | 状态 | 创建时间 | 操作 |
 |--------|------|--------|----------|----------|----------|--------|--------|------|----------|------|
 | ☐ | 1 | S001 | 一键艾灸(默认) | 一键艾灸 | - | ADMIN | 5 | 已发布 | 2026-01-01 | 查看/编辑 |
 | ☐ | 2 | S002 | 春季祛湿方案 | 专业模式 | 祛湿 | ADMIN | 4 | 已发布 | 2026-03-20 | 查看/编辑/下架 |
@@ -1395,7 +1395,7 @@ interface SchemeStep {
 |----------|------|------|
 | 方案名称 | 文本输入 | 支持模糊搜索 |
 | 模式类型 | 下拉选择 | 全部 / 一键艾灸 / 专业模式 / 自定义模式 / 延年圣手 |
-| 功效类型 | 下拉选择 | 全部 / 驱寒 / 祛湿 / 祛风 / 化瘀 / 活血 / 化痰 / 养颜 / 扶阳 |
+| 作用类型 | 下拉选择 | 全部 / 驱寒 / 祛湿 / 祛风 / 化瘀 / 活血 / 化痰 / 养颜 / 扶阳 |
 | 创作人 | 文本输入 | 支持模糊搜索,可搜索ADMIN或用户/专家姓名 |
 | 状态 | 下拉选择 | 全部 / 已发布 / 草稿 / 已下架 |
 | 创建时间范围 | 日期区间 | 开始日期 ~ 结束日期 |
@@ -1421,7 +1421,7 @@ interface SchemeStep {
 | 模板类型 | 说明 |
 |----------|------|
 | 一键艾灸 | 系统默认工艺,预设督脉5穴,可编辑参数 |
-| 专业模式 | 选择功效类型,系统预设对应穴位组合,可调整 |
+| 专业模式 | 选择作用类型,系统预设对应穴位组合,可调整 |
 | 自定义模式 | 自由选择穴位组合,自定义参数 |
 | 延年圣手 | 专家方案模板,可设置发布范围 |
 
@@ -1430,7 +1430,7 @@ interface SchemeStep {
 | 字段名 | 类型 | 必填 | 验证规则 | 说明 |
 |--------|------|------|----------|------|
 | 方案名称 | 文本 | 是 | 最长100字符 | - |
-| 功效类型 | 下拉选择 | 是 | 专业模式时必选 | 驱寒/祛湿/祛风/化瘀/活血/化痰/养颜/扶阳 |
+| 作用类型 | 下拉选择 | 是 | 专业模式时必选 | 驱寒/祛湿/祛风/化瘀/活血/化痰/养颜/扶阳 |
 | 适用症状 | 多选标签 | 是 | 至少选1项 | 怕冷/感冒/咳嗽/痰多/疼痛/腰酸/失眠/疲劳/皮肤暗沉/手脚冰凉/其他 |
 | 适用人群 | 单选 | 是 | 不限 / 仅男 / 仅女 | - |
 | 适用年龄范围 | 数字区间 | 是 | 最小1岁,最大120岁 | - |
@@ -1473,7 +1473,7 @@ interface SchemeStep {
 | 方案ID | S001 |
 | 方案名称 | 一键艾灸(默认) |
 | 模式类型 | 一键艾灸 |
-| 功效类型 | - |
+| 作用类型 | - |
 | 创作人 | ADMIN |
 | 状态 | 已发布 |
 | 创建时间 | 2026-01-01 10:00:00 |

+ 2 - 2
code/研年艾灸椅APP需求文档_v2.md

@@ -1200,7 +1200,7 @@
     参数:用户ID、体形数据(肩高/肩宽/体重)
     返回:各穴位坐标参数
   → 调用后台接口:功效-灸方参数匹配
-    参数:功效类型、穴位坐标
+    参数:作用类型、穴位坐标
     返回:灸方参数(穴位/温度/手法/时长)
   → 计算完成:显示方案摘要弹窗
 
@@ -2651,7 +2651,7 @@ POST /api/algorithm/acupoint-calculate
 POST /api/algorithm/moxa-plan
   请求参数:
     userId: string
-    efficacy: string (功效类型)
+    efficacy: string (作用类型)
     acupoints: array (穴位坐标)
   响应:
     plan: {