Parcourir la source

feat(signature): 通用手签名组件 + 问卷确认签名 + 购买/收货人签名落地

- 新增 components/hand-signature/hand-signature.vue:canvas 手绘签名,
  坐标用 boundingClientRect 换算画布偏移,导出 base64 PNG data URL,
  支持回显透传(预填签名时确认不会导出空白画布)
- health-plan-summary:问卷完成后插入 Step 3『确认问卷内容+手签名』,
  按成员展示问卷摘要(体征/病史/用药/生活方式/饮食),
  签名落库后才进入方案生成(Step 4)
- checkout:hand_signature 购买字段改用手签名组件,
  未完成签名禁止提交,签名值随 purchaseInfo 一起下单
- consignee-edit:新增手签名采集,存入 consignees.hand_signature,
  列表页『已设置手签名』因此有数据来源
- 后端:health_status.signature(迁移382 + schema.sql 同步),
  新接口 POST /api/health-status/signature 用 LambdaUpdateWrapper 仅更
  新签名列,避免 saveForMember 全字段覆盖清空其他字段
Sisyphus il y a 6 jours
Parent
commit
d16daa3357

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3091,6 +3091,9 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移381: 创建 activity_poster_templates 表 + 初始化 3 套默认海报模板(Web 管理端定义 → 小程序端海报模板选择)
         createActivityPosterTemplatesTable();
+
+        // 迁移382: health_status 添加 signature 列(用户手签名,base64 PNG data URL,用于问卷回答确认)
+        ensureColumn("health_status", "signature", "MEDIUMTEXT DEFAULT NULL COMMENT '用户手签名(base64 PNG data URL),确认问卷回答准确性'");
     }
 
     private void migrateHealthDimensionsTable() {

+ 18 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthStatusController.java

@@ -75,6 +75,24 @@ public class HealthStatusController {
                 : healthStatusService.save(userId, status));
     }
 
