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']);
}