更新
This commit is contained in:
+10
-4
@@ -1,5 +1,11 @@
|
|||||||
import App from './App'
|
import App from './App'
|
||||||
var baseUrl ='https://css.zhenyangtang.com.cn/';
|
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
|
// #ifndef VUE3
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import './uni.promisify.adaptor'
|
import './uni.promisify.adaptor'
|
||||||
@@ -20,13 +26,13 @@ Vue.prototype.apiUrl =function apiurl(promise, Loading = true){
|
|||||||
if (promise.data) {
|
if (promise.data) {
|
||||||
data = 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) {
|
return new Promise(function(resolve, reject) {
|
||||||
uni.request({
|
uni.request({
|
||||||
url: url,
|
url: url,
|
||||||
data: data,
|
data: data,
|
||||||
timeout: 15000,
|
timeout: promise.timeout != null ? promise.timeout : 15000,
|
||||||
method: promise.method ? promise.method : 'POST',
|
method: promise.method ? promise.method : 'POST',
|
||||||
header: header,
|
header: header,
|
||||||
sslVerify: false,
|
sslVerify: false,
|
||||||
@@ -73,7 +79,7 @@ function apiUrl(promise, Loading = true) {
|
|||||||
|
|
||||||
// 处理请求数据和URL
|
// 处理请求数据和URL
|
||||||
const data = promise.data || {}
|
const data = promise.data || {}
|
||||||
const url = baseUrl + (promise.url || '')
|
const url = joinApiUrl(baseUrl, promise.url || '')
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// 可选:根据Loading参数显示加载中
|
// 可选:根据Loading参数显示加载中
|
||||||
@@ -84,7 +90,7 @@ function apiUrl(promise, Loading = true) {
|
|||||||
uni.request({
|
uni.request({
|
||||||
url: url,
|
url: url,
|
||||||
data: data,
|
data: data,
|
||||||
timeout: 15000,
|
timeout: promise.timeout != null ? promise.timeout : 15000,
|
||||||
method: promise.method || 'POST', // 简化三元表达式
|
method: promise.method || 'POST', // 简化三元表达式
|
||||||
header: header,
|
header: header,
|
||||||
sslVerify: false,
|
sslVerify: false,
|
||||||
|
|||||||
@@ -374,6 +374,53 @@
|
|||||||
<text>数据仅供参考,请遵医嘱</text>
|
<text>数据仅供参考,请遵医嘱</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 无就诊卡:授权手机号后可录入 -->
|
||||||
|
<view v-if="phoneGateVisible" class="input-modal-mask" @click="closePhoneGate">
|
||||||
|
<view class="input-modal phone-gate-modal" @click.stop>
|
||||||
|
<view class="input-modal-grip" />
|
||||||
|
<view class="input-modal-head">
|
||||||
|
<view class="input-modal-title-wrap">
|
||||||
|
<text class="input-modal-title">授权手机号</text>
|
||||||
|
<text class="input-modal-sub">绑定手机号后即可记录血糖,无需先填写就诊卡</text>
|
||||||
|
</view>
|
||||||
|
<view class="input-modal-close" @click="closePhoneGate">
|
||||||
|
<text class="input-modal-close-icon">×</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="phone-gate-body">
|
||||||
|
<text class="phone-gate-tip">微信仅提供手机号,请选择性别以便写入就诊档案。</text>
|
||||||
|
<view class="phone-gate-gender">
|
||||||
|
<text class="phone-gate-gender-label">性别</text>
|
||||||
|
<view class="phone-gate-gender-row">
|
||||||
|
<view
|
||||||
|
class="phone-gate-gender-opt"
|
||||||
|
:class="{ active: phoneGateSex === 1 }"
|
||||||
|
@click="phoneGateSex = 1"
|
||||||
|
>
|
||||||
|
<text>男</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="phone-gate-gender-opt"
|
||||||
|
:class="{ active: phoneGateSex === 2 }"
|
||||||
|
@click="phoneGateSex = 2"
|
||||||
|
>
|
||||||
|
<text>女</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
class="phone-gate-btn"
|
||||||
|
open-type="getPhoneNumber"
|
||||||
|
:loading="phoneBinding"
|
||||||
|
:disabled="phoneBinding || !phoneGateSex"
|
||||||
|
@getphonenumber="onPhoneGateAuthorize"
|
||||||
|
>
|
||||||
|
微信授权手机号
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 录入今日数据弹层 -->
|
<!-- 录入今日数据弹层 -->
|
||||||
<view v-if="inputForm.visible" class="input-modal-mask" @click="closeInputForm">
|
<view v-if="inputForm.visible" class="input-modal-mask" @click="closeInputForm">
|
||||||
<view class="input-modal" @click.stop>
|
<view class="input-modal" @click.stop>
|
||||||
@@ -528,7 +575,11 @@ const gender = ref(0)
|
|||||||
|
|
||||||
/** 登录 / 就诊卡门禁校验中(避免未登录时闪屏) */
|
/** 登录 / 就诊卡门禁校验中(避免未登录时闪屏) */
|
||||||
const authChecking = ref(false)
|
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 loading = ref(false)
|
||||||
const cardsLoading = ref(false)
|
const cardsLoading = ref(false)
|
||||||
@@ -1020,15 +1071,6 @@ async function ensureLoggedIn() {
|
|||||||
return authSessionPromise
|
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() {
|
async function fetchCardList() {
|
||||||
if (!hasAuthToken()) return []
|
if (!hasAuthToken()) return []
|
||||||
@@ -1062,6 +1104,145 @@ async function applyCard(card) {
|
|||||||
gender.value = Number(card.gender) || 0
|
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) {
|
async function onSelectCard(card) {
|
||||||
if (!card || Number(card.id) === Number(diagnosisId.value)) return
|
if (!card || Number(card.id) === Number(diagnosisId.value)) return
|
||||||
bloodRecords.value = []
|
bloodRecords.value = []
|
||||||
@@ -1150,10 +1331,10 @@ function onRefresh() {
|
|||||||
fetchAll()
|
fetchAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
function goMorePage() {
|
async function goMorePage() {
|
||||||
if (!diagnosisId.value) {
|
if (!diagnosisId.value) {
|
||||||
showUserToast('请先选择就诊卡')
|
const ok = await ensureCanRecordGlucose()
|
||||||
return
|
if (!ok || !diagnosisId.value) return
|
||||||
}
|
}
|
||||||
uni.navigateTo({ url: '/tongji/pages/more' })
|
uni.navigateTo({ url: '/tongji/pages/more' })
|
||||||
}
|
}
|
||||||
@@ -1384,16 +1565,20 @@ function onInputField(field, e) {
|
|||||||
inputForm.value[field] = val
|
inputForm.value[field] = val
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openInputForm() {
|
async function openInputForm(skipEnsure = false) {
|
||||||
if (!diagnosisId.value) {
|
|
||||||
showUserToast('请先选择就诊卡')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const token = uni.getStorageSync('token')
|
const token = uni.getStorageSync('token')
|
||||||
if (!token) {
|
if (!token) {
|
||||||
showUserToast('请先登录后再录入')
|
showUserToast('请先登录后再录入')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!skipEnsure) {
|
||||||
|
const can = await ensureCanRecordGlucose()
|
||||||
|
if (!can) return
|
||||||
|
}
|
||||||
|
if (!diagnosisId.value) {
|
||||||
|
showUserToast('请先授权手机号')
|
||||||
|
return
|
||||||
|
}
|
||||||
// 微信小程序 canvas 是原生组件、z-index 无法被弹层覆盖,
|
// 微信小程序 canvas 是原生组件、z-index 无法被弹层覆盖,
|
||||||
// 这里通过 v-if 卸载它;下次显示时 selectorQuery 拿到的会是新节点,
|
// 这里通过 v-if 卸载它;下次显示时 selectorQuery 拿到的会是新节点,
|
||||||
// 因此先清空缓存的 ctx / node,避免下次 ensureCanvas 复用旧引用。
|
// 因此先清空缓存的 ctx / node,避免下次 ensureCanvas 复用旧引用。
|
||||||
@@ -1434,7 +1619,7 @@ function closeInputForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 任一录入弹层打开时需卸载 canvas(小程序原生组件会盖住普通 view)*/
|
/** 任一录入弹层打开时需卸载 canvas(小程序原生组件会盖住普通 view)*/
|
||||||
const recordOverlayOpen = computed(() => inputForm.value.visible)
|
const recordOverlayOpen = computed(() => inputForm.value.visible || phoneGateVisible.value)
|
||||||
|
|
||||||
async function submitInputForm() {
|
async function submitInputForm() {
|
||||||
if (inputForm.value.submitting || inputForm.value.loading) return
|
if (inputForm.value.submitting || inputForm.value.loading) return
|
||||||
@@ -2153,7 +2338,6 @@ async function bootstrap(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isInviteViewer.value = false
|
isInviteViewer.value = false
|
||||||
gateRedirected = false
|
|
||||||
authChecking.value = true
|
authChecking.value = true
|
||||||
|
|
||||||
// 登录完成前不写 diagnosisId,避免模板/子流程提前打业务接口
|
// 登录完成前不写 diagnosisId,避免模板/子流程提前打业务接口
|
||||||
@@ -2192,15 +2376,16 @@ async function bootstrap(options) {
|
|||||||
if (!diagnosisId.value || !patientId.value) {
|
if (!diagnosisId.value || !patientId.value) {
|
||||||
readUserContext()
|
readUserContext()
|
||||||
}
|
}
|
||||||
if (!diagnosisId.value) {
|
readUserMobile()
|
||||||
authChecking.value = false
|
if (!diagnosisId.value && userMobile.value) {
|
||||||
redirectToCardEntry()
|
await ensureDailyContextByPhone()
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
authChecking.value = false
|
authChecking.value = false
|
||||||
await fetchAll()
|
if (diagnosisId.value) {
|
||||||
|
await fetchAll()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoad((options) => {
|
onLoad((options) => {
|
||||||
@@ -2208,7 +2393,7 @@ onLoad((options) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
if (authChecking.value || gateRedirected) return
|
if (authChecking.value) return
|
||||||
nextTick(() => scheduleChartRedraw())
|
nextTick(() => scheduleChartRedraw())
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -4110,6 +4295,62 @@ async function onFamilyLike() {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-weight: 700;
|
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 {
|
.input-modal-loading {
|
||||||
min-height: 520rpx;
|
min-height: 520rpx;
|
||||||
padding: 120rpx 0;
|
padding: 120rpx 0;
|
||||||
|
|||||||
@@ -33,6 +33,90 @@
|
|||||||
</view>
|
</view>
|
||||||
</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 v-if="diagnosisId" class="glucose-history-block">
|
||||||
<view class="range-bar">
|
<view class="range-bar">
|
||||||
@@ -661,23 +745,30 @@
|
|||||||
</view>
|
</view>
|
||||||
<view v-if="dietForm.loading" class="input-modal-loading"><text>加载中…</text></view>
|
<view v-if="dietForm.loading" class="input-modal-loading"><text>加载中…</text></view>
|
||||||
<scroll-view v-else scroll-y class="input-modal-body">
|
<scroll-view v-else scroll-y class="input-modal-body">
|
||||||
|
<view v-if="dietAiRecommend.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>
|
||||||
|
<view class="input-modal-ai-tip-btn" @click="fillDietFormFromAi">
|
||||||
|
<text>一键填入</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
<view class="input-modal-tip"><text>至少填写一餐,完成后可浇水领取积分。</text></view>
|
<view class="input-modal-tip"><text>至少填写一餐,完成后可浇水领取积分。</text></view>
|
||||||
<view class="input-field">
|
<view class="input-field">
|
||||||
<view class="input-field-label"><text>早餐</text></view>
|
<view class="input-field-label"><text>早餐</text></view>
|
||||||
<view class="input-field-input">
|
<view class="input-field-input">
|
||||||
<input type="text" :value="dietForm.breakfast_foods" placeholder="如:燕麦、鸡蛋" placeholder-class="input-placeholder" @input="onDietField('breakfast_foods', $event)" />
|
<input type="text" :value="dietForm.breakfast_foods" placeholder="如:玉米碴粥、鸡蛋" placeholder-class="input-placeholder" @input="onDietField('breakfast_foods', $event)" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="input-field">
|
<view class="input-field">
|
||||||
<view class="input-field-label"><text>午餐</text></view>
|
<view class="input-field-label"><text>午餐</text></view>
|
||||||
<view class="input-field-input">
|
<view class="input-field-input">
|
||||||
<input type="text" :value="dietForm.lunch_foods" placeholder="如:糙米饭、青菜" placeholder-class="input-placeholder" @input="onDietField('lunch_foods', $event)" />
|
<input type="text" :value="dietForm.lunch_foods" placeholder="如:二米饭、炖白菜豆腐" placeholder-class="input-placeholder" @input="onDietField('lunch_foods', $event)" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="input-field">
|
<view class="input-field">
|
||||||
<view class="input-field-label"><text>晚餐</text></view>
|
<view class="input-field-label"><text>晚餐</text></view>
|
||||||
<view class="input-field-input">
|
<view class="input-field-input">
|
||||||
<input type="text" :value="dietForm.dinner_foods" placeholder="如:杂粮粥" placeholder-class="input-placeholder" @input="onDietField('dinner_foods', $event)" />
|
<input type="text" :value="dietForm.dinner_foods" placeholder="如:杂面窝头、炒茄子" placeholder-class="input-placeholder" @input="onDietField('dinner_foods', $event)" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="input-field input-field-textarea">
|
<view class="input-field input-field-textarea">
|
||||||
@@ -791,6 +882,7 @@ import SugarTreeGraphic from '../components/SugarTreeGraphic.vue'
|
|||||||
import TongjiIcon from '../components/TongjiIcon.vue'
|
import TongjiIcon from '../components/TongjiIcon.vue'
|
||||||
import CelebrateBurst from '../components/CelebrateBurst.vue'
|
import CelebrateBurst from '../components/CelebrateBurst.vue'
|
||||||
import { TREE_LEVELS, TREE_MAX_LEVEL, calcTreeFromPoints, pickTreeWhisper } from '../utils/treeLevels.js'
|
import { TREE_LEVELS, TREE_MAX_LEVEL, calcTreeFromPoints, pickTreeWhisper } from '../utils/treeLevels.js'
|
||||||
|
import { requestAiStream, parsePartialDietPlainText } from '../utils/aiStreamRequest.js'
|
||||||
|
|
||||||
const { proxy } = getCurrentInstance()
|
const { proxy } = getCurrentInstance()
|
||||||
|
|
||||||
@@ -1437,6 +1529,7 @@ async function fetchAll() {
|
|||||||
// 拉取游戏化积分/小树状态
|
// 拉取游戏化积分/小树状态
|
||||||
await fetchGamifyState()
|
await fetchGamifyState()
|
||||||
await loadFamilyLikeSummary()
|
await loadFamilyLikeSummary()
|
||||||
|
await fetchDietAiRecommend(false)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
uni.showToast({ title: '加载失败,请稍后再试', icon: 'none' })
|
uni.showToast({ title: '加载失败,请稍后再试', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1889,6 +1982,255 @@ const emptyDietForm = () => ({
|
|||||||
})
|
})
|
||||||
const dietForm = ref(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) {
|
function onDietField(field, e) {
|
||||||
dietForm.value[field] = e?.detail?.value ?? ''
|
dietForm.value[field] = e?.detail?.value ?? ''
|
||||||
}
|
}
|
||||||
@@ -6640,4 +6982,246 @@ function onFamilyLikeStripTap() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@import '../styles/daily-theme.scss';
|
@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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3025,7 +3025,7 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
->field([
|
->field([
|
||||||
'id', 'patient_id', 'patient_name', 'gender', 'age',
|
'id', 'patient_id', 'patient_name', 'gender', 'age',
|
||||||
'diagnosis_date', 'diagnosis_type', 'syndrome_type',
|
'diagnosis_date', 'diagnosis_type', 'syndrome_type',
|
||||||
'status', 'create_time', 'update_time'
|
'status', 'create_source', 'create_time', 'update_time'
|
||||||
])
|
])
|
||||||
->order('create_time', 'desc')
|
->order('create_time', 'desc')
|
||||||
->select()
|
->select()
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ use app\adminapi\logic\tcm\TrackingNoteLogic;
|
|||||||
use app\api\logic\tcm\DailyGamifyLogic;
|
use app\api\logic\tcm\DailyGamifyLogic;
|
||||||
use app\api\logic\tcm\DailyFamilyLikeLogic;
|
use app\api\logic\tcm\DailyFamilyLikeLogic;
|
||||||
use app\api\logic\tcm\DailyShareLogic;
|
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\adminapi\logic\ConfigLogic;
|
||||||
use app\common\model\tcm\Diagnosis;
|
use app\common\model\tcm\Diagnosis;
|
||||||
use app\common\model\tcm\BloodRecord;
|
use app\common\model\tcm\BloodRecord;
|
||||||
@@ -242,7 +244,8 @@ class TcmController extends BaseApiController
|
|||||||
'treatment_principle' => $this->request->post('treatment_principle'),
|
'treatment_principle' => $this->request->post('treatment_principle'),
|
||||||
'prescription' => $this->request->post('prescription'),
|
'prescription' => $this->request->post('prescription'),
|
||||||
'doctor_advice' => $this->request->post('doctor_advice'),
|
'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 可选:不传则创建时自动生成(新患者首张就诊卡)
|
// patient_id 可选:不传则创建时自动生成(新患者首张就诊卡)
|
||||||
try {
|
try {
|
||||||
@@ -469,6 +472,34 @@ class TcmController extends BaseApiController
|
|||||||
return ['ok' => true, 'diagnosis' => $diagnosis];
|
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 日常记录-获取今日已录入的血糖血压记录(患者端)
|
* @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<string, mixed>):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 日常记录-获取今日运动记录
|
* @notes 日常记录-获取今日运动记录
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -255,10 +255,14 @@ class UserLogic extends BaseLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 绑定手机号
|
// 绑定手机号
|
||||||
User::update([
|
$update = [
|
||||||
'id' => $params['user_id'],
|
'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;
|
return true;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
|||||||
@@ -27,10 +27,12 @@ class UserValidate extends BaseValidate
|
|||||||
|
|
||||||
protected $rule = [
|
protected $rule = [
|
||||||
'code' => 'require',
|
'code' => 'require',
|
||||||
|
'sex' => 'in:1,2',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $message = [
|
protected $message = [
|
||||||
'code.require' => '参数缺失',
|
'code.require' => '参数缺失',
|
||||||
|
'sex.in' => '性别参数无效',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
@@ -42,7 +44,7 @@ class UserValidate extends BaseValidate
|
|||||||
*/
|
*/
|
||||||
public function sceneGetMobileByMnp()
|
public function sceneGetMobileByMnp()
|
||||||
{
|
{
|
||||||
return $this->only(['code']);
|
return $this->only(['code', 'sex']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user