+    @Operation(summary = "提交手签名(仅更新签名列,不影响其他字段,base64 PNG data URL)")
+    @PostMapping("/signature")
+    public Result<Void> saveSignature(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        Long memberId = params != null ? ParamUtils.getLong(params.get("memberId")) : null;
+        String signature = params != null ? (String) params.get("signature") : null;
+        if (signature == null || signature.isEmpty()) {
+            return Result.error("请完成手签名");
+        }
+        try {
+            healthStatusService.updateSignature(userId, memberId, signature);
+            return Result.success(null);
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
     /** 至少填写一项才算有效 */
     private boolean isEmpty(HealthStatus s) {
         return s.getHeightCm() == null && s.getWeightKg() == null

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthStatus.java

@@ -49,6 +49,9 @@ public class HealthStatus implements Serializable {
     /** 生活方式与肠道健康:{mood, sleepDuration, bedtime, bowelFrequency, bowelForm, antibioticHistory, allergySymptoms} */
     private String lifestyle;
 
+    /** 用户手签名(base64 PNG data URL),用于确认问卷回答准确性 */
+    private String signature;
+
     /** 首次填写时间 */
     private Date filledAt;
 

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthStatusService.java

@@ -18,4 +18,7 @@ public interface HealthStatusService {
 
     /** 按成员保存/更新健康现状(memberId 为空时退化为账号级) */
     HealthStatus saveForMember(Long userId, Long memberId, HealthStatus status);
+
+    /** 仅更新手签名(不影响其他字段,MEDIUMTEXT base64 PNG data URL) */
+    void updateSignature(Long userId, Long memberId, String signature);
 }

+ 15 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthStatusServiceImpl.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.etotem.cfc.entity.HealthStatus;
 import com.etotem.cfc.mapper.HealthStatusMapper;
 import com.etotem.cfc.service.HealthStatusService;
@@ -69,4 +70,18 @@ public class HealthStatusServiceImpl implements HealthStatusService {
         }
         return getByMemberId(userId, memberId);
     }
+
+    @Override
+    public void updateSignature(Long userId, Long memberId, String signature) {
+        if (userId == null) throw new IllegalArgumentException("userId不能为空");
+        HealthStatus existing = getByMemberId(userId, memberId);
+        if (existing == null) {
+            throw new IllegalArgumentException("该成员暂无健康现状记录,请先完成问卷");
+        }
+        LambdaUpdateWrapper<HealthStatus> wrapper = new LambdaUpdateWrapper<HealthStatus>()
+                .eq(HealthStatus::getId, existing.getId())
+                .set(HealthStatus::getSignature, signature)
+                .set(HealthStatus::getUpdatedAt, new Date());
+        healthStatusMapper.update(null, wrapper);
+    }
 }

+ 1 - 0
cfc-backend/src/main/resources/schema.sql

@@ -5393,6 +5393,7 @@ CREATE TABLE IF NOT EXISTS health_status (
     medications JSON DEFAULT NULL COMMENT '在服药物/治疗 [{name,note}]',
     notes VARCHAR(500) DEFAULT NULL COMMENT '其他补充说明',
     lifestyle JSON DEFAULT NULL COMMENT '生活方式与肠道健康 {mood,sleepDuration,bedtime,bowelFrequency,bowelForm,antibioticHistory,allergySymptoms}',
+    signature MEDIUMTEXT DEFAULT NULL COMMENT '用户手签名(base64 PNG data URL),确认问卷回答准确性',
     filled_at DATETIME DEFAULT NULL COMMENT '首次填写时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     INDEX idx_user_member (user_id, member_id)

+ 288 - 0
cfc-frontend/components/hand-signature/hand-signature.vue

@@ -0,0 +1,288 @@
+<!--
+  通用手签名组件(uni-app 小程序/H5 兼容,Vue 2)
+  Props:
+    value          string   已有签名(base64 PNG data URL),用于回显
+    strokeColor    string   笔触颜色,默认 '#1a1a1a'
+    strokeWidth    number   笔触宽度(逻辑像素),默认 2
+    height         number   画布高度(px),默认 200
+  Emits:
+    confirm  dataUrl: string  画布转 base64 PNG data URL(点击「确认签名」时触发)
+    clear                画布被清空
+  用法:
+    <hand-signature @confirm="onSign" @clear="onClear" />
+    onSign(url) { this.signatureDataUrl = url }
+-->
+<template>
+  <view class="hand-signature">
+    <view class="signature-canvas-wrap">
+      <canvas
+        canvas-id="hsCanvas"
+        class="signature-canvas"
+        :style="'width:100%;height:' + height + 'px;'"
+        @touchstart.stop.prevent="onTouchStart"
+        @touchmove.stop.prevent="onTouchMove"
+        @touchend.stop.prevent="onTouchEnd"
+      />
+      <!-- 已签名回显(pointer-events-none 避免遮挡手绘) -->
+      <image
+        v-if="currentDataUrl && !hasContent"
+        :src="currentDataUrl"
+        mode="aspectFit"
+        class="signature-preview"
+      />
+    </view>
+    <view class="signature-actions">
+      <button class="btn-sign-clear" :disabled="savingLive || !hasContent && !currentDataUrl" @click="handleClear">
+        {{ savingLive ? '处理中...' : '重写' }}
+      </button>
+      <button class="btn-sign-confirm" :disabled="savingLive || !hasContent" @click="handleConfirm">
+        {{ savingLive ? '处理中...' : '确认签名' }}
+      </button>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  name: 'HandSignature',
+  props: {
+    value: {
+      type: String,
+      default: ''
+    },
+    strokeColor: {
+      type: String,
+      default: '#1a1a1a'
+    },
+    strokeWidth: {
+      type: Number,
+      default: 2
+    },
+    height: {
+      type: Number,
+      default: 200
+    }
+  },
+  data: function() {
+    return {
+      ctx: null,
+      drawing: false,
+      lastX: 0,
+      lastY: 0,
+      hasContent: false,
+      hasDrawn: false,
+      currentDataUrl: this.value || '',
+      savingLive: false,
+      rect: null
+    }
+  },
+  watch: {
+    value: function(val) {
+      if (val !== this.currentDataUrl) {
+        this.currentDataUrl = val || ''
+        this.hasContent = !!val
+        this.hasDrawn = !!val
+      }
+    }
+  },
+  mounted: function() {
+    var self = this
+    this.ctx = uni.createCanvasContext('hsCanvas', this)
+    if (!this.ctx) {
+      this.ctx = uni.createCanvasContext('hsCanvas')
+    }
+    // 获取画布实际位置与尺寸(导出/坐标换算用);canvas 可能在 nextTick 后才有布局,失败重试一次
+    this.loadRect()
+    this.$nextTick(function() {
+      if (!self.rect) {
+        setTimeout(function() { self.loadRect() }, 300)
+      }
+    })
+  },
+  methods: {
+    loadRect: function() {
+      var self = this
+      try {
+        var query = uni.createSelectorQuery()
+        if (self.$scope) query = query.in(self.$scope)
+        query.select('.signature-canvas').boundingClientRect(function(rect) {
+          if (rect) {
+            self.rect = rect
+            // 有预存签名时清空画布(用空白覆盖),回显交给 image 层
+            if (self.currentDataUrl && !self.hasContent) {
+              var ctx = self.ctx
+              if (ctx) {
+                ctx.setFillStyle('#ffffff')
+                ctx.fillRect(0, 0, rect.width, rect.height)
+                ctx.draw(true)
+              }
+            }
+          }
+        }).exec()
+      } catch (e) {}
+    },
+    getPos: function(e) {
+      var touch = e.touches && e.touches[0]
+      var cx = touch ? touch.clientX : (e.clientX || 0)
+      var cy = touch ? touch.clientY : (e.clientY || 0)
+      var rect = this.rect
+      if (rect) {
+        return { x: cx - rect.left, y: cy - rect.top }
+      }
+      return { x: cx, y: cy }
+    },
+    onTouchStart: function(e) {
+      var pos = this.getPos(e)
+      this.lastX = pos.x
+      this.lastY = pos.y
+      this.drawing = true
+    },
+    onTouchMove: function(e) {
+      if (!this.drawing) return
+      var pos = this.getPos(e)
+      var ctx = this.ctx
+      if (!ctx) return
+      ctx.beginPath()
+      ctx.moveTo(this.lastX, this.lastY)
+      ctx.lineTo(pos.x, pos.y)
+      ctx.setStrokeStyle(this.strokeColor)
+      ctx.setLineWidth(this.strokeWidth)
+      ctx.setLineCap('round')
+      ctx.setLineJoin('round')
+      ctx.stroke()
+      ctx.draw(false)
+      this.lastX = pos.x
+      this.lastY = pos.y
+      this.hasContent = true
+      this.hasDrawn = true
+    },
+    onTouchEnd: function() {
+      this.drawing = false
+    },
+    handleConfirm: function() {
+      var self = this
+      if (this.savingLive) return
+      // 未在画布上绘制、但有预填签名时:直接透传原签名,避免导出空白画布覆盖
+      if (!this.hasDrawn && this.currentDataUrl) {
+        this.$emit('confirm', this.currentDataUrl)
+        return
+      }
+      this.savingLive = true
+      var rect = this.rect || {}
+      var w = rect.width || 300
+      var h = rect.height || this.height
+      uni.canvasToTempFilePath({
+        canvasId: 'hsCanvas',
+        x: 0,
+        y: 0,
+        width: w,
+        height: h,
+        destWidth: w,
+        destHeight: h,
+        fileType: 'png',
+        quality: 1,
+        success: function(res) {
+          // 小程序:临时文件路径转 base64 data URL 提交后端
+          var fs = uni.getFileSystemManager()
+          fs.readFile({
+            filePath: res.tempFilePath,
+            encoding: 'base64',
+            success: function(fileRes) {
+              self.currentDataUrl = 'data:image/png;base64,' + (fileRes.data || '')
+              self.hasContent = true
+              self.$emit('confirm', self.currentDataUrl)
+              self.savingLive = false
+            },
+            fail: function() {
+              // 降级:直接返回临时路径(H5 环境 tempFilePath 即为 base64 data URL)
+              if (String(res.tempFilePath || '').indexOf('data:image') === 0) {
+                self.currentDataUrl = res.tempFilePath
+                self.hasContent = true
+                self.$emit('confirm', self.currentDataUrl)
+              }
+              self.savingLive = false
+            }
+          })
+        },
+        fail: function(err) {
+          console.warn('[HandSignature] 导出失败:', err)
+          uni.showToast({ title: '签名导出失败,请重试', icon: 'none' })
+          self.savingLive = false
+        }
+      })
+    },
+    handleClear: function() {
+      var self = this
+      if (this.savingLive) return
+      var ctx = this.ctx
+      if (ctx) {
+        var rect = this.rect || {}
+        ctx.clearRect(0, 0, rect.width || 600, rect.height || this.height)
+        ctx.draw(true)
+      }
+      this.hasContent = false
+      this.hasDrawn = false
+      this.currentDataUrl = ''
+      this.$emit('clear')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.hand-signature {
+  width: 100%;
+}
+.signature-canvas-wrap {
+  position: relative;
+  width: 100%;
+  border: 2rpx dashed #ccc;
+  border-radius: 12rpx;
+  background: #fff;
+  overflow: hidden;
+}
+.signature-canvas {
+  display: block;
+  background: #fff;
+}
+.signature-preview {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  pointer-events: none;
+}
+.signature-actions {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 16rpx;
+}
+.btn-sign-clear {
+  flex: 0 0 160rpx;
+  height: 72rpx;
+  line-height: 72rpx;
+  margin-right: 20rpx;
+  font-size: 26rpx;
+  color: #666;
+  background: #f5f5f5;
+  border: none;
+  border-radius: 36rpx;
+}
+.btn-sign-clear[disabled] {
+  opacity: 0.5;
+}
+.btn-sign-confirm {
+  flex: 0 0 200rpx;
+  height: 72rpx;
+  line-height: 72rpx;
+  font-size: 26rpx;
+  color: #fff;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  border: none;
+  border-radius: 36rpx;
+}
+.btn-sign-confirm[disabled] {
+  opacity: 0.5;
+}
+</style>

+ 209 - 13
cfc-frontend/pages/health/health-plan-summary.vue

@@ -69,8 +69,41 @@
       </view>
     </view>
 
-    <!-- Step 3: 生成方案 -->
+    <!-- Step 3: 问卷内容确认 + 手签名 -->
     <view class="section" v-if="step === 3 && !viewMode">
+      <view class="section-title">📝 确认问卷内容</view>
+      <view class="hint-text">请核对以下问卷信息准确无误后,手写签名确认</view>
+
+      <!-- 问卷内容摘要(按成员) -->
+      <view class="confirm-survey-list">
+        <view v-for="m in selectedMembers" :key="m.memberId" class="confirm-survey-member">
+          <view class="confirm-member-name">{{ m.name }}</view>
+          <view class="confirm-summary-row" v-for="(line, li) in buildSurveySummary(m.memberId)" :key="li">
+            <text class="confirm-label">{{ line.label }}</text>
+            <text class="confirm-value">{{ line.value }}</text>
+          </view>
+          <view v-if="!hasMemberData(m.memberId)" class="confirm-empty">该成员未填写问卷内容</view>
+        </view>
+      </view>
+
+      <!-- 手写签名 -->
+      <view class="signature-title">✍️ 手写签名确认</view>
+      <view class="hint-text">签名即表示您确认上述问卷信息填写准确、真实有效</view>
+      <hand-signature
+        ref="signaturePad"
+        :value="signatureDataUrl"
+        @confirm="onSignatureConfirm"
+        @clear="onSignatureClear"
+      />
+
+      <view class="btn-pair">
+        <button class="btn-confirm full-btn" :disabled="!signatureDataUrl || loading" @click="confirmAndGenerate">{{ loading ? '生成中...' : '确认签名并生成方案' }}</button>
+        <button class="btn-skip" @click="step = 2">返回修改问卷</button>
+      </view>
+    </view>
+
+    <!-- Step 4: 生成方案 -->
+    <view class="section" v-if="step === 4 && !viewMode">
       <view class="section-title">📊 方案生成</view>
 
       <!-- 加载中 -->
@@ -129,7 +162,7 @@
     </view>
 
     <!-- 最终确认保存 (所有 section 确认后) -->
-    <view class="section" v-if="step === 3 && allConfirmed && !editing && !loading && !viewMode">
+    <view class="section" v-if="step === 4 && allConfirmed && !editing && !loading && !viewMode">
       <view class="section-title">📋 确认方案</view>
       <view class="hint-text">所有板块已确认,点击保存生成最终方案</view>
       <view class="final-review">
@@ -182,14 +215,15 @@
 
 <script>
 import { generateHealthPlan, regenerateSection, confirmPlan, saveHealthPlan, getHealthPlanDetail } from '../../utils/api'
-import { getHealthStatus, saveHealthStatus, getDietPreferences, saveDietPreferences, getMyMembership } from '../../utils/api'
+import { getHealthStatus, saveHealthStatus, getDietPreferences, saveDietPreferences, getMyMembership, saveHealthSignature } from '../../utils/api'
 import MemberChipPicker from '@/pages/health/components/MemberChipPicker.vue'
 import ComprehensiveSurvey from '@/pages/health/components/ComprehensiveSurvey.vue'
+import HandSignature from '@/components/hand-signature/hand-signature.vue'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
   mixins: [fontScaleMixin],
-  components: { MemberChipPicker, ComprehensiveSurvey },
+  components: { MemberChipPicker, ComprehensiveSurvey, HandSignature },
   data() {
     return {
       // 步骤控制
@@ -228,7 +262,9 @@ export default {
       confirmingTasks: false,
       // UI 状态
       loading: false,
-      taskSubmitted: false
+      taskSubmitted: false,
+      // Step 3 签名确认
+      signatureDataUrl: ''
     }
   },
   computed: {
@@ -241,7 +277,7 @@ export default {
     needsTaskConfirm: function() {
       return !!(this.planDetail && this.planDetail.status === 'draft')
     },
-    steps: function() { return ['选人', '问卷', '方案'] },
+    steps: function() { return ['选人', '问卷', '签名', '方案'] },
     sectionKeys: function() { return ['nutrition', 'diet', 'exercise'] },
     sectionTitles: function() { return ['营养补充建议', '饮食建议', '运动计划'] },
     sectionIcons: function() { return ['💊', '🍽️', '🏃'] },
@@ -418,7 +454,8 @@ selectedMembers: function() {
       this.step = 2
     },
     skipToGenerate: function() {
-      this.step = 3
+      // 跳过问卷(未填写)→ 无内容可确认,直接进入方案生成(Step 4),无需签名
+      this.step = 4
       this.generatePlan()
     },
 
@@ -702,18 +739,132 @@ selectedMembers: function() {
         }
       })
       if (saveTasks.length === 0) {
-        // 无问卷数据,直接生成方案
+        // 无问卷数据,直接生成方案(无需签名确认)
         self.loading = false
+        self.step = 4
         self.generatePlan()
         return
       }
       Promise.all(saveTasks).then(function() {
         uni.showToast({ title: '信息已保存', icon: 'success' })
         self.loading = false
+        // 进入签名确认步骤,确认后再生成方案
+        self.signatureDataUrl = ''
+        self.step = 3
+      }).catch(function() {
+        // 保存失败也进入确认步骤(签名不依赖保存成功)
+        self.loading = false
+        self.signatureDataUrl = ''
+        self.step = 3
+      })
+    },
+
+    // ===== Step 3 签名确认 =====
+    // 按成员生成问卷内容摘要(键值对列表)
+    buildSurveySummary: function(memberId) {
+      var s = this.memberFormMap[String(memberId)] || null
+      var d = this.memberDietMap[String(memberId)] || null
+      var lines = []
+      var push = function(label, value) {
+        if (value && String(value).trim() !== '' && value !== false && !(Array.isArray(value) && value.length === 0)) {
+          lines.push({ label: label, value: Array.isArray(value) ? value.join('、') : String(value) })
+        }
+      }
+      if (s) {
+        var f = s.form || {}
+        push('身高', f.heightCm ? f.heightCm + ' cm' : '')
+        push('体重', f.weightKg ? f.weightKg + ' kg' : '')
+        push('血压', f.bloodPressure)
+        push('空腹血糖', f.bloodGlucose ? f.bloodGlucose + ' mmol/L' : '')
+        push('总胆固醇', s.lipidCholesterol ? s.lipidCholesterol + ' mmol/L' : '')
+        if (s.selectedDiseases && s.selectedDiseases.length > 0) {
+          push('疾病史', s.selectedDiseases.map(function(x) { return x.name }).join('、'))
+        }
+        if (s.noMedication) push('用药', '无')
+        else if (s.medRows && s.medRows.length > 0) {
+          push('在服药物', s.medRows.map(function(x) { return x.name || '' }).filter(function(n) { return n }).join('、'))
+        }
+        var ls = s.lifestyle || {}
+        push('心情', ls.mood)
+        push('睡眠时长', ls.sleepDuration)
+        push('入睡时间', ls.bedtime)
+        push('排便频率', ls.bowelFrequency)
+        push('排便性状', ls.bowelForm)
+        push('抗生素用药史', ls.antibioticHistory)
+        if (ls.allergySymptoms && ls.allergySymptoms.length > 0) push('过敏表现', ls.allergySymptoms.join('、'))
+        push('月经周期', ls.menstrualCycle)
+        push('特殊时期', ls.specialPeriod)
+        push('生长发育', ls.growthDevelopment)
+        push('挑食情况', ls.pickyEating)
+        push('骨质疏松风险', ls.osteoporosisRisk)
+        push('跌倒风险', ls.fallRisk)
+        push('屏幕时间', ls.screenTime)
+        push('情绪状态', ls.teenMood)
+        push('当前状态', ls.currentStatus)
+        push('运动频率', ls.exerciseFrequency)
+        push('运动时长', ls.exerciseDuration)
+        if (f.notes) push('补充说明', f.notes)
+      }
+      if (d) {
+        push('食物过敏', d.allergies)
+        push('绝对忌口', d.absoluteAvoid)
+        push('必吃食物', d.mustEat)
+        push('宗教饮食', d.religiousDiet && d.religiousDiet !== 'none' ? d.religiousDiet : '')
+        if (Number(d.spiceLevel) > 0) push('辣度', d.spiceLevel)
+        push('口味偏好', d.flavorPref)
+        push('菜系偏好', d.cuisinePref)
+        push('烹饪方式', d.cookingMethods)
+        if (d.noPreference) push('饮食偏好', '无特殊偏好')
+        push('蔬果量', d.vegetableFruit)
+        push('乳制品', d.dairy)
+        push('含糖饮料', d.sugaryDrinks)
+      }
+      return lines
+    },
+    // 该成员是否有问卷/饮食数据可供确认
+    hasMemberData: function(memberId) {
+      var s = this.memberFormMap[String(memberId)]
+      var d = this.memberDietMap[String(memberId)]
+      var hasForm = s && this.hasPayload(this.buildHealthPayload(s))
+      var hasDiet = d && (this.hasDietContent(d) || d.noPreference)
+      return !!(hasForm || hasDiet)
+    },
+    onSignatureConfirm: function(dataUrl) {
+      this.signatureDataUrl = dataUrl || ''
+    },
+    onSignatureClear: function() {
+      this.signatureDataUrl = ''
+    },
+    // 确认签名并生成方案:先把签名逐成员落库,再生成
+    confirmAndGenerate: function() {
+      var self = this
+      if (!self.signatureDataUrl) {
+        uni.showToast({ title: '请先完成手写签名', icon: 'none' })
+        return
+      }
+      if (self.loading) return
+      self.loading = true
+      // 为每个有问卷数据的成员落库签名(MEDIUMTEXT 只更新签名列)
+      var sigTasks = []
+      self.selectedMemberIds.forEach(function(mid) {
+        if (self.hasMemberData(mid)) {
+          sigTasks.push(saveHealthSignature(mid, self.signatureDataUrl))
+        }
+      })
+      if (sigTasks.length === 0) {
+        self.loading = false
+        self.step = 4
+        self.generatePlan()
+        return
+      }
+      Promise.all(sigTasks).then(function() {
+        self.loading = false
+        self.step = 4
         self.generatePlan()
       }).catch(function() {
-        // 保存失败也继续生成
+        // 签名落库失败不阻塞生成
         self.loading = false
+        self.step = 4
         self.generatePlan()
       })
     },
@@ -761,11 +912,11 @@ selectedMembers: function() {
             })
             self.planData = { overview: commonPart.overview || ((commonSections.length || Object.keys(individualPart).length) ? '' : (data.planContent || '')) }
             self.multiMemberPlanJson = { common: data.common, individual: data.individual }
-            self.step = 3
+            self.step = 4
           } else if (typeof data === 'string') {
             self.planData = { overview: data }
             self.multiMemberPlanJson = null
-            self.step = 3
+            self.step = 4
           } else if (data.overview) {
             self.planData = data
             self.multiMemberPlanJson = null
@@ -777,11 +928,11 @@ selectedMembers: function() {
                 }
               })
             }
-            self.step = 3
+            self.step = 4
           } else if (data.raw) {
             self.planData = { overview: data.raw.substring(0, 200) }
             self.multiMemberPlanJson = null
-            self.step = 3
+            self.step = 4
           } else {
             uni.showToast({ title: '生成失败,请重试', icon: 'none' })
           }
