更新
This commit is contained in:
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -518,6 +518,13 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="rx-wrap">
|
||||
<el-tabs
|
||||
v-model="prescriptionTabType"
|
||||
class="rx-slip-tabs"
|
||||
>
|
||||
<el-tab-pane label="药房联" name="internal" />
|
||||
<el-tab-pane label="处方联" name="user" />
|
||||
</el-tabs>
|
||||
<div class="rx-toolbar">
|
||||
<el-tag v-if="savedPrescription.void_status === 1" type="danger" size="small">已作废</el-tag>
|
||||
<el-tag v-else-if="approvedPreviewOnly" type="success" size="small">已通过</el-tag>
|
||||
@@ -597,21 +604,22 @@
|
||||
</div>
|
||||
|
||||
<div class="rx-rp">
|
||||
<div class="rx-watermark">药房联</div>
|
||||
<div class="rx-watermark">{{ prescriptionTabType === 'internal' ? '药房联' : '处方联' }}</div>
|
||||
<div class="rx-rp-head">
|
||||
<div class="rx-rp-label">Rp.</div>
|
||||
<div class="rx-rp-cols-head">
|
||||
<div class="rx-rp-col-head">
|
||||
<span>用药 (单剂)</span>
|
||||
<span>总量</span>
|
||||
<span>{{ prescriptionTabType === 'user' ? '用量' : '总量' }}</span>
|
||||
</div>
|
||||
<div class="rx-rp-col-head">
|
||||
<span>用药 (单剂)</span>
|
||||
<span>总量</span>
|
||||
<span>{{ prescriptionTabType === 'user' ? '用量' : '总量' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-herbs">
|
||||
<template v-if="prescriptionTabType === 'internal'">
|
||||
<template v-if="savedMainHerbs.length">
|
||||
<div class="rx-herb-section-label">主方</div>
|
||||
<div
|
||||
@@ -634,12 +642,38 @@
|
||||
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}克</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="savedMainHerbs.length">
|
||||
<div class="rx-herb-section-label">主方</div>
|
||||
<div
|
||||
v-for="(h, i) in savedMainHerbs"
|
||||
:key="'user-main-' + i"
|
||||
class="rx-herb-cell"
|
||||
>
|
||||
<span class="rx-herb-name">{{ h.name }}</span>
|
||||
<span class="rx-herb-total">{{ h.dosage }}克</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="savedAuxHerbs.length">
|
||||
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
|
||||
<div
|
||||
v-for="(h, i) in savedAuxHerbs"
|
||||
:key="'user-aux-' + i"
|
||||
class="rx-herb-cell"
|
||||
>
|
||||
<span class="rx-herb-name">{{ h.name }}</span>
|
||||
<span class="rx-herb-total">{{ h.dosage }}克</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rx-text">
|
||||
<p>主方服法:{{ rxUsageText }}</p>
|
||||
<p v-if="savedAuxUsageText">辅方服法:{{ savedAuxUsageText }}</p>
|
||||
<p v-if="prescriptionTabType === 'internal' || savedAuxUsageText">主方服法:{{ rxUsageText }}</p>
|
||||
<p v-else>服法:{{ rxUsageText }}</p>
|
||||
<p v-if="savedAuxUsageText">{{ prescriptionTabType === 'user' ? '辅服法' : '辅方服法' }}:{{ savedAuxUsageText }}</p>
|
||||
<p v-if="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
||||
<p v-if="rxRemarkText">备注:{{ rxRemarkText }}</p>
|
||||
<p v-if="savedPrescription.dietary_taboo && savedPrescription.dietary_taboo.length">
|
||||
@@ -671,18 +705,22 @@
|
||||
</div>
|
||||
<div class="rx-bot-cell rx-bot-meta">
|
||||
<span class="rx-bot-meta-key">天数:</span>
|
||||
<span class="rx-bot-meta-val">{{ savedPrescription.dose_count || '—' }}剂</span>
|
||||
<span class="rx-bot-meta-val">{{ rxSlipMedicationDaysText }}</span>
|
||||
</div>
|
||||
<div class="rx-bot-cell rx-bot-meta">
|
||||
<span class="rx-bot-meta-key">单剂量:</span>
|
||||
<span class="rx-bot-meta-key">剂量:</span>
|
||||
<span class="rx-bot-meta-val">{{ rxPerDoseAmount }}克</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="prescriptionTabType === 'user'" class="rx-bot-row rx-hospital-row">
|
||||
<div class="rx-hospital-line">成都双流甄养堂互联网医院有限公司 联系方式:4001667339</div>
|
||||
<div class="rx-hospital-line">地址:四川省成都市双流区黄甲街道黄龙大道二段280号</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细病历 - 单独A3纸张 -->
|
||||
<div v-if="savedPrescription.case_record" ref="caseRecordPrintRef" class="case-record-a3">
|
||||
<div v-if="savedPrescription.case_record && !hideCaseRecordPreview" ref="caseRecordPrintRef" class="case-record-a3">
|
||||
<div class="case-record-a3-inner">
|
||||
<h2 class="case-record-a3-title">{{ templateConfig.stationName }} 详细病历</h2>
|
||||
<div class="case-record-a3-body">
|
||||
@@ -1148,6 +1186,10 @@ const signatureActivePointerId = ref<number | null>(null)
|
||||
const SIGNATURE_CSS_W = 560
|
||||
const SIGNATURE_CSS_H = 168
|
||||
const savedPrescription = ref<any>(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;
|
||||
|
||||
@@ -272,6 +272,24 @@
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="渠道来源" prop="create_source">
|
||||
<el-select
|
||||
v-model="formData.create_source"
|
||||
placeholder="请选择渠道来源"
|
||||
class="w-full"
|
||||
:popper-options="{ strategy: 'fixed' }"
|
||||
popper-class="high-z-index"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in createSourceOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
@@ -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<void> {
|
||||
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: ''
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,10 @@ class DiagnosisLogic extends BaseLogic
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($params['create_source'])) {
|
||||
$params['create_source'] = 'admin';
|
||||
}
|
||||
|
||||
$model = Diagnosis::create($params);
|
||||
|
||||
// 图片写入 doctor_note 表
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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['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,22 +123,29 @@ class DailyDietAiLogic
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
$context = self::buildPatientContext($diagnosisId);
|
||||
if (!$context['ok']) {
|
||||
$emit('error', ['message' => $context['error'] ?? '获取档案失败']);
|
||||
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,19 +286,26 @@ 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) {
|
||||
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'] !== '') {
|
||||
@@ -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),
|
||||
'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<int,array<string,mixed>> $rows
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $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<string,mixed> $context
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
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<string,mixed> $context
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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 = <<<SYS
|
||||
你是村里懂糖尿病饮食的大夫助手,依据「霍大夫升糖指数(GI)」给农村老人推荐一日三餐。输出必须严格可执行。
|
||||
@@ -335,12 +504,14 @@ class DailyDietAiLogic
|
||||
SYS;
|
||||
|
||||
$user = sprintf(
|
||||
"患者:%s,%s,%s岁。日期:%s。近7日血糖:%s。\n\nGI知识:\n%s\n\n农家饭参考:\n%s\n\n请输出严格符合硬性规则的三餐 JSON。",
|
||||
"患者:%s,%s,%s岁。日期:%s。\n近7日逐日血糖:%s。\n血糖统计:%s;%s。\n(若近期偏高天数多或达标率低,请收紧主食与高GI食物,多安排蛋白与绿叶菜;若控制平稳可正常推荐。)\n\nGI知识:\n%s\n\n农家饭参考:\n%s\n\n请输出严格符合硬性规则的三餐 JSON。",
|
||||
$context['patient_name'] ?: '大爷大妈',
|
||||
$context['gender_text'] ?? '',
|
||||
$context['age'] ?? 0,
|
||||
$context['today'] ?? date('Y-m-d'),
|
||||
$bloodLine,
|
||||
$stat7Line,
|
||||
$stat30Line,
|
||||
GiKnowledge::summaryText(),
|
||||
GiKnowledge::ruralMealHints()
|
||||
);
|
||||
@@ -362,6 +533,8 @@ SYS;
|
||||
$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 = <<<SYS
|
||||
你是村里懂糖尿病饮食的大夫助手,依据「霍大夫升糖指数(GI)」给农村老人推荐一日三餐。
|
||||
@@ -381,13 +554,15 @@ SYS;
|
||||
SYS;
|
||||
|
||||
$user = sprintf(
|
||||
"患者:%s,%s,%s岁。日期:%s。近7日血糖:%s。\n\nGI摘要:\n%s",
|
||||
"患者:%s,%s,%s岁。日期:%s。\n近7日逐日血糖:%s。\n血糖统计:%s;%s。\n(偏高多或达标率低则收紧主食、多蛋白绿叶菜;平稳则正常推荐。)\n\nGI摘要:\n%s",
|
||||
$context['patient_name'] ?: '大爷大妈',
|
||||
$context['gender_text'] ?? '',
|
||||
$context['age'] ?? 0,
|
||||
$context['today'] ?? date('Y-m-d'),
|
||||
$bloodLine,
|
||||
mb_substr(GiKnowledge::summaryText(), 0, 380)
|
||||
$stat7Line,
|
||||
$stat30Line,
|
||||
mb_substr(GiKnowledge::summaryText(), 0, 320)
|
||||
);
|
||||
|
||||
if ($refresh) {
|
||||
|
||||
@@ -192,6 +192,89 @@ class GiKnowledge
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据血糖统计给出「运动降糖」建议(规则化,按控制好坏调整强度)
|
||||
*
|
||||
* @param array<string,mixed> $stats7 近7天统计
|
||||
* @param array<string,mixed> $stats30 近30天统计
|
||||
* @return array{level:string,level_label:string,headline:string,items:list<string>,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}
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user