2731 lines
96 KiB
Vue
2731 lines
96 KiB
Vue
<template>
|
||
<view class="weekly-page stitch-vitalmint">
|
||
<view v-if="authChecking" class="page-gate-loading">
|
||
<text class="page-gate-loading-text">正在登录…</text>
|
||
</view>
|
||
|
||
<block v-if="!authChecking">
|
||
<!-- TopAppBar(code.html) -->
|
||
<view class="st-header" :style="headerSafeStyle">
|
||
<view class="st-header-left" :style="headerLeftStyle">
|
||
<view class="st-avatar">
|
||
<TongjiIcon name="person" size="md" color="#006c49" />
|
||
</view>
|
||
<view class="st-header-text">
|
||
<text class="st-greet">{{ timeGreetingMeta.text }},{{ displayPatientName }}</text>
|
||
<text class="st-status">{{ todayChecked ? '今日血糖已记录' : '今日还没记血糖' }}</text>
|
||
</view>
|
||
</view>
|
||
<view
|
||
class="st-voice-round"
|
||
:class="{ disabled: !ttsEnabled, speaking: ttsSpeaking }"
|
||
:style="voiceBtnStyle"
|
||
@click="ttsSpeaking ? ttsStop() : speakLatestSummary()"
|
||
>
|
||
<TongjiIcon :name="ttsSpeaking ? 'pause' : 'volume'" size="md" color="#006c49" />
|
||
</view>
|
||
</view>
|
||
|
||
<scroll-view
|
||
v-if="cards.length > 1"
|
||
scroll-x
|
||
class="st-card-scroll"
|
||
:show-scrollbar="false"
|
||
>
|
||
<view class="st-card-row">
|
||
<view
|
||
v-for="card in cards"
|
||
:key="card.id"
|
||
class="st-card-chip"
|
||
:class="{ active: Number(card.id) === Number(diagnosisId) }"
|
||
@click="onSelectCard(card)"
|
||
>
|
||
<text>{{ card.patient_name || '就诊卡' }}</text>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<view class="st-main">
|
||
<!-- 1. 录入今日血糖 -->
|
||
|
||
|
||
<!-- 2. 血糖趋势 -->
|
||
<view v-if="diagnosisId" class="st-chart-card">
|
||
<view class="st-chart-head">
|
||
<view>
|
||
<text class="st-chart-title">血糖趋势</text>
|
||
<text class="st-chart-sub">{{ rangeText }} · mmol/L</text>
|
||
<view class="st-chart-more" @click="goRecordsPage">
|
||
<text class="st-chart-more-text">查看更多记录</text>
|
||
<TongjiIcon name="chevron-right" size="sm" color="#006c49" />
|
||
</view>
|
||
</view>
|
||
<view class="st-chart-legend">
|
||
<view
|
||
class="st-legend-item"
|
||
:class="{ inactive: !chartSeriesVisible.fasting }"
|
||
@click="toggleChartSeries('fasting')"
|
||
>
|
||
<view class="st-dot st-dot-fasting" />
|
||
<text>空腹</text>
|
||
</view>
|
||
<view
|
||
class="st-legend-item"
|
||
:class="{ inactive: !chartSeriesVisible.postprandial }"
|
||
@click="toggleChartSeries('postprandial')"
|
||
>
|
||
<view class="st-dot st-dot-post" />
|
||
<text>餐后</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view v-if="bloodHasData" class="st-chart-summary">
|
||
<view class="st-chart-summary-item">
|
||
<text class="st-chart-summary-num is-high">{{ bloodHighDayCount }}</text>
|
||
<text class="st-chart-summary-label">天偏高</text>
|
||
</view>
|
||
<view class="st-chart-summary-item">
|
||
<text class="st-chart-summary-num is-good">{{ bloodNormalDayCount }}</text>
|
||
<text class="st-chart-summary-label">天正常</text>
|
||
</view>
|
||
<view class="st-chart-summary-item">
|
||
<text class="st-chart-summary-num">{{ bloodRecordDayCount }}</text>
|
||
<text class="st-chart-summary-label">天有记录</text>
|
||
</view>
|
||
</view>
|
||
<view class="st-chart-area">
|
||
<canvas
|
||
v-if="!recordOverlayOpen"
|
||
id="bloodWaveChart"
|
||
type="2d"
|
||
class="st-chart-canvas"
|
||
@touchstart="onChartTouch"
|
||
@touchmove="onChartTouch"
|
||
/>
|
||
<view v-if="recordOverlayOpen" class="st-chart-placeholder">
|
||
<text>图表已暂停显示</text>
|
||
</view>
|
||
<view v-if="!bloodHasData && !loading && !recordOverlayOpen" class="st-chart-empty">
|
||
<text>当前时间范围暂无血糖数据</text>
|
||
</view>
|
||
<cover-view v-if="hoverInfo && !recordOverlayOpen" class="chart-tip" :style="{ left: hoverInfo.left + 'px', top: hoverInfo.top + 'px' }">
|
||
<cover-view class="chart-tip-date">{{ hoverInfo.date }}</cover-view>
|
||
<cover-view v-if="hoverInfo.fasting != null" class="chart-tip-row">
|
||
<cover-view class="st-dot st-dot-fasting" />
|
||
<cover-view>空腹 {{ hoverInfo.fasting }}</cover-view>
|
||
</cover-view>
|
||
<cover-view v-if="hoverInfo.postprandial != null" class="chart-tip-row">
|
||
<cover-view class="st-dot st-dot-post" />
|
||
<cover-view>餐后 {{ hoverInfo.postprandial }}</cover-view>
|
||
</cover-view>
|
||
</cover-view>
|
||
</view>
|
||
<view class="st-cta" @click="openInputForm()">
|
||
<TongjiIcon name="plus" size="md" color="#FFFFFF" />
|
||
<text>录入今日血糖</text>
|
||
</view>
|
||
|
||
<view class="st-game-entry" @click="goGamePage">
|
||
<view class="st-game-entry-icon">
|
||
<TongjiIcon name="activity" size="md" color="#006c49" />
|
||
</view>
|
||
<view class="st-game-entry-body">
|
||
<text class="st-game-entry-title">糖分突袭</text>
|
||
<text class="st-game-entry-sub">消除棋盘食物,边玩边学控糖</text>
|
||
</view>
|
||
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 3. 最新测量 & 概览 -->
|
||
<view class="st-latest-section">
|
||
<view
|
||
v-if="latestMeasurement.hasValue"
|
||
class="st-latest-hero"
|
||
:class="{ 'is-high': latestMeasurement.isHigh }"
|
||
>
|
||
<view class="st-latest-hero-pattern" aria-hidden="true" />
|
||
<text class="st-latest-label">最新测量 · {{ latestMeasurement.typeLabel }}</text>
|
||
<view class="st-latest-value-row">
|
||
<text class="st-latest-value">{{ latestMeasurement.value }}</text>
|
||
<text class="st-latest-unit">mmol/L</text>
|
||
</view>
|
||
<view v-if="latestMeasurement.isHigh" class="st-latest-flag">
|
||
<TongjiIcon name="alert-triangle" size="sm" color="#93000a" />
|
||
<text>偏高,请遵医嘱</text>
|
||
</view>
|
||
</view>
|
||
<view class="st-summary-grid">
|
||
<view class="st-summary-card">
|
||
<text class="st-summary-label">空腹</text>
|
||
<text class="st-summary-value" :class="{ 'is-high': elderFasting.isHigh }">{{ elderFasting.value }}</text>
|
||
<text v-if="elderFasting.isHigh" class="st-summary-tag">偏高</text>
|
||
</view>
|
||
<view class="st-summary-card">
|
||
<text class="st-summary-label">餐后</text>
|
||
<text class="st-summary-value" :class="{ 'is-high': elderPostprandial.isHigh }">{{ elderPostprandial.value }}</text>
|
||
<text v-if="elderPostprandial.isHigh" class="st-summary-tag">偏高</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 4. AI 饮食建议 -->
|
||
<view v-if="diagnosisId" class="st-ai-section">
|
||
<view class="st-ai-deco" aria-hidden="true" />
|
||
<view class="st-ai-head">
|
||
<view class="st-ai-head-left">
|
||
<view class="st-ai-icon" :class="{ thinking: dietAiLoading }">
|
||
<TongjiIcon name="sparkles" size="sm" color="#ffffff" />
|
||
</view>
|
||
<text class="st-ai-title">AI 饮食建议</text>
|
||
</view>
|
||
<view
|
||
class="st-ai-refresh"
|
||
:class="{ disabled: dietAiLoading }"
|
||
@click="refreshDietAiRecommend"
|
||
>
|
||
<TongjiIcon name="refresh" size="sm" color="#006c49" />
|
||
<text>{{ dietAiLoading ? '生成中' : '换一换' }}</text>
|
||
</view>
|
||
</view>
|
||
<text class="st-ai-sub">读您近 7 天 · 30 天血糖,定制今日三餐</text>
|
||
|
||
<view v-if="dietAiLoading && !hasDietAiMealContent && !dietAiStreaming && !dietAiReplacing" class="st-skeleton">
|
||
<view v-for="n in 2" :key="'sk-' + n" class="st-skeleton-line" />
|
||
<text class="st-skeleton-hint">正在生成今日饮食建议…</text>
|
||
</view>
|
||
<view v-else class="st-meal-list">
|
||
<view class="st-meal-glass">
|
||
<view class="st-meal-head">
|
||
<TongjiIcon name="sun" size="sm" color="#fb923c" />
|
||
<text class="st-meal-label">早餐</text>
|
||
</view>
|
||
<text class="st-meal-text">
|
||
{{ dietAiRecommend.breakfast || (dietAiLoading ? '…' : '—') }}<text v-if="dietAiLoading && dietAiStreamField === 'breakfast'" class="ai-stream-cursor">▍</text>
|
||
</text>
|
||
</view>
|
||
<view class="st-meal-glass">
|
||
<view class="st-meal-head">
|
||
<TongjiIcon name="leaf" size="sm" color="#006c49" />
|
||
<text class="st-meal-label">午餐</text>
|
||
</view>
|
||
<text class="st-meal-text">
|
||
{{ dietAiRecommend.lunch || (dietAiLoading ? '…' : '—') }}<text v-if="dietAiLoading && dietAiStreamField === 'lunch'" class="ai-stream-cursor">▍</text>
|
||
</text>
|
||
</view>
|
||
<view v-if="dietAiRecommend.dinner || (dietAiLoading && dietAiStreamField === 'dinner')" class="st-meal-glass">
|
||
<view class="st-meal-head">
|
||
<TongjiIcon name="moon" size="sm" color="#7e85cc" />
|
||
<text class="st-meal-label">晚餐</text>
|
||
</view>
|
||
<text class="st-meal-text">
|
||
{{ dietAiRecommend.dinner || '…' }}<text v-if="dietAiLoading && dietAiStreamField === 'dinner'" class="ai-stream-cursor">▍</text>
|
||
</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="st-main-spacer" />
|
||
</view>
|
||
|
||
<!-- 收起后的重新唤起按钮 -->
|
||
<view v-if="diagnosisId && floatAskClosed" class="st-float-reopen" @click="floatAskClosed = false">
|
||
<TongjiIcon name="sparkles" size="sm" color="#ffffff" />
|
||
</view>
|
||
|
||
<!-- 底部浮动 AI 输入栏 -->
|
||
<view v-if="diagnosisId && !floatAskClosed" class="st-float-ask">
|
||
<view class="st-float-close" @click="floatAskClosed = true">
|
||
<text class="st-float-close-icon">×</text>
|
||
</view>
|
||
<view class="st-float-inner">
|
||
<view class="st-float-icon">
|
||
<TongjiIcon name="sparkles" size="sm" color="#006c49" />
|
||
</view>
|
||
<input
|
||
class="st-float-input"
|
||
type="text"
|
||
:value="dietAiAskText"
|
||
placeholder="长按麦克风说话,或输入文字咨询 AI..."
|
||
placeholder-class="st-float-placeholder"
|
||
confirm-type="send"
|
||
@input="onDietAiAskInput"
|
||
@confirm="askDietAiFood"
|
||
/>
|
||
<view
|
||
class="st-float-mic"
|
||
:class="{ listening: sttListening || sttHolding, disabled: dietAiAsking || !sttSupported }"
|
||
@touchstart.stop.prevent="onDietAiSpeechStart"
|
||
@touchend.stop.prevent="onDietAiSpeechEnd"
|
||
@touchcancel.stop.prevent="onDietAiSpeechEnd"
|
||
>
|
||
<TongjiIcon name="mic" size="sm" :color="(sttListening || sttHolding) ? '#ffffff' : '#006c49'" />
|
||
</view>
|
||
<view
|
||
class="st-float-send"
|
||
:class="{ disabled: dietAiAsking }"
|
||
@click="askDietAiFood"
|
||
>
|
||
<TongjiIcon name="send" size="sm" color="#ffffff" />
|
||
</view>
|
||
</view>
|
||
<view v-if="sttHolding && !dietAiAsking" class="st-float-hold-hint">
|
||
<text>{{ sttListening ? '松手结束' : '准备录音…' }}</text>
|
||
</view>
|
||
<view v-if="dietAiAsking || dietAiAskResult.advice" class="st-float-result">
|
||
<view v-if="dietAiAskResult.level_label" class="st-float-badge">
|
||
<text>{{ dietAiAskResult.level_label }}</text>
|
||
</view>
|
||
<text class="st-float-advice">
|
||
{{ dietAiAskResult.advice || (dietAiAsking ? '正在想…' : '') }}<text v-if="dietAiAsking" class="ai-stream-cursor">▍</text>
|
||
</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 语音长按状态浮层:居中显示,避免被键盘/底栏遮挡 -->
|
||
<view v-if="sttHolding && !dietAiAsking" class="st-voice-overlay">
|
||
<view class="st-voice-card">
|
||
<view class="st-voice-icon" :class="{ active: sttListening }">
|
||
<TongjiIcon name="mic" size="lg" color="#ffffff" />
|
||
</view>
|
||
<text class="st-voice-title">{{ sttListening ? '正在聆听…' : '准备录音…' }}</text>
|
||
<text class="st-voice-sub">松开手指结束并识别</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 class="input-modal" :style="keyboardHeight ? { marginBottom: keyboardHeight + 'px' } : {}" @click.stop>
|
||
<view class="input-modal-grip" />
|
||
<view class="input-modal-head">
|
||
<view class="input-modal-title-wrap">
|
||
<text class="input-modal-title">{{ inputForm.id ? '修改今日血糖' : '录入今日血糖' }}</text>
|
||
<text class="input-modal-sub">{{ todayText }} · {{ patientName || '本人' }}</text>
|
||
</view>
|
||
<view class="input-modal-close" @click="closeInputForm">
|
||
<text class="input-modal-close-icon">×</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="inputForm.loading" class="input-modal-loading">
|
||
<text>加载今日数据中…</text>
|
||
</view>
|
||
<scroll-view v-else scroll-y class="input-modal-body input-modal-body-ready">
|
||
<view class="input-modal-tip">
|
||
<text>请如实填写,留空表示该项不上传。当天可多次修改。</text>
|
||
</view>
|
||
|
||
<view class="input-section-title">血糖(mmol/L)</view>
|
||
<view class="input-field">
|
||
<view class="input-field-label">
|
||
<view class="input-field-dot input-field-dot-fasting" />
|
||
<text>空腹血糖</text>
|
||
</view>
|
||
<view class="input-field-input">
|
||
<input
|
||
type="digit"
|
||
:adjust-position="false"
|
||
:value="inputForm.fasting_blood_sugar"
|
||
placeholder="如 6.5"
|
||
placeholder-class="input-placeholder"
|
||
@input="onInputField('fasting_blood_sugar', $event)"
|
||
/>
|
||
</view>
|
||
</view>
|
||
<view class="input-field">
|
||
<view class="input-field-label">
|
||
<view class="input-field-dot input-field-dot-postprandial" />
|
||
<text>餐后血糖</text>
|
||
</view>
|
||
<view class="input-field-input">
|
||
<input
|
||
type="digit"
|
||
:adjust-position="false"
|
||
:value="inputForm.postprandial_blood_sugar"
|
||
placeholder="如 8.0"
|
||
placeholder-class="input-placeholder"
|
||
@input="onInputField('postprandial_blood_sugar', $event)"
|
||
/>
|
||
</view>
|
||
</view>
|
||
<view class="input-field">
|
||
<view class="input-field-label">
|
||
<view class="input-field-dot input-field-dot-other" />
|
||
<text>其他血糖</text>
|
||
</view>
|
||
<view class="input-field-input">
|
||
<input
|
||
type="digit"
|
||
:adjust-position="false"
|
||
:value="inputForm.other_blood_sugar"
|
||
placeholder="如 7.2"
|
||
placeholder-class="input-placeholder"
|
||
@input="onInputField('other_blood_sugar', $event)"
|
||
/>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="input-section-title">备注</view>
|
||
<view class="input-field input-field-textarea">
|
||
<textarea
|
||
:value="inputForm.remark"
|
||
placeholder="如:饭前空腹测量、餐后2小时测量等(选填)"
|
||
placeholder-class="input-placeholder"
|
||
:show-confirm-bar="false"
|
||
:auto-height="true"
|
||
:adjust-position="false"
|
||
@input="onInputField('remark', $event)"
|
||
/>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<view class="input-modal-actions">
|
||
<view
|
||
v-if="inputForm.id"
|
||
class="input-modal-btn danger"
|
||
:class="{ disabled: inputForm.submitting }"
|
||
@click="deleteInputRecord"
|
||
>
|
||
<text>删除</text>
|
||
</view>
|
||
<view
|
||
class="input-modal-btn primary"
|
||
:class="{ disabled: inputForm.submitting || inputForm.loading }"
|
||
@click="submitInputForm"
|
||
>
|
||
<text>{{ inputForm.submitting ? '提交中…' : (inputForm.id ? '保存修改' : '提交录入') }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</block>
|
||
<TabBarDock :active="1" />
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, getCurrentInstance, nextTick, watch } from 'vue'
|
||
import { onLoad, onShow, onHide, onUnload, onPullDownRefresh, onShareAppMessage } from '@dcloudio/uni-app'
|
||
import SugarTreeGraphic from '../components/SugarTreeGraphic.vue'
|
||
import TongjiIcon from '../components/TongjiIcon.vue'
|
||
import { useDietAi } from '../composables/useDietAi.js'
|
||
import { useSpeechToText } from '../composables/useSpeechToText.js'
|
||
import TabBarDock from '@/components/app-tab-bar/tab-bar-dock.vue'
|
||
|
||
const { proxy } = getCurrentInstance()
|
||
|
||
/** 将接口 msg / 异常对象转为可展示的字符串,避免 [object Object] */
|
||
function formatUserMessage(msg, fallback = '') {
|
||
if (msg == null || msg === '') return fallback
|
||
if (typeof msg === 'string') return msg
|
||
if (typeof msg === 'number' || typeof msg === 'boolean') return String(msg)
|
||
if (Array.isArray(msg)) {
|
||
const parts = msg.map((item) => {
|
||
if (item == null) return ''
|
||
if (typeof item === 'string') return item
|
||
if (typeof item === 'number' || typeof item === 'boolean') return String(item)
|
||
if (typeof item === 'object') {
|
||
return item.msg || item.message || item.error || item.title || item.label || ''
|
||
}
|
||
return String(item)
|
||
}).filter(Boolean)
|
||
return parts.length ? parts.join(';') : fallback
|
||
}
|
||
if (typeof msg === 'object') {
|
||
return msg.msg || msg.message || msg.error || msg.title || msg.label || fallback
|
||
}
|
||
return String(msg)
|
||
}
|
||
|
||
function showUserToast(title, options = {}) {
|
||
const text = formatUserMessage(title, '')
|
||
if (!text) return
|
||
uni.showToast({
|
||
title: text,
|
||
icon: options.icon || 'none',
|
||
duration: options.duration
|
||
})
|
||
}
|
||
|
||
const diagnosisId = ref(0)
|
||
const patientId = ref(0)
|
||
const patientName = ref('')
|
||
const displayPatientName = computed(() => {
|
||
const name = String(patientName.value || '').trim()
|
||
if (!name) return '您好'
|
||
return name.length > 10 ? `${name.slice(0, 10)}…` : name
|
||
})
|
||
const headerSafeStyle = ref({})
|
||
const headerLeftStyle = ref({})
|
||
const voiceBtnStyle = ref({})
|
||
const age = ref(0)
|
||
const gender = ref(0)
|
||
|
||
const {
|
||
dietAiRecommend,
|
||
dietAiLoading,
|
||
dietAiStreaming,
|
||
dietAiReplacing,
|
||
dietAiStreamField,
|
||
dietAiAskText,
|
||
dietAiAsking,
|
||
dietAiAskResult,
|
||
hasDietAiMealContent,
|
||
fetchDietAiRecommend,
|
||
refreshDietAiRecommend,
|
||
askDietAiFood,
|
||
onDietAiAskInput,
|
||
applyDietAiToForm,
|
||
openDietForm
|
||
} = useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage })
|
||
|
||
/** 底部 AI 浮动卡片是否被用户收起 */
|
||
const floatAskClosed = ref(false)
|
||
|
||
/** 键盘高度(px):录入弹窗据此整体上移,避免输入框被键盘遮挡 */
|
||
const keyboardHeight = ref(0)
|
||
function onKeyboardHeightChange(res) {
|
||
keyboardHeight.value = res && res.height ? Math.round(res.height) : 0
|
||
}
|
||
|
||
const {
|
||
sttListening,
|
||
sttHolding,
|
||
sttSupported,
|
||
beginHoldSpeech,
|
||
endHoldSpeech,
|
||
stopSpeechToText
|
||
} = useSpeechToText({
|
||
onResult(text) {
|
||
const prev = String(dietAiAskText.value || '').trim()
|
||
dietAiAskText.value = prev ? `${prev} ${text}` : text
|
||
},
|
||
showToast: (msg) => showUserToast(msg)
|
||
})
|
||
|
||
function onDietAiSpeechStart() {
|
||
if (dietAiAsking.value) return
|
||
beginHoldSpeech()
|
||
}
|
||
|
||
function onDietAiSpeechEnd() {
|
||
endHoldSpeech()
|
||
}
|
||
|
||
/** AI 饮食建议依据:近7天 / 近30天 血糖统计 */
|
||
const dietAnalysisList = computed(() => {
|
||
const list = dietAiRecommend.value && Array.isArray(dietAiRecommend.value.analysis)
|
||
? dietAiRecommend.value.analysis
|
||
: []
|
||
return list.filter((s) => s && typeof s === 'object')
|
||
})
|
||
|
||
const hasDietAnalysis = computed(() => dietAnalysisList.value.length > 0)
|
||
|
||
/** AI 运动降糖建议(按血糖统计调整强度) */
|
||
const dietExercise = computed(() => {
|
||
const ex = dietAiRecommend.value && dietAiRecommend.value.exercise
|
||
return ex && typeof ex === 'object' && Array.isArray(ex.items) && ex.items.length ? ex : null
|
||
})
|
||
|
||
/** 达标率 → 颜色档位(绿/橙/红) */
|
||
function dietRateClass(rate) {
|
||
const n = Number(rate)
|
||
if (!Number.isFinite(n)) return 'rate-mid'
|
||
if (n >= 80) return 'rate-good'
|
||
if (n >= 50) return 'rate-mid'
|
||
return 'rate-low'
|
||
}
|
||
|
||
function dietAvgText(v) {
|
||
return v == null || v === '' ? '—' : v
|
||
}
|
||
|
||
/** 登录 / 就诊卡门禁校验中(避免未登录时闪屏) */
|
||
const authChecking = ref(false)
|
||
|
||
const userMobile = ref('')
|
||
const phoneGateVisible = ref(false)
|
||
const phoneGateSex = ref(0)
|
||
const phoneBinding = ref(false)
|
||
|
||
const loading = ref(false)
|
||
const cardsLoading = ref(false)
|
||
const cards = ref([])
|
||
const rangeMode = ref('7')
|
||
const rangeOptions = [
|
||
{ value: '7', label: '最近 7 天' },
|
||
{ value: '30', label: '最近 30 天' }
|
||
]
|
||
|
||
const diagnosisTypeMap = {
|
||
first_visit: '初诊',
|
||
follow_up: '复诊',
|
||
consultation: '会诊'
|
||
}
|
||
|
||
const syndromeTypeMap = {
|
||
qi_deficiency: '气虚',
|
||
blood_deficiency: '血虚',
|
||
yin_deficiency: '阴虚',
|
||
yang_deficiency: '阳虚',
|
||
qi_stagnation: '气滞',
|
||
blood_stasis: '血瘀',
|
||
phlegm_dampness: '痰湿',
|
||
damp_heat: '湿热',
|
||
cold_dampness: '寒湿',
|
||
wind_cold: '风寒',
|
||
wind_heat: '风热'
|
||
}
|
||
|
||
function getCardSubtitle(card) {
|
||
if (!card) return ''
|
||
const parts = []
|
||
if (card.gender_desc) parts.push(card.gender_desc)
|
||
else if (Number(card.gender) === 1) parts.push('男')
|
||
else if (Number(card.gender) === 2 || Number(card.gender) === 0) parts.push('女')
|
||
if (card.age) parts.push(`${card.age}岁`)
|
||
const dt = diagnosisTypeMap[card.diagnosis_type] || card.diagnosis_type
|
||
if (dt) parts.push(dt)
|
||
const st = syndromeTypeMap[card.syndrome_type] || card.syndrome_type
|
||
if (st) parts.push(st)
|
||
return parts.join(' · ')
|
||
}
|
||
|
||
const bloodRecords = ref([])
|
||
const dietRecords = ref([])
|
||
const exerciseRecords = ref([])
|
||
const trackingNotes = ref([])
|
||
|
||
const hoverInfo = ref(null)
|
||
let hoverTimer = null
|
||
|
||
const chartSeriesVisible = ref({ fasting: true, postprandial: true })
|
||
|
||
// 画布上下文相关
|
||
let canvasNode = null
|
||
let canvasCtx = null
|
||
let canvasWidth = 0
|
||
let canvasHeight = 0
|
||
let canvasDpr = 1
|
||
let chartCoords = null
|
||
|
||
const ageText = computed(() => (age.value > 0 ? `${age.value} 岁` : ''))
|
||
const genderText = computed(() => {
|
||
const map = { 1: '男', 2: '女' }
|
||
return map[Number(gender.value)] || ''
|
||
})
|
||
const rangeText = computed(() => {
|
||
const opt = rangeOptions.find((o) => o.value === rangeMode.value)
|
||
return opt ? opt.label : ''
|
||
})
|
||
|
||
// ============ 时间区间 ============
|
||
function formatDate(date) {
|
||
const y = date.getFullYear()
|
||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||
const d = String(date.getDate()).padStart(2, '0')
|
||
return `${y}-${m}-${d}`
|
||
}
|
||
|
||
function parseDate(str) {
|
||
if (!str) return new Date()
|
||
const [y, m, d] = str.split('-').map(Number)
|
||
return new Date(y, (m || 1) - 1, d || 1)
|
||
}
|
||
|
||
const currentRange = computed(() => {
|
||
const days = rangeMode.value === '30' ? 30 : 7
|
||
const end = new Date()
|
||
end.setHours(0, 0, 0, 0)
|
||
const start = new Date(end)
|
||
start.setDate(start.getDate() - (days - 1))
|
||
return { start: formatDate(start), end: formatDate(end) }
|
||
})
|
||
|
||
const visibleDates = computed(() => {
|
||
const { start, end } = currentRange.value
|
||
if (!start || !end) return []
|
||
const dates = []
|
||
const cursor = parseDate(end)
|
||
const lower = parseDate(start).getTime()
|
||
while (cursor.getTime() >= lower) {
|
||
dates.push(formatDate(cursor))
|
||
cursor.setDate(cursor.getDate() - 1)
|
||
}
|
||
return dates
|
||
})
|
||
|
||
const chartDates = computed(() => [...visibleDates.value].reverse())
|
||
|
||
// ============ 数据聚合 ============
|
||
function toNumber(v) {
|
||
if (v === null || v === undefined || v === '') return null
|
||
const n = Number(v)
|
||
return Number.isFinite(n) && n !== 0 ? n : null
|
||
}
|
||
|
||
function getBloodSugarThresholds(ageValue) {
|
||
const a = Number(ageValue)
|
||
// 年龄未知时用通用糖尿病控制目标兜底,避免高血糖不被标记
|
||
if (!Number.isFinite(a) || a <= 0) return { fasting: 7, postprandial: 10 }
|
||
if (a < 50) return { fasting: 7, postprandial: 9 }
|
||
return { fasting: 8, postprandial: 10 }
|
||
}
|
||
|
||
function isHighFasting(v) {
|
||
const n = toNumber(v)
|
||
const t = getBloodSugarThresholds(age.value)
|
||
return n !== null && t !== null && n >= t.fasting
|
||
}
|
||
|
||
function isHighPostprandial(v) {
|
||
const n = toNumber(v)
|
||
const t = getBloodSugarThresholds(age.value)
|
||
return n !== null && t !== null && n >= t.postprandial
|
||
}
|
||
|
||
function isHighBp(row) {
|
||
if (!row) return false
|
||
const s = toNumber(row.systolic_pressure)
|
||
const d = toNumber(row.diastolic_pressure)
|
||
return (s !== null && s > 140) || (d !== null && d > 90)
|
||
}
|
||
|
||
function formatBp(row) {
|
||
const s = row?.systolic_pressure
|
||
const d = row?.diastolic_pressure
|
||
if ((s === null || s === undefined || s === '' || s === 0) && (d === null || d === undefined || d === '' || d === 0)) return '—'
|
||
return `${s || '—'} / ${d || '—'}`
|
||
}
|
||
|
||
function formatValue(v) {
|
||
const n = toNumber(v)
|
||
return n === null ? '—' : String(n)
|
||
}
|
||
|
||
const intensityTextMap = { 1: '低强度', 2: '中强度', 3: '高强度' }
|
||
|
||
const bloodByDate = computed(() => {
|
||
const map = {}
|
||
for (const r of bloodRecords.value) {
|
||
const date = String(r.record_date || '')
|
||
if (!date) continue
|
||
if (!map[date]) {
|
||
map[date] = {
|
||
record_date: date,
|
||
record_time: r.record_time || '',
|
||
fasting_blood_sugar: '',
|
||
postprandial_blood_sugar: '',
|
||
other_blood_sugar: '',
|
||
systolic_pressure: '',
|
||
diastolic_pressure: '',
|
||
western_medicine: '',
|
||
insulin: '',
|
||
remark: ''
|
||
}
|
||
}
|
||
const merged = map[date]
|
||
const fields = ['fasting_blood_sugar', 'postprandial_blood_sugar', 'other_blood_sugar', 'systolic_pressure', 'diastolic_pressure', 'western_medicine', 'insulin', 'remark']
|
||
for (const f of fields) {
|
||
if ((merged[f] === '' || merged[f] === null || merged[f] === undefined) && r[f] !== '' && r[f] !== null && r[f] !== undefined) {
|
||
merged[f] = r[f]
|
||
}
|
||
}
|
||
}
|
||
for (const date in map) {
|
||
const m = map[date]
|
||
m.isHighFasting = isHighFasting(m.fasting_blood_sugar)
|
||
m.isHighPostprandial = isHighPostprandial(m.postprandial_blood_sugar)
|
||
m.isHighOther = isHighPostprandial(m.other_blood_sugar)
|
||
m.isHighBp = isHighBp(m)
|
||
m.bpText = formatBp(m)
|
||
}
|
||
return map
|
||
})
|
||
|
||
const dietByDate = computed(() => {
|
||
const map = {}
|
||
for (const r of dietRecords.value) {
|
||
const date = String(r.record_date || '')
|
||
if (!date || map[date]) continue
|
||
map[date] = r
|
||
}
|
||
return map
|
||
})
|
||
|
||
const exerciseByDate = computed(() => {
|
||
const map = {}
|
||
for (const r of exerciseRecords.value) {
|
||
const date = String(r.record_date || '')
|
||
if (!date || map[date]) continue
|
||
const item = { ...r }
|
||
item.intensityText = intensityTextMap[Number(r.intensity)] || ''
|
||
map[date] = item
|
||
}
|
||
return map
|
||
})
|
||
|
||
const trackingByDate = computed(() => {
|
||
const map = {}
|
||
for (const r of trackingNotes.value) {
|
||
const date = String(r.note_date || '')
|
||
if (!date || map[date]) continue
|
||
map[date] = r
|
||
}
|
||
return map
|
||
})
|
||
|
||
const weekdayNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||
|
||
function getWeekday(dateStr) {
|
||
return weekdayNames[parseDate(dateStr).getDay()] || ''
|
||
}
|
||
|
||
const todayStr = computed(() => formatDate(new Date()))
|
||
|
||
const recordDays = computed(() => {
|
||
const result = []
|
||
for (const date of visibleDates.value) {
|
||
const blood = bloodByDate.value[date]
|
||
if (!blood) continue
|
||
const hasSugar = toNumber(blood.fasting_blood_sugar) !== null
|
||
|| toNumber(blood.postprandial_blood_sugar) !== null
|
||
|| toNumber(blood.other_blood_sugar) !== null
|
||
if (!hasSugar) continue
|
||
const [y, m, d] = date.split('-')
|
||
result.push({
|
||
date,
|
||
dateLabel: `${m}-${d}`,
|
||
weekday: getWeekday(date),
|
||
isToday: date === todayStr.value,
|
||
blood
|
||
})
|
||
}
|
||
return result
|
||
})
|
||
|
||
const bloodHasData = computed(() => {
|
||
return chartDates.value.some((date) => {
|
||
const b = bloodByDate.value[date]
|
||
return b && (toNumber(b.fasting_blood_sugar) !== null || toNumber(b.postprandial_blood_sugar) !== null)
|
||
})
|
||
})
|
||
|
||
const bloodRecordDayCount = computed(() => {
|
||
return chartDates.value.filter((date) => {
|
||
const b = bloodByDate.value[date]
|
||
return b && (toNumber(b.fasting_blood_sugar) !== null || toNumber(b.postprandial_blood_sugar) !== null)
|
||
}).length
|
||
})
|
||
|
||
const bloodHighDayCount = computed(() => {
|
||
return chartDates.value.filter((date) => {
|
||
const b = bloodByDate.value[date]
|
||
return b && (b.isHighFasting || b.isHighPostprandial)
|
||
}).length
|
||
})
|
||
|
||
const bloodNormalDayCount = computed(() => bloodRecordDayCount.value - bloodHighDayCount.value)
|
||
|
||
// 趋势比较:dir = up/down/flat, delta = 与前一次的绝对差
|
||
// 注意:对于血糖/血压,「up = 升高 = 风险」,所以语义颜色按健康角度而非数学方向
|
||
function makeTrend(curr, prev, precision = 1) {
|
||
if (curr == null || prev == null) return null
|
||
const diff = Number(curr) - Number(prev)
|
||
if (!Number.isFinite(diff)) return null
|
||
const abs = Math.abs(diff)
|
||
const dir = abs < 0.01 ? 'flat' : diff > 0 ? 'up' : 'down'
|
||
const sign = dir === 'up' ? '+' : dir === 'down' ? '-' : ''
|
||
const deltaText = dir === 'flat' ? '持平' : `${sign}${abs.toFixed(precision)}`
|
||
return { dir, delta: abs, deltaText, prev }
|
||
}
|
||
|
||
// 找某字段在指定日期之前最近一条非空记录
|
||
function findPrev(field, beforeDate) {
|
||
const dates = chartDates.value
|
||
const idx = dates.indexOf(beforeDate)
|
||
if (idx <= 0) return null
|
||
for (let i = idx - 1; i >= 0; i--) {
|
||
const b = bloodByDate.value[dates[i]]
|
||
if (!b) continue
|
||
const v = toNumber(b[field])
|
||
if (v !== null) return { date: dates[i], value: v }
|
||
}
|
||
return null
|
||
}
|
||
|
||
function findPrevBp(beforeDate) {
|
||
const dates = chartDates.value
|
||
const idx = dates.indexOf(beforeDate)
|
||
if (idx <= 0) return null
|
||
for (let i = idx - 1; i >= 0; i--) {
|
||
const b = bloodByDate.value[dates[i]]
|
||
if (!b) continue
|
||
const s = toNumber(b.systolic_pressure)
|
||
const d = toNumber(b.diastolic_pressure)
|
||
if (s !== null || d !== null) return { date: dates[i], systolic: s, diastolic: d }
|
||
}
|
||
return null
|
||
}
|
||
|
||
const latestStat = computed(() => {
|
||
const dates = [...chartDates.value].reverse()
|
||
const dummy = { value: '—', date: '暂无记录', isHigh: false, trend: null }
|
||
const result = { fasting: { ...dummy }, postprandial: { ...dummy }, other: { ...dummy } }
|
||
for (const date of dates) {
|
||
const b = bloodByDate.value[date]
|
||
if (!b) continue
|
||
if (result.fasting.value === '—' && toNumber(b.fasting_blood_sugar) !== null) {
|
||
const cur = toNumber(b.fasting_blood_sugar)
|
||
const prev = findPrev('fasting_blood_sugar', date)
|
||
result.fasting = {
|
||
value: String(b.fasting_blood_sugar),
|
||
date: date.slice(5),
|
||
isHigh: b.isHighFasting,
|
||
trend: prev ? makeTrend(cur, prev.value) : null
|
||
}
|
||
}
|
||
if (result.postprandial.value === '—' && toNumber(b.postprandial_blood_sugar) !== null) {
|
||
const cur = toNumber(b.postprandial_blood_sugar)
|
||
const prev = findPrev('postprandial_blood_sugar', date)
|
||
result.postprandial = {
|
||
value: String(b.postprandial_blood_sugar),
|
||
date: date.slice(5),
|
||
isHigh: b.isHighPostprandial,
|
||
trend: prev ? makeTrend(cur, prev.value) : null
|
||
}
|
||
}
|
||
if (result.other.value === '—' && toNumber(b.other_blood_sugar) !== null) {
|
||
const cur = toNumber(b.other_blood_sugar)
|
||
const prev = findPrev('other_blood_sugar', date)
|
||
result.other = {
|
||
value: String(b.other_blood_sugar),
|
||
date: date.slice(5),
|
||
isHigh: b.isHighOther,
|
||
trend: prev ? makeTrend(cur, prev.value) : null
|
||
}
|
||
}
|
||
if (result.fasting.value !== '—' && result.postprandial.value !== '—' && result.other.value !== '—') break
|
||
}
|
||
return result
|
||
})
|
||
|
||
function elderMetricFromBlood(blood, field, label) {
|
||
if (!blood) return { label, value: '—', isHigh: false }
|
||
const num = toNumber(blood[field])
|
||
if (num === null) return { label, value: '—', isHigh: false }
|
||
const isHigh = field === 'fasting_blood_sugar'
|
||
? blood.isHighFasting
|
||
: field === 'postprandial_blood_sugar'
|
||
? blood.isHighPostprandial
|
||
: blood.isHighOther
|
||
return { label, value: String(blood[field]), isHigh: !!isHigh }
|
||
}
|
||
|
||
const todayBlood = computed(() => bloodByDate.value[todayStr.value] || null)
|
||
|
||
const elderFasting = computed(() =>
|
||
elderMetricFromBlood(todayBlood.value, 'fasting_blood_sugar', '空腹')
|
||
)
|
||
|
||
const elderPostprandial = computed(() =>
|
||
elderMetricFromBlood(todayBlood.value, 'postprandial_blood_sugar', '餐后')
|
||
)
|
||
|
||
/** 最新测量:优先展示餐后,其次空腹(对齐 MCP 设计稿) */
|
||
const latestMeasurement = computed(() => {
|
||
const blood = todayBlood.value
|
||
if (!blood) {
|
||
return { hasValue: false, typeLabel: '', value: '—', isHigh: false }
|
||
}
|
||
const post = toNumber(blood.postprandial_blood_sugar)
|
||
if (post !== null) {
|
||
return {
|
||
hasValue: true,
|
||
typeLabel: '餐后',
|
||
value: String(blood.postprandial_blood_sugar),
|
||
isHigh: !!blood.isHighPostprandial
|
||
}
|
||
}
|
||
const fasting = toNumber(blood.fasting_blood_sugar)
|
||
if (fasting !== null) {
|
||
return {
|
||
hasValue: true,
|
||
typeLabel: '空腹',
|
||
value: String(blood.fasting_blood_sugar),
|
||
isHigh: !!blood.isHighFasting
|
||
}
|
||
}
|
||
return { hasValue: false, typeLabel: '', value: '—', isHigh: false }
|
||
})
|
||
|
||
// 给某天某字段计算 day-over-day 趋势(用于日常记录列表的指标小标签)
|
||
function getDayTrend(date, field, precision = 1) {
|
||
const b = bloodByDate.value[date]
|
||
if (!b) return null
|
||
const cur = toNumber(b[field])
|
||
if (cur === null) return null
|
||
const prev = findPrev(field, date)
|
||
if (!prev) return null
|
||
return makeTrend(cur, prev.value, precision)
|
||
}
|
||
|
||
function getDayBpTrend(date) {
|
||
const b = bloodByDate.value[date]
|
||
if (!b) return null
|
||
const cur = toNumber(b.systolic_pressure)
|
||
if (cur === null) return null
|
||
const prev = findPrevBp(date)
|
||
if (!prev || prev.systolic == null) return null
|
||
return makeTrend(cur, prev.systolic, 0)
|
||
}
|
||
|
||
// ============ 登录 / 就诊卡门禁 ============
|
||
/** 进行中的登录任务(避免与 App.onLaunch 重复发起 mnpLogin,并保证业务接口在其之后) */
|
||
let authSessionPromise = null
|
||
|
||
function hasAuthToken() {
|
||
return !!String(uni.getStorageSync('token') || '').trim()
|
||
}
|
||
|
||
function clearAuthStorage() {
|
||
uni.removeStorageSync('token')
|
||
authSessionPromise = null
|
||
}
|
||
|
||
function wxLoginGetCode() {
|
||
return new Promise((resolve, reject) => {
|
||
uni.login({
|
||
provider: 'weixin',
|
||
success: (res) => {
|
||
if (res && res.code) resolve(res.code)
|
||
else reject(new Error('微信登录未返回 code'))
|
||
},
|
||
fail: reject
|
||
})
|
||
})
|
||
}
|
||
|
||
async function mnpLoginWithCode(code) {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/login/mnpLogin',
|
||
method: 'POST',
|
||
data: { code }
|
||
}, false)
|
||
if (res && res.code === 1 && res.data && res.data.token) {
|
||
uni.setStorageSync('token', res.data.token)
|
||
uni.setStorageSync('userData', res.data)
|
||
return res.data
|
||
}
|
||
clearAuthStorage()
|
||
throw new Error(formatUserMessage(res?.msg, '登录失败'))
|
||
}
|
||
|
||
async function doWxLogin() {
|
||
const code = await wxLoginGetCode()
|
||
await mnpLoginWithCode(code)
|
||
return hasAuthToken()
|
||
}
|
||
|
||
/** 校验 token 有效;无效则清除并重新 mnpLogin(缺 token 时后端 code=0,不能当作已登录) */
|
||
async function verifyOrLogin() {
|
||
if (!hasAuthToken()) {
|
||
return doWxLogin()
|
||
}
|
||
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)
|
||
return true
|
||
}
|
||
} catch (e) {
|
||
/* 网络异常走重新登录 */
|
||
}
|
||
clearAuthStorage()
|
||
try {
|
||
return await doWxLogin()
|
||
} catch (e2) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
/** 登录完成后再调业务接口;并发调用共享同一 Promise */
|
||
async function ensureLoggedIn() {
|
||
if (authSessionPromise) {
|
||
return authSessionPromise
|
||
}
|
||
authSessionPromise = verifyOrLogin()
|
||
.then((ok) => {
|
||
if (!ok) authSessionPromise = null
|
||
return !!ok
|
||
})
|
||
.catch(() => {
|
||
authSessionPromise = null
|
||
return false
|
||
})
|
||
return authSessionPromise
|
||
}
|
||
|
||
// ============ 就诊卡 ============
|
||
async function fetchCardList() {
|
||
if (!hasAuthToken()) return []
|
||
cardsLoading.value = true
|
||
try {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/tcm/getCardList',
|
||
method: 'GET',
|
||
data: {}
|
||
}, false)
|
||
if (res && res.code === 1 && Array.isArray(res.data)) {
|
||
cards.value = res.data
|
||
return res.data
|
||
}
|
||
cards.value = []
|
||
return []
|
||
} catch (e) {
|
||
cards.value = []
|
||
return []
|
||
} finally {
|
||
cardsLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function applyCard(card) {
|
||
if (!card || !hasAuthToken()) return
|
||
diagnosisId.value = Number(card.id) || 0
|
||
patientId.value = Number(card.patient_id) || 0
|
||
patientName.value = card.patient_name || ''
|
||
age.value = Number(card.age) || 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
|
||
resetCanvasCache()
|
||
nextTick(() => scheduleChartRedraw())
|
||
}
|
||
|
||
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 = []
|
||
dietRecords.value = []
|
||
exerciseRecords.value = []
|
||
trackingNotes.value = []
|
||
hoverInfo.value = null
|
||
await applyCard(card)
|
||
fetchAll()
|
||
}
|
||
|
||
// ============ 接口请求 ============
|
||
async function fetchAll() {
|
||
if (!hasAuthToken() || !diagnosisId.value) return
|
||
loading.value = true
|
||
const { start, end } = currentRange.value
|
||
try {
|
||
const [winRes, noteRes] = await Promise.all([
|
||
proxy.apiUrl({
|
||
url: '/api/tcm/dailyTrackingWindow',
|
||
method: 'GET',
|
||
data: {
|
||
diagnosis_id: diagnosisId.value,
|
||
patient_id: patientId.value || 0,
|
||
start_date: start,
|
||
end_date: end
|
||
}
|
||
}, false),
|
||
proxy.apiUrl({
|
||
url: '/api/tcm/dailyTrackingNotes',
|
||
method: 'GET',
|
||
data: {
|
||
diagnosis_id: diagnosisId.value,
|
||
patient_id: patientId.value || 0
|
||
}
|
||
}, false)
|
||
])
|
||
|
||
if (winRes && winRes.code === 1) {
|
||
const d = winRes.data || {}
|
||
bloodRecords.value = Array.isArray(d.blood_records) ? d.blood_records : []
|
||
dietRecords.value = Array.isArray(d.diet_records) ? d.diet_records : []
|
||
exerciseRecords.value = Array.isArray(d.exercise_records) ? d.exercise_records : []
|
||
if (d.diagnosis) {
|
||
if (!patientName.value) patientName.value = d.diagnosis.patient_name || ''
|
||
if (!age.value) age.value = Number(d.diagnosis.age) || 0
|
||
if (!gender.value) gender.value = Number(d.diagnosis.gender) || 0
|
||
if (!patientId.value) patientId.value = Number(d.diagnosis.patient_id) || 0
|
||
}
|
||
} else {
|
||
bloodRecords.value = []
|
||
dietRecords.value = []
|
||
exerciseRecords.value = []
|
||
if (winRes?.msg) showUserToast(winRes.msg)
|
||
}
|
||
|
||
if (noteRes && noteRes.code === 1) {
|
||
const list = Array.isArray(noteRes.data) ? noteRes.data : []
|
||
trackingNotes.value = list.filter((it) => visibleDates.value.includes(String(it?.note_date || '')))
|
||
} else {
|
||
trackingNotes.value = []
|
||
}
|
||
await fetchDietAiRecommend(false)
|
||
} catch (e) {
|
||
uni.showToast({ title: '加载失败,请稍后再试', icon: 'none' })
|
||
} finally {
|
||
loading.value = false
|
||
await nextTick()
|
||
scheduleChartRedraw()
|
||
// 自动播报:数据就绪后朗读一次最新摘要
|
||
if (ttsAutoPlay.value && !recordOverlayOpen.value) {
|
||
const token = ++ttsAutoToken
|
||
setTimeout(() => {
|
||
if (token === ttsAutoToken && !recordOverlayOpen.value) speakLatestSummary()
|
||
}, 350)
|
||
}
|
||
}
|
||
}
|
||
|
||
function onChangeRange(value) {
|
||
if (rangeMode.value === value) return
|
||
rangeMode.value = value
|
||
fetchAll()
|
||
}
|
||
|
||
function onRefresh() {
|
||
fetchAll()
|
||
}
|
||
|
||
async function goRecordsPage() {
|
||
if (!diagnosisId.value) {
|
||
const ok = await ensureCanRecordGlucose()
|
||
if (!ok || !diagnosisId.value) return
|
||
}
|
||
const q = [
|
||
`diagnosis_id=${diagnosisId.value}`,
|
||
patientId.value ? `patient_id=${patientId.value}` : ''
|
||
].filter(Boolean).join('&')
|
||
uni.navigateTo({ url: `/tongji/pages/more?${q}` })
|
||
}
|
||
|
||
async function goMorePage() {
|
||
if (!diagnosisId.value) {
|
||
const ok = await ensureCanRecordGlucose()
|
||
if (!ok || !diagnosisId.value) return
|
||
}
|
||
uni.navigateTo({ url: '/tongji/pages/more' })
|
||
}
|
||
|
||
function goGamePage() {
|
||
uni.navigateTo({
|
||
url: '/tongji/pages/game',
|
||
fail() {
|
||
uni.showToast({ title: '暂时无法打开游戏', icon: 'none' })
|
||
}
|
||
})
|
||
}
|
||
|
||
function navToUser() {
|
||
uni.redirectTo({ url: '/pages/user/user' })
|
||
}
|
||
|
||
// ============ 时段问候 / 打卡 / 健康日历 ============
|
||
/** 时段问候:文案 + 统一 TongjiIcon(与全页图标体系一致)*/
|
||
function buildTimeGreetingMeta(hour = new Date().getHours()) {
|
||
const h = Number(hour) || 0
|
||
if (h < 5) return { text: '夜深了', icon: 'moon' }
|
||
if (h < 11) return { text: '早上好', icon: 'sun' }
|
||
if (h < 13) return { text: '中午好', icon: 'sun' }
|
||
if (h < 18) return { text: '下午好', icon: 'sun' }
|
||
if (h < 22) return { text: '晚上好', icon: 'sunset' }
|
||
return { text: '夜深了', icon: 'moon' }
|
||
}
|
||
|
||
const timeGreetingMeta = computed(() => buildTimeGreetingMeta())
|
||
|
||
// 复用已有的 formatDate(date) -> 'YYYY-MM-DD'
|
||
|
||
function dayHasBloodRecord(key) {
|
||
const b = bloodByDate.value[key]
|
||
if (!b) return false
|
||
return toNumber(b.fasting_blood_sugar) !== null ||
|
||
toNumber(b.postprandial_blood_sugar) !== null ||
|
||
toNumber(b.other_blood_sugar) !== null
|
||
}
|
||
|
||
function dayCoverageLevel(key) {
|
||
const b = bloodByDate.value[key]
|
||
if (!b) return 0
|
||
const fields = ['fasting_blood_sugar', 'postprandial_blood_sugar', 'other_blood_sugar']
|
||
const score = fields.filter((f) => toNumber(b[f]) !== null).length
|
||
if (score === 0) return 0
|
||
if (score === 1) return 1
|
||
if (score === 2) return 2
|
||
return 3
|
||
}
|
||
|
||
// todayStr 已在上方定义,这里直接复用
|
||
const todayChecked = computed(() => dayHasBloodRecord(todayStr.value))
|
||
|
||
// 连续打卡天数:从今天往前回溯,连续有 blood 记录的天数;今天没打卡时从昨天起算(保留 streak 心态)
|
||
const streakDays = computed(() => {
|
||
const today = new Date()
|
||
let count = 0
|
||
let startedFromYesterday = false
|
||
for (let i = 0; i < 90; i++) {
|
||
const d = new Date(today)
|
||
d.setDate(today.getDate() - i)
|
||
const key = formatDate(d)
|
||
const has = dayHasBloodRecord(key)
|
||
if (has) {
|
||
count++
|
||
} else {
|
||
if (i === 0) {
|
||
// 今天还没测:从昨天开始算 streak(不打断)
|
||
startedFromYesterday = true
|
||
continue
|
||
}
|
||
break
|
||
}
|
||
}
|
||
return { days: count, startedFromYesterday }
|
||
})
|
||
|
||
// 近 7 天打卡情况:从 6 天前到今天
|
||
const last7Dots = computed(() => {
|
||
const arr = []
|
||
const today = new Date()
|
||
const wkChars = ['日', '一', '二', '三', '四', '五', '六']
|
||
for (let i = 6; i >= 0; i--) {
|
||
const d = new Date(today)
|
||
d.setDate(today.getDate() - i)
|
||
const key = formatDate(d)
|
||
arr.push({
|
||
date: key,
|
||
label: wkChars[d.getDay()],
|
||
isToday: i === 0,
|
||
hasRecord: dayHasBloodRecord(key)
|
||
})
|
||
}
|
||
return arr
|
||
})
|
||
|
||
const last7CheckedCount = computed(() => last7Dots.value.filter((d) => d.hasRecord).length)
|
||
|
||
function dayHasFastingRecord(key) {
|
||
const b = bloodByDate.value[key]
|
||
return !!(b && toNumber(b.fasting_blood_sugar) !== null)
|
||
}
|
||
|
||
function dayHasPostprandialRecord(key) {
|
||
const b = bloodByDate.value[key]
|
||
return !!(b && toNumber(b.postprandial_blood_sugar) !== null)
|
||
}
|
||
|
||
function dayIsCompleteRecord(key) {
|
||
return dayHasFastingRecord(key) && dayHasPostprandialRecord(key)
|
||
}
|
||
|
||
const weekCompleteDays = computed(() => last7Dots.value.filter((d) => dayIsCompleteRecord(d.date)).length)
|
||
|
||
// 健康日历热力图:取当前数据范围内所有日期,按周组装成 6x7(最多)网格
|
||
const heatmapWeeks = computed(() => {
|
||
const dates = chartDates.value
|
||
if (!dates || !dates.length) return []
|
||
// 确保按时间正序
|
||
const sorted = [...dates].sort()
|
||
// 找到第一天所在周的周一,最后一天所在周的周日
|
||
const first = new Date(sorted[0])
|
||
const last = new Date(sorted[sorted.length - 1])
|
||
// JS getDay: 0=Sun..6=Sat;我们以周一为起点
|
||
const firstDow = (first.getDay() + 6) % 7 // 0=Mon..6=Sun
|
||
const lastDow = (last.getDay() + 6) % 7
|
||
const gridStart = new Date(first)
|
||
gridStart.setDate(first.getDate() - firstDow)
|
||
const gridEnd = new Date(last)
|
||
gridEnd.setDate(last.getDate() + (6 - lastDow))
|
||
|
||
const totalDays = Math.round((gridEnd - gridStart) / 86400000) + 1
|
||
const weeks = []
|
||
let cur = new Date(gridStart)
|
||
for (let w = 0; w < Math.ceil(totalDays / 7); w++) {
|
||
const week = []
|
||
for (let i = 0; i < 7; i++) {
|
||
const key = formatDate(cur)
|
||
const inRange = sorted.includes(key)
|
||
week.push({
|
||
date: key,
|
||
day: cur.getDate(),
|
||
inRange,
|
||
isToday: key === todayStr.value,
|
||
level: inRange ? dayCoverageLevel(key) : -1
|
||
})
|
||
cur.setDate(cur.getDate() + 1)
|
||
}
|
||
weeks.push(week)
|
||
}
|
||
return weeks
|
||
})
|
||
|
||
const heatmapTotalRecords = computed(() => {
|
||
let c = 0
|
||
for (const w of heatmapWeeks.value) for (const c2 of w) if (c2.inRange && c2.level > 0) c++
|
||
return c
|
||
})
|
||
|
||
const heatmapInRangeDays = computed(() => {
|
||
let c = 0
|
||
for (const w of heatmapWeeks.value) for (const c2 of w) if (c2.inRange) c++
|
||
return c
|
||
})
|
||
|
||
// 月历的"月份标签":取热力图覆盖范围里跨度最大的月份
|
||
const heatmapMonthLabel = computed(() => {
|
||
const weeks = heatmapWeeks.value
|
||
if (!weeks.length) return ''
|
||
const counter = new Map()
|
||
for (const w of weeks) {
|
||
for (const c of w) {
|
||
if (!c.inRange) continue
|
||
const [y, m] = c.date.split('-')
|
||
const key = `${y}-${m}`
|
||
counter.set(key, (counter.get(key) || 0) + 1)
|
||
}
|
||
}
|
||
if (!counter.size) {
|
||
const first = weeks[0][0].date.split('-')
|
||
return `${Number(first[0])}年${Number(first[1])}月`
|
||
}
|
||
let best = ''
|
||
let bestCount = -1
|
||
for (const [k, v] of counter) {
|
||
if (v > bestCount) { best = k; bestCount = v }
|
||
}
|
||
const [y, m] = best.split('-')
|
||
return `${Number(y)}年${Number(m)}月`
|
||
})
|
||
|
||
function jumpToDayRecord(date) {
|
||
// 滚动到下方对应日期 records 列表项;若不在 visible 列表则提示
|
||
if (!recordDays.value.some((d) => d.date === date)) {
|
||
uni.showToast({ title: '该日无记录', icon: 'none' })
|
||
return
|
||
}
|
||
uni.createSelectorQuery().in(proxy)
|
||
.select(`#day-${date.replace(/-/g, '')}`)
|
||
.boundingClientRect()
|
||
.selectViewport()
|
||
.scrollOffset()
|
||
.exec((res) => {
|
||
if (!res || !res[0]) return
|
||
const top = res[0].top + (res[1] ? res[1].scrollTop : 0) - 80
|
||
uni.pageScrollTo({ scrollTop: top, duration: 280 })
|
||
})
|
||
}
|
||
|
||
// ============ 录入今日数据 ============
|
||
const todayText = computed(() => {
|
||
const d = new Date()
|
||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||
const day = String(d.getDate()).padStart(2, '0')
|
||
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||
return `${d.getFullYear()}-${m}-${day} ${weekdays[d.getDay()]}`
|
||
})
|
||
|
||
const emptyInputForm = () => ({
|
||
visible: false,
|
||
loading: false,
|
||
submitting: false,
|
||
id: 0,
|
||
fasting_blood_sugar: '',
|
||
postprandial_blood_sugar: '',
|
||
other_blood_sugar: '',
|
||
remark: ''
|
||
})
|
||
const inputForm = ref(emptyInputForm())
|
||
|
||
function prepareRecordOverlay() {
|
||
resetCanvasCache()
|
||
ttsStop()
|
||
}
|
||
|
||
function onInputField(field, e) {
|
||
const val = e?.detail?.value ?? ''
|
||
inputForm.value[field] = val
|
||
}
|
||
|
||
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 复用旧引用。
|
||
prepareRecordOverlay()
|
||
// 打开弹层,先拉取今日已有记录预填
|
||
inputForm.value = { ...emptyInputForm(), visible: true, loading: true }
|
||
try {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/tcm/dailyTodayBloodRecord',
|
||
method: 'GET',
|
||
data: { diagnosis_id: diagnosisId.value }
|
||
}, false)
|
||
if (res && res.code === 1 && res.data && res.data.record) {
|
||
const r = res.data.record
|
||
inputForm.value.id = Number(r.id) || 0
|
||
inputForm.value.fasting_blood_sugar = numToStr(r.fasting_blood_sugar)
|
||
inputForm.value.postprandial_blood_sugar = numToStr(r.postprandial_blood_sugar)
|
||
inputForm.value.other_blood_sugar = numToStr(r.other_blood_sugar)
|
||
inputForm.value.remark = String(r.remark || '')
|
||
}
|
||
} catch (e) {
|
||
// 静默:拉取失败时仍允许新增录入
|
||
} finally {
|
||
inputForm.value.loading = false
|
||
}
|
||
}
|
||
|
||
function numToStr(v) {
|
||
if (v === null || v === undefined || v === '' || v === 0 || v === '0') return ''
|
||
if (typeof v === 'object') return ''
|
||
return String(v)
|
||
}
|
||
|
||
function closeInputForm() {
|
||
if (inputForm.value.submitting) return
|
||
inputForm.value.visible = false
|
||
resetCanvasCache()
|
||
nextTick(() => scheduleChartRedraw())
|
||
}
|
||
|
||
/** 任一录入弹层打开时需卸载 canvas(小程序原生组件会盖住普通 view)*/
|
||
const recordOverlayOpen = computed(() => inputForm.value.visible || phoneGateVisible.value)
|
||
|
||
watch(recordOverlayOpen, (open) => {
|
||
if (!open) {
|
||
resetCanvasCache()
|
||
nextTick(() => scheduleChartRedraw())
|
||
}
|
||
})
|
||
|
||
async function submitInputForm() {
|
||
if (inputForm.value.submitting || inputForm.value.loading) return
|
||
|
||
// 至少填一个有效字段
|
||
const f = inputForm.value
|
||
const hasAny = [
|
||
f.fasting_blood_sugar, f.postprandial_blood_sugar, f.other_blood_sugar
|
||
].some((v) => v !== '' && v !== null && Number(v) > 0) || !!(f.remark && f.remark.trim())
|
||
if (!hasAny) {
|
||
uni.showToast({ title: '请至少填写一项数据', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
inputForm.value.submitting = true
|
||
try {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/tcm/dailySaveBloodRecord',
|
||
method: 'POST',
|
||
data: {
|
||
diagnosis_id: diagnosisId.value,
|
||
fasting_blood_sugar: f.fasting_blood_sugar,
|
||
postprandial_blood_sugar: f.postprandial_blood_sugar,
|
||
other_blood_sugar: f.other_blood_sugar,
|
||
remark: f.remark
|
||
}
|
||
}, false)
|
||
if (res && res.code === 1) {
|
||
showUserToast(res.msg || '已保存', { icon: 'success' })
|
||
inputForm.value.visible = false
|
||
await fetchAll()
|
||
} else {
|
||
showUserToast((res && res.msg) || '保存失败')
|
||
}
|
||
} catch (e) {
|
||
uni.showToast({ title: '网络异常,请稍后再试', icon: 'none' })
|
||
} finally {
|
||
inputForm.value.submitting = false
|
||
}
|
||
}
|
||
|
||
function deleteInputRecord() {
|
||
if (!inputForm.value.id || inputForm.value.submitting) return
|
||
uni.showModal({
|
||
title: '删除确认',
|
||
content: '确定删除今天已录入的数据吗?删除后无法恢复。',
|
||
confirmColor: '#dc2626',
|
||
success: async (m) => {
|
||
if (!m.confirm) return
|
||
inputForm.value.submitting = true
|
||
try {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/tcm/dailyDeleteBloodRecord',
|
||
method: 'POST',
|
||
data: {
|
||
diagnosis_id: diagnosisId.value,
|
||
id: inputForm.value.id
|
||
}
|
||
}, false)
|
||
if (res && res.code === 1) {
|
||
uni.showToast({ title: '已删除', icon: 'success' })
|
||
inputForm.value.visible = false
|
||
fetchAll()
|
||
} else {
|
||
showUserToast((res && res.msg) || '删除失败')
|
||
}
|
||
} catch (e) {
|
||
uni.showToast({ title: '网络异常,请稍后再试', icon: 'none' })
|
||
} finally {
|
||
inputForm.value.submitting = false
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// ============ 语音播报(适老化)============
|
||
// 优先用 微信同声传译插件 (mp-weixin);H5 走 window.speechSynthesis;
|
||
// 都不可用则轻提示,避免 showModal 挡住录入弹层。
|
||
const ttsEnabled = ref(true)
|
||
const ttsAutoPlay = ref(false)
|
||
const ttsSpeaking = ref(false)
|
||
let ttsAudioCtx = null
|
||
let ttsAutoToken = 0
|
||
|
||
function teardownTtsAudio() {
|
||
if (ttsAudioCtx) {
|
||
try { ttsAudioCtx.stop() } catch (e) {}
|
||
try { ttsAudioCtx.destroy && ttsAudioCtx.destroy() } catch (e) {}
|
||
ttsAudioCtx = null
|
||
}
|
||
}
|
||
|
||
function ttsStop() {
|
||
teardownTtsAudio()
|
||
// #ifdef H5
|
||
try {
|
||
if (typeof window !== 'undefined' && window.speechSynthesis) {
|
||
window.speechSynthesis.cancel()
|
||
}
|
||
} catch (e) {}
|
||
// #endif
|
||
ttsSpeaking.value = false
|
||
}
|
||
|
||
function normalizeTtsText(text) {
|
||
if (text == null) return ''
|
||
if (typeof text === 'string') return text.replace(/\s+/g, ' ').trim()
|
||
if (Array.isArray(text)) {
|
||
return text
|
||
.map((item) => {
|
||
if (typeof item === 'string') return item
|
||
if (item && typeof item === 'object') {
|
||
return item.title || item.label || item.desc || item.msg || item.message || ''
|
||
}
|
||
return formatUserMessage(item, '')
|
||
})
|
||
.filter(Boolean)
|
||
.join(',')
|
||
}
|
||
if (typeof text === 'object') {
|
||
return text.title || text.label || text.desc || text.msg || text.message || ''
|
||
}
|
||
return formatUserMessage(text, '')
|
||
}
|
||
|
||
function ttsSpeak(text) {
|
||
if (!ttsEnabled.value) return
|
||
const content = normalizeTtsText(text)
|
||
if (!content) return
|
||
|
||
ttsStop()
|
||
|
||
// H5:浏览器 SpeechSynthesis(慢速适老化)
|
||
// #ifdef H5
|
||
try {
|
||
if (typeof window !== 'undefined' && window.speechSynthesis) {
|
||
const u = new window.SpeechSynthesisUtterance(content)
|
||
u.lang = 'zh-CN'
|
||
u.rate = 0.85
|
||
u.pitch = 1
|
||
u.volume = 1
|
||
u.onstart = () => { ttsSpeaking.value = true }
|
||
u.onend = () => { ttsSpeaking.value = false }
|
||
u.onerror = () => { ttsSpeaking.value = false }
|
||
window.speechSynthesis.speak(u)
|
||
return
|
||
}
|
||
} catch (e) {}
|
||
// #endif
|
||
|
||
// 微信小程序:同声传译插件 TTS → mp3 → innerAudioContext 播放
|
||
// #ifdef MP-WEIXIN
|
||
try {
|
||
// eslint-disable-next-line no-undef
|
||
const plugin = requirePlugin('WechatSI')
|
||
ttsSpeaking.value = true
|
||
plugin.textToSpeech({
|
||
lang: 'zh_CN',
|
||
tts: true,
|
||
content,
|
||
success: (res) => {
|
||
if (!res || !res.filename) {
|
||
ttsSpeaking.value = false
|
||
return
|
||
}
|
||
teardownTtsAudio()
|
||
ttsAudioCtx = uni.createInnerAudioContext()
|
||
ttsAudioCtx.src = res.filename
|
||
ttsAudioCtx.obeyMuteSwitch = false
|
||
ttsAudioCtx.onPlay(() => { ttsSpeaking.value = true })
|
||
ttsAudioCtx.onEnded(() => { ttsSpeaking.value = false })
|
||
ttsAudioCtx.onError(() => { ttsSpeaking.value = false })
|
||
ttsAudioCtx.onStop(() => { ttsSpeaking.value = false })
|
||
ttsAudioCtx.play()
|
||
},
|
||
fail: (err) => {
|
||
ttsSpeaking.value = false
|
||
console.warn('WechatSI textToSpeech failed', err)
|
||
uni.showToast({ title: '语音不可用,请检查网络/插件', icon: 'none', duration: 2000 })
|
||
}
|
||
})
|
||
return
|
||
} catch (e) {
|
||
console.warn('WechatSI plugin not available', e)
|
||
}
|
||
// #endif
|
||
|
||
// 其它端兜底:轻提示,勿用 modal 遮挡录入等操作
|
||
showUserToast(content, { duration: 3200 })
|
||
}
|
||
|
||
function ttsToggleEnabled() {
|
||
ttsEnabled.value = !ttsEnabled.value
|
||
if (!ttsEnabled.value) {
|
||
ttsAutoPlay.value = false
|
||
ttsStop()
|
||
}
|
||
}
|
||
|
||
function ttsToggleAutoPlay() {
|
||
if (!ttsEnabled.value) {
|
||
ttsEnabled.value = true
|
||
}
|
||
ttsAutoPlay.value = !ttsAutoPlay.value
|
||
if (ttsAutoPlay.value) {
|
||
const text = buildLatestSummaryText()
|
||
if (text) ttsSpeak(text)
|
||
} else {
|
||
ttsStop()
|
||
}
|
||
}
|
||
|
||
// ============ 播报文案构造 ============
|
||
function readableNumber(v) {
|
||
if (v === null || v === undefined || v === '') return ''
|
||
return String(v).replace(/(\d+)\.(\d+)/g, (_, a, b) => `${a}点${b}`)
|
||
}
|
||
|
||
function buildLatestSummaryText() {
|
||
const f = latestStat.value.fasting
|
||
const p = latestStat.value.postprandial
|
||
const o = latestStat.value.other
|
||
const head = `${patientName.value || '您'}的最新血糖情况:`
|
||
const lines = []
|
||
if (f && f.value !== '—') {
|
||
let line = `空腹血糖 ${readableNumber(f.value)} 毫摩每升`
|
||
if (f.isHigh) line += ',偏高,请遵医嘱。'
|
||
else line += '。'
|
||
if (f.trend && f.trend.dir !== 'flat') {
|
||
line += `比上次${f.trend.dir === 'up' ? '升高' : '下降'} ${readableNumber(f.trend.delta.toFixed(1))}。`
|
||
}
|
||
lines.push(line)
|
||
}
|
||
if (p && p.value !== '—') {
|
||
let line = `餐后血糖 ${readableNumber(p.value)} 毫摩每升`
|
||
if (p.isHigh) line += ',偏高,请注意饮食。'
|
||
else line += '。'
|
||
if (p.trend && p.trend.dir !== 'flat') {
|
||
line += `比上次${p.trend.dir === 'up' ? '升高' : '下降'} ${readableNumber(p.trend.delta.toFixed(1))}。`
|
||
}
|
||
lines.push(line)
|
||
}
|
||
if (o && o.value !== '—') {
|
||
let line = `其他时段血糖 ${readableNumber(o.value)} 毫摩每升`
|
||
if (o.isHigh) line += ',偏高,请遵医嘱。'
|
||
else line += '。'
|
||
if (o.trend && o.trend.dir !== 'flat') {
|
||
line += `比上次${o.trend.dir === 'up' ? '升高' : '下降'} ${readableNumber(o.trend.delta.toFixed(1))}。`
|
||
}
|
||
lines.push(line)
|
||
}
|
||
if (!lines.length) {
|
||
return `${patientName.value || '您'}近期暂无血糖记录。`
|
||
}
|
||
return head + lines.join(' ')
|
||
}
|
||
|
||
function buildDayText(day) {
|
||
if (!day) return ''
|
||
const dateLabel = `${day.dateLabel.replace('-', '月')}日${day.weekday ? ' ' + day.weekday : ''}`
|
||
const lines = [`${dateLabel} 的健康记录:`]
|
||
if (day.blood) {
|
||
const b = day.blood
|
||
if (toNumber(b.fasting_blood_sugar) !== null) {
|
||
lines.push(`空腹血糖 ${readableNumber(b.fasting_blood_sugar)} 毫摩每升${b.isHighFasting ? ',偏高。' : '。'}`)
|
||
}
|
||
if (toNumber(b.postprandial_blood_sugar) !== null) {
|
||
lines.push(`餐后血糖 ${readableNumber(b.postprandial_blood_sugar)} 毫摩每升${b.isHighPostprandial ? ',偏高。' : '。'}`)
|
||
}
|
||
if (toNumber(b.other_blood_sugar) !== null) {
|
||
lines.push(`其他血糖 ${readableNumber(b.other_blood_sugar)} 毫摩每升${b.isHighOther ? ',偏高。' : '。'}`)
|
||
}
|
||
const sys = toNumber(b.systolic_pressure)
|
||
const dia = toNumber(b.diastolic_pressure)
|
||
if (sys !== null || dia !== null) {
|
||
let line = '血压'
|
||
if (sys !== null) line += ` 高压 ${readableNumber(sys)}`
|
||
if (dia !== null) line += `,低压 ${readableNumber(dia)}`
|
||
line += ' 毫米汞柱'
|
||
line += b.isHighBp ? ',偏高。' : '。'
|
||
lines.push(line)
|
||
}
|
||
if (b.western_medicine) lines.push(`西药:${b.western_medicine}。`)
|
||
if (b.insulin) lines.push(`胰岛素:${b.insulin}。`)
|
||
}
|
||
if (day.diet) {
|
||
const d = day.diet
|
||
if (d.breakfast_foods) lines.push(`早餐:${d.breakfast_foods}。`)
|
||
if (d.lunch_foods) lines.push(`午餐:${d.lunch_foods}。`)
|
||
if (d.dinner_foods) lines.push(`晚餐:${d.dinner_foods}。`)
|
||
}
|
||
if (day.exercise) {
|
||
const ex = day.exercise
|
||
const parts = []
|
||
if (ex.exercise_type) parts.push(ex.exercise_type)
|
||
if (ex.duration) parts.push(`${ex.duration} 分钟`)
|
||
if (ex.intensityText) parts.push(ex.intensityText)
|
||
if (parts.length) lines.push(`运动:${parts.join(',')}。`)
|
||
}
|
||
if (day.tracking && day.tracking.content) {
|
||
lines.push(`备注:${String(day.tracking.content).replace(/\n+/g, ',')}。`)
|
||
}
|
||
return lines.join(' ')
|
||
}
|
||
|
||
function speakLatestSummary() {
|
||
const text = buildLatestSummaryText()
|
||
if (text) ttsSpeak(text)
|
||
}
|
||
|
||
function speakDay(day) {
|
||
if (!day || typeof day !== 'object' || !day.date) return
|
||
const text = buildDayText(day)
|
||
if (text) ttsSpeak(text)
|
||
}
|
||
|
||
// ============ 波浪图绘制 ============
|
||
function resetCanvasCache() {
|
||
canvasCtx = null
|
||
canvasNode = null
|
||
canvasWidth = 0
|
||
canvasHeight = 0
|
||
}
|
||
|
||
function fallbackCanvasSize() {
|
||
try {
|
||
const sys = uni.getSystemInfoSync()
|
||
const margin = uni.upx2px(40) * 2
|
||
const cardPad = uni.upx2px(40) * 2
|
||
return {
|
||
width: Math.max(280, sys.windowWidth - margin - cardPad),
|
||
height: uni.upx2px(360)
|
||
}
|
||
} catch (e) {
|
||
return { width: 320, height: 180 }
|
||
}
|
||
}
|
||
|
||
function ensureCanvas(retryCount = 0) {
|
||
return new Promise((resolve) => {
|
||
if (canvasCtx && canvasWidth > 0 && canvasHeight > 0) {
|
||
resolve(true)
|
||
return
|
||
}
|
||
if (canvasCtx && (canvasWidth <= 0 || canvasHeight <= 0)) {
|
||
resetCanvasCache()
|
||
}
|
||
const query = uni.createSelectorQuery().in(proxy)
|
||
query.select('#bloodWaveChart')
|
||
.fields({ node: true, size: true })
|
||
.exec((res) => {
|
||
const item = res && res[0]
|
||
if (!item || !item.node) {
|
||
if (retryCount < 10) {
|
||
setTimeout(() => ensureCanvas(retryCount + 1).then(resolve), retryCount < 4 ? 100 : 180)
|
||
} else {
|
||
resolve(false)
|
||
}
|
||
return
|
||
}
|
||
|
||
let w = item.width || 0
|
||
let h = item.height || 0
|
||
if (w <= 0 || h <= 0) {
|
||
const fallback = fallbackCanvasSize()
|
||
w = w > 0 ? w : fallback.width
|
||
h = h > 0 ? h : fallback.height
|
||
}
|
||
if ((item.width <= 0 || item.height <= 0) && retryCount < 6) {
|
||
setTimeout(() => ensureCanvas(retryCount + 1).then(resolve), 120)
|
||
return
|
||
}
|
||
|
||
canvasNode = item.node
|
||
canvasCtx = canvasNode.getContext('2d')
|
||
try {
|
||
canvasDpr = uni.getSystemInfoSync().pixelRatio || 1
|
||
} catch (e) {
|
||
canvasDpr = 1
|
||
}
|
||
canvasWidth = w
|
||
canvasHeight = h
|
||
canvasNode.width = canvasWidth * canvasDpr
|
||
canvasNode.height = canvasHeight * canvasDpr
|
||
canvasCtx.setTransform(1, 0, 0, 1, 0, 0)
|
||
canvasCtx.scale(canvasDpr, canvasDpr)
|
||
resolve(true)
|
||
})
|
||
})
|
||
}
|
||
|
||
let redrawScheduled = false
|
||
function scheduleChartRedraw() {
|
||
if (redrawScheduled) return
|
||
redrawScheduled = true
|
||
setTimeout(async () => {
|
||
redrawScheduled = false
|
||
await ensureCanvas()
|
||
drawChart()
|
||
}, 60)
|
||
}
|
||
|
||
function buildSeries(field) {
|
||
return chartDates.value.map((date) => {
|
||
const b = bloodByDate.value[date]
|
||
return b ? toNumber(b[field]) : null
|
||
})
|
||
}
|
||
|
||
function computeYRange(...seriesList) {
|
||
const values = seriesList.flat().filter((v) => v !== null)
|
||
if (!values.length) return { yMin: 4, yMax: 12 }
|
||
let min = Math.min(...values)
|
||
let max = Math.max(...values)
|
||
// 给上下留一点空间
|
||
const span = max - min
|
||
if (span < 2) {
|
||
min = Math.max(0, min - 1)
|
||
max = max + 1
|
||
} else {
|
||
min = Math.max(0, min - span * 0.2)
|
||
max = max + span * 0.2
|
||
}
|
||
// 不低于4,不高于25
|
||
min = Math.max(2, Math.floor(min))
|
||
max = Math.min(28, Math.ceil(max))
|
||
if (max - min < 4) max = min + 4
|
||
return { yMin: min, yMax: max }
|
||
}
|
||
|
||
function drawChart() {
|
||
if (!canvasCtx || !canvasWidth || !canvasHeight) return
|
||
const ctx = canvasCtx
|
||
const w = canvasWidth
|
||
const h = canvasHeight
|
||
|
||
ctx.clearRect(0, 0, w, h)
|
||
|
||
const padding = { left: 40, right: 18, top: 26, bottom: 36 }
|
||
const cw = w - padding.left - padding.right
|
||
const ch = h - padding.top - padding.bottom
|
||
|
||
const fastingSeries = buildSeries('fasting_blood_sugar')
|
||
const postSeries = buildSeries('postprandial_blood_sugar')
|
||
const n = chartDates.value.length
|
||
const threshold = getBloodSugarThresholds(age.value)
|
||
const seriesDefs = [
|
||
{
|
||
key: 'fasting',
|
||
data: fastingSeries,
|
||
color: '#006c49',
|
||
fillTop: 'rgba(0,108,73,0.18)',
|
||
fillBottom: 'rgba(0,108,73,0)',
|
||
label: '空腹',
|
||
highThreshold: threshold ? threshold.fasting : null
|
||
},
|
||
{
|
||
key: 'postprandial',
|
||
data: postSeries,
|
||
color: '#a43a3a',
|
||
fillTop: 'rgba(164,58,58,0.18)',
|
||
fillBottom: 'rgba(164,58,58,0)',
|
||
label: '餐后',
|
||
highThreshold: threshold ? threshold.postprandial : null
|
||
}
|
||
]
|
||
const visibleDefs = seriesDefs.filter((item) => chartSeriesVisible.value[item.key])
|
||
const rangeSources = visibleDefs.length
|
||
? visibleDefs.map((item) => item.data)
|
||
: [fastingSeries, postSeries]
|
||
const { yMin, yMax } = computeYRange(...rangeSources)
|
||
const valueToY = (v) => padding.top + ch * (1 - (v - yMin) / (yMax - yMin))
|
||
|
||
// 1) 背景:纯净浅色 + 阈值以上一抹极淡红
|
||
ctx.fillStyle = '#fbfcfd'
|
||
ctx.fillRect(padding.left, padding.top, cw, ch)
|
||
if (threshold) {
|
||
const yThr = valueToY(threshold.fasting)
|
||
const top = Math.max(padding.top, Math.min(yThr, padding.top + ch))
|
||
if (top > padding.top) {
|
||
// 渐变让红色从顶部往阈值线方向过渡,更柔和
|
||
const dangerGrad = ctx.createLinearGradient(0, padding.top, 0, top)
|
||
dangerGrad.addColorStop(0, 'rgba(254, 226, 226, 0.55)')
|
||
dangerGrad.addColorStop(1, 'rgba(254, 226, 226, 0.15)')
|
||
ctx.fillStyle = dangerGrad
|
||
ctx.fillRect(padding.left, padding.top, cw, top - padding.top)
|
||
}
|
||
}
|
||
|
||
// 2) 网格 —— 极淡,不喧宾夺主
|
||
ctx.strokeStyle = '#eef2f5'
|
||
ctx.lineWidth = 1
|
||
const yTicks = 4
|
||
for (let i = 0; i <= yTicks; i++) {
|
||
const y = padding.top + (ch * i) / yTicks
|
||
ctx.beginPath()
|
||
ctx.moveTo(padding.left, y)
|
||
ctx.lineTo(padding.left + cw, y)
|
||
ctx.stroke()
|
||
}
|
||
|
||
// Y 轴刻度(适老化:加大 + 加深)
|
||
ctx.font = 'bold 12px sans-serif'
|
||
ctx.fillStyle = '#64748b'
|
||
ctx.textAlign = 'right'
|
||
ctx.textBaseline = 'middle'
|
||
for (let i = 0; i <= yTicks; i++) {
|
||
const y = padding.top + (ch * i) / yTicks
|
||
const v = yMax - ((yMax - yMin) * i) / yTicks
|
||
ctx.fillText(v.toFixed(1), padding.left - 8, y)
|
||
}
|
||
|
||
// 3) X 轴日期 —— 最多 5 个(适老化:加大、加深)
|
||
ctx.font = 'bold 12px sans-serif'
|
||
ctx.fillStyle = '#475569'
|
||
ctx.textAlign = 'center'
|
||
ctx.textBaseline = 'top'
|
||
if (n > 0) {
|
||
const maxLabels = n > 20 ? 5 : 6
|
||
const labelStep = Math.max(1, Math.ceil(n / maxLabels))
|
||
chartDates.value.forEach((date, i) => {
|
||
if (i !== 0 && i !== n - 1 && i % labelStep !== 0) return
|
||
const x = n === 1 ? padding.left + cw / 2 : padding.left + (cw * i) / (n - 1)
|
||
ctx.fillText(date.slice(5), x, padding.top + ch + 8)
|
||
})
|
||
}
|
||
|
||
// 4) 阈值参考线
|
||
if (threshold) {
|
||
const y = valueToY(threshold.fasting)
|
||
if (y >= padding.top && y <= padding.top + ch) {
|
||
ctx.strokeStyle = '#fda4af'
|
||
ctx.lineWidth = 1
|
||
try { ctx.setLineDash([6, 5]) } catch (e) {}
|
||
ctx.beginPath()
|
||
ctx.moveTo(padding.left, y)
|
||
ctx.lineTo(padding.left + cw, y)
|
||
ctx.stroke()
|
||
try { ctx.setLineDash([]) } catch (e) {}
|
||
ctx.font = 'bold 11px sans-serif'
|
||
ctx.fillStyle = '#dc2626'
|
||
ctx.textAlign = 'left'
|
||
ctx.textBaseline = 'middle'
|
||
ctx.fillText(`空腹阈 ${threshold.fasting}`, padding.left + 6, y - 9)
|
||
}
|
||
}
|
||
|
||
// 5) 绘制曲线
|
||
const xAt = (i) => (n === 1 ? padding.left + cw / 2 : padding.left + (cw * i) / (n - 1))
|
||
const coordByKey = { fasting: [], postprandial: [] }
|
||
// 7 天内显示圆点,30 天以纯曲线为主
|
||
const showDots = n <= 10
|
||
|
||
seriesDefs.forEach((meta) => {
|
||
const projected = meta.data.map((v, i) => ({
|
||
x: xAt(i),
|
||
y: v === null ? null : valueToY(v),
|
||
value: v
|
||
}))
|
||
coordByKey[meta.key] = projected
|
||
|
||
if (!chartSeriesVisible.value[meta.key]) return
|
||
|
||
const segments = []
|
||
let cur = []
|
||
projected.forEach((p) => {
|
||
if (p.y !== null) cur.push(p)
|
||
else if (cur.length) { segments.push(cur); cur = [] }
|
||
})
|
||
if (cur.length) segments.push(cur)
|
||
|
||
segments.forEach((seg) => {
|
||
if (seg.length === 1) {
|
||
ctx.fillStyle = meta.color
|
||
ctx.beginPath()
|
||
ctx.arc(seg[0].x, seg[0].y, 4, 0, Math.PI * 2)
|
||
ctx.fill()
|
||
return
|
||
}
|
||
|
||
const drawPath = () => {
|
||
ctx.beginPath()
|
||
ctx.moveTo(seg[0].x, seg[0].y)
|
||
for (let i = 0; i < seg.length - 1; i++) {
|
||
const p0 = seg[i - 1] || seg[i]
|
||
const p1 = seg[i]
|
||
const p2 = seg[i + 1]
|
||
const p3 = seg[i + 2] || seg[i + 1]
|
||
const cp1x = p1.x + (p2.x - p0.x) / 6
|
||
const cp1y = p1.y + (p2.y - p0.y) / 6
|
||
const cp2x = p2.x - (p3.x - p1.x) / 6
|
||
const cp2y = p2.y - (p3.y - p1.y) / 6
|
||
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y)
|
||
}
|
||
}
|
||
|
||
// 渐变填充
|
||
drawPath()
|
||
ctx.lineTo(seg[seg.length - 1].x, padding.top + ch)
|
||
ctx.lineTo(seg[0].x, padding.top + ch)
|
||
ctx.closePath()
|
||
const grad = ctx.createLinearGradient(0, padding.top, 0, padding.top + ch)
|
||
grad.addColorStop(0, meta.fillTop)
|
||
grad.addColorStop(1, meta.fillBottom)
|
||
ctx.fillStyle = grad
|
||
ctx.fill()
|
||
|
||
// 描线
|
||
drawPath()
|
||
ctx.strokeStyle = meta.color
|
||
ctx.lineWidth = 2.4
|
||
ctx.lineCap = 'round'
|
||
ctx.lineJoin = 'round'
|
||
ctx.stroke()
|
||
|
||
// 圆点(短窗口期)
|
||
if (showDots) {
|
||
seg.forEach((p) => {
|
||
ctx.beginPath()
|
||
ctx.arc(p.x, p.y, 3, 0, Math.PI * 2)
|
||
ctx.fillStyle = '#ffffff'
|
||
ctx.fill()
|
||
ctx.strokeStyle = meta.color
|
||
ctx.lineWidth = 1.8
|
||
ctx.stroke()
|
||
})
|
||
}
|
||
})
|
||
|
||
// 标注「最高点」—— 仅一个,简洁
|
||
let maxIdx = -1
|
||
let maxVal = -Infinity
|
||
projected.forEach((p, i) => {
|
||
if (p.value != null && p.value > maxVal) {
|
||
maxVal = p.value
|
||
maxIdx = i
|
||
}
|
||
})
|
||
if (maxIdx >= 0) {
|
||
const p = projected[maxIdx]
|
||
const isHigh = meta.highThreshold != null && p.value >= meta.highThreshold
|
||
|
||
// 强调点
|
||
ctx.beginPath()
|
||
ctx.arc(p.x, p.y, 5, 0, Math.PI * 2)
|
||
ctx.fillStyle = isHigh ? '#dc2626' : meta.color
|
||
ctx.fill()
|
||
ctx.strokeStyle = '#ffffff'
|
||
ctx.lineWidth = 2.5
|
||
ctx.stroke()
|
||
|
||
// 标签(适老化:加大峰值标签)
|
||
const labelText = String(p.value)
|
||
ctx.font = 'bold 12px sans-serif'
|
||
const textWidth = ctx.measureText(labelText).width
|
||
const padX = 8
|
||
const padY = 5
|
||
const boxWidth = textWidth + padX * 2
|
||
const boxHeight = 14 + padY * 2
|
||
let labelX = p.x
|
||
let labelAbove = true
|
||
if (p.y - 12 - boxHeight - 4 < padding.top) labelAbove = false
|
||
let labelY = labelAbove ? p.y - 12 - boxHeight / 2 : p.y + 12 + boxHeight / 2
|
||
// 边界保护
|
||
if (labelX - boxWidth / 2 < padding.left + 2) labelX = padding.left + boxWidth / 2 + 2
|
||
if (labelX + boxWidth / 2 > padding.left + cw - 2) labelX = padding.left + cw - boxWidth / 2 - 2
|
||
// 标签底色
|
||
ctx.fillStyle = isHigh ? '#dc2626' : meta.color
|
||
ctx.fillRect(labelX - boxWidth / 2, labelY - boxHeight / 2, boxWidth, boxHeight)
|
||
// 标签文字
|
||
ctx.fillStyle = '#ffffff'
|
||
ctx.textAlign = 'center'
|
||
ctx.textBaseline = 'middle'
|
||
ctx.fillText(labelText, labelX, labelY)
|
||
}
|
||
})
|
||
|
||
chartCoords = {
|
||
padding,
|
||
cw,
|
||
ch,
|
||
n,
|
||
fasting: coordByKey.fasting,
|
||
postprandial: coordByKey.postprandial
|
||
}
|
||
}
|
||
|
||
function toggleChartSeries(key) {
|
||
if (key !== 'fasting' && key !== 'postprandial') return
|
||
chartSeriesVisible.value[key] = !chartSeriesVisible.value[key]
|
||
hoverInfo.value = null
|
||
scheduleChartRedraw()
|
||
}
|
||
|
||
function onChartTouch(e) {
|
||
if (!chartCoords || !canvasWidth) return
|
||
if (!chartDates.value.length) return
|
||
let touchX = 0
|
||
let touchY = 0
|
||
if (e && e.touches && e.touches[0]) {
|
||
touchX = e.touches[0].x ?? e.touches[0].clientX ?? 0
|
||
touchY = e.touches[0].y ?? e.touches[0].clientY ?? 0
|
||
}
|
||
const { padding, cw, n } = chartCoords
|
||
if (touchX < padding.left || touchX > padding.left + cw) {
|
||
return
|
||
}
|
||
const ratio = n <= 1 ? 0 : (touchX - padding.left) / cw
|
||
let idx = Math.round(ratio * (n - 1))
|
||
if (idx < 0) idx = 0
|
||
if (idx > n - 1) idx = n - 1
|
||
const date = chartDates.value[idx]
|
||
const fp = chartSeriesVisible.value.fasting ? chartCoords.fasting[idx] : null
|
||
const pp = chartSeriesVisible.value.postprandial ? chartCoords.postprandial[idx] : null
|
||
const anchorX = fp?.x ?? pp?.x ?? padding.left + cw * (idx / Math.max(1, n - 1))
|
||
|
||
const left = Math.max(8, Math.min(canvasWidth - 130, anchorX - 60))
|
||
const top = Math.max(4, touchY - 70)
|
||
|
||
hoverInfo.value = {
|
||
date: date.slice(5),
|
||
fasting: fp?.value ?? null,
|
||
postprandial: pp?.value ?? null,
|
||
left,
|
||
top
|
||
}
|
||
if (hoverTimer) clearTimeout(hoverTimer)
|
||
hoverTimer = setTimeout(() => {
|
||
hoverInfo.value = null
|
||
}, 2500)
|
||
}
|
||
|
||
watch(rangeMode, () => {
|
||
scheduleChartRedraw()
|
||
})
|
||
|
||
// ============ 生命周期 ============
|
||
function readUserContext() {
|
||
try {
|
||
const userData = uni.getStorageSync('userData')
|
||
if (userData && userData.diagnosis) {
|
||
if (!patientId.value) patientId.value = Number(userData.diagnosis.patient_id) || 0
|
||
if (!diagnosisId.value) diagnosisId.value = Number(userData.diagnosis.id) || 0
|
||
if (!patientName.value) patientName.value = userData.diagnosis.patient_name || ''
|
||
if (!age.value) age.value = Number(userData.diagnosis.age) || 0
|
||
if (!gender.value) gender.value = Number(userData.diagnosis.gender) || 0
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
function initHeaderSafeArea() {
|
||
const voiceSize = uni.upx2px(80)
|
||
const voiceGap = uni.upx2px(16)
|
||
let capsulePad = uni.upx2px(200)
|
||
let paddingTopPx = uni.upx2px(24)
|
||
|
||
try {
|
||
const sys = uni.getSystemInfoSync()
|
||
const statusBarHeight = Number(sys.statusBarHeight) || 0
|
||
paddingTopPx = statusBarHeight + uni.upx2px(16)
|
||
|
||
// #ifdef MP-WEIXIN
|
||
const menu = typeof uni.getMenuButtonBoundingClientRect === 'function'
|
||
? uni.getMenuButtonBoundingClientRect()
|
||
: null
|
||
if (menu && menu.width > 0) {
|
||
capsulePad = sys.windowWidth - menu.left + uni.upx2px(12)
|
||
paddingTopPx = Math.max(paddingTopPx, menu.top)
|
||
voiceBtnStyle.value = {
|
||
position: 'absolute',
|
||
right: `${capsulePad}px`,
|
||
top: `${menu.top + (menu.height - voiceSize) / 2}px`
|
||
}
|
||
} else {
|
||
voiceBtnStyle.value = {}
|
||
}
|
||
// #endif
|
||
|
||
// #ifndef MP-WEIXIN
|
||
voiceBtnStyle.value = {}
|
||
// #endif
|
||
} catch (e) {
|
||
paddingTopPx = uni.upx2px(48)
|
||
voiceBtnStyle.value = {}
|
||
}
|
||
|
||
headerSafeStyle.value = {
|
||
paddingTop: `${paddingTopPx}px`,
|
||
paddingRight: `${capsulePad + voiceSize + voiceGap}px`
|
||
}
|
||
headerLeftStyle.value = {}
|
||
}
|
||
|
||
async function bootstrap(options) {
|
||
options = options || {}
|
||
initHeaderSafeArea()
|
||
// weekly 专页:不走家人邀请观看,只保留血糖记录 + AI 饮食
|
||
isInviteViewer.value = false
|
||
|
||
authChecking.value = true
|
||
|
||
// 登录完成前不写 diagnosisId,避免模板/子流程提前打业务接口
|
||
const passedDiagnosisId = Number(options.diagnosis_id || options.id || 0)
|
||
const passedPatientId = Number(options.patient_id || 0)
|
||
const passedPatientName = options.patient_name
|
||
? decodeURIComponent(String(options.patient_name))
|
||
: ''
|
||
const passedAge = Number(options.age) || 0
|
||
const passedGender = Number(options.gender) || 0
|
||
|
||
const loggedIn = await ensureLoggedIn()
|
||
if (!loggedIn || !hasAuthToken()) {
|
||
authChecking.value = false
|
||
showUserToast('登录失败,请稍后重试')
|
||
return
|
||
}
|
||
|
||
readUserContext()
|
||
|
||
const list = await fetchCardList()
|
||
|
||
if (Array.isArray(list) && list.length > 0) {
|
||
let target = null
|
||
if (passedDiagnosisId) {
|
||
target = list.find((c) => Number(c.id) === Number(passedDiagnosisId))
|
||
}
|
||
if (!target) target = list[0]
|
||
await applyCard(target)
|
||
} else {
|
||
if (passedDiagnosisId) diagnosisId.value = passedDiagnosisId
|
||
if (passedPatientId) patientId.value = passedPatientId
|
||
if (passedPatientName) patientName.value = passedPatientName
|
||
if (passedAge) age.value = passedAge
|
||
if (passedGender) gender.value = passedGender
|
||
if (!diagnosisId.value || !patientId.value) {
|
||
readUserContext()
|
||
}
|
||
readUserMobile()
|
||
if (!diagnosisId.value && userMobile.value) {
|
||
await ensureDailyContextByPhone()
|
||
}
|
||
}
|
||
|
||
authChecking.value = false
|
||
if (diagnosisId.value) {
|
||
await fetchAll()
|
||
}
|
||
}
|
||
|
||
onLoad((options) => {
|
||
bootstrap(options)
|
||
})
|
||
|
||
onShow(() => {
|
||
initHeaderSafeArea()
|
||
try { uni.onKeyboardHeightChange(onKeyboardHeightChange) } catch (e) {}
|
||
if (authChecking.value) return
|
||
resetCanvasCache()
|
||
nextTick(() => scheduleChartRedraw())
|
||
})
|
||
|
||
onPullDownRefresh(async () => {
|
||
if (await ensureLoggedIn()) {
|
||
await fetchAll()
|
||
}
|
||
uni.stopPullDownRefresh()
|
||
})
|
||
|
||
onHide(() => {
|
||
ttsStop()
|
||
stopSpeechToText()
|
||
try { uni.offKeyboardHeightChange(onKeyboardHeightChange) } catch (e) {}
|
||
keyboardHeight.value = 0
|
||
})
|
||
|
||
onUnload(() => {
|
||
ttsStop()
|
||
stopSpeechToText()
|
||
try { uni.offKeyboardHeightChange(onKeyboardHeightChange) } catch (e) {}
|
||
keyboardHeight.value = 0
|
||
})
|
||
|
||
// ============ 邀请观看(家人分享页)============
|
||
const isInviteViewer = ref(false)
|
||
const inviteLoading = ref(false)
|
||
const inviteError = ref('')
|
||
const invitePreview = ref({})
|
||
const inviteLiking = ref(false)
|
||
const inviteLikeNickname = ref('家人')
|
||
const inviteHeartBurst = ref(false)
|
||
const familyNicknameOptions = ['家人', '亲友', '老伴', '子女']
|
||
|
||
const VIEWER_KEY_STORAGE = 'tongji_viewer_key_v1'
|
||
|
||
function hapticLight() {
|
||
try {
|
||
uni.vibrateShort({ type: 'light' })
|
||
} catch (e) {}
|
||
}
|
||
|
||
function getViewerKey() {
|
||
try {
|
||
let key = uni.getStorageSync(VIEWER_KEY_STORAGE)
|
||
if (!key) {
|
||
key = `v_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`
|
||
uni.setStorageSync(VIEWER_KEY_STORAGE, key)
|
||
}
|
||
return String(key).slice(0, 64)
|
||
} catch (e) {
|
||
return `v_${Date.now()}`
|
||
}
|
||
}
|
||
|
||
const inviteTreeLevel = computed(() => Number(invitePreview.value.tree_level) || 0)
|
||
|
||
const inviteDailyRecords = computed(() => {
|
||
const list = invitePreview.value.daily_records
|
||
return Array.isArray(list) ? list : []
|
||
})
|
||
|
||
function formatInviteSugar(v) {
|
||
if (v == null || v === '') return '—'
|
||
return String(v)
|
||
}
|
||
|
||
const INVITE_TREE_WHISPERS = [
|
||
'Ta 正在努力控糖,您的鼓励很重要。',
|
||
'好习惯比完美数值更珍贵,一起加油。',
|
||
'每天记一笔,就是在照顾未来的自己。',
|
||
'点赞一下,让 Ta 知道家人在身边。'
|
||
]
|
||
|
||
function onInviteTreeWhisper() {
|
||
const line = INVITE_TREE_WHISPERS[Math.floor(Math.random() * INVITE_TREE_WHISPERS.length)]
|
||
showUserToast(line, { duration: 2800 })
|
||
hapticLight()
|
||
}
|
||
|
||
async function bootstrapInviteViewer(inviteCode) {
|
||
isInviteViewer.value = true
|
||
inviteLoading.value = true
|
||
inviteError.value = ''
|
||
invitePreview.value = {}
|
||
inviteHeartBurst.value = false
|
||
try {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/tcm/dailySharePreview',
|
||
method: 'GET',
|
||
data: {
|
||
invite_code: inviteCode,
|
||
viewer_key: getViewerKey()
|
||
}
|
||
}, false)
|
||
if (res && res.code === 1 && res.data) {
|
||
invitePreview.value = res.data
|
||
} else {
|
||
inviteError.value = formatUserMessage(res && res.msg, '邀请码无效或已过期')
|
||
}
|
||
} catch (e) {
|
||
inviteError.value = '加载失败,请稍后再试'
|
||
} finally {
|
||
inviteLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function onFamilyLike() {
|
||
const code = invitePreview.value.invite_code
|
||
if (!code || inviteLiking.value) return
|
||
if (invitePreview.value.liked_by_me) {
|
||
showUserToast('您今天已经点过赞啦')
|
||
return
|
||
}
|
||
inviteLiking.value = true
|
||
try {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/tcm/dailyFamilyLike',
|
||
method: 'POST',
|
||
data: {
|
||
invite_code: code,
|
||
viewer_key: getViewerKey(),
|
||
nickname: inviteLikeNickname.value
|
||
}
|
||
}, false)
|
||
if (res && res.code === 1 && res.data) {
|
||
const data = res.data
|
||
const alreadyLiked = !!data.already_liked
|
||
invitePreview.value = {
|
||
...invitePreview.value,
|
||
like_count: Number(data.like_count) || 0,
|
||
liked_by_me: true
|
||
}
|
||
if (alreadyLiked) {
|
||
showUserToast(data.praise_message || '您今天已经点过赞啦')
|
||
return
|
||
}
|
||
inviteHeartBurst.value = true
|
||
setTimeout(() => {
|
||
inviteHeartBurst.value = false
|
||
}, 900)
|
||
hapticLight()
|
||
triggerCelebrate('鼓励已送达', data.praise_message || '谢谢您的点赞')
|
||
} else {
|
||
showUserToast((res && res.msg) || '点赞失败')
|
||
}
|
||
} catch (e) {
|
||
showUserToast('点赞失败,请稍后再试')
|
||
} finally {
|
||
inviteLiking.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.page-gate-loading {
|
||
min-height: 60vh;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 80rpx 40rpx;
|
||
}
|
||
.page-gate-loading-text {
|
||
font-size: 28rpx;
|
||
color: #6c7a71;
|
||
}
|
||
</style>
|
||
<style lang="scss">
|
||
@import '../styles/stitch-vitalmint-theme.scss';
|
||
@import '../styles/weekly-stitch.scss';
|
||
@import '../styles/weekly-input-modal.scss';
|
||
</style>
|