This commit is contained in:
Your Name
2026-05-29 14:45:56 +08:00
parent 94e787ae80
commit 17ad153821
9 changed files with 1795 additions and 623 deletions
File diff suppressed because it is too large Load Diff
+18 -539
View File
@@ -33,90 +33,6 @@
</view>
</view>
<!-- AI 饮食助手霍大夫升糖指数 -->
<view v-if="diagnosisId" class="card ai-diet-card">
<view class="card-head ai-diet-head">
<view class="card-head-left">
<text class="card-title">AI 饮食助手</text>
<text class="card-subtitle">午饭多蛋白 · 淀粉不重复</text>
</view>
<view class="ai-diet-refresh" :class="{ disabled: dietAiLoading }" @click="refreshDietAiRecommend">
<TongjiIcon name="refresh" size="sm" color="#386641" />
<text>{{ dietAiLoading ? '生成中' : '换一换' }}</text>
</view>
</view>
<view v-if="dietAiLoading && !hasDietAiMealContent && !dietAiStreaming && !dietAiReplacing" class="ai-diet-loading">
<text>正在生成今日饮食建议</text>
</view>
<view v-else class="ai-diet-meals" :class="{ 'is-streaming': dietAiStreaming }">
<view class="ai-diet-meal">
<text class="ai-diet-meal-tag">早餐</text>
<text class="ai-diet-meal-text">
{{ dietAiRecommend.breakfast || (dietAiLoading ? '…' : '—') }}<text v-if="dietAiLoading && dietAiStreamField === 'breakfast'" class="ai-stream-cursor"></text>
</text>
</view>
<view class="ai-diet-meal">
<text class="ai-diet-meal-tag">午餐</text>
<text class="ai-diet-meal-text">
{{ dietAiRecommend.lunch || (dietAiLoading ? '…' : '—') }}<text v-if="dietAiLoading && dietAiStreamField === 'lunch'" class="ai-stream-cursor"></text>
</text>
</view>
<view class="ai-diet-meal">
<text class="ai-diet-meal-tag">晚餐</text>
<text class="ai-diet-meal-text">
{{ dietAiRecommend.dinner || (dietAiLoading ? '…' : '—') }}<text v-if="dietAiLoading && dietAiStreamField === 'dinner'" class="ai-stream-cursor"></text>
</text>
</view>
<text v-if="dietAiRecommend.tips || (dietAiLoading && dietAiStreamField === 'tips')" class="ai-diet-tips">
{{ dietAiRecommend.tips || '' }}<text v-if="dietAiLoading && dietAiStreamField === 'tips'" class="ai-stream-cursor"></text>
</text>
<view v-if="dietAiRecommend.avoid && dietAiRecommend.avoid.length" class="ai-diet-avoid">
<text class="ai-diet-avoid-label">今日尽量少碰</text>
<text class="ai-diet-avoid-text">{{ dietAiRecommend.avoid.join('、') }}</text>
</view>
<view v-if="!dietAiLoading" class="ai-diet-actions">
<view class="ai-diet-btn" @click="applyDietAiToForm">
<text>填入今日饮食</text>
</view>
<view class="ai-diet-btn outline" @click="openDietForm">
<text>手动记录</text>
</view>
</view>
</view>
<view class="ai-diet-ask">
<text class="ai-diet-ask-title">能不能吃</text>
<view class="ai-diet-ask-row">
<input
class="ai-diet-ask-input"
type="text"
:value="dietAiAskText"
placeholder="如:白馒头、地瓜、粘豆包"
placeholder-class="input-placeholder"
confirm-type="search"
@input="onDietAiAskInput"
@confirm="askDietAiFood"
/>
<view class="ai-diet-ask-btn" :class="{ disabled: dietAiAsking }" @click="askDietAiFood">
<text>{{ dietAiAsking ? '…' : '问 AI' }}</text>
</view>
</view>
<view v-if="dietAiAsking || dietAiAskResult.advice" class="ai-diet-ask-result">
<view v-if="dietAiAskResult.level_label" class="ai-diet-ask-badge" :class="'lv-' + (dietAiAskResult.level || 'unknown')">
<text>{{ dietAiAskResult.level_label }}</text>
</view>
<text class="ai-diet-ask-advice">
{{ dietAiAskResult.advice || (dietAiAsking ? '正在想…' : '') }}<text v-if="dietAiAsking" class="ai-stream-cursor"></text>
</text>
<text v-if="dietAiAskResult.portion" class="ai-diet-ask-portion">建议{{ dietAiAskResult.portion }}</text>
</view>
</view>
<text v-if="dietAiRecommend.rules_summary" class="ai-diet-rules">{{ dietAiRecommend.rules_summary }}</text>
<text v-if="dietAiRecommend.disclaimer" class="ai-diet-disclaimer">{{ dietAiRecommend.disclaimer }}</text>
</view>
<!-- 血糖历史日历 · 明细 -->
<view v-if="diagnosisId" class="glucose-history-block">
<view class="range-bar">
@@ -745,9 +661,9 @@
</view>
<view v-if="dietForm.loading" class="input-modal-loading"><text>加载中</text></view>
<scroll-view v-else scroll-y class="input-modal-body">
<view v-if="dietAiRecommend.breakfast" class="input-modal-ai-tip">
<view v-if="dietAiPrefill && dietAiPrefill.breakfast" class="input-modal-ai-tip">
<text class="input-modal-ai-tip-label">AI 推荐</text>
<text class="input-modal-ai-tip-text"> {{ dietAiRecommend.breakfast }} · {{ dietAiRecommend.lunch }} · {{ dietAiRecommend.dinner }}</text>
<text class="input-modal-ai-tip-text"> {{ dietAiPrefill.breakfast }} · {{ dietAiPrefill.lunch }} · {{ dietAiPrefill.dinner }}</text>
<view class="input-modal-ai-tip-btn" @click="fillDietFormFromAi">
<text>一键填入</text>
</view>
@@ -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;