diff --git a/TUICallKit-Vue3/tongji/composables/useDietAi.js b/TUICallKit-Vue3/tongji/composables/useDietAi.js new file mode 100644 index 00000000..154ed2ab --- /dev/null +++ b/TUICallKit-Vue3/tongji/composables/useDietAi.js @@ -0,0 +1,312 @@ +import { ref, computed } from 'vue' +import { requestAiStream, parsePartialDietPlainText } from '../utils/aiStreamRequest.js' + +const DIET_AI_PREFILL_KEY = 'tongji_diet_ai_prefill' + +const emptyDietAiRecommend = () => ({ + breakfast: '', + lunch: '', + dinner: '', + tips: '', + avoid: [], + analysis: [], + exercise: null, + disclaimer: '', + rules_summary: '', + source: '', + date: '' +}) + +/** AI 饮食助手(今日推荐 + 能不能吃) */ +export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage }) { + const dietAiRecommend = ref(emptyDietAiRecommend()) + const dietAiLoading = ref(false) + const dietAiStreaming = ref(false) + const dietAiReplacing = ref(false) + const dietAiStreamField = ref('') + let dietStreamBuffer = '' + const dietAiAskText = ref('') + const dietAiAsking = ref(false) + const dietAiAskResult = ref({ advice: '', level: '', level_label: '', portion: '', food: '' }) + + const hasDietAiMealContent = computed(() => { + const r = dietAiRecommend.value + return !!(r.breakfast || r.lunch || r.dinner || r.tips) + }) + + function detectDietStreamField(text) { + const tail = String(text || '').match(/(?:^|\n)(早餐|午餐|晚餐|提示|少碰)[::]\s*([^\n]*)$/) + if (!tail) return 'breakfast' + const map = { '早餐': 'breakfast', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'tips' } + return map[tail[1]] || 'breakfast' + } + + function mergePartialDietFromStream() { + const partial = parsePartialDietPlainText(dietStreamBuffer) + const next = { ...dietAiRecommend.value } + for (const key of ['breakfast', 'lunch', 'dinner', 'tips']) { + if (partial[key] != null) next[key] = partial[key] + } + if (Array.isArray(partial.avoid)) next.avoid = partial.avoid + dietAiRecommend.value = next + dietAiStreamField.value = detectDietStreamField(dietStreamBuffer) + } + + function applyDietRecommendPayload(payload) { + if (!payload) return + const prevAnalysis = dietAiRecommend.value.analysis + const prevExercise = dietAiRecommend.value.exercise + dietAiRecommend.value = { + ...emptyDietAiRecommend(), + ...payload, + avoid: Array.isArray(payload.avoid) ? payload.avoid : [], + analysis: Array.isArray(payload.analysis) && payload.analysis.length + ? payload.analysis + : (Array.isArray(prevAnalysis) ? prevAnalysis : []), + exercise: (payload.exercise && typeof payload.exercise === 'object') + ? payload.exercise + : (prevExercise || null) + } + } + + function resolveDietDonePayload(payload) { + if (!payload) return payload + if (payload.source !== 'rule' || !dietStreamBuffer) return payload + + const streamed = parsePartialDietPlainText(dietStreamBuffer) + if (!streamed.breakfast) return payload + + return { + ...emptyDietAiRecommend(), + ...payload, + breakfast: streamed.breakfast, + lunch: streamed.lunch || payload.lunch || '', + dinner: streamed.dinner || payload.dinner || '', + tips: streamed.tips || payload.tips || '', + avoid: (Array.isArray(streamed.avoid) && streamed.avoid.length) ? streamed.avoid : (payload.avoid || []), + source: 'ai', + disclaimer: 'AI 建议供参考,仍请结合医嘱与血糖监测。' + } + } + + function onDietAiStreamEvent(event, payload) { + if (event === 'analysis') { + if (Array.isArray(payload?.analysis)) { + dietAiRecommend.value = { ...dietAiRecommend.value, analysis: payload.analysis } + } + } else if (event === 'exercise') { + if (payload?.exercise && typeof payload.exercise === 'object') { + dietAiRecommend.value = { ...dietAiRecommend.value, exercise: payload.exercise } + } + } else if (event === 'delta') { + dietAiStreaming.value = true + if (payload.buffer != null) { + dietStreamBuffer = payload.buffer + } else { + dietStreamBuffer += payload.text || '' + } + mergePartialDietFromStream() + } else if (event === 'done') { + applyDietRecommendPayload(resolveDietDonePayload(payload)) + dietStreamBuffer = '' + dietAiStreamField.value = '' + } else if (event === 'error') { + showUserToast(formatUserMessage(payload?.message, '饮食建议加载失败')) + } + } + + async function fetchDietAiRecommendFallback(refresh) { + const res = await proxy.apiUrl({ + url: '/api/tcm/dailyDietAiRecommend', + method: 'GET', + timeout: 35000, + data: { + diagnosis_id: diagnosisId.value, + refresh: refresh ? 1 : 0, + ...(refresh ? { _t: Date.now() } : {}) + } + }, false) + if (res?.code === 1 && res.data) { + applyDietRecommendPayload(res.data) + } else if (res?.msg) { + showUserToast(res.msg) + } + } + + async function fetchDietAiRecommend(refresh = false) { + if (!diagnosisId.value || dietAiLoading.value) return + dietAiLoading.value = true + dietAiStreaming.value = false + dietAiReplacing.value = !!refresh + dietStreamBuffer = '' + dietAiStreamField.value = '' + if (refresh) { + applyDietRecommendPayload(emptyDietAiRecommend()) + } + try { + await requestAiStream({ + baseUrl: proxy.$url, + url: '/api/tcm/dailyDietAiRecommendStream', + method: 'GET', + data: { + diagnosis_id: diagnosisId.value, + refresh: refresh ? 1 : 0, + ...(refresh ? { _t: Date.now() } : {}) + }, + onEvent: onDietAiStreamEvent, + fallback: () => fetchDietAiRecommendFallback(refresh) + }) + } catch (e) { + try { + await fetchDietAiRecommendFallback(refresh) + } catch (e2) { + showUserToast('饮食建议加载失败') + } + } finally { + dietAiLoading.value = false + dietAiStreaming.value = false + dietAiReplacing.value = false + dietAiStreamField.value = '' + } + } + + function refreshDietAiRecommend() { + if (dietAiLoading.value) return + fetchDietAiRecommend(true) + } + + async function askDietAiFoodFallback(q) { + const res = await proxy.apiUrl({ + url: '/api/tcm/dailyDietAiAsk', + method: 'POST', + timeout: 35000, + data: { + diagnosis_id: diagnosisId.value, + question: q + } + }, false) + if (res?.code === 1 && res.data) { + dietAiAskResult.value = { + advice: res.data.advice || '', + level: res.data.level || '', + level_label: res.data.level_label || '', + portion: res.data.portion || '', + food: res.data.food || q + } + } else { + showUserToast(formatUserMessage(res?.msg, '咨询失败')) + } + } + + async function askDietAiFood() { + const q = String(dietAiAskText.value || '').trim() + if (!q) { + showUserToast('请输入想咨询的食物') + return + } + if (!diagnosisId.value) return + dietAiAsking.value = true + dietAiAskResult.value = { advice: '', level: '', level_label: '', portion: '', food: '' } + try { + await requestAiStream({ + baseUrl: proxy.$url, + url: '/api/tcm/dailyDietAiAskStream', + method: 'POST', + data: { + diagnosis_id: diagnosisId.value, + question: q + }, + onEvent: (event, payload) => { + if (event === 'start' && !dietAiAskResult.value.advice) { + dietAiAskResult.value = { ...dietAiAskResult.value, advice: payload.message || '正在想…' } + } else if (event === 'delta') { + dietAiAskResult.value = { + ...dietAiAskResult.value, + advice: payload.advice || dietAiAskResult.value.advice + } + } else if (event === 'done') { + dietAiAskResult.value = { + advice: payload.advice || '', + level: payload.level || '', + level_label: payload.level_label || '', + portion: payload.portion || '', + food: payload.food || q + } + } else if (event === 'error') { + showUserToast(formatUserMessage(payload?.message, '咨询失败')) + } + }, + fallback: () => askDietAiFoodFallback(q) + }) + } catch (e) { + try { + await askDietAiFoodFallback(q) + } catch (e2) { + showUserToast('网络异常,请稍后再试') + } + } finally { + dietAiAsking.value = false + } + } + + function onDietAiAskInput(e) { + dietAiAskText.value = e?.detail?.value ?? '' + } + + function saveDietPrefillToStorage() { + const r = dietAiRecommend.value + if (!r.breakfast) return + try { + uni.setStorageSync(DIET_AI_PREFILL_KEY, { + breakfast: r.breakfast, + lunch: r.lunch, + dinner: r.dinner, + tips: r.tips || '' + }) + } catch (e) {} + } + + function goMoreDietForm(withPrefill = false) { + if (withPrefill) saveDietPrefillToStorage() + uni.navigateTo({ url: '/tongji/pages/more?openDiet=1' }) + } + + async function applyDietAiToForm() { + if (!dietAiRecommend.value.breakfast) { + await fetchDietAiRecommend(false) + } + goMoreDietForm(true) + } + + function openDietForm() { + goMoreDietForm(false) + } + + return { + dietAiRecommend, + dietAiLoading, + dietAiStreaming, + dietAiReplacing, + dietAiStreamField, + dietAiAskText, + dietAiAsking, + dietAiAskResult, + hasDietAiMealContent, + fetchDietAiRecommend, + refreshDietAiRecommend, + askDietAiFood, + onDietAiAskInput, + applyDietAiToForm, + openDietForm + } +} + +/** more 页打开饮食表单时读取并清除预填 */ +export function consumeDietAiPrefill() { + try { + const data = uni.getStorageSync(DIET_AI_PREFILL_KEY) + uni.removeStorageSync(DIET_AI_PREFILL_KEY) + if (data && typeof data === 'object' && data.breakfast) return data + } catch (e) {} + return null +} diff --git a/TUICallKit-Vue3/tongji/pages/index.vue b/TUICallKit-Vue3/tongji/pages/index.vue index dbb64924..457f4fcd 100644 --- a/TUICallKit-Vue3/tongji/pages/index.vue +++ b/TUICallKit-Vue3/tongji/pages/index.vue @@ -365,11 +365,236 @@ 查看更多 - 健康日历 · 饮食运动 · 家人互动 + 健康日历 · 运动 · 家人互动 › + + + + + + + + + AI 饮食建议 + 读您近 7 天 · 30 天血糖,定制今日三餐 + + + + + {{ dietAiLoading ? '生成中' : '换一换' }} + + + + + + + + + + 正在生成今日饮食建议… + + + + + 今日三餐 + 生成中… + + + + + + + + 早餐 + + + {{ dietAiRecommend.breakfast || (dietAiLoading ? '…' : '—') }}▍ + + + + + + + + 午餐 + + + {{ dietAiRecommend.lunch || (dietAiLoading ? '…' : '—') }}▍ + + + + + + + + 晚餐 + + + {{ dietAiRecommend.dinner || (dietAiLoading ? '…' : '—') }}▍ + + + + + + + + 小贴士 + + + {{ dietAiRecommend.tips || '…' }}▍ + + + + + + + 今日尽量少碰 + + + {{ item }} + + + + + + 填入今日饮食 + + + 手动记录 + + + + + + + + + + + + 运动降糖 + {{ dietExercise.level_label }} + + + {{ dietExercise.headline }} + + + {{ idx + 1 }} + {{ it }} + + + + + {{ dietExercise.intensity }} + + {{ dietExercise.note }} + + + + + 能不能吃? + 输入食物名称,结合您的档案判断 + + + + + + {{ dietAiAsking ? '…' : '问 AI' }} + + + + + {{ dietAiAskResult.level_label }} + + + {{ dietAiAskResult.advice || (dietAiAsking ? '正在想…' : '') }}▍ + + 建议份量:{{ dietAiAskResult.portion }} + + + + + + + + 以上建议依据 · 血糖回顾 + + + + {{ s.label }} + + + + {{ s.compliance }} + % + + 达标率 + + + + 偏高 + + {{ s.high_days }}天 + + + + 空腹均 + {{ dietAvgText(s.fasting_avg) }} + + + 餐后均 + {{ dietAvgText(s.postprandial_avg) }} + + + {{ s.record_days }} 天有记录 + + + + 暂无记录 + + + + + + + {{ dietAiRecommend.rules_summary }} + {{ dietAiRecommend.disclaimer }} + + + 数据仅供参考,请遵医嘱 @@ -531,6 +756,7 @@ import { ref, computed, getCurrentInstance, nextTick, watch } from 'vue' import { onLoad, onShow, onHide, onUnload, onPullDownRefresh, onShareAppMessage } from '@dcloudio/uni-app' import SugarTreeGraphic from '../components/SugarTreeGraphic.vue' import TongjiIcon from '../components/TongjiIcon.vue' +import { useDietAi } from '../composables/useDietAi.js' const { proxy } = getCurrentInstance() @@ -573,6 +799,53 @@ const patientName = ref('') const age = ref(0) const gender = ref(0) +const { + dietAiRecommend, + dietAiLoading, + dietAiStreaming, + dietAiReplacing, + dietAiStreamField, + dietAiAskText, + dietAiAsking, + dietAiAskResult, + hasDietAiMealContent, + fetchDietAiRecommend, + refreshDietAiRecommend, + askDietAiFood, + onDietAiAskInput, + applyDietAiToForm, + openDietForm +} = useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage }) + +/** AI 饮食建议依据:近7天 / 近30天 血糖统计 */ +const dietAnalysisList = computed(() => { + const list = dietAiRecommend.value && Array.isArray(dietAiRecommend.value.analysis) + ? dietAiRecommend.value.analysis + : [] + return list.filter((s) => s && typeof s === 'object') +}) + +const hasDietAnalysis = computed(() => dietAnalysisList.value.length > 0) + +/** AI 运动降糖建议(按血糖统计调整强度) */ +const dietExercise = computed(() => { + const ex = dietAiRecommend.value && dietAiRecommend.value.exercise + return ex && typeof ex === 'object' && Array.isArray(ex.items) && ex.items.length ? ex : null +}) + +/** 达标率 → 颜色档位(绿/橙/红) */ +function dietRateClass(rate) { + const n = Number(rate) + if (!Number.isFinite(n)) return 'rate-mid' + if (n >= 80) return 'rate-good' + if (n >= 50) return 'rate-mid' + return 'rate-low' +} + +function dietAvgText(v) { + return v == null || v === '' ? '—' : v +} + /** 登录 / 就诊卡门禁校验中(避免未登录时闪屏) */ const authChecking = ref(false) @@ -699,7 +972,8 @@ function toNumber(v) { function getBloodSugarThresholds(ageValue) { const a = Number(ageValue) - if (!Number.isFinite(a) || a <= 0) return null + // 年龄未知时用通用糖尿病控制目标兜底,避免高血糖不被标记 + if (!Number.isFinite(a) || a <= 0) return { fasting: 7, postprandial: 10 } if (a < 50) return { fasting: 7, postprandial: 9 } return { fasting: 8, postprandial: 10 } } @@ -1305,6 +1579,7 @@ async function fetchAll() { } else { trackingNotes.value = [] } + await fetchDietAiRecommend(false) } catch (e) { uni.showToast({ title: '加载失败,请稍后再试', icon: 'none' }) } finally { @@ -5842,4 +6117,700 @@ async function onFamilyLike() { } @import '../styles/daily-theme.scss'; + +.ai-diet-card { + margin: 24rpx 24rpx 0; + padding: 0; + overflow: hidden; + border-radius: 28rpx; + border: 1rpx solid var(--border-soft); + box-shadow: var(--shadow-premium); +} +.ai-diet-hero { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16rpx; + padding: 28rpx 24rpx 24rpx; + background: linear-gradient(135deg, var(--primary-light) 0%, #f0faf4 55%, #ffffff 100%); + border-bottom: 1rpx solid var(--border-soft); +} +.ai-diet-hero-main { + display: flex; + align-items: flex-start; + gap: 16rpx; + flex: 1; + min-width: 0; +} +.ai-diet-hero-icon { + flex-shrink: 0; + width: 72rpx; + height: 72rpx; + display: flex; + align-items: center; + justify-content: center; + border-radius: 20rpx; + background: var(--primary-grad); + box-shadow: var(--shadow-glow-primary); +} +.ai-diet-hero-copy { + flex: 1; + min-width: 0; +} +.ai-diet-title { + display: block; + font-size: 34rpx; + font-weight: 800; + color: var(--text-primary); + letter-spacing: 0.5rpx; + line-height: 1.3; +} +.ai-diet-sub { + display: block; + margin-top: 8rpx; + font-size: 24rpx; + line-height: 1.5; + color: var(--text-secondary); +} +.ai-diet-refresh { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8rpx; + min-height: 64rpx; + padding: 0 20rpx; + border-radius: 999rpx; + background: #ffffff; + border: 2rpx solid var(--border-soft); + color: var(--primary); + font-size: 24rpx; + font-weight: 700; + box-shadow: 0 4rpx 12rpx rgba(32, 78, 43, 0.06); + transition: opacity 0.2s ease, background 0.2s ease; +} +.ai-diet-refresh.disabled { + opacity: 0.55; +} + +/* ===== 建议依据:近7天 / 近30天 血糖回顾 ===== */ +.ai-diet-analysis { + padding: 22rpx 24rpx 24rpx; + background: linear-gradient(180deg, #ffffff 0%, #f6fbf7 100%); + border-top: 1rpx solid var(--border-soft); +} +.ai-diet-analysis-head { + display: flex; + align-items: center; + gap: 8rpx; + margin-bottom: 18rpx; +} +.ai-diet-analysis-title { + font-size: 26rpx; + font-weight: 700; + color: var(--primary-container); + letter-spacing: 0.5rpx; +} +.ai-diet-analysis-grid { + display: flex; + gap: 16rpx; +} +.ai-diet-analysis-col { + flex: 1; + min-width: 0; + padding: 20rpx 18rpx; + border-radius: 20rpx; + background: var(--surface); + border: 1rpx solid var(--border-soft); + box-shadow: 0 2rpx 10rpx rgba(32, 78, 43, 0.04); +} +.ai-diet-analysis-range { + display: block; + font-size: 24rpx; + font-weight: 700; + color: var(--text-secondary); + margin-bottom: 14rpx; +} +.ai-diet-analysis-rate { + display: flex; + align-items: flex-end; + gap: 10rpx; + padding-bottom: 14rpx; + margin-bottom: 14rpx; + border-bottom: 1rpx dashed var(--border-soft); +} +.ai-diet-analysis-rate-val { + display: flex; + align-items: baseline; +} +.ai-diet-analysis-rate-num { + font-size: 56rpx; + font-weight: 800; + line-height: 1; + color: var(--primary); +} +.ai-diet-analysis-rate-pct { + font-size: 26rpx; + font-weight: 700; + color: var(--primary); + margin-left: 2rpx; +} +.ai-diet-analysis-rate-label { + font-size: 24rpx; + color: var(--text-muted); + padding-bottom: 6rpx; +} +.ai-diet-analysis-rate.rate-good .ai-diet-analysis-rate-num, +.ai-diet-analysis-rate.rate-good .ai-diet-analysis-rate-pct { + color: var(--success); +} +.ai-diet-analysis-rate.rate-mid .ai-diet-analysis-rate-num, +.ai-diet-analysis-rate.rate-mid .ai-diet-analysis-rate-pct { + color: var(--warning); +} +.ai-diet-analysis-rate.rate-low .ai-diet-analysis-rate-num, +.ai-diet-analysis-rate.rate-low .ai-diet-analysis-rate-pct { + color: var(--danger); +} +.ai-diet-analysis-metrics { + display: flex; + justify-content: space-between; + gap: 8rpx; +} +.ai-diet-analysis-metric { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6rpx; +} +.ai-diet-analysis-m-label { + font-size: 22rpx; + color: var(--text-muted); +} +.ai-diet-analysis-m-value { + font-size: 30rpx; + font-weight: 700; + color: var(--text-primary); + line-height: 1.1; +} +.ai-diet-analysis-m-value.is-warn { + color: var(--danger); +} +.ai-diet-analysis-m-unit { + font-size: 20rpx; + font-weight: 600; + color: var(--text-muted); + margin-left: 2rpx; +} +.ai-diet-analysis-days { + display: block; + margin-top: 14rpx; + font-size: 22rpx; + color: var(--text-muted); +} +.ai-diet-analysis-empty { + display: flex; + align-items: center; + gap: 8rpx; + padding: 18rpx 0; + font-size: 24rpx; + color: var(--text-muted); +} + +/* ===== 今日三餐分组标题 ===== */ +.ai-diet-section-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14rpx; +} +.ai-diet-section-title { + font-size: 28rpx; + font-weight: 800; + color: var(--text-primary); + letter-spacing: 0.5rpx; +} +.ai-diet-section-tag { + font-size: 22rpx; + font-weight: 600; + color: var(--primary-container); + padding: 4rpx 16rpx; + border-radius: 999rpx; + background: var(--primary-light); +} + +.ai-diet-skeleton { + padding: 24rpx; +} +.ai-diet-skeleton-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12rpx; + margin-bottom: 20rpx; +} +.ai-diet-skeleton-tag { + width: 88rpx; + height: 40rpx; + border-radius: 12rpx; + background: linear-gradient(90deg, var(--slate-100) 25%, var(--slate-200) 50%, var(--slate-100) 75%); + background-size: 200% 100%; + animation: ai-diet-shimmer 1.2s ease-in-out infinite; +} +.ai-diet-skeleton-line { + flex: 1; + min-width: 60%; + height: 28rpx; + border-radius: 8rpx; + background: linear-gradient(90deg, var(--slate-100) 25%, var(--slate-200) 50%, var(--slate-100) 75%); + background-size: 200% 100%; + animation: ai-diet-shimmer 1.2s ease-in-out infinite; +} +.ai-diet-skeleton-line.short { + flex-basis: 100%; + min-width: 40%; + max-width: 72%; + animation-delay: 0.15s; +} +.ai-diet-skeleton-hint { + display: block; + margin-top: 8rpx; + text-align: center; + font-size: 26rpx; + color: var(--primary-container); +} +@keyframes ai-diet-shimmer { + 0% { background-position: 100% 0; } + 100% { background-position: -100% 0; } +} +@media (prefers-reduced-motion: reduce) { + .ai-diet-skeleton-tag, + .ai-diet-skeleton-line { + animation: none; + background: var(--slate-100); + } +} +.ai-diet-body { + padding: 20rpx 24rpx 8rpx; +} +.ai-diet-body.is-streaming .ai-diet-meal-text { + transition: color 0.15s ease; +} +.ai-diet-meal-list { + display: flex; + flex-direction: column; + gap: 12rpx; +} +.ai-diet-meal-card { + padding: 20rpx; + border-radius: 20rpx; + background: var(--surface); + border: 1rpx solid var(--border-soft); + border-left-width: 6rpx; +} +.ai-diet-meal-card.meal-breakfast { + border-left-color: #d97706; + background: linear-gradient(90deg, rgba(217, 119, 6, 0.06) 0%, #ffffff 28%); +} +.ai-diet-meal-card.meal-lunch { + border-left-color: var(--primary); + background: linear-gradient(90deg, var(--primary-glow) 0%, #ffffff 28%); +} +.ai-diet-meal-card.meal-dinner { + border-left-color: #4f46e5; + background: linear-gradient(90deg, rgba(79, 70, 229, 0.06) 0%, #ffffff 28%); +} +.ai-diet-meal-head { + display: flex; + align-items: center; + gap: 10rpx; + margin-bottom: 10rpx; +} +.ai-diet-meal-icon { + width: 44rpx; + height: 44rpx; + display: flex; + align-items: center; + justify-content: center; + border-radius: 12rpx; + background: var(--primary-light); +} +.ai-diet-meal-label { + font-size: 26rpx; + font-weight: 800; + color: var(--primary); + letter-spacing: 1rpx; +} +.ai-diet-meal-text { + display: block; + font-size: 30rpx; + line-height: 1.6; + color: var(--text-primary); +} +.ai-diet-tip-box { + margin-top: 16rpx; + padding: 18rpx 20rpx; + border-radius: 16rpx; + background: var(--primary-light); + border: 1rpx solid rgba(32, 78, 43, 0.12); +} +.ai-diet-tip-head { + display: flex; + align-items: center; + gap: 8rpx; + margin-bottom: 8rpx; +} +.ai-diet-tip-label { + font-size: 24rpx; + font-weight: 700; + color: var(--primary-container); +} +.ai-diet-tip-text { + display: block; + font-size: 28rpx; + line-height: 1.55; + color: var(--text-secondary); +} +.ai-diet-avoid { + margin-top: 16rpx; + padding: 18rpx 20rpx; + border-radius: 16rpx; + background: var(--danger-light); + border: 1rpx solid rgba(220, 38, 38, 0.15); +} +.ai-diet-avoid-head { + display: flex; + align-items: center; + gap: 8rpx; + margin-bottom: 12rpx; +} +.ai-diet-avoid-label { + font-size: 26rpx; + color: #b91c1c; + font-weight: 700; +} +.ai-diet-avoid-tags { + display: flex; + flex-wrap: wrap; + gap: 10rpx; +} +.ai-diet-avoid-tag { + padding: 8rpx 16rpx; + border-radius: 999rpx; + font-size: 24rpx; + color: #991b1b; + background: #ffffff; + border: 1rpx solid rgba(220, 38, 38, 0.2); +} +.ai-diet-actions { + display: flex; + gap: 16rpx; + margin-top: 24rpx; + padding-bottom: 8rpx; +} +.ai-diet-btn { + flex: 1; + min-height: 88rpx; + display: flex; + align-items: center; + justify-content: center; + border-radius: 20rpx; + font-size: 28rpx; + font-weight: 700; + transition: opacity 0.2s ease; +} +.ai-diet-btn.primary { + background: var(--primary-grad); + color: #ffffff; + box-shadow: var(--shadow-glow-primary); + border: none; +} +.ai-diet-btn.ghost { + background: #ffffff; + border: 2rpx solid var(--border-soft); + color: var(--primary-container); +} + +/* ===== 运动降糖建议 ===== */ +.ai-diet-exercise { + margin: 8rpx 24rpx 24rpx; + padding: 24rpx; + border-radius: 20rpx; + background: linear-gradient(180deg, #f3f9f5 0%, #ffffff 60%); + border: 1rpx solid var(--border-soft); + border-left: 6rpx solid var(--primary); +} +.ai-diet-exercise.lv-high { + border-left-color: var(--danger); + background: linear-gradient(180deg, #fef4f3 0%, #ffffff 60%); +} +.ai-diet-exercise.lv-medium { + border-left-color: var(--warning); + background: linear-gradient(180deg, #fff8f0 0%, #ffffff 60%); +} +.ai-diet-exercise-head { + display: flex; + align-items: center; + gap: 12rpx; + margin-bottom: 14rpx; +} +.ai-diet-exercise-icon { + flex-shrink: 0; + width: 52rpx; + height: 52rpx; + display: flex; + align-items: center; + justify-content: center; + border-radius: 14rpx; + background: var(--primary-grad); + box-shadow: var(--shadow-glow-primary); +} +.ai-diet-exercise.lv-high .ai-diet-exercise-icon { + background: linear-gradient(160deg, #dc2626 0%, #ef4444 100%); + box-shadow: 0 8rpx 20rpx rgba(220, 38, 38, 0.18); +} +.ai-diet-exercise.lv-medium .ai-diet-exercise-icon { + background: linear-gradient(160deg, #d97706 0%, #f59e0b 100%); + box-shadow: 0 8rpx 20rpx rgba(217, 119, 6, 0.18); +} +.ai-diet-exercise-head-text { + display: flex; + align-items: center; + gap: 12rpx; + flex: 1; + min-width: 0; +} +.ai-diet-exercise-title { + font-size: 30rpx; + font-weight: 800; + color: var(--text-primary); + letter-spacing: 0.5rpx; +} +.ai-diet-exercise-tag { + font-size: 22rpx; + font-weight: 700; + padding: 4rpx 16rpx; + border-radius: 999rpx; + color: var(--primary); + background: var(--primary-light); +} +.ai-diet-exercise-tag.lv-high { + color: var(--danger); + background: var(--danger-light); +} +.ai-diet-exercise-tag.lv-medium { + color: var(--warning); + background: var(--warning-light); +} +.ai-diet-exercise-headline { + display: block; + font-size: 28rpx; + line-height: 1.55; + color: var(--text-secondary); + margin-bottom: 16rpx; +} +.ai-diet-exercise-list { + display: flex; + flex-direction: column; + gap: 12rpx; +} +.ai-diet-exercise-item { + display: flex; + align-items: flex-start; + gap: 14rpx; +} +.ai-diet-exercise-bullet { + flex-shrink: 0; + width: 40rpx; + height: 40rpx; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--primary-light); + color: var(--primary); + font-size: 24rpx; + font-weight: 800; +} +.ai-diet-exercise.lv-high .ai-diet-exercise-bullet { + background: var(--danger-light); + color: var(--danger); +} +.ai-diet-exercise.lv-medium .ai-diet-exercise-bullet { + background: var(--warning-light); + color: var(--warning); +} +.ai-diet-exercise-item-text { + flex: 1; + min-width: 0; + font-size: 30rpx; + line-height: 1.55; + color: var(--text-primary); +} +.ai-diet-exercise-meta { + display: flex; + align-items: center; + gap: 8rpx; + margin-top: 18rpx; + padding: 12rpx 18rpx; + border-radius: 14rpx; + background: var(--surface-muted); + font-size: 24rpx; + font-weight: 600; + color: var(--text-secondary); +} +.ai-diet-exercise-note { + display: block; + margin-top: 14rpx; + font-size: 24rpx; + line-height: 1.55; + color: var(--text-muted); +} + +.ai-diet-ask-panel { + margin: 8rpx 24rpx 24rpx; + padding: 24rpx; + border-radius: 20rpx; + background: var(--slate-50); + border: 1rpx solid var(--border-soft); +} +.ai-diet-ask-head { + margin-bottom: 16rpx; +} +.ai-diet-ask-title { + display: block; + font-size: 30rpx; + font-weight: 800; + color: var(--primary); + line-height: 1.35; +} +.ai-diet-ask-hint { + display: block; + margin-top: 6rpx; + font-size: 24rpx; + color: var(--text-muted); + line-height: 1.45; +} +.ai-diet-ask-row { + display: flex; + gap: 12rpx; + align-items: stretch; +} +.ai-diet-ask-input { + flex: 1; + min-height: 88rpx; + padding: 0 24rpx; + border-radius: 16rpx; + background: #ffffff; + border: 2rpx solid var(--border-soft); + font-size: 30rpx; + color: var(--text-primary); + box-sizing: border-box; +} +.ai-diet-ask-btn { + flex-shrink: 0; + min-width: 148rpx; + min-height: 88rpx; + display: flex; + align-items: center; + justify-content: center; + gap: 6rpx; + padding: 0 20rpx; + border-radius: 16rpx; + background: var(--primary-grad); + color: #ffffff; + font-size: 28rpx; + font-weight: 700; + box-shadow: var(--shadow-glow-primary); +} +.ai-diet-ask-btn.disabled { + opacity: 0.55; +} +.ai-diet-ask-result { + margin-top: 16rpx; + padding: 20rpx; + border-radius: 16rpx; + background: #ffffff; + border: 1rpx solid var(--border-soft); +} +.ai-diet-ask-result.lv-high { + border-color: rgba(220, 38, 38, 0.25); + background: var(--danger-light); +} +.ai-diet-ask-result.lv-medium { + border-color: rgba(199, 125, 10, 0.3); + background: var(--warning-light); +} +.ai-diet-ask-badge { + display: inline-block; + padding: 6rpx 18rpx; + border-radius: 999rpx; + font-size: 22rpx; + font-weight: 700; + margin-bottom: 12rpx; + background: var(--primary-light); + border: 2rpx solid var(--primary); + color: var(--primary); +} +.ai-diet-ask-badge.lv-high { + border-color: #dc2626; + background: #ffffff; + color: #dc2626; +} +.ai-diet-ask-badge.lv-medium { + border-color: #c77d0a; + background: #ffffff; + color: #c77d0a; +} +.ai-diet-ask-badge.lv-low { + border-color: var(--primary); + background: #ffffff; + color: var(--primary); +} +.ai-diet-ask-advice { + display: block; + font-size: 30rpx; + line-height: 1.6; + color: var(--text-primary); +} +.ai-stream-cursor { + display: inline; + color: var(--primary-container); + animation: ai-cursor-blink 0.9s step-end infinite; +} +@keyframes ai-cursor-blink { + 50% { opacity: 0; } +} +@media (prefers-reduced-motion: reduce) { + .ai-stream-cursor { + animation: none; + opacity: 0.6; + } +} +.ai-diet-ask-portion { + display: block; + margin-top: 12rpx; + padding-top: 12rpx; + border-top: 1rpx dashed var(--border-soft); + font-size: 26rpx; + color: var(--primary-container); + font-weight: 600; +} +.ai-diet-footer { + padding: 0 24rpx 24rpx; +} +.ai-diet-rules { + display: block; + font-size: 24rpx; + color: var(--primary); + line-height: 1.5; + font-weight: 600; +} +.ai-diet-disclaimer { + display: block; + margin-top: 10rpx; + font-size: 22rpx; + color: var(--text-muted); + line-height: 1.45; +} diff --git a/TUICallKit-Vue3/tongji/pages/more.vue b/TUICallKit-Vue3/tongji/pages/more.vue index 71350dae..1f0cd174 100644 --- a/TUICallKit-Vue3/tongji/pages/more.vue +++ b/TUICallKit-Vue3/tongji/pages/more.vue @@ -33,90 +33,6 @@ - - - - - AI 饮食助手 - 午饭多蛋白 · 淀粉不重复 - - - - {{ dietAiLoading ? '生成中' : '换一换' }} - - - - - 正在生成今日饮食建议… - - - - - 早餐 - - {{ dietAiRecommend.breakfast || (dietAiLoading ? '…' : '—') }}▍ - - - - 午餐 - - {{ dietAiRecommend.lunch || (dietAiLoading ? '…' : '—') }}▍ - - - - 晚餐 - - {{ dietAiRecommend.dinner || (dietAiLoading ? '…' : '—') }}▍ - - - - {{ dietAiRecommend.tips || '…' }}▍ - - - 今日尽量少碰: - {{ dietAiRecommend.avoid.join('、') }} - - - - 填入今日饮食 - - - 手动记录 - - - - - - 能不能吃? - - - - {{ dietAiAsking ? '…' : '问 AI' }} - - - - - {{ dietAiAskResult.level_label }} - - - {{ dietAiAskResult.advice || (dietAiAsking ? '正在想…' : '') }}▍ - - 建议:{{ dietAiAskResult.portion }} - - - {{ dietAiRecommend.rules_summary }} - {{ dietAiRecommend.disclaimer }} - - @@ -745,9 +661,9 @@ 加载中… - + AI 推荐 - 早 {{ dietAiRecommend.breakfast }} · 午 {{ dietAiRecommend.lunch }} · 晚 {{ dietAiRecommend.dinner }} + 早 {{ dietAiPrefill.breakfast }} · 午 {{ dietAiPrefill.lunch }} · 晚 {{ dietAiPrefill.dinner }} 一键填入 @@ -882,7 +798,7 @@ import SugarTreeGraphic from '../components/SugarTreeGraphic.vue' import TongjiIcon from '../components/TongjiIcon.vue' import CelebrateBurst from '../components/CelebrateBurst.vue' import { TREE_LEVELS, TREE_MAX_LEVEL, calcTreeFromPoints, pickTreeWhisper } from '../utils/treeLevels.js' -import { requestAiStream, parsePartialDietPlainText } from '../utils/aiStreamRequest.js' +import { consumeDietAiPrefill } from '../composables/useDietAi.js' const { proxy } = getCurrentInstance() @@ -1529,7 +1445,6 @@ async function fetchAll() { // 拉取游戏化积分/小树状态 await fetchGamifyState() await loadFamilyLikeSummary() - await fetchDietAiRecommend(false) } catch (e) { uni.showToast({ title: '加载失败,请稍后再试', icon: 'none' }) } finally { @@ -1981,239 +1896,11 @@ const emptyDietForm = () => ({ note: '' }) const dietForm = ref(emptyDietForm()) - -const emptyDietAiRecommend = () => ({ - breakfast: '', - lunch: '', - dinner: '', - tips: '', - avoid: [], - disclaimer: '', - rules_summary: '', - source: '', - date: '' -}) -const dietAiRecommend = ref(emptyDietAiRecommend()) -const dietAiLoading = ref(false) -const dietAiStreaming = ref(false) -const dietAiReplacing = ref(false) -const dietAiStreamField = ref('') -let dietStreamBuffer = '' -const dietAiAskText = ref('') -const dietAiAsking = ref(false) -const dietAiAskResult = ref({ advice: '', level: '', level_label: '', portion: '', food: '' }) - -const hasDietAiMealContent = computed(() => { - const r = dietAiRecommend.value - return !!(r.breakfast || r.lunch || r.dinner || r.tips) -}) - -function detectDietStreamField(text) { - const tail = String(text || '').match(/(?:^|\n)(早餐|午餐|晚餐|提示|少碰)[::]\s*([^\n]*)$/) - if (!tail) return 'breakfast' - const map = { '早餐': 'breakfast', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'tips' } - return map[tail[1]] || 'breakfast' -} - -function mergePartialDietFromStream() { - const partial = parsePartialDietPlainText(dietStreamBuffer) - const next = { ...dietAiRecommend.value } - for (const key of ['breakfast', 'lunch', 'dinner', 'tips']) { - if (partial[key] != null) next[key] = partial[key] - } - if (Array.isArray(partial.avoid)) next.avoid = partial.avoid - dietAiRecommend.value = next - dietAiStreamField.value = detectDietStreamField(dietStreamBuffer) -} - -function applyDietRecommendPayload(payload) { - if (!payload) return - dietAiRecommend.value = { - ...emptyDietAiRecommend(), - ...payload, - avoid: Array.isArray(payload.avoid) ? payload.avoid : [] - } -} - -/** 流式已展示 AI 内容时,避免 done 用规则模板覆盖 */ -function resolveDietDonePayload(payload) { - if (!payload) return payload - if (payload.source !== 'rule' || !dietStreamBuffer) return payload - - const streamed = parsePartialDietPlainText(dietStreamBuffer) - if (!streamed.breakfast) return payload - - return { - ...emptyDietAiRecommend(), - ...payload, - breakfast: streamed.breakfast, - lunch: streamed.lunch || payload.lunch || '', - dinner: streamed.dinner || payload.dinner || '', - tips: streamed.tips || payload.tips || '', - avoid: (Array.isArray(streamed.avoid) && streamed.avoid.length) ? streamed.avoid : (payload.avoid || []), - source: 'ai', - disclaimer: 'AI 建议供参考,仍请结合医嘱与血糖监测。' - } -} - -function onDietAiStreamEvent(event, payload) { - if (event === 'delta') { - dietAiStreaming.value = true - if (payload.buffer != null) { - dietStreamBuffer = payload.buffer - } else { - dietStreamBuffer += payload.text || '' - } - mergePartialDietFromStream() - } else if (event === 'done') { - applyDietRecommendPayload(resolveDietDonePayload(payload)) - dietStreamBuffer = '' - dietAiStreamField.value = '' - } else if (event === 'error') { - showUserToast(formatUserMessage(payload?.message, '饮食建议加载失败')) - } -} - -async function fetchDietAiRecommendFallback(refresh) { - const res = await proxy.apiUrl({ - url: '/api/tcm/dailyDietAiRecommend', - method: 'GET', - timeout: 35000, - data: { - diagnosis_id: diagnosisId.value, - refresh: refresh ? 1 : 0, - ...(refresh ? { _t: Date.now() } : {}) - } - }, false) - if (res?.code === 1 && res.data) { - applyDietRecommendPayload(res.data) - } else if (res?.msg) { - showUserToast(res.msg) - } -} - -async function fetchDietAiRecommend(refresh = false) { - if (!diagnosisId.value || dietAiLoading.value) return - dietAiLoading.value = true - dietAiStreaming.value = false - dietAiReplacing.value = !!refresh - dietStreamBuffer = '' - dietAiStreamField.value = '' - if (refresh) { - applyDietRecommendPayload(emptyDietAiRecommend()) - } - try { - await requestAiStream({ - baseUrl: proxy.$url, - url: '/api/tcm/dailyDietAiRecommendStream', - method: 'GET', - data: { - diagnosis_id: diagnosisId.value, - refresh: refresh ? 1 : 0, - ...(refresh ? { _t: Date.now() } : {}) - }, - onEvent: onDietAiStreamEvent, - fallback: () => fetchDietAiRecommendFallback(refresh) - }) - } catch (e) { - try { - await fetchDietAiRecommendFallback(refresh) - } catch (e2) { - showUserToast('饮食建议加载失败') - } - } finally { - dietAiLoading.value = false - dietAiStreaming.value = false - dietAiReplacing.value = false - dietAiStreamField.value = '' - } -} - -function refreshDietAiRecommend() { - if (dietAiLoading.value) return - fetchDietAiRecommend(true) -} - -async function askDietAiFoodFallback(q) { - const res = await proxy.apiUrl({ - url: '/api/tcm/dailyDietAiAsk', - method: 'POST', - timeout: 35000, - data: { - diagnosis_id: diagnosisId.value, - question: q - } - }, false) - if (res?.code === 1 && res.data) { - dietAiAskResult.value = { - advice: res.data.advice || '', - level: res.data.level || '', - level_label: res.data.level_label || '', - portion: res.data.portion || '', - food: res.data.food || q - } - } else { - showUserToast(formatUserMessage(res?.msg, '咨询失败')) - } -} - -async function askDietAiFood() { - const q = String(dietAiAskText.value || '').trim() - if (!q) { - showUserToast('请输入想咨询的食物') - return - } - if (!diagnosisId.value) return - dietAiAsking.value = true - dietAiAskResult.value = { advice: '', level: '', level_label: '', portion: '', food: '' } - try { - await requestAiStream({ - baseUrl: proxy.$url, - url: '/api/tcm/dailyDietAiAskStream', - method: 'POST', - data: { - diagnosis_id: diagnosisId.value, - question: q - }, - onEvent: (event, payload) => { - if (event === 'start' && !dietAiAskResult.value.advice) { - dietAiAskResult.value = { ...dietAiAskResult.value, advice: payload.message || '正在想…' } - } else if (event === 'delta') { - dietAiAskResult.value = { - ...dietAiAskResult.value, - advice: payload.advice || dietAiAskResult.value.advice - } - } else if (event === 'done') { - dietAiAskResult.value = { - advice: payload.advice || '', - level: payload.level || '', - level_label: payload.level_label || '', - portion: payload.portion || '', - food: payload.food || q - } - } else if (event === 'error') { - showUserToast(formatUserMessage(payload?.message, '咨询失败')) - } - }, - fallback: () => askDietAiFoodFallback(q) - }) - } catch (e) { - try { - await askDietAiFoodFallback(q) - } catch (e2) { - showUserToast('网络异常,请稍后再试') - } - } finally { - dietAiAsking.value = false - } -} - -function onDietAiAskInput(e) { - dietAiAskText.value = e?.detail?.value ?? '' -} +const dietAiPrefill = ref(null) function fillDietFormFromAi() { - const r = dietAiRecommend.value + const r = dietAiPrefill.value + if (!r) return if (r.breakfast) dietForm.value.breakfast_foods = r.breakfast if (r.lunch) dietForm.value.lunch_foods = r.lunch if (r.dinner) dietForm.value.dinner_foods = r.dinner @@ -2223,19 +1910,15 @@ function fillDietFormFromAi() { } } -async function applyDietAiToForm() { - if (!dietAiRecommend.value.breakfast) { - await fetchDietAiRecommend(false) - } - await openDietForm() - fillDietFormFromAi() -} - function onDietField(field, e) { dietForm.value[field] = e?.detail?.value ?? '' } -async function openDietForm() { +async function openDietForm(options = {}) { + const autoFill = !!options.autoFill + if (!dietAiPrefill.value) { + dietAiPrefill.value = consumeDietAiPrefill() + } if (!diagnosisId.value) { showUserToast('请先选择就诊卡') return @@ -2264,12 +1947,16 @@ async function openDietForm() { } catch (e) {} finally { dietForm.value.loading = false + if (autoFill && dietAiPrefill.value?.breakfast) { + fillDietFormFromAi() + } } } function closeDietForm() { if (dietForm.value.submitting) return dietForm.value.visible = false + dietAiPrefill.value = null nextTick(() => {}) } @@ -3165,6 +2852,9 @@ async function bootstrap(options) { authChecking.value = false await fetchAll() + if (options.openDiet === '1' || options.openDiet === 1) { + await openDietForm({ autoFill: true }) + } } onLoad((options) => { @@ -6983,217 +6673,6 @@ function onFamilyLikeStripTap() { @import '../styles/daily-theme.scss'; -.ai-diet-card { - margin: 24rpx 24rpx 0; -} -.ai-diet-head { - align-items: flex-start; -} -.ai-diet-refresh { - display: flex; - align-items: center; - gap: 8rpx; - padding: 12rpx 20rpx; - border-radius: 999rpx; - border: 2rpx solid rgba(32, 78, 43, 0.25); - background: #fff; - color: var(--primary); - font-size: 24rpx; - font-weight: 600; -} -.ai-diet-refresh.disabled { - opacity: 0.5; -} -.ai-diet-loading { - padding: 32rpx 0; - text-align: center; - color: var(--primary-container); - font-size: 28rpx; -} -.ai-diet-meals { - padding-top: 8rpx; -} -.ai-diet-meals.is-streaming .ai-diet-meal-text { - transition: color 0.15s ease; -} -.ai-diet-meal { - display: flex; - gap: 16rpx; - align-items: flex-start; - padding: 16rpx 0; - border-bottom: 1rpx solid rgba(32, 78, 43, 0.08); -} -.ai-diet-meal:last-of-type { - border-bottom: none; -} -.ai-diet-meal-tag { - flex-shrink: 0; - min-width: 72rpx; - padding: 6rpx 12rpx; - border-radius: 12rpx; - background: #fff; - border: 2rpx solid var(--primary); - color: var(--primary); - font-size: 24rpx; - font-weight: 700; - text-align: center; -} -.ai-diet-meal-text { - flex: 1; - font-size: 28rpx; - line-height: 1.55; - color: #1b1c1a; -} -.ai-diet-tips { - display: block; - margin-top: 16rpx; - font-size: 26rpx; - line-height: 1.5; - color: var(--primary-container); -} -.ai-diet-avoid { - margin-top: 12rpx; - padding: 16rpx; - border-radius: 16rpx; - background: rgba(220, 38, 38, 0.06); - border: 1rpx solid rgba(220, 38, 38, 0.12); -} -.ai-diet-avoid-label { - font-size: 24rpx; - color: #b91c1c; - font-weight: 600; -} -.ai-diet-avoid-text { - font-size: 26rpx; - color: #991b1b; -} -.ai-diet-actions { - display: flex; - gap: 16rpx; - margin-top: 24rpx; -} -.ai-diet-btn { - flex: 1; - min-height: 80rpx; - display: flex; - align-items: center; - justify-content: center; - border-radius: 20rpx; - background: #fff; - border: 2rpx solid var(--primary); - color: var(--primary); - font-size: 28rpx; - font-weight: 700; -} -.ai-diet-btn.outline { - border-color: rgba(32, 78, 43, 0.25); - color: var(--primary-container); -} -.ai-diet-ask { - margin-top: 28rpx; - padding-top: 24rpx; - border-top: 1rpx solid rgba(32, 78, 43, 0.1); -} -.ai-diet-ask-title { - display: block; - font-size: 28rpx; - font-weight: 700; - color: var(--primary); - margin-bottom: 16rpx; -} -.ai-diet-ask-row { - display: flex; - gap: 12rpx; - align-items: center; -} -.ai-diet-ask-input { - flex: 1; - height: 80rpx; - padding: 0 24rpx; - border-radius: 16rpx; - background: #fff; - border: 2rpx solid rgba(32, 78, 43, 0.2); - font-size: 28rpx; - color: #1b1c1a; -} -.ai-diet-ask-btn { - flex-shrink: 0; - min-width: 128rpx; - height: 80rpx; - display: flex; - align-items: center; - justify-content: center; - border-radius: 16rpx; - background: #fff; - border: 2rpx solid var(--primary); - color: var(--primary); - font-size: 28rpx; - font-weight: 700; -} -.ai-diet-ask-btn.disabled { - opacity: 0.5; -} -.ai-diet-ask-result { - margin-top: 16rpx; - padding: 20rpx; - border-radius: 16rpx; - background: rgba(32, 78, 43, 0.04); - border: 1rpx solid rgba(32, 78, 43, 0.12); -} -.ai-diet-ask-badge { - display: inline-block; - padding: 4rpx 16rpx; - border-radius: 999rpx; - font-size: 22rpx; - font-weight: 700; - margin-bottom: 12rpx; - background: #fff; - border: 2rpx solid var(--primary); - color: var(--primary); -} -.ai-diet-ask-badge.lv-high { - border-color: #dc2626; - color: #dc2626; -} -.ai-diet-ask-badge.lv-medium { - border-color: #c77d0a; - color: #c77d0a; -} -.ai-diet-ask-advice { - display: block; - font-size: 28rpx; - line-height: 1.55; - color: #1b1c1a; -} -.ai-stream-cursor { - display: inline; - color: #386641; - animation: ai-cursor-blink 0.9s step-end infinite; -} -@keyframes ai-cursor-blink { - 50% { opacity: 0; } -} -.ai-diet-ask-portion { - display: block; - margin-top: 8rpx; - font-size: 24rpx; - color: var(--primary-container); -} -.ai-diet-rules { - display: block; - margin-top: 16rpx; - font-size: 24rpx; - color: var(--primary); - line-height: 1.45; - font-weight: 600; -} -.ai-diet-disclaimer { - display: block; - margin-top: 20rpx; - font-size: 22rpx; - color: #727970; - line-height: 1.4; -} .input-modal-ai-tip { margin-bottom: 20rpx; padding: 20rpx; diff --git a/admin/src/components/tcm-prescription/index.vue b/admin/src/components/tcm-prescription/index.vue index 4a6aba21..0b677816 100644 --- a/admin/src/components/tcm-prescription/index.vue +++ b/admin/src/components/tcm-prescription/index.vue @@ -518,6 +518,13 @@ + + + + 已作废 已通过 @@ -597,49 +604,76 @@ - 药房联 + {{ prescriptionTabType === 'internal' ? '药房联' : '处方联' }} Rp. 用药 (单剂) - 总量 + {{ prescriptionTabType === 'user' ? '用量' : '总量' }} 用药 (单剂) - 总量 + {{ prescriptionTabType === 'user' ? '用量' : '总量' }} - - 主方 - - {{ h.name }} ({{ h.dosage }}克) - {{ rxHerbTotal(h.dosage) }}克 - + + + 主方 + + {{ h.name }} ({{ h.dosage }}克) + {{ rxHerbTotal(h.dosage) }}克 + + + + 辅方 + + {{ h.name }} ({{ h.dosage }}克) + {{ rxHerbTotal(h.dosage) }}克 + + - - 辅方 - - {{ h.name }} ({{ h.dosage }}克) - {{ rxHerbTotal(h.dosage) }}克 - + + + 主方 + + {{ h.name }} + {{ h.dosage }}克 + + + + 辅方 + + {{ h.name }} + {{ h.dosage }}克 + + - 主方服法:{{ rxUsageText }} - 辅方服法:{{ savedAuxUsageText }} + 主方服法:{{ rxUsageText }} + 服法:{{ rxUsageText }} + {{ prescriptionTabType === 'user' ? '辅服法' : '辅方服法' }}:{{ savedAuxUsageText }} 医嘱:{{ rxAdviceText }} 备注:{{ rxRemarkText }} @@ -671,18 +705,22 @@ 天数: - {{ savedPrescription.dose_count || '—' }}剂 + {{ rxSlipMedicationDaysText }} - 单剂量: + 剂量: {{ rxPerDoseAmount }}克 + + 成都双流甄养堂互联网医院有限公司 联系方式:4001667339 + 地址:四川省成都市双流区黄甲街道黄龙大道二段280号 + - + {{ templateConfig.stationName }} 详细病历 @@ -1148,6 +1186,10 @@ const signatureActivePointerId = ref(null) const SIGNATURE_CSS_W = 560 const SIGNATURE_CSS_H = 168 const savedPrescription = ref(null) +/** 预览联次:药房联 / 处方联(与消费者处方查看一致) */ +const prescriptionTabType = ref<'internal' | 'user'>('internal') +/** 诊单列表「查看」仅展示处方联,不展示详细病历 A3 */ +const hideCaseRecordPreview = ref(false) /** 已通过且未作废:与消费者处方「查看」一致,仅预览,不可作废/再开新方 */ const approvedPreviewOnly = computed(() => { @@ -1279,38 +1321,76 @@ const rxPharmacyRemarkText = computed(() => { return v.pharmacy_remark || v.pharmacy_note || '' }) -const rxOutPelletText = computed(() => { +/** 笺面用药天数:与 consumer/prescription/order_list 一致 */ +const rxSlipMedicationDaysText = computed(() => { const v = savedPrescription.value as any - if (!v) return '' - const out = computeRxOutPellet(v) - return out ? `${out}克` : '' + if (!v) return '—' + const md = v.medication_days ?? v.usage_days + if (md != null && String(md).trim() !== '') return `${md} 天` + const dc = v.dose_count + if (dc != null && String(dc).trim() !== '' && Number(dc) > 0) return `${dc} 天` + return '—' }) -function computeRxOutPellet(v: any): string { +/** 主方出丸:用药疗程 medication_days → usage_days → dose_count;出丸 = 每袋用量×袋数×每天次数×服用天数 */ +const slipPillGrams = computed(() => { + const d = savedPrescription.value as any + if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return null + const days = Number(d.medication_days ?? d.usage_days ?? d.dose_count) + const times = Number(d.times_per_day) + const doseG = Number(d.dosage_amount) + const bags = Number(d.dosage_bag_count) > 0 ? Number(d.dosage_bag_count) : 1 + if (!Number.isFinite(days) || !Number.isFinite(times) || !Number.isFinite(doseG)) return null + if (days <= 0 || times <= 0 || doseG <= 0) return null + return days * times * doseG * bags +}) + +/** 辅方出丸:公式同主方,用 aux_usage 字段 */ +const slipAuxPillGrams = computed(() => { + const d = savedPrescription.value as any + if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return null + if (!savedAuxHerbs.value.length) return null + const aux = normalizeAuxUsageForm(d.aux_usage, d.prescription_type || '浓缩水丸') + const days = Number(d.medication_days ?? aux.usage_days) + const times = Number(aux.times_per_day) + const doseG = Number(aux.dosage_amount) + const bags = Number(aux.dosage_bag_count) > 0 ? Number(aux.dosage_bag_count) : 1 + if (!Number.isFinite(days) || !Number.isFinite(times) || !Number.isFinite(doseG)) return null + if (days <= 0 || times <= 0 || doseG <= 0) return null + return days * times * doseG * bags +}) + +/** 出丸:优先 slipPillGrams + slipAuxPillGrams,否则 out_pellet / 药材总量×剂数 */ +const rxOutPelletGrams = computed(() => { + const main = slipPillGrams.value + const aux = slipAuxPillGrams.value + const mainNum = typeof main === 'number' && isFinite(main) && main > 0 ? main : 0 + const auxNum = typeof aux === 'number' && isFinite(aux) && aux > 0 ? aux : 0 + const sum = mainNum + auxNum + if (sum > 0) return formatRxNum(sum) + const v = savedPrescription.value as any if (!v) return '' - if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') { - const days = Number(v.usage_days ?? v.dose_count) - const times = Number(v.times_per_day) - const doseG = Number(v.dosage_amount) - const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1 - if ( - Number.isFinite(days) && - Number.isFinite(times) && - Number.isFinite(doseG) && - days > 0 && - times > 0 && - doseG > 0 - ) { - return formatRxNum(days * times * doseG * bags) - } - } const explicit = v.out_pellet || v.total_weight if (explicit && Number(explicit) > 0) return formatRxNum(Number(explicit)) const list = savedSlipHerbsList.value if (!list.length) return '' const perDose = list.reduce((acc: number, h: any) => acc + (Number(h?.dosage) || 0), 0) return formatRxNum(perDose * rxDoseCount.value) -} +}) + +const rxOutPelletText = computed(() => { + const out = rxOutPelletGrams.value + if (!out) return '' + const main = slipPillGrams.value + const aux = slipAuxPillGrams.value + if ( + typeof main === 'number' && isFinite(main) && main > 0 && + typeof aux === 'number' && isFinite(aux) && aux > 0 + ) { + return `${out}克(主方 ${formatRxNum(main)}克 + 辅方 ${formatRxNum(aux)}克)` + } + return `${out}克` +}) function formatRxNum(n: number): string { if (!isFinite(n)) return '0' @@ -1790,7 +1870,14 @@ const open = async (data: any, options?: { templateId?: number; stationName?: st } } -const openById = async (prescriptionId: number) => { +type OpenByIdOptions = { + /** 默认 internal;诊单列表查看传 user 展示处方联 */ + slipType?: 'internal' | 'user' + /** 为 true 时不展示详细病历 A3 */ + hideCaseRecord?: boolean +} + +const openById = async (prescriptionId: number, options?: OpenByIdOptions) => { // 防止重复打开 if (isOpening.value) { console.warn('处方正在打开中,请勿重复点击') @@ -1798,6 +1885,8 @@ const openById = async (prescriptionId: number) => { } isOpening.value = true + prescriptionTabType.value = options?.slipType ?? 'internal' + hideCaseRecordPreview.value = options?.hideCaseRecord ?? false try { const res = await prescriptionDetail({ id: prescriptionId }) @@ -1819,6 +1908,8 @@ const openById = async (prescriptionId: number) => { const handleClose = () => { visible.value = false savedPrescription.value = null + prescriptionTabType.value = 'internal' + hideCaseRecordPreview.value = false } /** @@ -2549,6 +2640,15 @@ defineExpose({ margin: -20px; } +.rx-slip-tabs { + margin-bottom: 0; + z-index: 1; + position: relative; + background: #fff; + padding: 0 4px; + border-radius: 6px 6px 0 0; +} + .rx-toolbar { display: flex; align-items: center; @@ -2813,6 +2913,23 @@ defineExpose({ min-height: 70px; } +.rx-hospital-row { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + gap: 2px; + padding: 10px 12px; + min-height: 52px; + text-align: left; +} + +.rx-hospital-line { + font-size: 12px; + color: #374151; + line-height: 1.6; +} + .rx-bot-cell { border-right: 1px solid #c8c8c8; padding: 6px 10px; diff --git a/admin/src/views/tcm/diagnosis/edit.vue b/admin/src/views/tcm/diagnosis/edit.vue index 3884fb2e..b69113ec 100644 --- a/admin/src/views/tcm/diagnosis/edit.vue +++ b/admin/src/views/tcm/diagnosis/edit.vue @@ -272,6 +272,24 @@ + + + + + + + @@ -855,9 +873,18 @@ const formData = ref({ remark: '', current_medications: '', status: 1, + create_source: 'admin', external_userid: '' }) +/** 就诊卡创建来源(与 zyt_tcm_diagnosis.create_source 一致) */ +const createSourceOptions = [ + { value: '', label: '未设置' }, + { value: 'admin', label: '后台建档' }, + { value: 'mnp', label: '小程序完整建档' }, + { value: 'mnp_daily', label: '小程序手机号快捷建档' } +] + // 保存原始完整数据 const originalPhone = ref('') const originalIdCard = ref('') @@ -1127,6 +1154,7 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise { data.id_card = maskIdCard(data.id_card || '') formData.value = data + formData.value.create_source = data.create_source ?? '' const y = data.diabetes_discovery_year formData.value.diabetes_discovery_year = y == null || y === '' ? '' : String(y) } @@ -1215,7 +1243,7 @@ const handleSubmit = async () => { } const handleViewCase = (row: any) => { - prescriptionRef.value?.openById(row.id) + prescriptionRef.value?.openById(row.id, { slipType: 'user', hideCaseRecord: true }) } // 开方:传入当前诊单数据,包含详细病历(深拷贝避免响应式引用) @@ -1288,6 +1316,7 @@ const handleClose = () => { remark: '', current_medications: '', status: 1, + create_source: 'admin', external_userid: '' } diff --git a/server/app/adminapi/logic/tcm/DiagnosisLogic.php b/server/app/adminapi/logic/tcm/DiagnosisLogic.php index 5da73c71..8f643b71 100755 --- a/server/app/adminapi/logic/tcm/DiagnosisLogic.php +++ b/server/app/adminapi/logic/tcm/DiagnosisLogic.php @@ -114,6 +114,10 @@ class DiagnosisLogic extends BaseLogic } } + if (empty($params['create_source'])) { + $params['create_source'] = 'admin'; + } + $model = Diagnosis::create($params); // 图片写入 doctor_note 表 diff --git a/server/app/adminapi/validate/tcm/DiagnosisValidate.php b/server/app/adminapi/validate/tcm/DiagnosisValidate.php index 6bc43e17..ca804be7 100755 --- a/server/app/adminapi/validate/tcm/DiagnosisValidate.php +++ b/server/app/adminapi/validate/tcm/DiagnosisValidate.php @@ -33,6 +33,7 @@ class DiagnosisValidate extends BaseValidate 'age' => 'require|number|between:0,150', 'diagnosis_type' => 'require', 'status' => 'in:0,1', + 'create_source' => 'max:32', 'current_medications' => 'max:2000', 'tongue_images' => 'array', 'report_files' => 'array', @@ -57,6 +58,7 @@ class DiagnosisValidate extends BaseValidate 'age.between' => '年龄范围0-150', 'diagnosis_type.require' => '请选择诊断类型', 'status.in' => '状态参数错误', + 'create_source.max' => '渠道来源长度不能超过32个字符', 'current_medications.max' => '在用药物最多2000个字符', 'diabetes_discovery_year.max' => '发现糖尿病患病史最多50个字符', 'start_date.date' => '开始日期格式不正确', @@ -76,7 +78,7 @@ class DiagnosisValidate extends BaseValidate public function sceneEdit() { - return $this->only(['id', 'patient_name', 'id_card', 'phone', 'gender', 'age', 'diagnosis_date', 'diagnosis_type', 'syndrome_type', 'marital_status', 'height', 'weight', 'region', 'systolic_pressure', 'diastolic_pressure', 'fasting_blood_sugar', 'diabetes_discovery_year', 'local_hospital_diagnosis', 'local_hospital_name', 'past_history', 'symptoms', 'tongue_coating', 'pulse', 'treatment_principle', 'prescription', 'doctor_advice', 'remark', 'current_medications', 'status']); + return $this->only(['id', 'patient_name', 'id_card', 'phone', 'gender', 'age', 'diagnosis_date', 'diagnosis_type', 'syndrome_type', 'marital_status', 'height', 'weight', 'region', 'systolic_pressure', 'diastolic_pressure', 'fasting_blood_sugar', 'diabetes_discovery_year', 'local_hospital_diagnosis', 'local_hospital_name', 'past_history', 'symptoms', 'tongue_coating', 'pulse', 'treatment_principle', 'prescription', 'doctor_advice', 'remark', 'current_medications', 'status', 'create_source']); } public function sceneId() diff --git a/server/app/api/logic/tcm/DailyDietAiLogic.php b/server/app/api/logic/tcm/DailyDietAiLogic.php index 6f5b02bb..b581601a 100644 --- a/server/app/api/logic/tcm/DailyDietAiLogic.php +++ b/server/app/api/logic/tcm/DailyDietAiLogic.php @@ -31,20 +31,24 @@ class DailyDietAiLogic return ['ok' => false, 'error' => '缺少就诊卡']; } + $context = self::buildPatientContext($diagnosisId); + if (!$context['ok']) { + return ['ok' => false, 'error' => $context['error'] ?? '获取档案失败']; + } + $analysis = self::buildAnalysisPayload($context['data']); + $exercise = self::buildExercisePayload($context['data']); + $cacheKey = self::recommendCacheKey($diagnosisId); if (!$refresh) { $cached = Cache::get($cacheKey); if (is_array($cached) && !empty($cached['breakfast'])) { - $cached['cached'] = true; + $cached['cached'] = true; + $cached['analysis'] = $analysis; + $cached['exercise'] = $exercise; return ['ok' => true, 'data' => $cached]; } } - $context = self::buildPatientContext($diagnosisId); - if (!$context['ok']) { - return ['ok' => false, 'error' => $context['error'] ?? '获取档案失败']; - } - $data = null; if (AiChatService::isEnabled()) { $raw = self::aiDailyRecommend($context['data'], $refresh); @@ -61,6 +65,8 @@ class DailyDietAiLogic $data['date'] = date('Y-m-d'); $data['cached'] = false; Cache::set($cacheKey, $data, self::CACHE_TTL); + $data['analysis'] = $analysis; + $data['exercise'] = $exercise; return ['ok' => true, 'data' => $data]; } @@ -117,21 +123,28 @@ class DailyDietAiLogic return; } - $cacheKey = self::recommendCacheKey($diagnosisId); - if (!$refresh) { - $cached = Cache::get($cacheKey); - if (is_array($cached) && !empty($cached['breakfast'])) { - $cached['cached'] = true; - $emit('done', $cached); - return; - } - } - $context = self::buildPatientContext($diagnosisId); if (!$context['ok']) { $emit('error', ['message' => $context['error'] ?? '获取档案失败']); return; } + $analysis = self::buildAnalysisPayload($context['data']); + $exercise = self::buildExercisePayload($context['data']); + // 先把「分析依据」「运动建议」推给前端,提升首屏感知 + $emit('analysis', ['analysis' => $analysis]); + $emit('exercise', ['exercise' => $exercise]); + + $cacheKey = self::recommendCacheKey($diagnosisId); + if (!$refresh) { + $cached = Cache::get($cacheKey); + if (is_array($cached) && !empty($cached['breakfast'])) { + $cached['cached'] = true; + $cached['analysis'] = $analysis; + $cached['exercise'] = $exercise; + $emit('done', $cached); + return; + } + } $data = null; $fullText = ''; @@ -168,6 +181,8 @@ class DailyDietAiLogic $data['date'] = date('Y-m-d'); $data['cached'] = false; Cache::set($cacheKey, $data, self::CACHE_TTL); + $data['analysis'] = $analysis; + $data['exercise'] = $exercise; $emit('done', $data); } @@ -271,20 +286,27 @@ class DailyDietAiLogic $row = $diagnosis->toArray(); $gender = (int) ($row['gender'] ?? 0); $genderText = $gender === 1 ? '男' : ($gender === 0 ? '女' : '未知'); + $age = (int) ($row['age'] ?? 0); - $since = strtotime('-7 days 00:00:00'); + // 拉取近 30 天血糖(涵盖近 7 天),统一计算 7/30 天统计 + $since30 = strtotime('-30 days 00:00:00'); $bloodList = BloodRecord::where('diagnosis_id', $diagnosisId) - ->where('record_date', '>=', $since) + ->where('record_date', '>=', $since30) ->where('delete_time', null) ->order('record_date', 'desc') - ->limit(7) ->field('record_date,fasting_blood_sugar,postprandial_blood_sugar,other_blood_sugar') ->select() ->toArray(); + $since7 = strtotime('-7 days 00:00:00'); + $list7 = array_values(array_filter($bloodList, static function ($b) use ($since7) { + return (int) ($b['record_date'] ?? 0) >= $since7; + })); + + // 近 7 天逐日明细(喂给 AI 的细节) $bloodSummary = []; - foreach ($bloodList as $b) { - $date = !empty($b['record_date']) ? date('Y-m-d', (int) $b['record_date']) : ''; + foreach (array_slice($list7, 0, 7) as $b) { + $date = !empty($b['record_date']) ? date('Y-m-d', (int) $b['record_date']) : ''; $parts = []; if ($b['fasting_blood_sugar'] !== null && $b['fasting_blood_sugar'] !== '') { $parts[] = '空腹' . $b['fasting_blood_sugar']; @@ -300,18 +322,163 @@ class DailyDietAiLogic } } + $stats7 = self::aggregateBloodStats($list7, $age, '近7天'); + $stats30 = self::aggregateBloodStats($bloodList, $age, '近30天'); + return [ 'ok' => true, 'data' => [ - 'patient_name' => (string) ($row['patient_name'] ?? ''), - 'age' => (int) ($row['age'] ?? 0), - 'gender_text' => $genderText, - 'blood_recent' => $bloodSummary, - 'today' => date('Y-m-d'), + 'patient_name' => (string) ($row['patient_name'] ?? ''), + 'age' => $age, + 'gender_text' => $genderText, + 'blood_recent' => $bloodSummary, + 'stats_7d' => $stats7, + 'stats_30d' => $stats30, + 'today' => date('Y-m-d'), ], ]; } + /** + * 根据年龄返回血糖偏高阈值(与前端 getBloodSugarThresholds 保持一致) + * + * @return array{fasting:float,postprandial:float}|null + */ + private static function bloodThresholds(int $age): array + { + // 年龄未知时用通用糖尿病控制目标兜底,避免「无阈值=全部达标」 + if ($age <= 0) { + return ['fasting' => 7.0, 'postprandial' => 10.0]; + } + return $age < 50 + ? ['fasting' => 7.0, 'postprandial' => 9.0] + : ['fasting' => 8.0, 'postprandial' => 10.0]; + } + + /** + * 汇总一段时间内的血糖统计:有记录天数、偏高天数、达标率、均值 + * + * @param array> $rows + * @return array + */ + private static function aggregateBloodStats(array $rows, int $age, string $label): array + { + $thresholds = self::bloodThresholds($age); + + $byDate = []; + $fastingSum = 0.0; + $fastingCnt = 0; + $postSum = 0.0; + $postCnt = 0; + + foreach ($rows as $b) { + $ts = (int) ($b['record_date'] ?? 0); + if ($ts <= 0) { + continue; + } + $date = date('Y-m-d', $ts); + $fasting = ($b['fasting_blood_sugar'] !== null && $b['fasting_blood_sugar'] !== '') ? (float) $b['fasting_blood_sugar'] : null; + $post = ($b['postprandial_blood_sugar'] !== null && $b['postprandial_blood_sugar'] !== '') ? (float) $b['postprandial_blood_sugar'] : null; + if ($fasting === null && $post === null) { + continue; + } + + if (!isset($byDate[$date])) { + $byDate[$date] = ['high' => false]; + } + if ($fasting !== null) { + $fastingSum += $fasting; + $fastingCnt++; + if ($thresholds && $fasting >= $thresholds['fasting']) { + $byDate[$date]['high'] = true; + } + } + if ($post !== null) { + $postSum += $post; + $postCnt++; + if ($thresholds && $post >= $thresholds['postprandial']) { + $byDate[$date]['high'] = true; + } + } + } + + $recordDays = count($byDate); + $highDays = 0; + foreach ($byDate as $d) { + if ($d['high']) { + $highDays++; + } + } + $normalDays = max(0, $recordDays - $highDays); + $compliance = $recordDays > 0 ? (int) round($normalDays / $recordDays * 100) : 0; + + return [ + 'label' => $label, + 'record_days' => $recordDays, + 'high_days' => $highDays, + 'normal_days' => $normalDays, + 'compliance' => $compliance, + 'fasting_avg' => $fastingCnt > 0 ? round($fastingSum / $fastingCnt, 1) : null, + 'postprandial_avg' => $postCnt > 0 ? round($postSum / $postCnt, 1) : null, + ]; + } + + /** + * 把统计数据拼成喂给 AI 的一行人类可读文本 + * + * @param array $stats + */ + private static function statsToLine(array $stats): string + { + if (($stats['record_days'] ?? 0) <= 0) { + return $stats['label'] . '无血糖记录'; + } + $segs = [ + $stats['label'] . "有记录{$stats['record_days']}天", + "偏高{$stats['high_days']}天", + "达标率{$stats['compliance']}%", + ]; + if ($stats['fasting_avg'] !== null) { + $segs[] = "空腹均值{$stats['fasting_avg']}"; + } + if ($stats['postprandial_avg'] !== null) { + $segs[] = "餐后均值{$stats['postprandial_avg']}"; + } + return implode(',', $segs); + } + + /** + * 给前端展示的「分析依据」结构(近7天 + 近30天) + * + * @param array $context + * @return array> + */ + private static function buildAnalysisPayload(array $context): array + { + $out = []; + foreach (['stats_7d', 'stats_30d'] as $key) { + $s = $context[$key] ?? null; + if (is_array($s)) { + $out[] = $s; + } + } + return $out; + } + + /** + * 给前端展示的「运动降糖」建议(按血糖统计调整强度) + * + * @param array $context + * @return array + */ + private static function buildExercisePayload(array $context): array + { + return GiKnowledge::exercisePlan( + is_array($context['stats_7d'] ?? null) ? $context['stats_7d'] : [], + is_array($context['stats_30d'] ?? null) ? $context['stats_30d'] : [] + ); + } + /** * @return array{0:string,1:string} */ @@ -320,6 +487,8 @@ class DailyDietAiLogic $bloodLine = empty($context['blood_recent']) ? '近7日无血糖记录' : implode(';', $context['blood_recent']); + $stat7Line = self::statsToLine($context['stats_7d'] ?? ['label' => '近7天', 'record_days' => 0]); + $stat30Line = self::statsToLine($context['stats_30d'] ?? ['label' => '近30天', 'record_days' => 0]); $system = << '近7天', 'record_days' => 0]); + $stat30Line = self::statsToLine($context['stats_30d'] ?? ['label' => '近30天', 'record_days' => 0]); $system = << $stats7 近7天统计 + * @param array $stats30 近30天统计 + * @return array{level:string,level_label:string,headline:string,items:list,intensity:string,note:string} + */ + public static function exercisePlan(array $stats7 = [], array $stats30 = []): array + { + // 优先用记录更全的口径判断;都没记录则用近7天 + $primary = ((int) ($stats30['record_days'] ?? 0) > 0) ? $stats30 : $stats7; + $recordDays = (int) ($primary['record_days'] ?? 0); + $compliance = (int) ($primary['compliance'] ?? 0); + $highDays = (int) ($primary['high_days'] ?? 0); + + if ($recordDays <= 0) { + $level = 'unknown'; + } elseif ($compliance < 50 || $highDays * 2 >= $recordDays) { + $level = 'high'; // 偏高多、达标率低 = 控制不佳 + } elseif ($compliance < 80) { + $level = 'medium'; + } else { + $level = 'good'; + } + + $note = '餐后 1 小时内开始动,别空腹剧烈运动;身上带几块糖,头晕、心慌、出虚汗就马上停下歇着。'; + + switch ($level) { + case 'high': + return [ + 'level' => 'high', + 'level_label' => '血糖偏高 · 多动', + 'headline' => '近期血糖偏高,三顿饭后都动一动,最能帮着把糖降下来。', + 'items' => [ + '早饭后快走 15 分钟,走到微微出汗', + '午饭后快走或原地踏步 20–30 分钟,降餐后血糖最管用', + '晚饭后慢走 30 分钟,吃完别马上坐下、躺下', + ], + 'intensity' => '中等强度 · 每天累计约 60 分钟', + 'note' => $note, + ]; + case 'medium': + return [ + 'level' => 'medium', + 'level_label' => '基本平稳 · 再稳稳', + 'headline' => '血糖大体平稳,坚持餐后活动,把它稳住。', + 'items' => [ + '三餐后各散步 20 分钟,午饭后可以走快些', + '每天加一段八段锦或太极拳,10–15 分钟', + '能走楼梯少坐电梯,多干点家务、地里活', + ], + 'intensity' => '中低强度 · 每天累计 40–50 分钟', + 'note' => $note, + ]; + case 'good': + return [ + 'level' => 'good', + 'level_label' => '控制不错 · 保持', + 'headline' => '血糖控制得不错,保持规律活动就行。', + 'items' => [ + '餐后散步 15–20 分钟,雷打不动', + '每天一段八段锦、太极或柔和拉伸', + '天好多到院里、地里走动,别久坐', + ], + 'intensity' => '中低强度 · 每天累计 30–40 分钟', + 'note' => $note, + ]; + default: + return [ + 'level' => 'unknown', + 'level_label' => '先记血糖 · 再调运动', + 'headline' => '血糖记录还少,先按基础来:饭后多走动。', + 'items' => [ + '三餐后都散步 15–20 分钟', + '每天一段八段锦或太极拳', + '少久坐,坐 1 小时就起来活动几分钟', + ], + 'intensity' => '中低强度 · 每天累计约 30 分钟', + 'note' => $note, + ]; + } + } + /** * @return array{food:string,level:string,level_label:string,advice:string,source:string} */
主方服法:{{ rxUsageText }}
辅方服法:{{ savedAuxUsageText }}
服法:{{ rxUsageText }}
{{ prescriptionTabType === 'user' ? '辅服法' : '辅方服法' }}:{{ savedAuxUsageText }}
医嘱:{{ rxAdviceText }}
备注:{{ rxRemarkText }}
@@ -671,18 +705,22 @@