From 7b4620445454b2e72adfb2277001e94e239a491f Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 29 May 2026 09:28:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TUICallKit-Vue3/main.js | 14 +- TUICallKit-Vue3/tongji/pages/index.vue | 293 ++++++++- TUICallKit-Vue3/tongji/pages/more.vue | 590 +++++++++++++++++- .../app/adminapi/logic/tcm/DiagnosisLogic.php | 2 +- server/app/api/controller/TcmController.php | 163 ++++- server/app/api/logic/UserLogic.php | 10 +- server/app/api/validate/UserValidate.php | 4 +- 7 files changed, 1037 insertions(+), 39 deletions(-) diff --git a/TUICallKit-Vue3/main.js b/TUICallKit-Vue3/main.js index f1d13fa0..ce23c462 100644 --- a/TUICallKit-Vue3/main.js +++ b/TUICallKit-Vue3/main.js @@ -1,5 +1,11 @@ import App from './App' var baseUrl ='https://css.zhenyangtang.com.cn/'; + +function joinApiUrl(base, path) { + const b = String(base || '').replace(/\/+$/, '') + const p = String(path || '').replace(/^\/+/, '') + return p ? `${b}/${p}` : b +} // #ifndef VUE3 import Vue from 'vue' import './uni.promisify.adaptor' @@ -20,13 +26,13 @@ Vue.prototype.apiUrl =function apiurl(promise, Loading = true){ if (promise.data) { data = promise.data } - let url=Vue.prototype.$url+promise.url + let url = joinApiUrl(Vue.prototype.$url, promise.url) return new Promise(function(resolve, reject) { uni.request({ url: url, data: data, - timeout: 15000, + timeout: promise.timeout != null ? promise.timeout : 15000, method: promise.method ? promise.method : 'POST', header: header, sslVerify: false, @@ -73,7 +79,7 @@ function apiUrl(promise, Loading = true) { // 处理请求数据和URL const data = promise.data || {} - const url = baseUrl + (promise.url || '') + const url = joinApiUrl(baseUrl, promise.url || '') return new Promise((resolve, reject) => { // 可选:根据Loading参数显示加载中 @@ -84,7 +90,7 @@ function apiUrl(promise, Loading = true) { uni.request({ url: url, data: data, - timeout: 15000, + timeout: promise.timeout != null ? promise.timeout : 15000, method: promise.method || 'POST', // 简化三元表达式 header: header, sslVerify: false, diff --git a/TUICallKit-Vue3/tongji/pages/index.vue b/TUICallKit-Vue3/tongji/pages/index.vue index 0b254e0d..dbb64924 100644 --- a/TUICallKit-Vue3/tongji/pages/index.vue +++ b/TUICallKit-Vue3/tongji/pages/index.vue @@ -374,6 +374,53 @@ 数据仅供参考,请遵医嘱 + + + + + + + 授权手机号 + 绑定手机号后即可记录血糖,无需先填写就诊卡 + + + × + + + + 微信仅提供手机号,请选择性别以便写入就诊档案。 + + 性别 + + + + + + + + + + + + + + @@ -528,7 +575,11 @@ const gender = ref(0) /** 登录 / 就诊卡门禁校验中(避免未登录时闪屏) */ const authChecking = ref(false) -let gateRedirected = false + +const userMobile = ref('') +const phoneGateVisible = ref(false) +const phoneGateSex = ref(0) +const phoneBinding = ref(false) const loading = ref(false) const cardsLoading = ref(false) @@ -1020,15 +1071,6 @@ async function ensureLoggedIn() { return authSessionPromise } -function redirectToCardEntry() { - if (gateRedirected) return - gateRedirected = true - const returnUrl = encodeURIComponent('/tongji/pages/index') - uni.redirectTo({ - url: `/pages/Card/edit_card?add=1&returnUrl=${returnUrl}` - }) -} - // ============ 就诊卡 ============ async function fetchCardList() { if (!hasAuthToken()) return [] @@ -1062,6 +1104,145 @@ async function applyCard(card) { gender.value = Number(card.gender) || 0 } +function readUserMobile() { + try { + const userData = uni.getStorageSync('userData') || {} + userMobile.value = String(userData.mobile || userData.phone || '').trim() + } catch (e) { + userMobile.value = '' + } + return userMobile.value +} + +function syncPhoneGateSexFromUser() { + try { + const userData = uni.getStorageSync('userData') || {} + const sex = Number(userData.sex) + phoneGateSex.value = sex === 1 || sex === 2 ? sex : 0 + } catch (e) { + phoneGateSex.value = 0 + } +} + +function openPhoneGate() { + syncPhoneGateSexFromUser() + phoneGateVisible.value = true +} + +async function refreshUserMobile() { + try { + const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false) + if (res && res.code === 1 && res.data) { + uni.setStorageSync('userData', res.data) + userMobile.value = String(res.data.mobile || res.data.phone || '').trim() + } + } catch (e) { + /* 静默 */ + } + return userMobile.value +} + +async function ensureDailyContextByPhone(sex) { + if (diagnosisId.value) return true + + const payload = {} + const sexVal = sex ?? phoneGateSex.value + if (sexVal === 1 || sexVal === 2) { + payload.sex = sexVal + } + + const res = await proxy.apiUrl({ + url: '/api/tcm/dailyEnsurePhoneContext', + method: 'POST', + data: payload + }, false) + + if (res && res.code === 1 && res.data && res.data.diagnosis_id) { + await applyCard({ + id: res.data.diagnosis_id, + patient_id: res.data.patient_id, + patient_name: res.data.patient_name || '', + age: res.data.age || 0, + gender: res.data.gender || 0 + }) + if (res.data.created) { + await fetchCardList() + } + return true + } + + if (res && res.data && res.data.need_mobile) { + return 'need_mobile' + } + + showUserToast(formatUserMessage(res?.msg, '无法开始录入')) + return false +} + +async function ensureCanRecordGlucose() { + if (diagnosisId.value) return true + + readUserMobile() + if (!userMobile.value) { + openPhoneGate() + return false + } + + const ok = await ensureDailyContextByPhone() + if (ok === 'need_mobile') { + openPhoneGate() + return false + } + return !!ok +} + +function closePhoneGate() { + if (phoneBinding.value) return + phoneGateVisible.value = false +} + +async function onPhoneGateAuthorize(e) { + const detail = e?.detail || {} + if (detail.errMsg && !String(detail.errMsg).includes('ok')) { + showUserToast('需要授权手机号后才能录入血糖') + return + } + const code = detail.code + if (!code) { + showUserToast('获取手机号失败,请重试') + return + } + if (phoneGateSex.value !== 1 && phoneGateSex.value !== 2) { + showUserToast('请先选择性别') + return + } + + phoneBinding.value = true + try { + const res = await proxy.apiUrl({ + url: '/api/user/getMobileByMnp', + method: 'POST', + data: { code, sex: phoneGateSex.value } + }, false) + if (!res || res.code !== 1) { + showUserToast(formatUserMessage(res?.msg, '绑定手机号失败')) + return + } + await refreshUserMobile() + phoneGateVisible.value = false + const ok = await ensureDailyContextByPhone(phoneGateSex.value) + if (ok === true) { + await openInputForm(true) + } else if (ok === 'need_mobile') { + openPhoneGate() + } + } catch (err) { + showUserToast('网络异常,请稍后再试') + } finally { + phoneBinding.value = false + } +} + async function onSelectCard(card) { if (!card || Number(card.id) === Number(diagnosisId.value)) return bloodRecords.value = [] @@ -1150,10 +1331,10 @@ function onRefresh() { fetchAll() } -function goMorePage() { +async function goMorePage() { if (!diagnosisId.value) { - showUserToast('请先选择就诊卡') - return + const ok = await ensureCanRecordGlucose() + if (!ok || !diagnosisId.value) return } uni.navigateTo({ url: '/tongji/pages/more' }) } @@ -1384,16 +1565,20 @@ function onInputField(field, e) { inputForm.value[field] = val } -async function openInputForm() { - if (!diagnosisId.value) { - showUserToast('请先选择就诊卡') - return - } +async function openInputForm(skipEnsure = false) { const token = uni.getStorageSync('token') if (!token) { showUserToast('请先登录后再录入') return } + if (!skipEnsure) { + const can = await ensureCanRecordGlucose() + if (!can) return + } + if (!diagnosisId.value) { + showUserToast('请先授权手机号') + return + } // 微信小程序 canvas 是原生组件、z-index 无法被弹层覆盖, // 这里通过 v-if 卸载它;下次显示时 selectorQuery 拿到的会是新节点, // 因此先清空缓存的 ctx / node,避免下次 ensureCanvas 复用旧引用。 @@ -1434,7 +1619,7 @@ function closeInputForm() { } /** 任一录入弹层打开时需卸载 canvas(小程序原生组件会盖住普通 view)*/ -const recordOverlayOpen = computed(() => inputForm.value.visible) +const recordOverlayOpen = computed(() => inputForm.value.visible || phoneGateVisible.value) async function submitInputForm() { if (inputForm.value.submitting || inputForm.value.loading) return @@ -2153,7 +2338,6 @@ async function bootstrap(options) { } isInviteViewer.value = false - gateRedirected = false authChecking.value = true // 登录完成前不写 diagnosisId,避免模板/子流程提前打业务接口 @@ -2192,15 +2376,16 @@ async function bootstrap(options) { if (!diagnosisId.value || !patientId.value) { readUserContext() } - if (!diagnosisId.value) { - authChecking.value = false - redirectToCardEntry() - return + readUserMobile() + if (!diagnosisId.value && userMobile.value) { + await ensureDailyContextByPhone() } } authChecking.value = false - await fetchAll() + if (diagnosisId.value) { + await fetchAll() + } } onLoad((options) => { @@ -2208,7 +2393,7 @@ onLoad((options) => { }) onShow(() => { - if (authChecking.value || gateRedirected) return + if (authChecking.value) return nextTick(() => scheduleChartRedraw()) }) @@ -4110,6 +4295,62 @@ async function onFamilyLike() { line-height: 1; font-weight: 700; } +.phone-gate-body { + padding: 32rpx; +} +.phone-gate-tip { + display: block; + font-size: 28rpx; + color: var(--primary-container); + line-height: 1.6; + margin-bottom: 24rpx; +} +.phone-gate-gender { + margin-bottom: 32rpx; +} +.phone-gate-gender-label { + display: block; + font-size: 28rpx; + font-weight: 700; + color: var(--primary); + margin-bottom: 16rpx; +} +.phone-gate-gender-row { + display: flex; + gap: 20rpx; +} +.phone-gate-gender-opt { + flex: 1; + min-height: 88rpx; + display: flex; + align-items: center; + justify-content: center; + border-radius: 20rpx; + background: #ffffff; + border: 2rpx solid rgba(32, 78, 43, 0.25); + color: var(--primary-container); + font-size: 32rpx; + font-weight: 600; +} +.phone-gate-gender-opt.active { + border-color: var(--primary); + color: var(--primary); + box-shadow: inset 0 0 0 1rpx var(--primary); +} +.phone-gate-btn { + width: 100%; + min-height: 96rpx; + line-height: 96rpx; + border-radius: 20rpx; + background: #ffffff; + color: var(--primary); + font-size: 32rpx; + font-weight: 700; + border: 2rpx solid var(--primary); +} +.phone-gate-btn::after { + border: none; +} .input-modal-loading { min-height: 520rpx; padding: 120rpx 0; diff --git a/TUICallKit-Vue3/tongji/pages/more.vue b/TUICallKit-Vue3/tongji/pages/more.vue index d6b225de..71350dae 100644 --- a/TUICallKit-Vue3/tongji/pages/more.vue +++ b/TUICallKit-Vue3/tongji/pages/more.vue @@ -33,6 +33,90 @@ + + + + + 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 }} + + @@ -661,23 +745,30 @@ 加载中… + + AI 推荐 + 早 {{ dietAiRecommend.breakfast }} · 午 {{ dietAiRecommend.lunch }} · 晚 {{ dietAiRecommend.dinner }} + + 一键填入 + + 至少填写一餐,完成后可浇水领取积分。 早餐 - + 午餐 - + 晚餐 - + @@ -791,6 +882,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' const { proxy } = getCurrentInstance() @@ -1437,6 +1529,7 @@ async function fetchAll() { // 拉取游戏化积分/小树状态 await fetchGamifyState() await loadFamilyLikeSummary() + await fetchDietAiRecommend(false) } catch (e) { uni.showToast({ title: '加载失败,请稍后再试', icon: 'none' }) } finally { @@ -1889,6 +1982,255 @@ const emptyDietForm = () => ({ }) 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 ?? '' +} + +function fillDietFormFromAi() { + const r = dietAiRecommend.value + 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 + if (r.tips) { + const note = String(dietForm.value.note || '').trim() + dietForm.value.note = note ? `${note};${r.tips}` : r.tips + } +} + +async function applyDietAiToForm() { + if (!dietAiRecommend.value.breakfast) { + await fetchDietAiRecommend(false) + } + await openDietForm() + fillDietFormFromAi() +} + function onDietField(field, e) { dietForm.value[field] = e?.detail?.value ?? '' } @@ -6640,4 +6982,246 @@ 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; + border-radius: 16rpx; + background: rgba(32, 78, 43, 0.04); + border: 1rpx solid rgba(32, 78, 43, 0.12); +} +.input-modal-ai-tip-label { + display: block; + font-size: 24rpx; + font-weight: 700; + color: var(--primary); + margin-bottom: 8rpx; +} +.input-modal-ai-tip-text { + display: block; + font-size: 24rpx; + line-height: 1.5; + color: var(--primary-container); +} +.input-modal-ai-tip-btn { + display: inline-flex; + margin-top: 12rpx; + padding: 8rpx 20rpx; + border-radius: 999rpx; + border: 2rpx solid var(--primary); + color: var(--primary); + font-size: 24rpx; + font-weight: 600; +} diff --git a/server/app/adminapi/logic/tcm/DiagnosisLogic.php b/server/app/adminapi/logic/tcm/DiagnosisLogic.php index ef22ba1f..5da73c71 100755 --- a/server/app/adminapi/logic/tcm/DiagnosisLogic.php +++ b/server/app/adminapi/logic/tcm/DiagnosisLogic.php @@ -3025,7 +3025,7 @@ class DiagnosisLogic extends BaseLogic ->field([ 'id', 'patient_id', 'patient_name', 'gender', 'age', 'diagnosis_date', 'diagnosis_type', 'syndrome_type', - 'status', 'create_time', 'update_time' + 'status', 'create_source', 'create_time', 'update_time' ]) ->order('create_time', 'desc') ->select() diff --git a/server/app/api/controller/TcmController.php b/server/app/api/controller/TcmController.php index 28bafdbc..8c2618b3 100755 --- a/server/app/api/controller/TcmController.php +++ b/server/app/api/controller/TcmController.php @@ -19,6 +19,8 @@ use app\adminapi\logic\tcm\TrackingNoteLogic; use app\api\logic\tcm\DailyGamifyLogic; use app\api\logic\tcm\DailyFamilyLikeLogic; use app\api\logic\tcm\DailyShareLogic; +use app\api\logic\tcm\DailyPhoneLogic; +use app\api\logic\tcm\DailyDietAiLogic; use app\adminapi\logic\ConfigLogic; use app\common\model\tcm\Diagnosis; use app\common\model\tcm\BloodRecord; @@ -242,7 +244,8 @@ class TcmController extends BaseApiController 'treatment_principle' => $this->request->post('treatment_principle'), 'prescription' => $this->request->post('prescription'), 'doctor_advice' => $this->request->post('doctor_advice'), - 'remark' => $this->request->post('remark') + 'remark' => $this->request->post('remark'), + 'create_source' => DailyPhoneLogic::CREATE_SOURCE_MNP, ]; // patient_id 可选:不传则创建时自动生成(新患者首张就诊卡) try { @@ -469,6 +472,34 @@ class TcmController extends BaseApiController return ['ok' => true, 'diagnosis' => $diagnosis]; } + /** + * @notes 日常记录-手机号快捷建档(无就诊卡时,已绑定手机号即可录入血糖) + * + * 路由:POST /api/tcm/dailyEnsurePhoneContext + * + * @return \think\response\Json + */ + public function dailyEnsurePhoneContext() + { + $sex = $this->request->post('sex', null); + $options = []; + if ($sex !== null && $sex !== '') { + $options['sex'] = (int) $sex; + } + + $result = DailyPhoneLogic::ensureDailyContext((int) $this->userId, $options); + if (empty($result['ok'])) { + $data = []; + if (!empty($result['need_mobile'])) { + $data['need_mobile'] = 1; + } + return $this->fail($result['error'] ?? '无法开始录入', $data); + } + + unset($result['ok'], $result['error'], $result['need_mobile']); + return $this->data($result); + } + /** * @notes 日常记录-获取今日已录入的血糖血压记录(患者端) * @@ -777,6 +808,136 @@ class TcmController extends BaseApiController } } + /** + * @notes 日常记录-AI 今日饮食推荐(霍大夫升糖指数) + * + * 路由:GET /api/tcm/dailyDietAiRecommend?diagnosis_id=:id&refresh=0|1 + */ + public function dailyDietAiRecommend() + { + $diagnosisId = (int) $this->request->get('diagnosis_id', 0); + $check = $this->ensurePatientOwnsDiagnosis($diagnosisId); + if (!$check['ok']) { + return $this->fail($check['error']); + } + + $refresh = (int) $this->request->get('refresh', 0) === 1; + $result = DailyDietAiLogic::getDailyRecommend($diagnosisId, $refresh); + if (empty($result['ok'])) { + return $this->fail($result['error'] ?? '获取推荐失败'); + } + + return $this->data($result['data']); + } + + /** + * @notes 日常记录-AI 饮食问答(能不能吃某食物) + * + * 路由:POST /api/tcm/dailyDietAiAsk body: diagnosis_id, question + */ + public function dailyDietAiAsk() + { + $diagnosisId = (int) $this->request->post('diagnosis_id', 0); + $check = $this->ensurePatientOwnsDiagnosis($diagnosisId); + if (!$check['ok']) { + return $this->fail($check['error']); + } + + $question = trim((string) $this->request->post('question', '')); + $result = DailyDietAiLogic::askFood($diagnosisId, $question); + if (empty($result['ok'])) { + return $this->fail($result['error'] ?? '咨询失败'); + } + + return $this->data($result['data']); + } + + /** + * @notes 日常记录-AI 今日饮食推荐(SSE 流式) + * + * 路由:GET /api/tcm/dailyDietAiRecommendStream?diagnosis_id=:id&refresh=0|1 + */ + public function dailyDietAiRecommendStream() + { + $diagnosisId = (int) $this->request->get('diagnosis_id', 0); + $check = $this->ensurePatientOwnsDiagnosis($diagnosisId); + if (!$check['ok']) { + return $this->fail($check['error']); + } + + $refresh = (int) $this->request->get('refresh', 0) === 1; + $this->runDailyDietSse(static function (callable $emit) use ($diagnosisId, $refresh): void { + DailyDietAiLogic::streamDailyRecommend($diagnosisId, $refresh, $emit); + }); + } + + /** + * @notes 日常记录-AI 饮食问答(SSE 流式) + * + * 路由:POST /api/tcm/dailyDietAiAskStream body: diagnosis_id, question + */ + public function dailyDietAiAskStream() + { + $diagnosisId = (int) $this->request->post('diagnosis_id', 0); + $check = $this->ensurePatientOwnsDiagnosis($diagnosisId); + if (!$check['ok']) { + return $this->fail($check['error']); + } + + $question = trim((string) $this->request->post('question', '')); + $this->runDailyDietSse(static function (callable $emit) use ($diagnosisId, $question): void { + DailyDietAiLogic::streamAskFood($diagnosisId, $question, $emit); + }); + } + + /** + * @param callable(callable(string, array):void):void $runner + */ + private function runDailyDietSse(callable $runner): void + { + while (ob_get_level() > 0) { + ob_end_clean(); + } + + @ini_set('output_buffering', 'off'); + @ini_set('zlib.output_compression', '0'); + if (function_exists('apache_setenv')) { + @apache_setenv('no-gzip', '1'); + } + + header('Content-Type: text/event-stream; charset=utf-8'); + header('Cache-Control: no-cache, no-transform'); + header('Connection: keep-alive'); + header('X-Accel-Buffering: no'); + header('Content-Encoding: none'); + + // 撑开 Nginx/FastCGI 缓冲,让后续 chunk 能立即下发 + echo ':' . str_repeat(' ', 2048) . "\n\n"; + if (function_exists('ob_flush')) { + @ob_flush(); + } + flush(); + + $emit = static function (string $event, array $payload): void { + echo 'event: ' . $event . "\n"; + echo 'data: ' . json_encode($payload, JSON_UNESCAPED_UNICODE) . "\n\n"; + if (function_exists('ob_flush')) { + @ob_flush(); + } + flush(); + }; + + $emit('start', ['message' => '已连接,正在生成…']); + + try { + $runner($emit); + } catch (\Throwable $e) { + $emit('error', ['message' => '服务异常,请稍后再试']); + } + + exit; + } + /** * @notes 日常记录-获取今日运动记录 * diff --git a/server/app/api/logic/UserLogic.php b/server/app/api/logic/UserLogic.php index 58d3b081..44695aa0 100755 --- a/server/app/api/logic/UserLogic.php +++ b/server/app/api/logic/UserLogic.php @@ -255,10 +255,14 @@ class UserLogic extends BaseLogic } // 绑定手机号 - User::update([ + $update = [ 'id' => $params['user_id'], - 'mobile' => $phoneNumber - ]); + 'mobile' => $phoneNumber, + ]; + if (isset($params['sex']) && in_array((int) $params['sex'], [1, 2], true)) { + $update['sex'] = (int) $params['sex']; + } + User::update($update); return true; } catch (\Exception $e) { diff --git a/server/app/api/validate/UserValidate.php b/server/app/api/validate/UserValidate.php index ef622e6b..47debe2f 100755 --- a/server/app/api/validate/UserValidate.php +++ b/server/app/api/validate/UserValidate.php @@ -27,10 +27,12 @@ class UserValidate extends BaseValidate protected $rule = [ 'code' => 'require', + 'sex' => 'in:1,2', ]; protected $message = [ 'code.require' => '参数缺失', + 'sex.in' => '性别参数无效', ]; @@ -42,7 +44,7 @@ class UserValidate extends BaseValidate */ public function sceneGetMobileByMnp() { - return $this->only(['code']); + return $this->only(['code', 'sex']); }