@@ -1609,6 +1760,51 @@ selectedMembers: function() {
   box-sizing: border-box;
 }
 .empty-text { font-size: calc(26rpx * var(--font-scale, 1)); color: #999; }
+
+/* Step 3: 问卷内容确认 + 手签名 */
+.confirm-survey-list {
+  margin-top: 16rpx;
+}
+.confirm-survey-member {
+  background: #F5F7FA;
+  border-radius: 12rpx;
+  padding: 20rpx;
+  margin-bottom: 20rpx;
+}
+.confirm-member-name {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  font-weight: bold;
+  color: #F97316;
+  margin-bottom: 12rpx;
+}
+.confirm-summary-row {
+  display: flex;
+  font-size: calc(24rpx * var(--font-scale, 1));
+  padding: 8rpx 0;
+  border-bottom: 1rpx solid #EDEDED;
+}
+.confirm-summary-row:last-child { border-bottom: none; }
+.confirm-label {
+  flex: 0 0 160rpx;
+  color: #999;
+}
+.confirm-value {
+  flex: 1;
+  color: #333;
+  word-break: break-all;
+}
+.confirm-empty {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: #bbb;
+  padding: 20rpx 0;
+}
+.signature-title {
+  font-size: calc(28rpx * var(--font-scale, 1));
+  font-weight: bold;
+  color: #333;
+  margin-top: 30rpx;
+  margin-bottom: 10rpx;
+}
 .member-load-hint {
   font-size: calc(22rpx * var(--font-scale, 1));
   color: #F97316;

+ 47 - 1
cfc-frontend/pages/shop/checkout/checkout.vue

@@ -109,6 +109,7 @@
         <text class="section-title">购买信息</text>
         <view
           v-for="field in requiredFields"
+          v-if="field.fieldKey !== 'hand_signature'"
           :key="field.fieldKey"
           class="purchase-field-row"
         >
@@ -125,6 +126,16 @@
           />
           <text v-if="field.prefillValue" class="purchase-field-prefill">已填写</text>
         </view>
+
+        <!-- 手签名购买信息:手写签名组件,签名数据 base64 直接存 purchaseForm -->
+        <view v-if="hasHandSignatureField" class="purchase-signature-block">
+          <view class="purchase-signature-title">✍️ 手写签名</view>
+          <hand-signature
+            :value="purchaseForm.hand_signature"
+            @confirm="onHandSignatureConfirm"
+            @clear="onHandSignatureClear"
+          />
+        </view>
       </view>
 
       <!-- Order Items Section -->
@@ -472,10 +483,12 @@ import config from '@/config.js'
 import { getCouponList, getCouponCheckoutList, addressList, getProductRequiredFields, getMyMembership, getGiftMembershipProgress, productDetail, getFamilyPlatformBalance, exchangeCouponByCf, shopDiscountCalculate } from '../../../utils/api.js'
 import { parseDate } from '@/utils/format.js'
 import CountdownTag from '@/pages/shop/components/countdown-tag.vue'
+import HandSignature from '@/components/hand-signature/hand-signature.vue'
 
 export default {
   components: {
-    CountdownTag
+    CountdownTag,
+    HandSignature
   },
   data() {
     return {
@@ -519,6 +532,14 @@ export default {
     }
   },
   computed: {
+    // 当前商品是否要求手签名(ProductPurchaseFieldService 预定义字段 hand_signature)
+    hasHandSignatureField() {
+      var fields = this.requiredFields || []
+      for (var i = 0; i < fields.length; i++) {
+        if (fields[i].fieldKey === 'hand_signature') return true
+      }
+      return false
+    },
     giftAccumulatedYuan() {
       if (!this.giftProgress || !this.giftProgress.accumulatedAmount) return 0
       return this.giftProgress.accumulatedAmount / 100
@@ -839,6 +860,16 @@ export default {
       }
       this.purchaseForm = form
     },
+    onHandSignatureConfirm(dataUrl) {
+      var form = this.purchaseForm
+      form.hand_signature = dataUrl || ''
+      this.purchaseForm = form
+    },
+    onHandSignatureClear() {
+      var form = this.purchaseForm
+      form.hand_signature = ''
+      this.purchaseForm = form
+    },
     openAddressPicker() {
       if (this.addressList.length === 0) {
         uni.showToast({ title: '暂无地址,请先添加', icon: 'none' })
@@ -1002,6 +1033,11 @@ export default {
         uni.showToast({ title: '请选择商品', icon: 'none' })
         return
       }
+      // 购买信息要求手签名时,必须先完成手写签名
+      if (this.hasHandSignatureField && !this.purchaseForm.hand_signature) {
+        uni.showToast({ title: '请先完成手写签名', icon: 'none' })
+        return
+      }
       this.submitting = true
       var orderItems = []
       for (var i = 0; i < this.items.length; i++) {
@@ -1647,6 +1683,16 @@ showOnlineSlotPicker: function() {
   color: #67c23a;
   margin-left: 8rpx;
 }
+/* ===== 购买信息-手签名 ===== */
+.purchase-signature-block {
+  margin-top: 24rpx;
+}
+.purchase-signature-title {
+  font-size: 26rpx;
+  color: #333;
+  font-weight: bold;
+  margin-bottom: 12rpx;
+}
 
 /* 配送方式选择 */
 .delivery-sub-label {

+ 42 - 0
cfc-frontend/pages/shop/consignee-edit/consignee-edit.vue

@@ -57,6 +57,22 @@
         </picker>
       </view>
 
+      <!-- 手签名 -->
+      <view class="form-row form-row-signature">
+        <text class="form-label">手签名</text>
+        <view class="signature-cell">
+          <text v-if="form.handSignature" class="signature-set">已设置手签名</text>
+          <text v-else class="placeholder">请在下方手写签名(选填)</text>
+        </view>
+      </view>
+      <view class="signature-block">
+        <hand-signature
+          :value="form.handSignature"
+          @confirm="onHandSignatureConfirm"
+          @clear="onHandSignatureClear"
+        />
+      </view>
+
       <!-- 设为默认 -->
       <view class="form-row form-row-switch">
         <text class="form-label">设为默认收货人</text>
@@ -77,8 +93,10 @@
 
 <script>
 import { consigneeDetail, consigneeSave } from '../../../utils/api.js'
+import HandSignature from '@/components/hand-signature/hand-signature.vue'
 
 export default {
+  components: { HandSignature },
   data() {
     return {
       form: {
@@ -87,6 +105,7 @@ export default {
         idCard: '',
         ethnicity: '',
         bloodType: '',
+        handSignature: '',
         isDefault: 0
       },
       bloodTypes: ['A型', 'B型', 'O型', 'AB型', '其他'],
@@ -113,6 +132,7 @@ export default {
             idCard: res.data.idCard || '',
             ethnicity: res.data.ethnicity || '',
             bloodType: res.data.bloodType || '',
+            handSignature: res.data.handSignature || '',
             isDefault: res.data.isDefault || 0
           }
         }
@@ -123,6 +143,12 @@ export default {
     onBloodTypeChange(e) {
       this.form.bloodType = this.bloodTypes[e.detail.value]
     },
+    onHandSignatureConfirm(dataUrl) {
+      this.form.handSignature = dataUrl || ''
+    },
+    onHandSignatureClear() {
+      this.form.handSignature = ''
+    },
     onDefaultChange(e) {
       this.form.isDefault = e.detail.value ? 1 : 0
     },
@@ -203,6 +229,22 @@ export default {
 .form-row-switch .form-label {
   width: auto;
 }
+.form-row-signature {
+  border-bottom: none;
+}
+.signature-cell {
+  flex: 1;
+  text-align: right;
+  font-size: 28rpx;
+  color: #333;
+  padding: 8rpx 0;
+}
+.signature-set {
+  color: #67c23a;
+}
+.signature-block {
+  padding: 0 0 24rpx;
+}
 .save-btn {
   width: 100%;
   margin-top: 40rpx;

+ 1 - 0
cfc-frontend/utils/api.js

@@ -3004,6 +3004,7 @@ export const trackRecommendAction = (data) => request('/api/recommend/track', 'P
 
 export const getHealthStatus = (memberId) => request('/api/health-status/get', 'POST', memberId ? { memberId: memberId } : {})
 export const saveHealthStatus = (data) => request('/api/health-status/save', 'POST', data)
+export const saveHealthSignature = (memberId, signature) => request('/api/health-status/signature', 'POST', { memberId: memberId, signature: signature })
 
 // ===== 饮食模块 =====
 export const getDietPreferences = (data) => request('/api/diet/preferences/current-member', 'POST', data || {})