ソースを参照

feat(survey): 粪便性状改为布里斯托 7 型图片选择

- 选项:由旧 5 档纯文字(干硬颗粒/条状干硬/正常香蕉状/软便/水样)改为
  布里斯托大便分类法(Bristol Stool Scale)标准 7 型,每型一张示意图 +
  名称 + 特征描述;UI 为 2 列图片网格(flex wrap,非 Grid),选中态用
  暖橙描边(#FF8C42)
- 图片:static/health/bristol/bristol-{1..7}.png(200×200 透明底 PNG,
  合计约 61KB 计入主包);由 scripts/gen-bristol-stool-images.py 用 Pillow
  生成(4 倍超采样 + LANCZOS,暖棕配色与问卷主题一致)——为示意图非实拍照片
- 共享模块 utils/bowel-form.js:BOWEL_FORM_OPTIONS(value/label/desc/img)
  + normalizeBowelForm(),把旧 5 档文案归一为新 7 型(干硬颗粒→1型、
  条状干硬→2型、正常香蕉状→4型、软便→5型、水样→7型)
- ComprehensiveSurvey.vue:初始化时归一 bowelForm;health-plan-summary.vue
  的 parseLifestyle 同样归一,保证确认页摘要回显一致
- 存储值仍为自由文本(如「4型(光滑柔软)」),后端只做 JSON 存储、
  LangGraph adapter.py 直拼「粪便性状:xxx」入提示词,零后端改动零 DDL
- .gitignore:static 图片放行规则补 **/*.png(原规则只放行一级目录,
  嵌套目录图片会被静默忽略);生成脚本按仓库既有做法 -f 强加
- 旧 wizard 集群(LifestyleStep/ConfirmStep/HealthQuestionnaireWizard)
  仍为原 5 档文字选项,但已 DEPRECATED 且零引用,本次不动

验证:vuecheck 2 文件 OK + node --check 新模块 OK + audit-duplicate-api-calls
0 error/108 warning + audit-beautify-debt 无新增命中
Sisyphus 6 日 前
親
コミット
ddb3b02bf9

+ 1 - 0
.gitignore

@@ -53,6 +53,7 @@ worktrees/
 *.png
 !docs/**/*.png
 !cfc-frontend/static/*.png
+!cfc-frontend/static/**/*.png
 !cfc-web/public/logo.png
 !web/img/*.png
 !web/img/product-intro/*.png

+ 34 - 6
cfc-frontend/pages/health/components/ComprehensiveSurvey.vue

@@ -342,11 +342,16 @@
           </view>
         </view>
         <view class="card">
-          <view class="card-head"><text class="card-title">粪便性状</text><text class="card-hint">单选</text></view>
-          <view class="chip-row-wrap">
-            <view v-for="opt in bowelFormOptions" :key="opt" class="chip-item-wrap"
-                  :class="{ 'chip-active': lifestyle.bowelForm === opt }"
-                  @tap="setLifestyle('bowelForm', opt)"><text>{{ opt }}</text></view>
+          <view class="card-head"><text class="card-title">粪便性状</text><text class="card-hint">对照图片单选</text></view>
+          <view class="stool-tip">参照布里斯托大便分类法,选择最接近您多数情况的一项</view>
+          <view class="stool-grid">
+            <view v-for="opt in bowelFormOptions" :key="opt.value" class="stool-item"
+                  :class="{ 'stool-active': lifestyle.bowelForm === opt.value }"
+                  @tap="setLifestyle('bowelForm', opt.value)">
+              <image class="stool-img" :src="opt.img" mode="aspectFit" />
+              <text class="stool-name">{{ opt.label }}</text>
+              <text class="stool-desc">{{ opt.desc }}</text>
+            </view>
           </view>
         </view>
         <view class="card">
@@ -474,6 +479,7 @@
 </template>
 
 <script>
+import { BOWEL_FORM_OPTIONS, normalizeBowelForm } from '@/utils/bowel-form.js'
 // ===== 预设常量(与旧子组件 / preferences.vue 保持一致) =====
 var PRESET_ALLERGIES = ['海鲜', '花生', '乳糖', '鸡蛋', '牛奶', '豆类', '麸质', '坚果', '芒果', '其他']
 var PRESET_AVOID = ['牛肉', '羊肉', '猪肉', '内脏', '油腻', '辛辣', '生冷', '腌制']
@@ -630,7 +636,7 @@ export default {
       screenTimeOptions: ['<1小时/天', '1-3小时/天', '3-5小时/天', '>5小时/天'],
       teenMoodOptions: ['开朗', '一般', '易怒', '低落'],
       bowelFreqOptions: ['每天1次(正常)', '隔天1次', '每周2-3次', '每周<2次'],
-      bowelFormOptions: ['干硬颗粒', '条状干硬', '正常香蕉状', '软便', '水样'],
+      bowelFormOptions: BOWEL_FORM_OPTIONS,
       exerciseFreqOptions: ['不运动', '每周1-2次', '每周3-4次', '每周5次以上'],
       exerciseDurationOptions: ['30分钟以内', '30-60分钟', '60分钟以上'],
       sleepDurationOptions: ['<6小时', '6-7小时', '7-8小时', '>8小时'],
@@ -744,6 +750,8 @@ export default {
         })
       }
       this.lifestyle = obj
+      // 旧版 5 档粪便性状文案 → 布里斯托 7 型(存量数据归一,避免回显不匹配)
+      if (obj.bowelForm) this.$set(this.lifestyle, 'bowelForm', normalizeBowelForm(obj.bowelForm))
       this._initDiseaseCount = (this.innerForm.selectedDiseases || []).length
       this._initMedCount = (this.innerForm.medRows || []).length
       // 快捷标签回显
@@ -1104,6 +1112,26 @@ export default {
 .chip-row-wrap { display: flex; flex-wrap: wrap; gap: 16rpx; }
 .chip-item-wrap { display: flex; align-items: center; gap: 4rpx; padding: 12rpx 28rpx; background: #FFF3E6; color: #6B5744; border-radius: 999rpx; font-size: 24rpx; border: 2rpx solid transparent; }
 .chip-item-wrap.chip-active { background: #FF8C42; color: #FFFFFF; border-color: #FF8C42; font-weight: 600; }
+/* ===== 粪便性状(布里斯托 7 型图片选择) ===== */
+.stool-tip { font-size: 22rpx; color: #B08968; margin-bottom: 16rpx; }
+.stool-grid { display: flex; flex-wrap: wrap; justify-content: space-between; }
+.stool-item {
+  width: 48.5%;
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 16rpx 12rpx;
+  margin-bottom: 16rpx;
+  background: #FFFDFA;
+  border: 2rpx solid #F0E4D6;
+  border-radius: 16rpx;
+}
+.stool-item.stool-active { background: #FFF3E6; border-color: #FF8C42; }
+.stool-img { width: 130rpx; height: 130rpx; }
+.stool-name { font-size: 24rpx; font-weight: 600; color: #4A3728; margin-top: 10rpx; text-align: center; }
+.stool-active .stool-name { color: #FF8C42; }
+.stool-desc { font-size: 20rpx; color: #B08968; margin-top: 4rpx; line-height: 1.4; text-align: center; }
 .risk-badge { color: #E53E3E; font-size: 20rpx; }
 .chip-active .risk-badge { color: #FFE6D2; }
 .custom-row { display: flex; align-items: center; margin-top: 16rpx; gap: 16rpx; }

+ 2 - 1
cfc-frontend/pages/health/health-plan-summary.vue

@@ -234,6 +234,7 @@ import { getHealthStatus, saveHealthStatus, getDietPreferences, saveDietPreferen
 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 { normalizeBowelForm } from '@/utils/bowel-form.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
@@ -711,7 +712,7 @@ selectedMembers: function() {
           obj.sleepDuration = parsed.sleepDuration || ''
           obj.bedtime = parsed.bedtime || ''
           obj.bowelFrequency = parsed.bowelFrequency || ''
-          obj.bowelForm = parsed.bowelForm || ''
+          obj.bowelForm = normalizeBowelForm(parsed.bowelForm || '')
           obj.antibioticHistory = parsed.antibioticHistory || ''
           obj.allergySymptoms = Array.isArray(parsed.allergySymptoms) ? parsed.allergySymptoms.slice() : []
           obj.menstrualCycle = parsed.menstrualCycle || ''

+ 189 - 0
cfc-frontend/scripts/gen-bristol-stool-images.py

@@ -0,0 +1,189 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+生成布里斯托大便分类法(Bristol Stool Scale)类型 1-7 示意图。
+输出:cfc-frontend/static/health/bristol/bristol-{1..7}.png(200x200 透明底 PNG)
+
+用法:python3 scripts/gen-bristol-stool-images.py
+依赖:Pillow
+
+风格:暖棕配色(#8C6444 主体 / #684830 描边),与问卷暖色主题一致;
+4 倍超采样后 LANCZOS 缩放,保证小程序端边缘平滑。
+"""
+import math
+import os
+import random
+
+from PIL import Image, ImageDraw
+
+OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "static", "health", "bristol")
+SUPER = 4                       # 超采样倍数
+SIZE = 200                      # 最终尺寸
+S = SIZE * SUPER                # 绘制画布尺寸
+
+BODY = (140, 100, 68, 255)      # 主体
+BODY_LIGHT = (176, 136, 100, 255)  # 高光
+EDGE = (104, 72, 48, 255)       # 描边
+CRACK = (94, 66, 44, 255)       # 裂痕
+
+random.seed(20261018)
+
+
+def new_canvas():
+    img = Image.new("RGBA", (S, S), (0, 0, 0, 0))
+    return img, ImageDraw.Draw(img)
+
+
+def outlined_ellipse(draw, cx, cy, rx, ry, outline=5):
+    """带描边的椭圆"""
+    draw.ellipse([cx - rx - outline, cy - ry - outline, cx + rx + outline, cy + ry + outline], fill=EDGE)
+    draw.ellipse([cx - rx, cy - ry, cx + rx, cy + ry], fill=BODY)
+
+
+def outlined_chain(draw, pts, r, outline=5):
+    """圆形链:先画描边层再画主体层,内部不出现多余描边"""
+    for (x, y) in pts:
+        draw.ellipse([x - r - outline, y - r - outline, x + r + outline, y + r + outline], fill=EDGE)
+    for (x, y) in pts:
+        draw.ellipse([x - r, y - r, x + r, y + r], fill=BODY)
+
+
+def outlined_polygon(draw, pts, outline=5):
+    """多边形:等比放大后作描边层"""
+    cx = sum(p[0] for p in pts) / len(pts)
+    cy = sum(p[1] for p in pts) / len(pts)
+    scale = 1.0 + outline / 60.0
+    big = [(cx + (x - cx) * scale, cy + (y - cy) * scale) for (x, y) in pts]
+    draw.polygon(big, fill=EDGE)
+    draw.polygon(pts, fill=BODY)
+
+
+def ragged_pts(cx, cy, rx, ry, n=48, noise=0.10):
+    """不规则(毛边)轮廓点集"""
+    pts = []
+    for i in range(n):
+        a = 2 * math.pi * i / n
+        k = 1 + noise * math.sin(a * 3.1) + noise * 0.7 * math.sin(a * 7.7)
+        pts.append((cx + rx * k * math.cos(a), cy + ry * k * math.sin(a)))
+    return pts
+
+
+def curve(p0, p1, p2, steps=40):
+    """二次贝塞尔采样,用于平滑弯曲的肠状轮廓"""
+    out = []
+    for i in range(steps + 1):
+        t = i / steps
+        x = (1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * p1[0] + t ** 2 * p2[0]
+        y = (1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * p1[1] + t ** 2 * p2[1]
+        out.append((x, y))
+    return out
+
+
+def highlight(draw, cx, cy, rx, ry):
+    """柔和高光(半透明,模拟体积)"""
+    layer = Image.new("RGBA", (S, S), (0, 0, 0, 0))
+    ld = ImageDraw.Draw(layer)
+    ld.ellipse([cx - rx, cy - ry, cx + rx, cy + ry], fill=(255, 245, 235, 70))
+    return Image.alpha_composite(draw._image, layer)
+
+
+def save(img, n):
+    out = os.path.join(OUT_DIR, "bristol-%d.png" % n)
+    img.resize((SIZE, SIZE), Image.LANCZOS).save(out, "PNG", optimize=True)
+    print("已生成", os.path.relpath(out), os.path.getsize(out), "bytes")
+
+
+# ---- Type 1:一粒粒硬球(很难排出) ----
+def type1():
+    img, d = new_canvas()
+    for (cx, cy, r) in [(300, 300, 88), (500, 268, 76), (338, 512, 72), (536, 500, 80)]:
+        outlined_ellipse(d, cx, cy, r, r * 0.94)
+    return img
+
+
+# ---- Type 2:香肠状,表面凹凸(由小块拼成) ----
+def type2():
+    img, d = new_canvas()
+    pts = []
+    radii = [46, 62, 52, 68, 50, 66, 54, 70, 48, 60, 42]
+    for i, r in enumerate(radii):
+        t = i / float(len(radii) - 1)
+        x = 110 + t * 580
+        y = 400 + 34 * math.sin(t * math.pi * 1.5) + (16 if i % 2 else -14)
+        pts.append((x, y, r))
+    for (x, y, r) in pts:
+        d.ellipse([x - r - 5, y - r - 5, x + r + 5, y + r + 5], fill=EDGE)
+    for (x, y, r) in pts:
+        d.ellipse([x - r, y - r, x + r, y + r], fill=BODY)
+    return img
+
+
+# ---- Type 3:香肠状,表面有裂痕(正常偏干) ----
+def type3():
+    img, d = new_canvas()
+    path = curve((130, 430), (400, 300), (670, 410))
+    outlined_chain(d, [(x, y) for (x, y) in path], 78)
+    # 沿肠体分布的不规则裂痕(垂直于走向,略带角度)
+    for t, ang, length in ((0.22, -16, 118), (0.38, 12, 132), (0.56, -10, 108), (0.72, 18, 124), (0.88, -8, 96)):
+        i = int(t * (len(path) - 1))
+        x, y = path[i]
+        i2 = min(len(path) - 1, i + 1)
+        dx = path[i2][0] - x
+        dy = path[i2][1] - y
+        base = math.atan2(dy, dx) + math.pi / 2 + math.radians(ang)
+        hx = math.cos(base) * length / 2.0
+        hy = math.sin(base) * length / 2.0
+        d.line([x - hx, y - hy, x + hx, y + hy], fill=CRACK, width=9)
+        d.ellipse([x - hx - 4, y - hy - 4, x - hx + 4, y - hy + 4], fill=CRACK)
+        d.ellipse([x + hx - 4, y + hy - 4, x + hx + 4, y + hy + 4], fill=CRACK)
+    return img
+
+
+# ---- Type 4:光滑柔软(理想:像香肠或蛇) ----
+def type4():
+    img, d = new_canvas()
+    path = curve((120, 470), (400, 250), (690, 430), steps=60)
+    outlined_chain(d, path, 74)
+    # 背部高光带
+    for i in range(6, len(path) - 6, 3):
+        x, y = path[i]
+        d.ellipse([x - 26, y - 44, x + 26, y - 16], fill=BODY_LIGHT)
+    return img
+
+
+# ---- Type 5:软块状,边缘清晰(缺乏纤维) ----
+def type5():
+    img, d = new_canvas()
+    for (cx, cy, rx, ry) in [(268, 300, 86, 72), (512, 288, 78, 68),
+                             (240, 520, 74, 66), (470, 528, 92, 74), (660, 420, 66, 60)]:
+        outlined_ellipse(d, cx, cy, rx, ry)
+    return img
+
+
+# ---- Type 6:糊状,边缘不清(蓬松) ----
+def type6():
+    img, d = new_canvas()
+    for (cx, cy, rx, ry) in [(300, 320, 130, 100), (560, 300, 110, 88),
+                             (330, 540, 118, 92), (590, 540, 126, 96)]:
+        outlined_polygon(d, ragged_pts(cx, cy, rx, ry, noise=0.14))
+    return img
+
+
+# ---- Type 7:水样,无固形物(腹泻) ----
+def type7():
+    img, d = new_canvas()
+    puddle = ragged_pts(400, 430, 290, 132, n=72, noise=0.09)
+    outlined_polygon(d, puddle)
+    outlined_ellipse(d, 250, 190, 46, 38)
+    outlined_ellipse(d, 566, 168, 34, 28)
+    return img
+
+
+def main():
+    os.makedirs(OUT_DIR, exist_ok=True)
+    for n, fn in enumerate([type1, type2, type3, type4, type5, type6, type7], start=1):
+        save(fn(), n)
+
+
+if __name__ == "__main__":
+    main()

BIN
cfc-frontend/static/health/bristol/bristol-1.png


BIN
cfc-frontend/static/health/bristol/bristol-2.png


BIN
cfc-frontend/static/health/bristol/bristol-3.png


BIN
cfc-frontend/static/health/bristol/bristol-4.png


BIN
cfc-frontend/static/health/bristol/bristol-5.png


BIN
cfc-frontend/static/health/bristol/bristol-6.png


BIN
cfc-frontend/static/health/bristol/bristol-7.png


+ 28 - 0
cfc-frontend/utils/bowel-form.js

@@ -0,0 +1,28 @@
+// 布里斯托大便分类法(Bristol Stool Scale)7 型 — 粪便性状图片选项
+// 图片由 scripts/gen-bristol-stool-images.py 生成,位于 static/health/bristol/
+// 存储值即 value 字符串(写入 health_status.lifestyle.bowelForm,供 AI 生成方案时理解)
+
+export const BOWEL_FORM_OPTIONS = [
+  { value: '1型(一粒粒硬球)', label: '1型 一粒粒硬球', desc: '很难排出,像坚果', img: '/static/health/bristol/bristol-1.png' },
+  { value: '2型(香肠状表面凹凸)', label: '2型 表面凹凸', desc: '由小块拼成的香肠状', img: '/static/health/bristol/bristol-2.png' },
+  { value: '3型(香肠状有裂痕)', label: '3型 表面有裂痕', desc: '香肠状,偏干', img: '/static/health/bristol/bristol-3.png' },
+  { value: '4型(光滑柔软)', label: '4型 光滑柔软', desc: '最理想的状态', img: '/static/health/bristol/bristol-4.png' },
+  { value: '5型(软块边缘清晰)', label: '5型 软块状', desc: '边缘清晰的软块,偏缺纤维', img: '/static/health/bristol/bristol-5.png' },
+  { value: '6型(糊状边缘不清)', label: '6型 糊状', desc: '蓬松糊状,边缘不整', img: '/static/health/bristol/bristol-6.png' },
+  { value: '7型(水样无固形物)', label: '7型 水样', desc: '完全水样,无固形物', img: '/static/health/bristol/bristol-7.png' }
+]
+
+// 旧版 5 档文案 → 布里斯托 7 型(存量问卷数据兼容,仅在读取旧值时归一)
+var BOWEL_FORM_LEGACY = {
+  '干硬颗粒': '1型(一粒粒硬球)',
+  '条状干硬': '2型(香肠状表面凹凸)',
+  '正常香蕉状': '4型(光滑柔软)',
+  '软便': '5型(软块边缘清晰)',
+  '水样': '7型(水样无固形物)'
+}
+
+/** 归一粪便性状取值:旧版文案映射为 7 型,其余原样返回 */
+export function normalizeBowelForm(v) {
+  if (!v) return ''
+  return BOWEL_FORM_LEGACY[v] || v
+}