This commit is contained in:
Your Name
2026-06-03 14:20:51 +08:00
parent ec4e0b9f29
commit df65344d8f
6 changed files with 619 additions and 16 deletions
+152 -15
View File
@@ -56,27 +56,55 @@
<!-- 2. 血糖趋势无就诊卡或累计录入不足 7 天显示示例曲线 -->
<view class="st-chart-card">
<view class="st-chart-head">
<view style="padding: 0 20rpx;">
<view class="st-chart-head-text">
<text class="st-chart-title">血糖趋势</text>
<text class="st-chart-sub">{{ rangeText }} · mmol/L</text>
</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 class="st-chart-toolbar">
<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
class="st-legend-item"
:class="{ inactive: !chartSeriesVisible.postprandial }"
@click="toggleChartSeries('postprandial')"
v-if="bloodCareEligible"
class="st-chart-ai-chip"
:class="{
active: bloodCarePanelOpen,
loading: bloodCareLoading,
hint: !bloodCarePanelOpen && !bloodCareLoading
}"
role="button"
aria-label="AI 照护建议"
@click.stop="toggleBloodCareFromChart"
>
<view class="st-dot st-dot-post" />
<text>餐后</text>
<view class="st-chart-ai-chip-inner">
<view class="st-chart-ai-chip-icon">
<TongjiIcon
name="sparkles"
size="sm"
:color="bloodCarePanelOpen ? '#ffffff' : '#006c49'"
/>
</view>
<text class="st-chart-ai-chip-label">{{
bloodCareLoading
? '生成中'
: (bloodCarePanelOpen ? '收起建议' : '照护建议')
}}</text>
</view>
</view>
</view>
</view>
@@ -98,6 +126,88 @@
<view v-if="chartUseMockData" class="st-chart-foot">
<text class="st-chart-mock-hint">示例数据累计记录满 7 天后显示您的真实趋势</text>
</view>
<!-- 趋势卡内点击标题栏 AI 按钮后展开 -->
<view v-if="bloodCareEligible && bloodCarePanelOpen" class="st-chart-care-panel">
<view class="st-chart-care-head">
<text class="st-chart-care-title">AI 照护建议</text>
<view class="st-chart-care-actions">
<view
class="st-chart-care-refresh"
:class="{ disabled: bloodCareLoading }"
@click.stop="refreshBloodCareAi"
>
<TongjiIcon name="refresh" size="sm" color="#006c49" />
<text>{{ bloodCareLoading ? '生成中' : '换一换' }}</text>
</view>
<view class="st-chart-care-close" @click.stop="closeBloodCarePanel">
<text>收起</text>
</view>
</view>
</view>
<text class="st-chart-care-sub">基于您累计 7 天血糖 · 监测与中医调理方向非处方</text>
<view v-if="bloodCareControlLabel" class="st-care-badge st-care-badge--in-chart">
<text>{{ bloodCareControlLabel }}</text>
</view>
<view v-if="bloodCareLoading && !hasBloodCareContent" class="st-skeleton st-skeleton--in-chart">
<view class="st-skeleton-line" />
<view class="st-skeleton-line short" />
</view>
<view v-else-if="hasBloodCareContent" class="st-care-cards st-care-cards--in-chart">
<view class="st-meal-glass st-care-block">
<view class="st-meal-head">
<text class="st-meal-label">总评</text>
</view>
<text class="st-meal-text">
{{ bloodCarePlan.summary || '…' }}<text v-if="bloodCareLoading && bloodCareStreamField === 'summary'" class="ai-stream-cursor"></text>
</text>
</view>
<view class="st-meal-glass st-care-block">
<view class="st-meal-head">
<text class="st-meal-label">血糖管理</text>
</view>
<text class="st-meal-text">
{{ bloodCarePlan.blood_advice || '…' }}<text v-if="bloodCareLoading && bloodCareStreamField === 'blood_advice'" class="ai-stream-cursor"></text>
</text>
</view>
<view class="st-meal-glass st-care-block st-care-block--tcm">
<view class="st-meal-head">
<text class="st-meal-label">中医调理</text>
</view>
<text class="st-meal-text">
{{ bloodCarePlan.tcm_plan || '…' }}<text v-if="bloodCareLoading && bloodCareStreamField === 'tcm_plan'" class="ai-stream-cursor"></text>
</text>
</view>
<view v-if="bloodCarePlan.watch_points && bloodCarePlan.watch_points.length" class="st-care-watch">
<text class="st-care-watch-title">留意</text>
<view v-for="(item, wi) in bloodCarePlan.watch_points" :key="wi" class="st-care-watch-item">
<text class="st-care-watch-dot">·</text>
<text class="st-care-watch-text">{{ item }}</text>
</view>
</view>
<view v-if="bloodCarePlan.next_steps" class="st-meal-glass st-care-block st-care-block--next">
<view class="st-meal-head">
<text class="st-meal-label">复诊提醒</text>
</view>
<text class="st-meal-text">
{{ bloodCarePlan.next_steps }}<text v-if="bloodCareLoading && bloodCareStreamField === 'next_steps'" class="ai-stream-cursor"></text>
</text>
</view>
</view>
<view v-if="bloodCareAnalysis.length" class="st-care-stats st-care-stats--in-chart">
<view v-for="(s, si) in bloodCareAnalysis" :key="si" class="st-care-stat-chip">
<text class="st-care-stat-label">{{ s.label }}</text>
<text class="st-care-stat-val">记录 {{ s.record_days }} · 达标 {{ s.compliance }}%</text>
</view>
</view>
<text v-if="bloodCareDisclaimer" class="st-care-disclaimer st-care-disclaimer--in-chart">{{ bloodCareDisclaimer }}</text>
</view>
<view class="st-game-entry st-game-entry--in-card" @click="goGamePage" style="margin: 0 20rpx 0 20rpx;">
<view class="st-game-entry-icon">
<TongjiIcon name="activity" size="sm" color="#006c49" />
@@ -109,6 +219,7 @@
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
</view>
</view>
<view class="st-training-section">
<dev-training-entry inline tone="weekly" />
</view>
@@ -414,6 +525,7 @@ import { onLoad, onShow, onHide, onUnload, onPullDownRefresh, onShareAppMessage
import SugarTreeGraphic from '../components/SugarTreeGraphic.vue'
import TongjiIcon from '../components/TongjiIcon.vue'
import { useDietAi } from '../composables/useDietAi.js'
import { useBloodCareAi } from '../composables/useBloodCareAi.js'
import { useSpeechToText } from '../composables/useSpeechToText.js'
import TabBarDock from '@/components/app-tab-bar/tab-bar-dock.vue'
import DevTrainingEntry from '@/components/dev-training-entry/index.vue'
@@ -924,6 +1036,27 @@ const chartHasDisplayData = computed(() => {
return bloodHasData.value
})
const {
bloodCarePlan,
bloodCareLoading,
bloodCareStreamField,
bloodCareEligible,
bloodCarePanelOpen,
hasBloodCareContent,
bloodCareAnalysis,
bloodCareControlLabel,
bloodCareDisclaimer,
toggleBloodCareFromChart,
closeBloodCarePanel,
refreshBloodCareAi,
resetBloodCarePlan
} = useBloodCareAi(proxy, {
diagnosisId,
chartUseMockData,
showUserToast,
formatUserMessage
})
const bloodHighDayCount = computed(() => {
return chartDates.value.filter((date) => {
const b = bloodByDate.value[date]
@@ -1380,6 +1513,7 @@ async function onSelectCard(card) {
exerciseRecords.value = []
trackingNotes.value = []
hoverInfo.value = null
resetBloodCarePlan()
await applyCard(card)
fetchAll()
}
@@ -1436,6 +1570,9 @@ async function fetchAll() {
trackingNotes.value = []
}
await fetchDietAiRecommend(false)
if (!bloodCareEligible.value) {
resetBloodCarePlan()
}
} catch (e) {
uni.showToast({ title: '加载失败,请稍后再试', icon: 'none' })
} finally {
@@ -244,11 +244,110 @@
}
.weekly-page .st-chart-head {
display: flex;
flex-direction: column;
align-items: stretch;
margin-bottom: 12rpx;
gap: 12rpx;
padding: 0 20rpx;
}
.weekly-page .st-chart-head-text {
width: 100%;
}
/* 图例 + AI 胶囊同一行,避免大块图标压过标题 */
.weekly-page .st-chart-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
gap: 16rpx;
width: 100%;
}
.weekly-page .st-chart-ai-chip {
flex-shrink: 0;
min-height: 88rpx;
display: flex;
align-items: center;
}
.weekly-page .st-chart-ai-chip-inner {
display: flex;
align-items: center;
gap: 10rpx;
min-height: 64rpx;
padding: 10rpx 22rpx 10rpx 12rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96) 0%, rgba(236, 253, 245, 0.92) 100%);
border: 1rpx solid rgba(0, 108, 73, 0.14);
box-shadow:
0 2rpx 6rpx rgba(15, 23, 42, 0.04),
inset 0 1rpx 0 rgba(255, 255, 255, 0.9);
transition:
background 0.22s ease,
border-color 0.22s ease,
box-shadow 0.22s ease,
transform 0.15s ease;
}
.weekly-page .st-chart-ai-chip-icon {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background: rgba(0, 108, 73, 0.08);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.weekly-page .st-chart-ai-chip-label {
font-size: 26rpx;
font-weight: 700;
color: #0f3d2e;
letter-spacing: 0.02em;
white-space: nowrap;
}
.weekly-page .st-chart-ai-chip.hint .st-chart-ai-chip-inner {
border-color: rgba(0, 108, 73, 0.28);
box-shadow:
0 2rpx 8rpx rgba(0, 108, 73, 0.1),
0 0 0 2rpx rgba(78, 222, 163, 0.22);
}
.weekly-page .st-chart-ai-chip.active .st-chart-ai-chip-inner {
background: linear-gradient(135deg, #1a8f5c 0%, #006c49 100%);
border-color: rgba(0, 108, 73, 0.35);
box-shadow: 0 6rpx 18rpx rgba(0, 108, 73, 0.2);
}
.weekly-page .st-chart-ai-chip.active .st-chart-ai-chip-icon {
background: rgba(255, 255, 255, 0.18);
}
.weekly-page .st-chart-ai-chip.active .st-chart-ai-chip-label {
color: #ffffff;
}
.weekly-page .st-chart-ai-chip.loading .st-chart-ai-chip-inner {
opacity: 0.82;
}
.weekly-page .st-chart-ai-chip:active .st-chart-ai-chip-inner {
transform: scale(0.98);
opacity: 0.92;
}
@media (prefers-reduced-motion: reduce) {
.weekly-page .st-chart-ai-chip-inner {
transition: background 0.01s, border-color 0.01s, opacity 0.01s;
}
.weekly-page .st-chart-ai-chip:active .st-chart-ai-chip-inner {
transform: none;
}
}
.weekly-page .st-chart-title {
@@ -285,7 +384,107 @@
.weekly-page .st-chart-legend {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--st-inline-gap);
flex: 1;
min-width: 0;
}
/* 趋势卡内展开的 AI 建议 */
.weekly-page .st-chart-care-panel {
margin: 12rpx 20rpx 0;
padding: 28rpx 24rpx 24rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.72);
border: 1rpx solid rgba(221, 228, 221, 0.65);
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.95);
}
.weekly-page .st-chart-care-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
margin-bottom: 8rpx;
}
.weekly-page .st-chart-care-title {
font-size: 30rpx;
font-weight: 700;
color: var(--on-surface);
}
.weekly-page .st-chart-care-actions {
display: flex;
align-items: center;
gap: 12rpx;
flex-shrink: 0;
}
.weekly-page .st-chart-care-refresh {
display: flex;
align-items: center;
gap: 6rpx;
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.7);
font-size: 22rpx;
font-weight: 600;
color: var(--primary);
}
.weekly-page .st-chart-care-refresh.disabled {
opacity: 0.5;
}
.weekly-page .st-chart-care-close {
min-height: 64rpx;
padding: 10rpx 18rpx;
display: flex;
align-items: center;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.55);
font-size: 22rpx;
font-weight: 600;
color: var(--on-surface-variant);
}
.weekly-page .st-chart-care-close:active {
opacity: 0.75;
}
.weekly-page .st-chart-care-sub {
display: block;
margin-bottom: 16rpx;
font-size: 24rpx;
line-height: 1.4;
color: var(--on-surface-variant);
}
.weekly-page .st-care-badge--in-chart {
margin-bottom: 16rpx;
}
.weekly-page .st-skeleton--in-chart {
padding: 8rpx 0;
}
.weekly-page .st-care-cards--in-chart {
gap: 16rpx;
}
.weekly-page .st-care-cards--in-chart .st-meal-glass {
padding: 24rpx;
}
.weekly-page .st-care-stats--in-chart {
margin-top: 16rpx;
}
.weekly-page .st-care-disclaimer--in-chart {
margin-top: 16rpx;
font-size: 22rpx;
}
.weekly-page .st-legend-item {
@@ -693,6 +892,114 @@
text-align: center;
}
.weekly-page .st-skeleton-line.short {
width: 55%;
}
/* ===== AI 血糖照护(嵌在趋势卡内) ===== */
.weekly-page .st-care-badge {
position: relative;
z-index: 1;
display: inline-flex;
margin-bottom: 24rpx;
padding: 8rpx 20rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.65);
font-size: 24rpx;
font-weight: 600;
color: var(--primary);
}
.weekly-page .st-care-cards {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.weekly-page .st-care-block--tcm {
background: rgba(254, 243, 199, 0.55);
border-color: rgba(217, 119, 6, 0.12);
}
.weekly-page .st-care-block--next {
background: rgba(224, 242, 254, 0.55);
border-color: rgba(14, 165, 233, 0.12);
}
.weekly-page .st-care-watch {
padding: 24rpx 32rpx;
border-radius: var(--st-radius-2xl);
background: rgba(255, 255, 255, 0.75);
border: 1rpx solid rgba(255, 255, 255, 0.9);
}
.weekly-page .st-care-watch-title {
display: block;
margin-bottom: 12rpx;
font-size: 28rpx;
font-weight: 700;
color: var(--on-surface);
}
.weekly-page .st-care-watch-item {
display: flex;
align-items: flex-start;
gap: 8rpx;
margin-bottom: 8rpx;
}
.weekly-page .st-care-watch-dot {
font-size: 32rpx;
line-height: 1.2;
color: var(--primary);
}
.weekly-page .st-care-watch-text {
flex: 1;
font-size: 28rpx;
line-height: 1.43;
color: var(--on-surface-variant);
}
.weekly-page .st-care-stats {
position: relative;
z-index: 1;
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 20rpx;
}
.weekly-page .st-care-stat-chip {
padding: 12rpx 20rpx;
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.55);
}
.weekly-page .st-care-stat-label {
display: block;
font-size: 22rpx;
color: var(--outline);
}
.weekly-page .st-care-stat-val {
font-size: 24rpx;
font-weight: 600;
color: var(--on-surface-variant);
}
.weekly-page .st-care-disclaimer {
position: relative;
z-index: 1;
display: block;
margin-top: 20rpx;
font-size: 24rpx;
line-height: 1.4;
color: var(--outline);
}
/* ===== 底部浮动输入 ===== */
.weekly-page .st-float-ask {
position: fixed;
@@ -90,6 +90,49 @@ export function parsePartialDietPlainText(text) {
return out
}
/** 从流式纯文本中提取血糖照护建议(支持未写完的最后一行) */
export function parsePartialBloodCarePlainText(text) {
const out = {}
if (!text) return out
const lineRe = /^(总评|血糖|中医|留意|复诊)[:]\s*(.*)$/gm
let match
while ((match = lineRe.exec(text)) !== null) {
const key = {
'总评': 'summary',
'血糖': 'blood_advice',
'中医': 'tcm_plan',
'留意': 'watch_points',
'复诊': 'next_steps'
}[match[1]]
const val = (match[2] || '').trim()
if (key === 'watch_points') {
out.watch_points = val.split(/[、,;\s]+/).map((s) => s.trim()).filter(Boolean)
} else {
out[key] = val
}
}
const tail = text.match(/(?:^|\n)(总评|血糖|中医|留意|复诊)[:]\s*([^\n]*)$/)
if (tail) {
const key = {
'总评': 'summary',
'血糖': 'blood_advice',
'中医': 'tcm_plan',
'留意': 'watch_points',
'复诊': 'next_steps'
}[tail[1]]
const val = (tail[2] || '').trim()
if (key === 'watch_points') {
if (val) out.watch_points = val.split(/[、,;\s]+/).map((s) => s.trim()).filter(Boolean)
} else {
out[key] = val
}
}
return out
}
/** @deprecated 使用 parsePartialDietPlainText */
export function parsePartialDietJson(text) {
return parsePartialDietPlainText(text)