Files
zyt/TUICallKit-Vue3/tongji/pages/index.vue
T
2026-05-29 17:08:57 +08:00

6976 lines
201 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="daily-page" :class="{ 'is-invite-viewer': isInviteViewer }">
<!-- 邀请码观看近7日记录仅今日有效-->
<view v-if="isInviteViewer" class="invite-view-wrap">
<view class="invite-hero">
<view class="invite-hero-bg" />
<view class="invite-hero-content">
<view class="invite-hero-badge">
<TongjiIcon name="view" size="md" color="#FFFFFF" />
<text class="invite-hero-badge-text">家人分享</text>
</view>
<text class="invite-hero-title">{{ invitePreview.viewer_notice || '近7日稳糖记录' }}</text>
<text class="invite-hero-sub">{{ invitePreview.expires_hint || '邀请码仅今日有效' }}</text>
<view v-if="invitePreview.patient_label" class="invite-hero-patient">
<TongjiIcon name="users" size="sm" color="#A5F3FC" />
<text>{{ invitePreview.patient_label }}</text>
</view>
</view>
</view>
<view v-if="inviteLoading" class="invite-skeleton-wrap">
<view class="invite-skeleton-card" />
<view class="invite-skeleton-card invite-skeleton-short" />
<view class="invite-skeleton-card invite-skeleton-tall" />
</view>
<view v-else-if="invitePreview.invite_code" class="invite-body">
<!-- 邀请码 -->
<view class="invite-card invite-card-code">
<view class="invite-card-head">
<view class="invite-card-icon-wrap invite-card-icon-wrap--cyan">
<TongjiIcon name="ticket" size="md" color="#204E2B" />
</view>
<view class="invite-card-head-text">
<text class="invite-card-label">今日邀请码</text>
<text class="invite-card-hint">凭此码今日可查看</text>
</view>
</view>
<text class="invite-code-value">{{ invitePreview.invite_code }}</text>
</view>
<!-- 本周概览 -->
<view class="invite-card invite-card-summary">
<view class="invite-summary-top">
<view class="invite-tree-panel invite-tree-tappable" @click="onInviteTreeWhisper">
<SugarTreeGraphic :level="inviteTreeLevel" size="md" />
<text class="invite-tree-tap-hint">点树听一句鼓励</text>
</view>
<view class="invite-tree-meta">
<text class="invite-tree-title">{{ invitePreview.tree_title || '控糖树' }}</text>
<text class="invite-tree-desc">{{ invitePreview.tree_desc || '' }}</text>
<text class="invite-tree-week">{{ invitePreview.week_label || '' }}</text>
</view>
</view>
<view class="invite-stat-grid">
<view class="invite-stat-item">
<view class="invite-stat-icon-wrap">
<TongjiIcon name="calendar" size="sm" color="#204E2B" />
</view>
<text class="invite-stat-num">{{ invitePreview.record_days }}</text>
<text class="invite-stat-label">天有记录</text>
</view>
<view class="invite-stat-item">
<view class="invite-stat-icon-wrap">
<TongjiIcon name="check-circle" size="sm" color="#386641" />
</view>
<text class="invite-stat-num">{{ invitePreview.complete_days }}</text>
<text class="invite-stat-label">完整一日</text>
</view>
<view class="invite-stat-item">
<view class="invite-stat-icon-wrap">
<TongjiIcon name="flame" size="sm" color="#EA580C" />
</view>
<text class="invite-stat-num">{{ invitePreview.streak_days }}</text>
<text class="invite-stat-label">连续天数</text>
</view>
</view>
<view v-if="invitePreview.quote" class="invite-quote-box">
<text class="invite-quote">{{ invitePreview.quote }}</text>
</view>
</view>
<!-- 近7日血糖 -->
<view class="invite-card invite-card-records">
<view class="invite-section-head">
<view class="invite-card-icon-wrap invite-card-icon-wrap--teal">
<TongjiIcon name="glucose" size="md" color="#386641" />
</view>
<view class="invite-section-head-text">
<text class="invite-section-title">近7日血糖记录</text>
<text class="invite-section-sub">空腹 / 餐后 / 血压</text>
</view>
</view>
<view v-if="!inviteDailyRecords.length" class="invite-records-empty">
<TongjiIcon name="minus" size="lg" color="#94A3B8" />
<text>暂无记录数据</text>
</view>
<view v-else class="invite-timeline">
<view
v-for="(day, idx) in inviteDailyRecords"
:key="day.date"
class="invite-day-row"
:class="{ 'is-empty': !day.has_record, 'is-last': idx === inviteDailyRecords.length - 1 }"
>
<view class="invite-day-rail">
<view class="invite-day-dot" :class="{ filled: day.has_record }" />
<view v-if="idx < inviteDailyRecords.length - 1" class="invite-day-line" />
</view>
<view class="invite-day-panel">
<view class="invite-day-head">
<text class="invite-day-date">{{ day.date_label }}</text>
<text class="invite-day-week">{{ day.weekday }}</text>
</view>
<view v-if="day.has_record" class="invite-metric-grid">
<view class="invite-metric-chip">
<text class="invite-metric-label">空腹</text>
<view class="invite-metric-value-row">
<text class="invite-metric-value" :class="{ 'is-high': day.fasting_high }">
{{ formatInviteSugar(day.fasting) }}
</text>
<text v-if="day.fasting != null" class="invite-metric-unit">mmol/L</text>
</view>
<view v-if="day.fasting_high" class="invite-metric-flag">
<TongjiIcon name="alert-triangle" size="sm" color="#DC2626" />
<text>偏高</text>
</view>
</view>
<view class="invite-metric-chip">
<text class="invite-metric-label">餐后</text>
<view class="invite-metric-value-row">
<text class="invite-metric-value" :class="{ 'is-high': day.postprandial_high }">
{{ formatInviteSugar(day.postprandial) }}
</text>
<text v-if="day.postprandial != null" class="invite-metric-unit">mmol/L</text>
</view>
<view v-if="day.postprandial_high" class="invite-metric-flag">
<TongjiIcon name="alert-triangle" size="sm" color="#DC2626" />
<text>偏高</text>
</view>
</view>
<view v-if="day.bp_text" class="invite-metric-chip">
<text class="invite-metric-label">血压</text>
<view class="invite-metric-value-row">
<text class="invite-metric-value" :class="{ 'is-high': day.bp_high }">{{ day.bp_text }}</text>
<text class="invite-metric-unit">mmHg</text>
</view>
<view v-if="day.bp_high" class="invite-metric-flag">
<TongjiIcon name="alert-triangle" size="sm" color="#DC2626" />
<text>偏高</text>
</view>
</view>
</view>
<view v-else class="invite-day-empty">
<TongjiIcon name="minus" size="sm" color="#94A3B8" />
<text>当日未记录</text>
</view>
</view>
</view>
</view>
</view>
<!-- 家人点赞 -->
<view class="invite-card invite-card-like">
<view class="invite-section-head">
<view class="invite-card-icon-wrap invite-card-icon-wrap--green">
<TongjiIcon name="heart" size="md" color="#386641" />
</view>
<view class="invite-section-head-text">
<text class="invite-section-title"> Ta 点个赞</text>
<text class="invite-section-sub">今日 {{ invitePreview.like_count || 0 }} 位家人已鼓励</text>
</view>
</view>
<view class="invite-nickname-row">
<view
v-for="n in familyNicknameOptions"
:key="n"
class="invite-nickname-chip"
:class="{ active: inviteLikeNickname === n }"
@click="inviteLikeNickname = n"
>
<text>{{ n }}</text>
</view>
</view>
<view
class="invite-like-btn"
:class="{ done: invitePreview.liked_by_me, loading: inviteLiking, burst: inviteHeartBurst }"
@click="onFamilyLike"
>
<TongjiIcon
v-if="!invitePreview.liked_by_me"
name="heart"
size="md"
color="#FFFFFF"
/>
<text>{{ invitePreview.liked_by_me ? '今日已点赞,谢谢您的鼓励' : '送 Ta 一份鼓励' }}</text>
</view>
</view>
<view class="invite-footer">
<TongjiIcon name="activity" size="sm" color="#64748B" />
<text class="invite-disclaimer">仅展示近7天分享范围内记录 · 习惯参考不替代医嘱</text>
</view>
</view>
<view v-else class="invite-error-card">
<view class="invite-error-icon-wrap">
<TongjiIcon name="alert-triangle" size="xl" color="#DC2626" />
</view>
<text class="invite-error-title">无法查看</text>
<text class="invite-error-text">{{ inviteError || '邀请码无效或已过期' }}</text>
</view>
</view>
<view v-if="authChecking && !isInviteViewer" class="page-gate-loading">
<text class="page-gate-loading-text">正在登录</text>
</view>
<block v-if="!isInviteViewer && !authChecking">
<!-- 适老极简首页只保留今日血糖 -->
<view class="elder-header">
<text class="elder-greet">{{ timeGreetingMeta.text }}{{ patientName || '您好' }}</text>
<text class="elder-status" :class="todayChecked ? 'is-done' : 'is-pending'">
{{ todayChecked ? '今日血糖已记录' : '今日还没记血糖' }}
</text>
<scroll-view
v-if="cards.length > 1"
scroll-x
class="elder-card-scroll"
:show-scrollbar="false"
>
<view class="elder-card-row">
<view
v-for="card in cards"
:key="card.id"
class="elder-card-chip"
:class="{ active: Number(card.id) === Number(diagnosisId) }"
@click="onSelectCard(card)"
>
<text>{{ card.patient_name || '就诊卡' }}</text>
</view>
</view>
</scroll-view>
</view>
<view class="elder-actions">
<view class="elder-btn elder-btn-primary" @click="openInputForm()">
<TongjiIcon name="plus" size="lg" color="#FFFFFF" />
<text class="elder-btn-text">录入今日血糖</text>
</view>
<view
class="elder-btn elder-btn-voice"
:class="{ speaking: ttsSpeaking, disabled: !ttsEnabled }"
@click="ttsSpeaking ? ttsStop() : speakLatestSummary()"
>
<TongjiIcon :name="ttsSpeaking ? 'pause' : 'volume'" size="md" color="#204E2B" />
<text class="elder-btn-text">{{ ttsSpeaking ? '停止朗读' : '朗读血糖' }}</text>
</view>
</view>
<!-- 血糖趋势主页面-->
<view v-if="diagnosisId" class="elder-chart-block">
<view class="card chart-card">
<view class="card-head">
<view class="card-head-left">
<text class="card-title">血糖趋势</text>
<text class="card-subtitle">{{ rangeText }} · mmol/L</text>
</view>
<view class="card-legend">
<view
class="legend-item"
:class="{ inactive: !chartSeriesVisible.fasting }"
@click="toggleChartSeries('fasting')"
>
<view class="legend-dot legend-dot-fasting" />
<text>空腹</text>
</view>
<view
class="legend-item"
:class="{ inactive: !chartSeriesVisible.postprandial }"
@click="toggleChartSeries('postprandial')"
>
<view class="legend-dot legend-dot-postprandial" />
<text>餐后</text>
</view>
</view>
</view>
<view v-if="bloodHasData && bloodHighDayCount > 0" class="chart-summary">
<view class="chart-summary-item">
<text class="chart-summary-num">{{ bloodHighDayCount }}</text>
<text class="chart-summary-label">天偏高</text>
</view>
<view class="chart-summary-item">
<text class="chart-summary-num">{{ bloodNormalDayCount }}</text>
<text class="chart-summary-label">天正常</text>
</view>
<view class="chart-summary-item">
<text class="chart-summary-num">{{ bloodRecordDayCount }}</text>
<text class="chart-summary-label">天有记录</text>
</view>
</view>
<view class="chart-canvas-wrap">
<canvas
v-if="!recordOverlayOpen"
id="bloodWaveChart"
type="2d"
class="chart-canvas"
@touchstart="onChartTouch"
@touchmove="onChartTouch"
/>
<view v-if="recordOverlayOpen" class="chart-canvas-placeholder">
<text>图表已暂停显示</text>
</view>
<view v-if="!bloodHasData && !loading && !recordOverlayOpen" class="chart-empty">
<text class="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="legend-dot legend-dot-fasting" />
<cover-view class="chart-tip-text">空腹 {{ hoverInfo.fasting }}</cover-view>
</cover-view>
<cover-view v-if="hoverInfo.postprandial != null" class="chart-tip-row">
<cover-view class="legend-dot legend-dot-postprandial" />
<cover-view class="chart-tip-text">餐后 {{ hoverInfo.postprandial }}</cover-view>
</cover-view>
</cover-view>
</view>
</view>
<view class="range-bar">
<view
v-for="opt in rangeOptions"
:key="opt.value"
class="range-item"
:class="{ active: rangeMode === opt.value }"
@click="onChangeRange(opt.value)"
>
<text>{{ opt.label }}</text>
</view>
</view>
</view>
<view class="elder-today-panel">
<text class="elder-panel-title">今日血糖</text>
<view class="elder-today-grid">
<view class="elder-metric" :class="{ 'is-high': elderFasting.isHigh }">
<text class="elder-metric-label">{{ elderFasting.label }}</text>
<view class="elder-metric-value-row">
<text class="elder-metric-value">{{ elderFasting.value }}</text>
<text v-if="elderFasting.value !== '—'" class="elder-metric-unit">mmol/L</text>
</view>
<text v-if="elderFasting.isHigh" class="elder-metric-flag">偏高请遵医嘱</text>
</view>
<view class="elder-metric" :class="{ 'is-high': elderPostprandial.isHigh }">
<text class="elder-metric-label">{{ elderPostprandial.label }}</text>
<view class="elder-metric-value-row">
<text class="elder-metric-value">{{ elderPostprandial.value }}</text>
<text v-if="elderPostprandial.value !== '—'" class="elder-metric-unit">mmol/L</text>
</view>
<text v-if="elderPostprandial.isHigh" class="elder-metric-flag">偏高请遵医嘱</text>
</view>
</view>
</view>
<view class="elder-more-link" @click="goMorePage">
<view class="elder-more-body">
<text class="elder-more-title">查看更多</text>
<text class="elder-more-sub">健康日历 · 运动 · 家人互动</text>
</view>
<text class="elder-more-arrow"></text>
</view>
<!-- AI 饮食助手霍大夫升糖指数 · 结合近7/30天血糖 -->
<view v-if="diagnosisId" class="card ai-diet-card">
<view class="ai-diet-hero">
<view class="ai-diet-hero-aurora" aria-hidden="true">
<view class="ai-diet-blob ai-diet-blob-1" />
<view class="ai-diet-blob ai-diet-blob-2" />
<view class="ai-diet-blob ai-diet-blob-3" />
</view>
<view class="ai-diet-hero-content">
<view class="ai-diet-hero-main">
<view class="ai-diet-hero-icon" :class="{ thinking: dietAiLoading }" aria-hidden="true">
<view class="ai-diet-hero-icon-ring" />
<TongjiIcon name="sparkles" size="md" color="#ffffff" />
</view>
<view class="ai-diet-hero-copy">
<view class="ai-diet-hero-titles">
<text class="ai-diet-title">AI 饮食建议</text>
<view class="ai-diet-badge">
<view class="ai-diet-badge-dot" />
<text>智能定制</text>
</view>
</view>
<text class="ai-diet-sub">读您近 7 · 30 天血糖定制今日三餐</text>
</view>
</view>
<view
class="ai-diet-refresh"
:class="{ disabled: dietAiLoading }"
role="button"
aria-label="换一换今日饮食建议"
@click="refreshDietAiRecommend"
>
<TongjiIcon name="refresh" size="sm" color="#ffffff" />
<text>{{ dietAiLoading ? '生成中' : '换一换' }}</text>
</view>
</view>
</view>
<view v-if="dietAiLoading && !hasDietAiMealContent && !dietAiStreaming && !dietAiReplacing" class="ai-diet-skeleton">
<view v-for="n in 3" :key="'sk-' + n" class="ai-diet-skeleton-row">
<view class="ai-diet-skeleton-tag" />
<view class="ai-diet-skeleton-line" />
<view class="ai-diet-skeleton-line short" />
</view>
<text class="ai-diet-skeleton-hint">正在生成今日饮食建议</text>
</view>
<view v-else class="ai-diet-body" :class="{ 'is-streaming': dietAiStreaming }">
<view class="ai-diet-section-head">
<text class="ai-diet-section-title">今日三餐</text>
<text v-if="dietAiStreaming" class="ai-diet-section-tag">生成中</text>
</view>
<view class="ai-diet-meal-list">
<view class="ai-diet-meal-card meal-breakfast">
<view class="ai-diet-meal-head">
<view class="ai-diet-meal-icon" aria-hidden="true">
<TongjiIcon name="sun" size="sm" color="#ffffff" />
</view>
<text class="ai-diet-meal-label">早餐</text>
</view>
<text class="ai-diet-meal-text">
{{ dietAiRecommend.breakfast || (dietAiLoading ? '…' : '—') }}<text v-if="dietAiLoading && dietAiStreamField === 'breakfast'" class="ai-stream-cursor"></text>
</text>
</view>
<view class="ai-diet-meal-card meal-lunch">
<view class="ai-diet-meal-head">
<view class="ai-diet-meal-icon" aria-hidden="true">
<TongjiIcon name="sunset" size="sm" color="#ffffff" />
</view>
<text class="ai-diet-meal-label">午餐</text>
</view>
<text class="ai-diet-meal-text">
{{ dietAiRecommend.lunch || (dietAiLoading ? '…' : '—') }}<text v-if="dietAiLoading && dietAiStreamField === 'lunch'" class="ai-stream-cursor"></text>
</text>
</view>
<view class="ai-diet-meal-card meal-dinner">
<view class="ai-diet-meal-head">
<view class="ai-diet-meal-icon" aria-hidden="true">
<TongjiIcon name="moon" size="sm" color="#ffffff" />
</view>
<text class="ai-diet-meal-label">晚餐</text>
</view>
<text class="ai-diet-meal-text">
{{ dietAiRecommend.dinner || (dietAiLoading ? '…' : '—') }}<text v-if="dietAiLoading && dietAiStreamField === 'dinner'" class="ai-stream-cursor"></text>
</text>
</view>
</view>
<view v-if="dietAiRecommend.tips || (dietAiLoading && dietAiStreamField === 'tips')" class="ai-diet-tip-box">
<view class="ai-diet-tip-head">
<TongjiIcon name="info" size="sm" color="#386641" />
<text class="ai-diet-tip-label">小贴士</text>
</view>
<text class="ai-diet-tip-text">
{{ dietAiRecommend.tips || '…' }}<text v-if="dietAiLoading && dietAiStreamField === 'tips'" class="ai-stream-cursor"></text>
</text>
</view>
<view v-if="dietAiRecommend.avoid && dietAiRecommend.avoid.length" class="ai-diet-avoid">
<view class="ai-diet-avoid-head">
<TongjiIcon name="alert-triangle" size="sm" color="#b91c1c" />
<text class="ai-diet-avoid-label">今日尽量少碰</text>
</view>
<view class="ai-diet-avoid-tags">
<text v-for="(item, idx) in dietAiRecommend.avoid" :key="'avoid-' + idx" class="ai-diet-avoid-tag">{{ item }}</text>
</view>
</view>
<view v-if="!dietAiLoading" class="ai-diet-actions">
<view class="ai-diet-btn primary" @click="applyDietAiToForm">
<text>填入今日饮食</text>
</view>
<view class="ai-diet-btn ghost" @click="openDietForm">
<text>手动记录</text>
</view>
</view>
</view>
<!-- 运动降糖建议 -->
<view v-if="dietExercise" class="ai-diet-exercise" :class="'lv-' + dietExercise.level">
<view class="ai-diet-exercise-head">
<view class="ai-diet-exercise-icon" aria-hidden="true">
<TongjiIcon name="activity" size="sm" color="#ffffff" />
</view>
<view class="ai-diet-exercise-head-text">
<text class="ai-diet-exercise-title">运动降糖</text>
<text class="ai-diet-exercise-tag" :class="'lv-' + dietExercise.level">{{ dietExercise.level_label }}</text>
</view>
</view>
<text v-if="dietExercise.headline" class="ai-diet-exercise-headline">{{ dietExercise.headline }}</text>
<view class="ai-diet-exercise-list">
<view
v-for="(it, idx) in dietExercise.items"
:key="'ex-' + idx"
class="ai-diet-exercise-item"
>
<text class="ai-diet-exercise-bullet">{{ idx + 1 }}</text>
<text class="ai-diet-exercise-item-text">{{ it }}</text>
</view>
</view>
<view v-if="dietExercise.intensity" class="ai-diet-exercise-meta">
<TongjiIcon name="flame" size="sm" color="#d97706" />
<text>{{ dietExercise.intensity }}</text>
</view>
<text v-if="dietExercise.note" class="ai-diet-exercise-note">{{ dietExercise.note }}</text>
</view>
<view class="ai-diet-ask-panel">
<view class="ai-diet-ask-head">
<text class="ai-diet-ask-title">能不能吃</text>
<text class="ai-diet-ask-hint">输入食物名称结合您的档案判断</text>
</view>
<view class="ai-diet-ask-row">
<input
class="ai-diet-ask-input"
type="text"
:value="dietAiAskText"
placeholder="如:白馒头、地瓜、粘豆包"
placeholder-class="input-placeholder"
confirm-type="search"
aria-label="想咨询的食物名称"
@input="onDietAiAskInput"
@confirm="askDietAiFood"
/>
<view
class="ai-diet-ask-btn"
:class="{ disabled: dietAiAsking }"
role="button"
aria-label=" AI 咨询该食物"
@click="askDietAiFood"
>
<TongjiIcon v-if="!dietAiAsking" name="sparkles" size="sm" color="#ffffff" />
<text>{{ dietAiAsking ? '…' : '问 AI' }}</text>
</view>
</view>
<view v-if="dietAiAsking || dietAiAskResult.advice" class="ai-diet-ask-result" :class="'lv-' + (dietAiAskResult.level || 'unknown')">
<view v-if="dietAiAskResult.level_label" class="ai-diet-ask-badge" :class="'lv-' + (dietAiAskResult.level || 'unknown')">
<text>{{ dietAiAskResult.level_label }}</text>
</view>
<text class="ai-diet-ask-advice">
{{ dietAiAskResult.advice || (dietAiAsking ? '正在想…' : '') }}<text v-if="dietAiAsking" class="ai-stream-cursor"></text>
</text>
<text v-if="dietAiAskResult.portion" class="ai-diet-ask-portion">建议份量{{ dietAiAskResult.portion }}</text>
</view>
</view>
<!-- 建议依据近7天 / 近30天血糖回顾佐证置底 -->
<view v-if="hasDietAnalysis" class="ai-diet-analysis">
<view class="ai-diet-analysis-head">
<TongjiIcon name="activity" size="sm" color="#386641" />
<text class="ai-diet-analysis-title">以上建议依据 · 血糖回顾</text>
</view>
<view class="ai-diet-analysis-grid">
<view
v-for="(s, i) in dietAnalysisList"
:key="'an-' + i"
class="ai-diet-analysis-col"
>
<text class="ai-diet-analysis-range">{{ s.label }}</text>
<block v-if="s.record_days > 0">
<view class="ai-diet-analysis-rate" :class="dietRateClass(s.compliance)">
<view class="ai-diet-analysis-rate-val">
<text class="ai-diet-analysis-rate-num">{{ s.compliance }}</text>
<text class="ai-diet-analysis-rate-pct">%</text>
</view>
<text class="ai-diet-analysis-rate-label">达标率</text>
</view>
<view class="ai-diet-analysis-metrics">
<view class="ai-diet-analysis-metric">
<text class="ai-diet-analysis-m-label">偏高</text>
<text class="ai-diet-analysis-m-value" :class="{ 'is-warn': s.high_days > 0 }">
{{ s.high_days }}<text class="ai-diet-analysis-m-unit"></text>
</text>
</view>
<view class="ai-diet-analysis-metric">
<text class="ai-diet-analysis-m-label">空腹均</text>
<text class="ai-diet-analysis-m-value">{{ dietAvgText(s.fasting_avg) }}</text>
</view>
<view class="ai-diet-analysis-metric">
<text class="ai-diet-analysis-m-label">餐后均</text>
<text class="ai-diet-analysis-m-value">{{ dietAvgText(s.postprandial_avg) }}</text>
</view>
</view>
<text class="ai-diet-analysis-days">{{ s.record_days }} 天有记录</text>
</block>
<view v-else class="ai-diet-analysis-empty">
<TongjiIcon name="minus" size="sm" color="#94A3B8" />
<text>暂无记录</text>
</view>
</view>
</view>
</view>
<view v-if="dietAiRecommend.rules_summary || dietAiRecommend.disclaimer" class="ai-diet-footer">
<text v-if="dietAiRecommend.rules_summary" class="ai-diet-rules">{{ dietAiRecommend.rules_summary }}</text>
<text v-if="dietAiRecommend.disclaimer" class="ai-diet-disclaimer">{{ dietAiRecommend.disclaimer }}</text>
</view>
</view>
<view class="footer-tip">
<text>数据仅供参考请遵医嘱</text>
</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" @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"
: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"
: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"
: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"
@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>
</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'
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 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 饮食建议依据:近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', '餐后')
)
// 给某天某字段计算 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
}
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 goMorePage() {
if (!diagnosisId.value) {
const ok = await ensureCanRecordGlucose()
if (!ok || !diagnosisId.value) return
}
uni.navigateTo({ url: '/tongji/pages/more' })
}
// ============ 时段问候 / 打卡 / 健康日历 ============
/** 时段问候:文案 + 统一 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() {
canvasCtx = null
canvasNode = null
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
nextTick(() => scheduleChartRedraw())
}
/** 任一录入弹层打开时需卸载 canvas(小程序原生组件会盖住普通 view)*/
const recordOverlayOpen = computed(() => inputForm.value.visible || phoneGateVisible.value)
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 ensureCanvas() {
return new Promise((resolve) => {
if (canvasCtx) {
resolve()
return
}
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) {
resolve()
return
}
canvasNode = item.node
canvasCtx = canvasNode.getContext('2d')
try {
canvasDpr = uni.getSystemInfoSync().pixelRatio || 1
} catch (e) {
canvasDpr = 1
}
canvasWidth = item.width
canvasHeight = item.height
canvasNode.width = canvasWidth * canvasDpr
canvasNode.height = canvasHeight * canvasDpr
canvasCtx.scale(canvasDpr, canvasDpr)
resolve()
})
})
}
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: '#204E2B',
fillTop: 'rgba(32,78,43,0.18)',
fillBottom: 'rgba(32,78,43,0)',
label: '空腹',
highThreshold: threshold ? threshold.fasting : null
},
{
key: 'postprandial',
data: postSeries,
color: '#D97706',
fillTop: 'rgba(217,119,6,0.18)',
fillBottom: 'rgba(217,119,6,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) {}
}
async function bootstrap(options) {
options = options || {}
const inviteCode = String(options.invite_code || '').trim()
if (options.from === 'share' && inviteCode) {
authChecking.value = false
await bootstrapInviteViewer(inviteCode)
return
}
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(() => {
if (authChecking.value) return
nextTick(() => scheduleChartRedraw())
})
onPullDownRefresh(async () => {
if (isInviteViewer.value) {
const code = invitePreview.value.invite_code
if (code) await bootstrapInviteViewer(code)
} else if (await ensureLoggedIn()) {
await fetchAll()
}
uni.stopPullDownRefresh()
})
onHide(() => {
ttsStop()
})
onUnload(() => {
ttsStop()
})
// ============ 邀请观看(家人分享页)============
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: #64748b;
}
.more-entry-bar {
margin: -72rpx 32rpx 24rpx;
padding: 28rpx 32rpx;
background: #ffffff;
border-radius: 24rpx;
box-shadow: var(--shadow-premium);
border: var(--border-premium);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
position: relative;
z-index: 2;
}
.more-entry-left {
flex: 1;
min-width: 0;
}
.more-entry-title {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #0f172a;
}
.more-entry-sub {
display: block;
margin-top: 6rpx;
font-size: 24rpx;
color: #64748b;
}
.more-entry-arrow {
font-size: 40rpx;
color: #386641;
line-height: 1;
}
.daily-page {
/* 详见 ../styles/daily-theme.scss */
--radius-card: 32rpx;
--radius-pill: 999rpx;
--transition-smooth: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
min-height: 100vh;
padding-bottom: 64rpx;
}
/* === Hero 区域 === */
.hero {
position: relative;
padding: 60rpx 36rpx 120rpx; /* 适度增加空间,增强空气感 */
color: #ffffff;
overflow: hidden;
}
.hero-bg {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #163d22 0%, #204e2b 55%, #386641 100%);
z-index: 0;
&::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at 85% 15%, rgba(175, 226, 179, 0.15) 0%, transparent 60%);
}
}
.hero-content {
position: relative;
z-index: 1;
}
.hero-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12rpx;
}
.hero-main {
flex: 1;
min-width: 0;
padding-top: 4rpx;
}
.hero-title {
font-size: 56rpx; /* 大字体适老化 */
font-weight: 800;
color: #ffffff;
line-height: 1.2;
letter-spacing: 1rpx;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.15);
}
.hero-subtitle {
display: block;
font-size: 30rpx;
color: rgba(255, 255, 255, 0.95);
margin-top: 6rpx;
font-weight: 500;
}
.hero-pill-group {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 12rpx;
max-width: 380rpx;
}
.hero-pill {
display: inline-flex;
align-items: center;
gap: 10rpx;
background: rgba(255, 255, 255, 0.12);
border: 1rpx solid rgba(255, 255, 255, 0.2);
border-radius: var(--radius-pill);
padding: 18rpx 30rpx;
backdrop-filter: blur(12px);
min-height: 80rpx; /* 点击热区符合规范 */
box-sizing: border-box;
transition: var(--transition-smooth);
&:active {
background: rgba(255, 255, 255, 0.24);
transform: scale(0.96);
}
&.voice {
background: rgba(255, 255, 255, 0.18);
border: 2rpx solid rgba(255, 255, 255, 0.4);
padding: 16rpx 30rpx;
}
&.voice.speaking {
background: #fbbf24;
border-color: #fde68a;
color: var(--slate-900);
animation: voice-pulse 1.4s ease-in-out infinite;
.hero-pill-text {
color: var(--slate-900);
}
}
&.voice.disabled {
opacity: 0.5;
}
&.input {
background: #ffffff;
border: 2rpx solid #ffffff;
padding: 16rpx 32rpx;
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.1);
.hero-pill-text {
color: var(--primary);
font-weight: 700;
}
&:active {
background: var(--primary-light);
}
}
}
@keyframes voice-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.55); }
50% { box-shadow: 0 0 0 12rpx rgba(251, 191, 36, 0); }
}
.hero-pill-icon-wrap {
width: 36rpx;
height: 36rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.hero-pill-text {
font-size: 32rpx;
color: #ffffff;
font-weight: 600;
}
/* === Bento 风格指标卡 === */
.stat-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
margin: -60rpx 24rpx 0;
position: relative;
z-index: 3;
}
.stat-card {
background: #ffffff;
border-radius: var(--radius-card);
padding: 30rpx 26rpx;
box-shadow: var(--shadow-premium);
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 250rpx;
border: 1rpx solid rgba(15, 23, 42, 0.04);
box-sizing: border-box;
transition: var(--transition-smooth);
position: relative;
overflow: hidden;
&:active {
transform: translateY(-2rpx);
box-shadow: 0 16rpx 36rpx rgba(15, 23, 42, 0.08);
}
}
.stat-fasting {
background: radial-gradient(circle at 100% 0%, #e6fdf9 0%, #ffffff 65%);
}
.stat-postprandial {
background: radial-gradient(circle at 100% 0%, #fff7ed 0%, #ffffff 65%);
}
.stat-other {
grid-column: span 2;
background: radial-gradient(circle at 100% 0%, #f5f3ff 0%, #ffffff 65%);
display: grid !important;
grid-template-areas:
"head value"
"foot value" !important;
grid-template-columns: 1fr auto !important;
grid-template-rows: auto auto !important;
align-items: center !important;
min-height: 170rpx !important;
padding: 26rpx 32rpx !important;
.stat-card-head {
grid-area: head !important;
display: flex !important;
align-items: center !important;
justify-content: flex-start !important;
margin: 0 !important;
padding: 0 !important;
gap: 12rpx !important;
}
.stat-card-value-row {
grid-area: value !important;
display: flex !important;
align-items: baseline !important;
margin: 0 !important;
padding: 0 !important;
gap: 6rpx !important;
}
.stat-card-foot {
grid-area: foot !important;
display: flex !important;
align-items: center !important;
justify-content: flex-start !important;
margin-top: 12rpx !important;
padding: 0 !important;
gap: 16rpx !important;
}
}
.stat-card.is-high {
box-shadow: var(--shadow-glow-danger);
border-color: rgba(239, 68, 68, 0.08);
}
.stat-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8rpx;
}
.stat-card-tag {
display: inline-flex;
align-items: center;
padding: 8rpx 18rpx;
border-radius: 12rpx;
font-size: 26rpx;
font-weight: 700;
}
.stat-fasting .stat-card-tag {
background: rgba(13, 148, 136, 0.09);
color: #386641;
}
.stat-postprandial .stat-card-tag {
background: rgba(245, 158, 11, 0.09);
color: #d97706;
}
.stat-other .stat-card-tag {
background: rgba(99, 102, 241, 0.09);
color: #4f46e5;
}
.stat-card-tag-dot {
display: none;
}
.stat-card-tag-text {
font-size: 26rpx;
font-weight: 700;
}
.stat-card-flag {
display: inline-flex;
align-items: center;
gap: 6rpx;
padding: 6rpx 14rpx;
border-radius: var(--radius-pill);
background: rgba(239, 68, 68, 0.08);
border: 1rpx solid rgba(239, 68, 68, 0.15);
}
.stat-card-flag-icon {
font-size: 20rpx;
color: var(--danger);
font-weight: 700;
line-height: 1;
}
.stat-card-flag-text {
font-size: 22rpx;
color: var(--danger);
font-weight: 700;
}
.stat-card-value-row {
display: flex;
align-items: baseline;
gap: 6rpx;
margin-top: 16rpx;
margin-bottom: 8rpx;
}
.stat-card-value {
font-size: 64rpx;
font-weight: 900;
background: linear-gradient(180deg, var(--slate-900) 0%, #334155 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
line-height: 1.05;
letter-spacing: -1rpx;
&.is-high {
background: var(--danger-grad);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
.stat-card-unit {
font-size: 24rpx;
color: var(--slate-600);
font-weight: 600;
}
.stat-card-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8rpx;
margin-top: 4rpx;
}
.stat-card-date {
font-size: 24rpx;
color: var(--slate-600);
font-weight: 500;
}
.stat-card-trend {
display: inline-flex;
align-items: center;
gap: 4rpx;
padding: 6rpx 14rpx;
border-radius: var(--radius-pill);
transition: var(--transition-smooth);
&.trend-up {
background: rgba(239, 68, 68, 0.08);
.stat-card-trend-arrow,
.stat-card-trend-delta {
color: var(--danger);
}
}
&.trend-down {
background: rgba(16, 185, 129, 0.08);
.stat-card-trend-arrow,
.stat-card-trend-delta {
color: var(--success);
}
}
&.trend-flat {
background: var(--slate-100);
.stat-card-trend-arrow,
.stat-card-trend-delta {
color: var(--slate-600);
}
}
}
.stat-card-trend-arrow {
font-size: 24rpx;
font-weight: 800;
line-height: 1;
}
.stat-card-trend-delta {
font-size: 24rpx;
font-weight: 600;
}
/* === 就诊卡选择器 === */
.card-switcher {
display: flex;
align-items: center;
gap: 16rpx;
margin: 22rpx 24rpx 0;
padding: 8rpx 12rpx;
background: transparent;
border-radius: 0;
box-shadow: none;
}
.card-switcher-head {
display: flex;
align-items: center;
gap: 8rpx;
flex-shrink: 0;
padding-right: 10rpx;
border-right: 2rpx solid var(--slate-200);
}
.card-switcher-label {
font-size: 28rpx;
color: var(--slate-900);
font-weight: 700;
}
.card-switcher-count {
font-size: 24rpx;
color: var(--slate-600);
background: var(--slate-100);
padding: 4rpx 14rpx;
border-radius: var(--radius-pill);
font-weight: 600;
}
.card-switcher-scroll {
flex: 1;
min-width: 0;
white-space: nowrap;
}
.card-chip-row {
display: inline-flex;
flex-wrap: nowrap;
gap: 8rpx;
padding: 2rpx;
}
.card-chip {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 8rpx;
padding: 16rpx 28rpx;
border-radius: var(--radius-pill);
background: #ffffff;
border: 1rpx solid var(--slate-200);
transition: var(--transition-smooth);
min-height: 68rpx;
box-sizing: border-box;
&.active {
background: var(--primary-grad);
border-color: transparent;
box-shadow: var(--shadow-glow-primary);
.card-chip-name,
.card-chip-meta {
color: #ffffff;
}
}
&:active {
transform: scale(0.96);
}
}
.card-chip-name {
font-size: 28rpx;
font-weight: 700;
color: #1e293b;
line-height: 1.1;
}
.card-chip-meta {
font-size: 24rpx;
color: var(--slate-600);
line-height: 1.1;
}
.range-bar {
display: flex;
gap: 12rpx;
margin: 22rpx 24rpx 0;
background: #ffffff;
border-radius: var(--radius-pill);
padding: 10rpx;
box-shadow: var(--shadow-sm);
border: 1rpx solid var(--slate-100);
position: relative;
z-index: 2;
}
.range-item {
flex: 1;
text-align: center;
padding: 22rpx 0;
border-radius: var(--radius-pill);
font-size: 32rpx;
color: #334155;
font-weight: 600;
min-height: 76rpx;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition-smooth);
&.active {
background: var(--primary-grad);
color: #ffffff;
font-weight: 700;
box-shadow: var(--shadow-glow-primary);
}
&:active:not(.active) {
background: var(--slate-100);
}
}
/* === 语音播报设置 === */
.voice-bar {
margin: 22rpx 24rpx 0;
background: #ffffff;
border-radius: var(--radius-card);
padding: 26rpx;
box-shadow: var(--shadow-premium);
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
border: 2rpx solid #e0f2fe;
transition: var(--transition-smooth);
&:active {
border-color: #bae6fd;
}
}
.voice-bar-left {
display: flex;
align-items: center;
gap: 16rpx;
flex: 1;
min-width: 0;
}
.voice-bar-icon {
font-size: 48rpx;
line-height: 1;
}
.voice-bar-text {
display: flex;
flex-direction: column;
gap: 4rpx;
min-width: 0;
}
.voice-bar-title {
font-size: 34rpx;
font-weight: 700;
color: var(--slate-900);
}
.voice-bar-hint {
font-size: 26rpx;
color: var(--slate-600);
margin-top: 4rpx;
}
.voice-bar-actions {
display: flex;
align-items: center;
gap: 16rpx;
flex-shrink: 0;
}
.voice-switch {
display: flex;
align-items: center;
gap: 12rpx;
padding: 10rpx 12rpx 10rpx 18rpx;
border-radius: var(--radius-pill);
background: var(--slate-100);
transition: var(--transition-smooth);
min-height: 76rpx;
box-sizing: border-box;
&.on {
background: linear-gradient(135deg, #fef3c7, #fde68a);
}
}
.voice-switch-label {
font-size: 28rpx;
color: #1e293b;
font-weight: 700;
}
.voice-switch-track {
width: 80rpx;
height: 44rpx;
background: var(--slate-300);
border-radius: var(--radius-pill);
position: relative;
transition: var(--transition-smooth);
.voice-switch.on & {
background: var(--warning);
}
}
.voice-switch-knob {
position: absolute;
width: 36rpx;
height: 36rpx;
border-radius: var(--radius-pill);
background: #ffffff;
top: 4rpx;
left: 4rpx;
box-shadow: 0 2rpx 6rpx rgba(15, 23, 42, 0.15);
transition: var(--transition-smooth);
.voice-switch.on & {
transform: translateX(36rpx);
}
}
.voice-toggle {
width: 76rpx;
height: 76rpx;
border-radius: var(--radius-pill);
background: var(--primary-grad);
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-glow-primary);
transition: var(--transition-smooth);
&.off {
background: var(--slate-400);
box-shadow: none;
}
&:active {
transform: scale(0.95);
}
}
.voice-toggle-icon {
font-size: 36rpx;
color: #ffffff;
line-height: 1;
}
.card {
background: #ffffff;
border-radius: var(--radius-card);
padding: 30rpx 15rpx;
box-shadow: var(--shadow-premium);
animation: card-rise 0.45s ease-out both;
}
/* 动效延迟展现列表,视觉更灵动 */
.streak-strip { animation: card-rise 0.45s ease-out 0.03s both; }
.card-switcher { animation: card-rise 0.45s ease-out 0.06s both; }
.range-bar { animation: card-rise 0.4s ease-out 0.08s both; }
.voice-bar { animation: card-rise 0.4s ease-out 0.10s both; }
.chart-card { animation: card-rise 0.5s ease-out 0.12s both; }
.calendar-card { animation: card-rise 0.5s ease-out 0.14s both; }
.records-section { animation: card-rise 0.5s ease-out 0.16s both; }
@keyframes card-rise {
from {
opacity: 0;
transform: translateY(16rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes stat-card-pop {
from {
opacity: 0;
transform: translateY(24rpx) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.stat-card {
animation: stat-card-pop 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.stat-fasting { animation-delay: 0.04s; }
.stat-postprandial { animation-delay: 0.08s; }
.stat-other { animation-delay: 0.12s; }
.card-head {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-size: 36rpx;
font-weight: 800;
color: var(--slate-900);
letter-spacing: 0.5rpx;
}
.card-subtitle {
display: block;
font-size: 26rpx;
color: var(--slate-600);
margin-top: 6rpx;
}
.card-legend {
display: flex;
gap: 18rpx;
}
.legend-item {
display: flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 12rpx;
margin: -8rpx -12rpx;
font-size: 22rpx;
color: var(--slate-600);
border-radius: 999rpx;
transition: opacity 0.2s ease;
&.inactive {
opacity: 0.42;
.legend-dot-fasting,
.legend-dot-postprandial {
background: #cbd5e1;
}
}
&:active {
opacity: 0.72;
}
}
.legend-dot {
width: 16rpx;
height: 16rpx;
border-radius: var(--radius-pill);
}
.legend-dot-fasting { background: var(--primary); }
.legend-dot-postprandial { background: var(--warning); }
.chart-summary {
display: flex;
align-items: stretch;
gap: 0;
margin-top: 18rpx;
padding: 16rpx 4rpx;
background: linear-gradient(135deg, var(--slate-50) 0%, var(--slate-100) 100%);
border-radius: 18rpx;
border: 1rpx solid #eef2f5;
}
.chart-summary-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4rpx;
position: relative;
}
.chart-summary-item + .chart-summary-item::before {
content: '';
position: absolute;
left: 0;
top: 18%;
bottom: 18%;
width: 1rpx;
background: var(--slate-200);
}
.chart-summary-num {
font-size: 44rpx;
font-weight: 800;
color: var(--slate-900);
line-height: 1.1;
letter-spacing: -0.5rpx;
}
.chart-summary-item:first-child .chart-summary-num {
color: var(--danger);
}
.chart-summary-item:nth-child(2) .chart-summary-num {
color: var(--primary);
}
.chart-summary-label {
font-size: 24rpx;
color: var(--slate-600);
letter-spacing: 1rpx;
font-weight: 500;
margin-top: 4rpx;
}
.chart-canvas-wrap {
position: relative;
width: 100%;
height: 460rpx;
margin-top: 22rpx;
}
.chart-canvas-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, var(--slate-50) 0%, var(--slate-100) 100%);
border-radius: 18rpx;
color: var(--slate-600);
font-size: 28rpx;
letter-spacing: 1rpx;
font-weight: 500;
}
.chart-canvas {
width: 100%;
height: 100%;
display: block;
position: relative;
z-index: 1;
}
.chart-empty {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14rpx;
color: var(--slate-600);
}
.chart-empty-icon {
font-size: 72rpx;
}
.chart-empty-text {
font-size: 30rpx;
color: var(--slate-600);
font-weight: 500;
}
.chart-tip {
position: absolute;
min-width: 220rpx;
padding: 16rpx 20rpx;
background: rgba(15, 23, 42, 0.94);
color: #ffffff;
border-radius: 12rpx;
box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.18);
pointer-events: none;
z-index: 999;
}
.chart-tip-date {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
}
.chart-tip-row {
display: flex;
align-items: center;
gap: 10rpx;
margin-top: 8rpx;
}
.chart-tip-text {
font-size: 28rpx;
color: #ffffff;
font-weight: 500;
}
/* === 日常记录列表样式 === */
.records-section {
margin: 28rpx 24rpx 0;
}
.records-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
padding: 0 6rpx;
}
.records-count {
font-size: 28rpx;
color: var(--slate-600);
font-weight: 600;
}
.day-card {
background: #ffffff;
border-radius: 28rpx;
padding: 30rpx 28rpx;
margin-bottom: 22rpx;
box-shadow: var(--shadow-premium);
border: var(--border-premium);
}
.day-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14rpx;
border-bottom: 1rpx solid var(--slate-100);
padding-bottom: 12rpx;
}
.day-head-left {
display: flex;
align-items: center;
gap: 10rpx;
}
.day-date {
font-size: 36rpx;
font-weight: 800;
color: var(--slate-900);
}
.day-today-pill {
font-size: 24rpx;
padding: 4rpx 16rpx;
border-radius: var(--radius-pill);
background: var(--primary-grad);
color: #ffffff;
font-weight: 600;
box-shadow: var(--shadow-glow-primary);
}
.day-head-right {
display: flex;
align-items: center;
gap: 14rpx;
}
.day-weekday {
font-size: 26rpx;
color: var(--slate-600);
font-weight: 500;
}
.day-speak-btn {
display: flex;
align-items: center;
gap: 8rpx;
padding: 14rpx 28rpx;
border-radius: var(--radius-pill);
background: var(--primary-light);
border: 1rpx solid rgba(13, 148, 136, 0.2);
box-shadow: none;
min-height: 64rpx;
box-sizing: border-box;
transition: var(--transition-smooth);
.day-speak-icon {
font-size: 26rpx;
color: var(--primary);
}
.day-speak-text {
font-size: 26rpx;
color: var(--primary);
font-weight: 700;
}
&:active {
background: var(--primary-glow);
transform: scale(0.96);
}
}
.day-block {
margin-top: 18rpx;
padding: 24rpx;
background: var(--slate-50);
border-radius: 20rpx;
border: 1rpx solid var(--slate-100);
}
.day-block-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.day-block-tag {
font-size: 24rpx;
padding: 8rpx 20rpx;
border-radius: var(--radius-pill);
font-weight: 700;
line-height: 1;
&.tag-blood {
background: var(--primary-light);
color: var(--primary);
border: 1rpx solid rgba(13, 148, 136, 0.15);
}
&.tag-diet {
background: var(--warning-light);
color: #d97706;
border: 1rpx solid rgba(245, 158, 11, 0.15);
}
&.tag-exercise {
background: var(--violet-light);
color: #4f46e5;
border: 1rpx solid rgba(99, 102, 241, 0.15);
}
&.tag-tracking {
background: var(--slate-100);
color: var(--slate-600);
border: 1rpx solid var(--slate-200);
}
}
.day-block-time {
font-size: 26rpx;
color: var(--slate-600);
font-weight: 500;
}
.metric-grid {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 14rpx;
}
.metric-cell {
flex: 1 1 calc(50% - 6rpx);
background: #ffffff;
border-radius: 14rpx;
padding: 18rpx 18rpx 16rpx;
position: relative;
border: 1rpx solid var(--slate-100);
}
.metric-row-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8rpx;
}
.metric-label {
font-size: 26rpx;
color: var(--slate-600);
font-weight: 600;
}
.metric-flag {
display: inline-flex;
align-items: center;
gap: 4rpx;
padding: 2rpx 10rpx 2rpx 6rpx;
border-radius: var(--radius-pill);
background: var(--danger-light);
border: 1rpx solid #fecaca;
&.up {
background: var(--danger-grad);
border-color: transparent;
box-shadow: var(--shadow-glow-danger);
}
}
.metric-flag-icon {
font-size: 16rpx;
color: #ffffff;
font-weight: 700;
line-height: 1;
}
.metric-flag-text {
font-size: 18rpx;
color: #ffffff;
font-weight: 600;
}
.metric-value-row {
display: flex;
align-items: baseline;
gap: 6rpx;
margin-top: 6rpx;
}
.metric-value {
font-size: 40rpx;
font-weight: 800;
color: var(--slate-900);
line-height: 1.1;
&.is-high {
color: var(--danger);
}
}
.metric-unit {
font-size: 24rpx;
color: var(--slate-600);
font-weight: 500;
}
.metric-trend {
display: inline-flex;
align-items: center;
gap: 4rpx;
margin-top: 10rpx;
padding: 2rpx 12rpx 2rpx 8rpx;
border-radius: var(--radius-pill);
background: var(--slate-100);
border: 1rpx solid transparent;
&.trend-up {
background: #fef3c7;
border-color: #fde68a;
.metric-trend-arrow,
.metric-trend-text {
color: #b45309;
}
}
&.trend-down {
background: #dcfce7;
border-color: #bbf7d0;
.metric-trend-arrow,
.metric-trend-text {
color: #15803d;
}
}
&.trend-flat {
background: var(--slate-100);
border-color: var(--slate-200);
.metric-trend-arrow,
.metric-trend-text {
color: var(--slate-600);
}
}
}
.metric-trend-arrow {
font-size: 20rpx;
font-weight: 700;
line-height: 1;
}
.metric-trend-text {
font-size: 24rpx;
font-weight: 700;
}
.day-meta-row {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
margin-top: 14rpx;
}
.meta-chip {
font-size: 22rpx;
color: var(--slate-600);
background: #ffffff;
border-radius: var(--radius-pill);
padding: 6rpx 18rpx;
border: 1rpx solid var(--slate-200);
}
.day-remark {
margin-top: 14rpx;
font-size: 28rpx;
color: var(--slate-900);
line-height: 1.7;
}
.diet-rows {
margin-top: 14rpx;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.diet-row {
display: flex;
gap: 10rpx;
align-items: flex-start;
}
.diet-meal {
flex-shrink: 0;
width: 140rpx;
font-size: 28rpx;
color: #b45309;
font-weight: 700;
}
.diet-text {
flex: 1;
font-size: 28rpx;
color: var(--slate-900);
line-height: 1.7;
}
.exercise-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12rpx;
margin-top: 14rpx;
}
.exercise-type {
font-size: 26rpx;
color: var(--slate-900);
font-weight: 600;
}
.exercise-duration {
font-size: 24rpx;
color: var(--violet);
background: var(--violet-light);
border-radius: var(--radius-pill);
padding: 4rpx 16rpx;
font-weight: 600;
}
.exercise-intensity {
font-size: 24rpx;
color: var(--slate-600);
font-weight: 500;
}
.tracking-content {
margin-top: 12rpx;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.tracking-line {
font-size: 28rpx;
color: var(--slate-900);
line-height: 1.7;
word-break: break-all;
}
.state-card {
background: #ffffff;
border-radius: var(--radius-card);
padding: 60rpx 24rpx;
text-align: center;
box-shadow: var(--shadow-premium);
border: var(--border-premium);
}
.state-icon {
display: block;
font-size: 76rpx;
margin-bottom: 16rpx;
}
.state-text {
font-size: 30rpx;
color: var(--slate-600);
font-weight: 500;
}
.footer-tip {
text-align: center;
font-size: 24rpx;
color: var(--slate-400);
margin-top: 40rpx;
line-height: 1.6;
}
/* === 顶部 Hero 漂浮小饰品 === */
.hero-orb {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
pointer-events: none;
filter: blur(4rpx);
}
.hero-orb-1 {
width: 220rpx;
height: 220rpx;
top: -60rpx;
right: -40rpx;
background: rgba(255, 255, 255, 0.12);
}
.hero-orb-2 {
width: 140rpx;
height: 140rpx;
top: 40rpx;
right: 240rpx;
background: rgba(253, 224, 71, 0.1);
}
.hero-orb-3 {
width: 90rpx;
height: 90rpx;
bottom: 30rpx;
left: 40%;
background: rgba(255, 255, 255, 0.08);
}
.hero-greet-row {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 6rpx;
}
.hero-greet-icon-wrap {
width: 52rpx;
height: 52rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.18);
border: 1rpx solid rgba(255, 255, 255, 0.28);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.hero-greet-text {
font-size: 30rpx;
color: rgba(255, 255, 255, 0.95);
letter-spacing: 2rpx;
font-weight: 500;
}
.hero-subtitle-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-top: 10rpx;
flex-wrap: wrap;
}
.hero-status-chip {
display: inline-flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 18rpx 8rpx 12rpx;
border-radius: var(--radius-pill);
font-size: 28rpx;
font-weight: 600;
backdrop-filter: blur(8px);
&.done {
background: rgba(34, 197, 94, 0.25);
color: #ffffff;
border: 1rpx solid rgba(187, 247, 208, 0.5);
}
&.pending {
background: rgba(251, 191, 36, 0.28);
color: #ffffff;
border: 1rpx solid rgba(253, 230, 138, 0.5);
}
}
.hero-status-icon {
width: 32rpx;
height: 32rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.hero-status-text {
line-height: 1.2;
}
/* === 打卡条 === */
.streak-strip {
position: relative;
margin: 22rpx 24rpx 0;
background: linear-gradient(135deg, #ffffff 0%, var(--primary-light) 100%);
border: 1rpx solid rgba(13, 148, 136, 0.12);
border-radius: var(--radius-card);
padding: 32rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
box-shadow: 0 10rpx 30rpx rgba(13, 148, 136, 0.05);
overflow: hidden;
min-height: 132rpx;
box-sizing: border-box;
transition: var(--transition-smooth);
&:active {
transform: scale(0.99);
}
&::after {
content: '';
position: absolute;
top: -40rpx;
right: -40rpx;
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background: radial-gradient(circle, rgba(251, 191, 36, 0.15) 0%, rgba(251, 191, 36, 0) 70%);
pointer-events: none;
}
}
.streak-strip-left {
display: flex;
align-items: center;
gap: 16rpx;
flex-shrink: 0;
}
.streak-flame {
width: 92rpx;
height: 92rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 -3rpx 0 rgba(245, 158, 11, 0.18);
flex-shrink: 0;
&.active {
background: linear-gradient(135deg, #fb923c 0%, #f97316 100%);
box-shadow: 0 6rpx 14rpx rgba(249, 115, 22, 0.35), inset 0 -3rpx 0 rgba(0, 0, 0, 0.08);
}
}
.streak-flame-icon {
font-size: 52rpx;
line-height: 1;
}
.streak-info {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.streak-num-row {
display: flex;
align-items: baseline;
gap: 8rpx;
flex-wrap: wrap;
}
.streak-num {
font-size: 64rpx;
font-weight: 800;
line-height: 1;
letter-spacing: -1rpx;
background: var(--primary-grad);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.streak-num-unit {
font-size: 32rpx;
color: var(--slate-900);
font-weight: 700;
}
.streak-hint {
margin-left: 8rpx;
font-size: 22rpx;
color: var(--slate-600);
background: var(--slate-100);
border-radius: var(--radius-pill);
padding: 4rpx 14rpx;
font-weight: 500;
}
.streak-label {
font-size: 26rpx;
color: var(--slate-600);
letter-spacing: 0.5rpx;
margin-top: 2rpx;
}
.streak-strip-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10rpx;
flex-shrink: 0;
}
.streak-dots-row {
display: flex;
gap: 12rpx;
}
.streak-dot {
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
}
.streak-dot-label {
font-size: 22rpx;
color: var(--slate-600);
line-height: 1;
font-weight: 600;
}
.streak-dot-mark {
width: 26rpx;
height: 26rpx;
border-radius: var(--radius-pill);
background: var(--slate-200);
}
.streak-dot.done .streak-dot-mark {
background: var(--primary-grad);
box-shadow: 0 2rpx 6rpx rgba(32, 78, 43, 0.3);
}
.streak-dot.today {
.streak-dot-label {
color: var(--slate-900);
font-weight: 700;
}
.streak-dot-mark {
width: 30rpx;
height: 30rpx;
border: 2rpx solid var(--primary);
}
}
.streak-dot.today.done .streak-dot-mark {
border-color: #ffffff;
box-shadow: 0 0 0 3rpx var(--primary), 0 2rpx 8rpx rgba(32, 78, 43, 0.4);
}
.streak-week-text {
font-size: 24rpx;
color: var(--slate-600);
letter-spacing: 1rpx;
font-weight: 600;
}
/* === 健康月历 === */
.calendar-card {
margin: 22rpx 24rpx 0;
}
.calendar-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
}
.calendar-head-left {
flex: 1;
min-width: 0;
}
.calendar-stat {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2rpx;
padding: 4rpx 18rpx 4rpx 22rpx;
background: linear-gradient(135deg, #eef6ef 0%, #ccfbf1 100%);
border-radius: 20rpx;
border-left: 6rpx solid var(--primary);
}
.calendar-stat-num {
font-size: 48rpx;
font-weight: 800;
color: #386641;
line-height: 1.1;
letter-spacing: -1rpx;
}
.calendar-stat-label {
font-size: 22rpx;
color: #0f766e;
font-weight: 600;
}
.calendar-weekdays {
display: flex;
margin-top: 22rpx;
padding: 0 4rpx;
}
.calendar-weekday {
flex: 1;
text-align: center;
font-size: 26rpx;
color: var(--slate-600);
font-weight: 700;
letter-spacing: 1rpx;
line-height: 1.4;
padding: 8rpx 0;
}
.calendar-weekday-weekend {
color: var(--warning);
}
.calendar-grid {
margin-top: 6rpx;
background: var(--slate-50);
border-radius: 20rpx;
padding: 12rpx 6rpx;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.calendar-row {
display: flex;
gap: 6rpx;
}
.calendar-cell {
flex: 1;
aspect-ratio: 1 / 1;
min-height: 84rpx;
border-radius: 18rpx;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
position: relative;
transition: var(--transition-smooth);
box-sizing: border-box;
overflow: hidden;
border: 2rpx solid transparent;
&.level-0 {
background: #ffffff;
border-color: var(--slate-100);
}
&.off {
background: transparent;
opacity: 0.4;
}
&.level-1 {
background: var(--primary-light);
border-color: rgba(13, 148, 136, 0.15);
.calendar-cell-day { color: var(--primary); }
}
&.level-2 {
background: #ccfbf1;
border-color: #99f6e4;
.calendar-cell-day { color: #0f766e; }
}
&.level-3 {
background: var(--primary-grad);
.calendar-cell-day { color: #ffffff; }
box-shadow: 0 4rpx 12rpx rgba(13, 148, 136, 0.2);
}
&.today {
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%) !important;
border: 4rpx solid #ffffff;
box-shadow: 0 0 0 3rpx #ea580c, 0 6rpx 16rpx rgba(234, 88, 12, 0.35);
.calendar-cell-day { color: #ffffff; }
}
&.clickable:active {
transform: scale(0.94);
}
}
.calendar-cell-day {
font-size: 32rpx;
color: var(--slate-900);
font-weight: 700;
line-height: 1;
}
.calendar-cell.today .calendar-cell-day {
color: #ffffff;
font-weight: 800;
transform: translateY(-6rpx);
}
.calendar-cell.off .calendar-cell-day {
color: var(--slate-400);
font-weight: 500;
}
.calendar-cell-mark {
position: absolute;
right: 8rpx;
bottom: 8rpx;
width: 10rpx;
height: 10rpx;
border-radius: var(--radius-pill);
background: rgba(255, 255, 255, 0.85);
box-shadow: 0 0 0 2rpx rgba(15, 23, 42, 0.08);
}
.calendar-cell.level-1 .calendar-cell-mark {
background: #386641;
box-shadow: none;
}
.calendar-cell.today .calendar-cell-mark {
display: none;
}
.calendar-cell-today-tag {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: rgba(220, 38, 38, 0.92);
color: #ffffff;
font-size: 18rpx;
font-weight: 700;
padding: 3rpx 0;
text-align: center;
line-height: 1.2;
letter-spacing: 2rpx;
}
.calendar-legend {
margin-top: 18rpx;
padding: 14rpx 6rpx 4rpx;
}
.calendar-legend-row {
display: flex;
align-items: center;
justify-content: space-around;
gap: 8rpx;
}
.calendar-legend-item {
display: flex;
align-items: center;
gap: 8rpx;
}
.calendar-legend-cell {
width: 28rpx;
height: 28rpx;
border-radius: 8rpx;
background: var(--slate-100);
border: 1rpx solid var(--slate-200);
&.level-1 { background: #ccfbf1; border-color: #99f6e4; }
&.level-2 { background: linear-gradient(135deg, #cfe8d3 0%, #afe2b3 100%); border-color: #386641; }
&.level-3 { background: var(--primary-grad); border-color: transparent; }
&.legend-today {
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
border: 2rpx solid #ffffff;
box-shadow: 0 0 0 2rpx #ea580c;
}
}
.calendar-legend-text {
font-size: 24rpx;
color: var(--slate-600);
font-weight: 600;
}
.heatmap-foot {
margin-top: 22rpx;
padding-top: 18rpx;
border-top: 1rpx dashed var(--slate-200);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
}
.heatmap-foot-text {
font-size: 28rpx;
color: #334155;
font-weight: 600;
}
.heatmap-foot-cta {
background: var(--primary-grad);
color: #ffffff;
font-size: 28rpx;
font-weight: 700;
padding: 18rpx 30rpx;
border-radius: var(--radius-pill);
box-shadow: var(--shadow-glow-primary);
min-height: 76rpx;
display: flex;
align-items: center;
transition: var(--transition-smooth);
&:active {
transform: scale(0.96);
}
}
/* === 弹窗表单(bottom sheet=== */
.input-modal-mask {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(12px);
z-index: 999;
display: flex;
align-items: flex-end;
justify-content: center;
animation: input-modal-mask-in 0.38s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.input-modal {
width: 100%;
max-width: 750rpx;
background: #ffffff;
border-radius: 36rpx 36rpx 0 0;
padding: 16rpx 0 calc(20rpx + env(safe-area-inset-bottom));
box-shadow: 0 -12rpx 36rpx rgba(15, 23, 42, 0.12);
max-height: 88vh;
display: flex;
flex-direction: column;
animation: input-modal-rise 0.46s cubic-bezier(0.22, 1, 0.36, 1) both;
position: relative;
z-index: 1000;
will-change: transform, opacity;
}
@keyframes input-modal-mask-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes input-modal-rise {
from { transform: translate3d(0, 100%, 0); opacity: 0.96; }
to { transform: translate3d(0, 0, 0); opacity: 1; }
}
@keyframes input-modal-content-in {
from { opacity: 0; transform: translate3d(0, 24rpx, 0); }
to { opacity: 1; transform: translate3d(0, 0, 0); }
}
.input-modal-grip {
width: 80rpx;
height: 6rpx;
border-radius: var(--radius-pill);
background: var(--slate-200);
margin: 0 auto 8rpx;
}
.input-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12rpx 32rpx 16rpx;
border-bottom: 1rpx solid var(--slate-100);
}
.input-modal-title-wrap {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.input-modal-title {
font-size: 40rpx;
font-weight: 800;
color: var(--slate-900);
letter-spacing: 0.5rpx;
}
.input-modal-sub {
font-size: 26rpx;
color: var(--slate-600);
margin-top: 4rpx;
}
.input-modal-close {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-pill);
background: var(--slate-100);
transition: var(--transition-smooth);
&:active {
background: var(--slate-200);
}
}
.input-modal-close-icon {
font-size: 42rpx;
color: var(--slate-600);
line-height: 1;
font-weight: 700;
}
.phone-gate-body {
padding: 32rpx;
}
.phone-gate-tip {
display: block;
font-size: 28rpx;
color: var(--primary-container);
line-height: 1.6;
margin-bottom: 24rpx;
}
.phone-gate-gender {
margin-bottom: 32rpx;
}
.phone-gate-gender-label {
display: block;
font-size: 28rpx;
font-weight: 700;
color: var(--primary);
margin-bottom: 16rpx;
}
.phone-gate-gender-row {
display: flex;
gap: 20rpx;
}
.phone-gate-gender-opt {
flex: 1;
min-height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 20rpx;
background: #ffffff;
border: 2rpx solid rgba(32, 78, 43, 0.25);
color: var(--primary-container);
font-size: 32rpx;
font-weight: 600;
}
.phone-gate-gender-opt.active {
border-color: var(--primary);
color: var(--primary);
box-shadow: inset 0 0 0 1rpx var(--primary);
}
.phone-gate-btn {
width: 100%;
min-height: 96rpx;
line-height: 96rpx;
border-radius: 20rpx;
background: #ffffff;
color: var(--primary);
font-size: 32rpx;
font-weight: 700;
border: 2rpx solid var(--primary);
}
.phone-gate-btn::after {
border: none;
}
.input-modal-loading {
min-height: 520rpx;
padding: 120rpx 0;
text-align: center;
color: var(--slate-600);
font-size: 30rpx;
display: flex;
align-items: center;
justify-content: center;
}
.input-modal-body {
flex: 1;
min-height: 520rpx;
padding: 20rpx 32rpx 28rpx;
overflow-y: auto;
overflow-x: hidden;
box-sizing: border-box;
width: 100%;
}
.input-modal-body-ready {
animation: input-modal-content-in 0.34s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.input-modal-tip {
background: var(--primary-light);
border: 1rpx dashed #99f6e4;
border-radius: 14rpx;
padding: 18rpx 20rpx;
font-size: 26rpx;
color: #0f766e;
line-height: 1.5;
margin-bottom: 22rpx;
}
.input-section-title {
font-size: 28rpx;
font-weight: 700;
color: #334155;
letter-spacing: 1rpx;
margin: 18rpx 0 14rpx;
}
.intensity-row {
display: flex;
gap: 12rpx;
margin-bottom: 16rpx;
}
.intensity-chip {
flex: 1;
text-align: center;
padding: 16rpx 8rpx;
border-radius: 14rpx;
background: var(--slate-50);
border: 2rpx solid var(--slate-200);
font-size: 26rpx;
color: var(--slate-600);
transition: var(--transition-smooth);
&.active {
background: var(--success-light);
border-color: var(--success);
color: #047857;
font-weight: 700;
}
}
.input-field {
display: flex;
align-items: center;
gap: 18rpx;
padding: 14rpx 0;
border-bottom: 1rpx solid var(--slate-100);
min-width: 0;
width: 100%;
box-sizing: border-box;
min-height: 88rpx;
}
.input-field-label {
display: flex;
align-items: center;
gap: 10rpx;
flex: 0 0 auto;
width: 180rpx;
font-size: 30rpx;
color: var(--slate-900);
font-weight: 700;
}
.input-field-dot {
width: 14rpx;
height: 14rpx;
border-radius: var(--radius-pill);
background: var(--slate-400);
flex-shrink: 0;
}
.input-field-dot-fasting { background: var(--primary); }
.input-field-dot-postprandial { background: var(--warning); }
.input-field-dot-other { background: var(--violet); }
.input-field-input {
flex: 1 1 0;
min-width: 0;
background: var(--slate-50);
border-radius: 16rpx;
padding: 18rpx 20rpx;
box-sizing: border-box;
overflow: hidden;
min-height: 76rpx;
display: flex;
align-items: center;
border: 2rpx solid transparent;
transition: var(--transition-smooth);
&:focus-within {
border-color: var(--primary);
background: #ffffff;
box-shadow: 0 0 0 4rpx var(--primary-glow);
}
input {
width: 100%;
min-width: 0;
font-size: 34rpx;
color: var(--slate-900);
text-align: right;
box-sizing: border-box;
font-weight: 600;
}
}
.input-placeholder {
color: var(--slate-400);
font-size: 28rpx;
}
.input-field-row {
display: flex;
gap: 18rpx;
min-width: 0;
width: 100%;
box-sizing: border-box;
}
.input-field-half {
flex: 1 1 0;
min-width: 0;
border-bottom: 1rpx solid var(--slate-100);
}
.input-field-half .input-field-label {
width: 80rpx;
flex: 0 0 auto;
}
.input-field-row .input-field-input {
padding: 16rpx 16rpx;
input {
font-size: 32rpx;
}
}
.input-field-textarea {
flex-direction: column;
align-items: stretch;
border-bottom: none;
padding: 10rpx 0 0;
textarea {
width: 100%;
min-height: 140rpx;
background: var(--slate-50);
border-radius: 14rpx;
padding: 20rpx 22rpx;
font-size: 30rpx;
color: var(--slate-900);
box-sizing: border-box;
line-height: 1.6;
border: 2rpx solid transparent;
transition: var(--transition-smooth);
&:focus {
border-color: var(--primary);
background: #ffffff;
box-shadow: 0 0 0 4rpx var(--primary-glow);
}
}
}
.input-modal-actions {
display: flex;
gap: 18rpx;
padding: 20rpx 32rpx 0;
border-top: 1rpx solid var(--slate-100);
}
.input-modal-btn {
flex: 1;
height: 100rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
font-weight: 700;
letter-spacing: 2rpx;
transition: var(--transition-smooth);
&.primary {
background: var(--primary-grad);
color: #ffffff;
box-shadow: var(--shadow-glow-primary);
&:active {
transform: translateY(1rpx);
}
}
&.danger {
flex: 0 0 180rpx;
background: var(--danger-light);
color: var(--danger);
border: 1rpx solid rgba(239, 68, 68, 0.2);
&:active {
background: #ffe4e6;
}
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
/* === 稳糖乐园 === */
.pg-card {
margin: 28rpx 24rpx 0;
background: #ffffff;
border-radius: 32rpx;
padding: 30rpx 28rpx;
box-shadow: var(--shadow-premium);
border: var(--border-premium);
animation: card-rise 0.45s ease-out 0.08s both;
.card-head {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1rpx solid var(--slate-100);
padding-bottom: 20rpx;
}
.pg-points-badge {
display: flex;
flex-direction: column;
align-items: flex-end;
background: linear-gradient(135deg, #eef6ef 0%, #ccfbf1 100%);
border-left: 6rpx solid var(--primary);
padding: 8rpx 18rpx 8rpx 20rpx;
border-radius: 16rpx;
.pg-points-num {
font-size: 38rpx;
font-weight: 800;
color: #386641;
line-height: 1.1;
}
.pg-points-label {
font-size: 20rpx;
color: #0f766e;
font-weight: 600;
}
}
}
.pg-content-stack {
display: flex;
flex-direction: column;
gap: 28rpx;
margin-top: 24rpx;
}
.record-quick-zone {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.record-quick-head {
display: flex;
flex-direction: column;
gap: 4rpx;
padding-left: 6rpx;
}
.record-quick-title {
font-size: 28rpx;
font-weight: 800;
color: #334155;
}
.record-quick-hint {
font-size: 22rpx;
color: var(--slate-600);
font-weight: 600;
}
.record-quick-row {
display: flex;
gap: 12rpx;
}
.record-quick-chip {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
padding: 24rpx 12rpx;
background: #ffffff;
border: 1rpx solid var(--slate-200);
border-radius: 24rpx;
box-shadow: 0 4rpx 12rpx rgba(15, 23, 42, 0.02);
transition: var(--transition-smooth);
&:active {
transform: scale(0.98);
}
&.pending {
border-color: rgba(13, 148, 136, 0.2);
background: linear-gradient(180deg, var(--primary-light) 0%, #ffffff 100%);
}
&.done {
border-color: rgba(16, 185, 129, 0.2);
background: linear-gradient(180deg, var(--success-light) 0%, #ffffff 100%);
}
}
.record-quick-icon-wrap {
width: 56rpx;
height: 56rpx;
border-radius: var(--radius-pill);
background: var(--slate-50);
display: flex;
align-items: center;
justify-content: center;
.record-quick-chip.pending & {
background: #ccfbf1;
}
.record-quick-chip.done & {
background: #dcfce7;
}
}
.record-quick-label {
font-size: 26rpx;
font-weight: 800;
color: var(--slate-900);
}
.record-quick-status {
font-size: 22rpx;
font-weight: 700;
color: #386641;
.record-quick-chip.done & {
color: var(--success);
}
}
.tree-growth-zone {
display: flex;
align-items: flex-start;
gap: 20rpx;
background: linear-gradient(145deg, #eef6ef 0%, #f8fafc 55%, #fffbeb 100%);
border-radius: 24rpx;
padding: 24rpx 20rpx;
border: 2rpx solid #99f6e4;
}
.tree-graphic-wrap {
position: relative;
flex-shrink: 0;
width: 152rpx;
min-height: 200rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.tree-lv-badge {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
z-index: 2;
padding: 4rpx 14rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #204e2b, #386641);
color: #ffffff;
font-size: 20rpx;
font-weight: 800;
box-shadow: 0 4rpx 12rpx rgba(32, 78, 43, 0.35);
}
.tree-mood-pill {
position: absolute;
top: 36rpx;
right: -8rpx;
z-index: 2;
display: flex;
align-items: center;
gap: 4rpx;
padding: 4rpx 10rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.92);
border: 1rpx solid #e2e8f0;
box-shadow: 0 2rpx 8rpx rgba(15, 23, 42, 0.08);
}
.tree-mood-emoji {
font-size: 22rpx;
line-height: 1;
}
.tree-mood-text {
font-size: 20rpx;
font-weight: 700;
color: #64748b;
}
.tree-next-hint {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
color: #0f766e;
font-weight: 600;
line-height: 1.45;
}
.tree-roadmap-scroll {
width: 100%;
margin-top: 14rpx;
white-space: nowrap;
}
.tree-roadmap-row {
display: inline-flex;
gap: 10rpx;
padding-bottom: 4rpx;
}
.tree-roadmap-node {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
min-width: 56rpx;
padding: 8rpx 6rpx;
border-radius: 14rpx;
background: #faf9f5;
border: 1rpx solid #e2e8f0;
opacity: 0.55;
&.done {
opacity: 0.85;
background: #ecfdf5;
border-color: #a7f3d0;
}
&.current {
opacity: 1;
background: linear-gradient(180deg, #ccfbf1, #ffffff);
border-color: #386641;
box-shadow: 0 4rpx 14rpx rgba(32, 78, 43, 0.25);
transform: scale(1.06);
}
&.locked {
opacity: 0.4;
}
}
.tree-roadmap-emoji {
font-size: 26rpx;
line-height: 1;
}
.tree-roadmap-lv {
font-size: 18rpx;
font-weight: 700;
color: #64748b;
.tree-roadmap-node.current & {
color: #0f766e;
}
}
.tree-tappable:active {
opacity: 0.92;
}
.tree-tap-hint,
.invite-tree-tap-hint {
margin-top: 4rpx;
font-size: 20rpx;
color: var(--slate-600);
font-weight: 600;
}
.invite-tree-tappable {
display: flex;
flex-direction: column;
align-items: center;
}
.float-reward {
position: absolute;
top: 8rpx;
right: 0;
z-index: 3;
animation: float-reward-rise 1.1s ease-out forwards;
text {
font-size: 32rpx;
font-weight: 800;
color: var(--success);
text-shadow: 0 2rpx 8rpx rgba(5, 150, 105, 0.25);
}
}
@keyframes float-reward-rise {
0% { opacity: 1; transform: translateY(0) scale(0.8); }
100% { opacity: 0; transform: translateY(-56rpx) scale(1.15); }
}
.mission-complete-banner {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 20rpx;
padding: 18rpx 20rpx;
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
border: 2rpx solid #fde68a;
border-radius: 20rpx;
animation: mission-glow 2s ease-in-out infinite;
}
@keyframes mission-glow {
0%, 100% { box-shadow: 0 0 0 rgba(251, 191, 36, 0); }
50% { box-shadow: 0 0 20rpx rgba(251, 191, 36, 0.35); }
}
.mission-complete-title {
display: block;
font-size: 28rpx;
font-weight: 800;
color: #92400e;
}
.mission-complete-sub {
display: block;
margin-top: 4rpx;
font-size: 24rpx;
color: #b45309;
}
.badge-wall {
margin-top: 12rpx;
}
.badge-wall-title {
display: block;
font-size: 32rpx;
font-weight: 800;
color: #475569;
margin-bottom: 16rpx;
padding-left: 6rpx;
}
.badge-wall-scroll {
width: 100%;
white-space: nowrap;
}
.badge-wall-row {
display: inline-flex;
gap: 14rpx;
padding-bottom: 8rpx;
}
.badge-chip {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 10rpx;
min-width: 140rpx;
padding: 18rpx 20rpx;
background: #ffffff;
border: 1rpx solid var(--slate-200);
border-radius: 18rpx;
box-shadow: var(--shadow-sm);
}
.badge-chip-icon {
width: 58rpx;
height: 58rpx;
border-radius: var(--radius-pill);
background: var(--slate-50);
display: flex;
align-items: center;
justify-content: center;
}
.badge-chip-label {
font-size: 28rpx;
font-weight: 700;
color: #334155;
white-space: nowrap;
}
.tree-progress-wrap {
flex: 1;
display: flex;
flex-direction: column;
gap: 10rpx;
min-width: 0;
}
.tree-level-info {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12rpx;
}
.tree-level-tag {
font-size: 30rpx;
font-weight: 700;
color: var(--slate-900);
background: var(--slate-200);
padding: 6rpx 18rpx;
border-radius: var(--radius-pill);
}
.tree-progress-text {
font-size: 28rpx;
color: var(--slate-600);
font-weight: 600;
}
.tree-progress-track {
height: 16rpx;
background: var(--slate-200);
border-radius: var(--radius-pill);
overflow: hidden;
}
.tree-progress-bar {
height: 100%;
background: var(--primary-grad);
border-radius: var(--radius-pill);
transition: width 0.4s ease;
}
.tree-water-action {
margin-top: 6rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.water-hint {
font-size: 22rpx;
color: var(--slate-600);
font-weight: 600;
text-align: center;
line-height: 1.4;
}
.water-btn {
width: 100%;
height: 76rpx;
background: var(--primary-grad);
color: #ffffff;
border-radius: var(--radius-pill);
font-size: 28rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
border: none;
box-shadow: var(--shadow-glow-primary);
transition: var(--transition-smooth);
&:active {
transform: scale(0.98);
background: linear-gradient(135deg, #386641 0%, var(--primary) 100%);
}
&.disabled {
opacity: 0.7;
pointer-events: none;
}
&.pulse {
animation: water-btn-pulse 1.6s ease-in-out infinite;
}
}
@keyframes water-btn-pulse {
0%, 100% { box-shadow: var(--shadow-glow-primary); }
50% { box-shadow: 0 4rpx 24rpx rgba(32, 78, 43, 0.5); }
}
.today-tasks-zone {
display: flex;
flex-direction: column;
gap: 14rpx;
}
.tasks-zone-title {
font-size: 28rpx;
font-weight: 800;
color: #334155;
letter-spacing: 0.5rpx;
padding-left: 6rpx;
}
.tasks-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.task-row-item {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--slate-50);
border: 1rpx solid var(--slate-200);
border-radius: 20rpx;
padding: 20rpx 24rpx;
min-height: 100rpx;
box-sizing: border-box;
transition: var(--transition-smooth);
&.pending {
border-color: #99f6e4;
background: linear-gradient(90deg, var(--primary-light) 0%, #ffffff 100%);
}
&.completed {
border-color: #ccfbf1;
background: var(--primary-light);
}
&.claimed {
opacity: 0.92;
}
&.just-done {
animation: task-pop 0.65s ease;
}
}
@keyframes task-pop {
0% { transform: scale(1); }
40% { transform: scale(1.03); box-shadow: 0 8rpx 20rpx rgba(32, 78, 43, 0.2); }
100% { transform: scale(1); }
}
.task-left {
display: flex;
align-items: center;
gap: 16rpx;
min-width: 0;
}
.task-status-icon {
width: 36rpx;
height: 36rpx;
border-radius: var(--radius-pill);
border: 2rpx solid var(--slate-400);
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
font-weight: 800;
color: var(--slate-400);
line-height: 1;
flex-shrink: 0;
&.checked {
border-color: var(--primary);
background: var(--primary);
color: #ffffff;
}
}
.task-name {
font-size: 28rpx;
font-weight: 700;
color: var(--slate-900);
.task-row-item.completed & {
color: #0f766e;
}
}
.task-right {
display: flex;
align-items: center;
gap: 16rpx;
flex-shrink: 0;
}
.task-points {
font-size: 24rpx;
font-weight: 600;
color: var(--slate-600);
.task-row-item.completed & {
color: var(--primary);
}
}
.task-action-btn {
padding: 10rpx 22rpx;
border-radius: var(--radius-pill);
background: var(--slate-200);
color: var(--slate-600);
font-size: 24rpx;
font-weight: 700;
min-height: 52rpx;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
transition: var(--transition-smooth);
&.pending {
background: var(--primary);
color: #ffffff;
box-shadow: 0 4rpx 10rpx rgba(32, 78, 43, 0.2);
&:active {
transform: scale(0.96);
}
}
&:not(.disabled):not(.claimed):not(.pending) {
background: var(--primary);
color: #ffffff;
box-shadow: 0 4rpx 10rpx rgba(32, 78, 43, 0.2);
&:active {
transform: scale(0.96);
}
}
&.claimed {
background: #ccfbf1;
color: #386641;
}
&.disabled {
opacity: 0.6;
}
}
.pg-share-row {
margin-top: 8rpx;
}
.pg-share-btn {
display: flex;
align-items: center;
gap: 16rpx;
padding: 20rpx 22rpx;
background: var(--primary-light);
border: 1rpx solid #99f6e4;
border-radius: 20rpx;
transition: var(--transition-smooth);
&:active {
opacity: 0.85;
transform: translateY(1rpx);
}
}
.pg-share-icon-wrap {
width: 72rpx;
height: 72rpx;
border-radius: 18rpx;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.pg-share-title {
display: block;
font-size: 34rpx;
font-weight: 800;
color: #0f766e;
}
.pg-share-sub {
display: block;
margin-top: 6rpx;
font-size: 28rpx;
color: var(--slate-600);
}
/* === 家人点赞及鼓励 === */
.family-like-strip {
display: flex;
align-items: center;
gap: 18rpx;
margin: 20rpx 24rpx 0;
padding: 24rpx 28rpx;
background: #ffffff;
border-radius: 24rpx;
border: 1.5rpx solid #fecdd3;
box-shadow: 0 4rpx 16rpx rgba(225, 29, 72, 0.06);
transition: var(--transition-smooth);
&.has-likes {
background: linear-gradient(135deg, #fff1f2 0%, #ffffff 100%);
}
&:active {
transform: scale(0.99);
}
}
.family-like-icon {
font-size: 40rpx;
}
.family-like-body {
flex: 1;
min-width: 0;
}
.family-like-title {
display: block;
font-size: 34rpx;
font-weight: 800;
color: #9f1239;
}
.family-like-sub {
display: block;
margin-top: 8rpx;
font-size: 30rpx;
color: var(--slate-600);
font-weight: 500;
}
.family-like-recent {
margin-top: 10rpx;
display: flex;
flex-wrap: wrap;
gap: 10rpx;
}
.family-like-recent-item {
font-size: 26rpx;
color: #be123c;
background: #ffe4e6;
padding: 6rpx 16rpx;
border-radius: var(--radius-pill);
font-weight: 600;
}
.family-like-arrow {
font-size: 36rpx;
color: var(--slate-400);
}
.encourage-strip {
display: flex;
align-items: flex-start;
gap: 16rpx;
margin: 16rpx 24rpx 0;
padding: 24rpx 24rpx;
background: #ecfdf5;
border-radius: 20rpx;
border: 1rpx solid #a7f3d0;
transition: var(--transition-smooth);
&:active {
background: #d1fae5;
}
}
.encourage-icon {
font-size: 36rpx;
}
.encourage-text {
display: block;
font-size: 32rpx;
font-weight: 700;
color: #065f46;
line-height: 1.5;
}
.encourage-tip {
display: block;
margin-top: 8rpx;
font-size: 28rpx;
color: var(--slate-600);
font-weight: 500;
}
/* === 共享/邀请查看样式 === */
.invite-view-wrap {
min-height: 100vh;
background: linear-gradient(180deg, #eef6ef 0%, #f4f4f0 42%, #faf9f5 100%);
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
}
.invite-hero {
position: relative;
padding: 48rpx 32rpx 56rpx;
overflow: hidden;
}
.invite-hero-bg {
position: absolute;
inset: 0;
background: linear-gradient(145deg, #204e2b 0%, #386641 55%, #163d22 100%);
}
.invite-hero-content {
position: relative;
z-index: 1;
color: #ffffff;
}
.invite-hero-badge {
display: inline-flex;
align-items: center;
gap: 10rpx;
padding: 10rpx 18rpx;
background: rgba(255, 255, 255, 0.16);
border: 1rpx solid rgba(255, 255, 255, 0.28);
border-radius: var(--radius-pill);
margin-bottom: 18rpx;
}
.invite-hero-badge-text {
font-size: 24rpx;
font-weight: 600;
letter-spacing: 1rpx;
}
.invite-hero-title {
display: block;
font-size: 40rpx;
font-weight: 800;
line-height: 1.35;
color: #ffffff;
}
.invite-hero-sub {
display: block;
margin-top: 10rpx;
font-size: 26rpx;
color: rgba(255, 255, 255, 0.92);
line-height: 1.45;
}
.invite-hero-patient {
display: inline-flex;
align-items: center;
gap: 8rpx;
margin-top: 16rpx;
padding: 8rpx 16rpx;
background: rgba(255, 255, 255, 0.12);
border-radius: 12rpx;
font-size: 26rpx;
font-weight: 600;
}
.invite-body {
margin-top: -28rpx;
padding: 0 24rpx 24rpx;
position: relative;
z-index: 2;
}
.invite-card {
background: #ffffff;
border-radius: var(--radius-card);
padding: 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 8rpx 28rpx rgba(8, 145, 178, 0.08);
border: 1rpx solid rgba(8, 145, 178, 0.08);
}
.invite-card-head,
.invite-section-head {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
}
.invite-card-icon-wrap {
width: 72rpx;
height: 72rpx;
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.invite-card-icon-wrap--cyan { background: #eef6ef; }
.invite-card-icon-wrap--teal { background: #eef6ef; }
.invite-card-icon-wrap--green { background: #ecfdf5; }
.invite-card-head-text,
.invite-section-head-text {
flex: 1;
min-width: 0;
}
.invite-card-label {
display: block;
font-size: 28rpx;
font-weight: 700;
color: #1b1c1a;
}
.invite-card-hint {
display: block;
margin-top: 4rpx;
font-size: 24rpx;
color: var(--slate-600);
}
.invite-code-value {
display: block;
text-align: center;
font-size: 44rpx;
font-weight: 800;
letter-spacing: 6rpx;
color: #204e2b;
padding: 20rpx 12rpx;
background: linear-gradient(180deg, #eef6ef, #eef6ef);
border-radius: 16rpx;
border: 2rpx dashed rgba(8, 145, 178, 0.25);
}
.invite-summary-top {
display: flex;
align-items: center;
gap: 20rpx;
margin-bottom: 20rpx;
}
.invite-tree-panel {
flex-shrink: 0;
}
.invite-tree-meta {
flex: 1;
min-width: 0;
}
.invite-tree-title {
display: block;
font-size: 30rpx;
font-weight: 800;
color: #1b1c1a;
}
.invite-tree-desc {
display: block;
margin-top: 6rpx;
font-size: 26rpx;
color: var(--slate-600);
line-height: 1.4;
}
.invite-tree-week {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
color: var(--slate-400);
}
.invite-stat-grid {
display: flex;
gap: 12rpx;
}
.invite-stat-item {
flex: 1;
text-align: center;
padding: 18rpx 10rpx;
background: var(--slate-50);
border-radius: 18rpx;
border: 1rpx solid var(--slate-200);
}
.invite-stat-icon-wrap {
width: 48rpx;
height: 48rpx;
margin: 0 auto 8rpx;
border-radius: var(--radius-pill);
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2rpx 8rpx rgba(15, 23, 42, 0.06);
}
.invite-stat-num {
display: block;
font-size: 38rpx;
font-weight: 800;
color: #1b1c1a;
line-height: 1.1;
}
.invite-stat-label {
display: block;
font-size: 22rpx;
color: var(--slate-600);
margin-top: 6rpx;
}
.invite-quote-box {
margin-top: 16rpx;
padding: 18rpx 20rpx;
background: #eef6ef;
border-radius: 16rpx;
border-left: 6rpx solid #204e2b;
}
.invite-quote {
font-size: 26rpx;
color: #1b1c1a;
line-height: 1.55;
}
.invite-section-title {
display: block;
font-size: 30rpx;
font-weight: 800;
color: #1b1c1a;
}
.invite-section-sub {
display: block;
margin-top: 4rpx;
font-size: 24rpx;
color: var(--slate-600);
}
.invite-records-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
padding: 36rpx 24rpx;
text-align: center;
font-size: 26rpx;
color: var(--slate-600);
background: var(--slate-50);
border-radius: 16rpx;
}
.invite-timeline {
display: flex;
flex-direction: column;
gap: 0;
}
.invite-day-row {
display: flex;
gap: 16rpx;
&.is-empty .invite-day-panel {
opacity: 0.85;
}
}
.invite-day-rail {
width: 28rpx;
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
padding-top: 8rpx;
}
.invite-day-dot {
width: 16rpx;
height: 16rpx;
border-radius: var(--radius-pill);
background: var(--slate-200);
border: 3rpx solid #ffffff;
box-shadow: 0 0 0 2rpx #cbd5e1;
&.filled {
background: #204e2b;
box-shadow: 0 0 0 2rpx #b8dcc0;
}
}
.invite-day-line {
flex: 1;
width: 2rpx;
min-height: 24rpx;
margin: 6rpx 0;
background: linear-gradient(180deg, #cbd5e1, #e2e8f0);
}
.invite-day-panel {
flex: 1;
min-width: 0;
padding: 0 0 20rpx;
}
.invite-day-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 12rpx;
}
.invite-day-date {
font-size: 30rpx;
font-weight: 800;
color: var(--slate-900);
}
.invite-day-week {
font-size: 24rpx;
color: var(--slate-600);
}
.invite-metric-grid {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
}
.invite-metric-chip {
flex: 1;
min-width: calc(50% - 6rpx);
padding: 14rpx 16rpx;
background: var(--slate-50);
border-radius: 14rpx;
border: 1rpx solid var(--slate-200);
}
.invite-metric-label {
display: block;
font-size: 22rpx;
font-weight: 600;
color: var(--slate-600);
margin-bottom: 6rpx;
}
.invite-metric-value-row {
display: flex;
align-items: baseline;
gap: 6rpx;
flex-wrap: wrap;
}
.invite-metric-value {
font-size: 34rpx;
font-weight: 800;
color: #204e2b;
line-height: 1.2;
&.is-high {
color: var(--danger);
}
}
.invite-metric-unit {
font-size: 22rpx;
color: var(--slate-600);
}
.invite-metric-flag {
display: inline-flex;
align-items: center;
gap: 4rpx;
margin-top: 8rpx;
padding: 4rpx 10rpx;
font-size: 20rpx;
font-weight: 600;
color: var(--danger);
background: var(--danger-light);
border-radius: 8rpx;
}
.invite-day-empty {
display: flex;
align-items: center;
gap: 8rpx;
padding: 14rpx 16rpx;
font-size: 26rpx;
color: var(--slate-400);
background: var(--slate-50);
border-radius: 14rpx;
}
.invite-nickname-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-bottom: 20rpx;
}
.invite-nickname-chip {
min-height: 72rpx;
padding: 14rpx 28rpx;
border-radius: var(--radius-pill);
background: var(--slate-50);
border: 2rpx solid var(--slate-200);
font-size: 28rpx;
color: var(--slate-600);
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition-smooth);
&:active {
opacity: 0.88;
}
&.active {
background: var(--success);
border-color: var(--success);
color: #ffffff;
font-weight: 700;
}
}
.invite-like-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
min-height: 96rpx;
padding: 24rpx 32rpx;
background: var(--success-grad);
color: #ffffff;
border-radius: var(--radius-pill);
font-size: 30rpx;
font-weight: 700;
box-shadow: var(--shadow-glow-success);
transition: var(--transition-smooth);
&:active {
transform: scale(0.98);
opacity: 0.92;
}
&.done {
background: var(--success-light);
color: var(--success);
border: 2rpx solid #a7f3d0;
box-shadow: none;
}
&.loading { opacity: 0.72; }
&.burst { animation: invite-heart-burst 0.85s ease; }
}
@keyframes invite-heart-burst {
0% { transform: scale(1); }
30% { transform: scale(1.06); box-shadow: 0 12rpx 32rpx rgba(5, 150, 105, 0.4); }
100% { transform: scale(1); }
}
.invite-footer {
display: flex;
align-items: flex-start;
justify-content: center;
gap: 10rpx;
padding: 8rpx 12rpx 0;
}
.invite-disclaimer {
flex: 1;
font-size: 22rpx;
color: var(--slate-600);
line-height: 1.5;
text-align: left;
}
.invite-skeleton-wrap {
padding: 0 24rpx;
margin-top: -28rpx;
}
.invite-skeleton-card {
height: 160rpx;
margin-bottom: 20rpx;
border-radius: var(--radius-card);
background: linear-gradient(90deg, var(--slate-200) 25%, var(--slate-100) 50%, var(--slate-200) 75%);
background-size: 200% 100%;
animation: invite-shimmer 1.2s ease-in-out infinite;
}
.invite-skeleton-short { height: 120rpx; }
.invite-skeleton-tall { height: 320rpx; }
@keyframes invite-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.invite-error-card {
margin: 32rpx 24rpx;
padding: 48rpx 32rpx;
background: #ffffff;
border-radius: var(--radius-card);
text-align: center;
box-shadow: var(--shadow-premium);
}
.invite-error-icon-wrap {
width: 96rpx;
height: 96rpx;
margin: 0 auto 20rpx;
border-radius: var(--radius-pill);
background: var(--danger-light);
display: flex;
align-items: center;
justify-content: center;
}
.invite-error-title {
display: block;
font-size: 32rpx;
font-weight: 800;
color: #1b1c1a;
margin-bottom: 10rpx;
}
.invite-error-text {
font-size: 26rpx;
color: var(--slate-600);
line-height: 1.5;
}
/* === 战报弹层 === */
.report-mask {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(8px);
display: flex;
align-items: flex-end;
}
.report-panel {
width: 100%;
max-height: 88vh;
background: #ffffff;
border-radius: 28rpx 28rpx 0 0;
padding: 28rpx 28rpx calc(28rpx + env(safe-area-inset-bottom));
}
.report-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.report-panel-title {
font-size: 34rpx;
font-weight: 800;
color: var(--slate-900);
}
.report-panel-close {
font-size: 44rpx;
color: var(--slate-400);
padding: 0 8rpx;
}
.report-poster {
padding: 24rpx;
background: linear-gradient(180deg, var(--primary-light) 0%, #ffffff 100%);
border-radius: var(--radius-card);
border: 1rpx solid #ccfbf1;
}
.report-poster-top {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 12rpx;
}
.report-poster-name {
font-size: 32rpx;
font-weight: 700;
color: var(--slate-900);
}
.report-poster-week {
display: block;
font-size: 24rpx;
color: var(--slate-600);
margin-bottom: 16rpx;
}
.report-stat-grid {
display: flex;
gap: 12rpx;
margin-bottom: 16rpx;
}
.report-stat-item {
flex: 1;
text-align: center;
padding: 14rpx 8rpx;
background: #ffffff;
border-radius: 16rpx;
border: 1rpx solid var(--slate-100);
}
.report-stat-num {
display: block;
font-size: 34rpx;
font-weight: 800;
color: var(--success);
}
.report-stat-label {
display: block;
font-size: 22rpx;
color: var(--slate-600);
}
.report-poster-quote {
display: block;
font-size: 26rpx;
color: #334155;
line-height: 1.5;
}
.report-poster-disclaimer {
display: block;
margin-top: 12rpx;
font-size: 22rpx;
color: var(--slate-400);
}
.report-actions {
margin-top: 24rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.report-share-btn {
height: 88rpx;
line-height: 88rpx;
text-align: center;
background: var(--primary-grad);
color: #ffffff;
font-size: 30rpx;
font-weight: 700;
border-radius: var(--radius-pill);
border: none;
box-shadow: var(--shadow-glow-primary);
}
.report-secondary-btn {
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
color: var(--slate-600);
font-size: 28rpx;
}
.daily-page.is-invite-viewer {
background: var(--primary-light, #eef6ef);
padding-bottom: 0;
}
@import '../styles/daily-theme.scss';
.ai-diet-card {
margin: 28rpx 24rpx 0;
padding: 0;
overflow: hidden;
border-radius: 34rpx;
border: none;
background: #ffffff;
box-shadow: 0 18rpx 48rpx rgba(14, 138, 138, 0.14), 0 6rpx 16rpx rgba(32, 78, 43, 0.06);
}
/* ===== Hero:清新极光渐变 ===== */
.ai-diet-hero {
position: relative;
overflow: hidden;
padding: 32rpx 26rpx;
background: linear-gradient(135deg, #1f6f4a 0%, #0d8a82 58%, #14b8a6 100%);
}
.ai-diet-hero-aurora {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.ai-diet-blob {
position: absolute;
border-radius: 50%;
opacity: 0.6;
}
.ai-diet-blob-1 {
width: 280rpx;
height: 280rpx;
top: -120rpx;
right: -60rpx;
background: radial-gradient(circle, rgba(94, 234, 212, 0.9) 0%, rgba(94, 234, 212, 0) 70%);
animation: ai-diet-float 7s ease-in-out infinite;
}
.ai-diet-blob-2 {
width: 220rpx;
height: 220rpx;
bottom: -110rpx;
left: -50rpx;
background: radial-gradient(circle, rgba(165, 243, 252, 0.8) 0%, rgba(165, 243, 252, 0) 70%);
animation: ai-diet-float 9s ease-in-out infinite reverse;
}
.ai-diet-blob-3 {
width: 170rpx;
height: 170rpx;
top: 6rpx;
left: 42%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.55) 0%, rgba(255, 255, 255, 0) 70%);
animation: ai-diet-float 11s ease-in-out infinite;
}
@keyframes ai-diet-float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-16rpx, 16rpx); }
}
.ai-diet-hero-content {
position: relative;
z-index: 1;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
}
.ai-diet-hero-main {
display: flex;
align-items: center;
gap: 18rpx;
flex: 1;
min-width: 0;
}
.ai-diet-hero-icon {
position: relative;
flex-shrink: 0;
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.2);
border: 1rpx solid rgba(255, 255, 255, 0.4);
box-shadow: 0 8rpx 22rpx rgba(0, 0, 0, 0.14), inset 0 1rpx 0 rgba(255, 255, 255, 0.4);
}
.ai-diet-hero-icon-ring {
position: absolute;
top: -8rpx;
left: -8rpx;
right: -8rpx;
bottom: -8rpx;
border-radius: 30rpx;
border: 3rpx solid rgba(255, 255, 255, 0.5);
opacity: 0;
}
.ai-diet-hero-icon.thinking .ai-diet-hero-icon-ring {
animation: ai-diet-ring 1.6s ease-out infinite;
}
@keyframes ai-diet-ring {
0% { opacity: 0.7; transform: scale(0.82); }
100% { opacity: 0; transform: scale(1.28); }
}
.ai-diet-hero-copy {
flex: 1;
min-width: 0;
}
.ai-diet-hero-titles {
display: flex;
align-items: center;
gap: 12rpx;
flex-wrap: wrap;
}
.ai-diet-title {
font-size: 38rpx;
font-weight: 800;
color: #ffffff;
letter-spacing: 1rpx;
line-height: 1.25;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.12);
}
.ai-diet-badge {
display: flex;
align-items: center;
gap: 7rpx;
padding: 5rpx 16rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.22);
border: 1rpx solid rgba(255, 255, 255, 0.4);
}
.ai-diet-badge text {
font-size: 20rpx;
font-weight: 700;
color: #ffffff;
letter-spacing: 0.5rpx;
}
.ai-diet-badge-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #a7f3d0;
box-shadow: 0 0 10rpx #6ee7b7;
animation: ai-diet-pulse 1.4s ease-in-out infinite;
}
@keyframes ai-diet-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.7); }
}
.ai-diet-sub {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.5;
color: rgba(255, 255, 255, 0.88);
}
.ai-diet-refresh {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8rpx;
min-height: 60rpx;
padding: 0 22rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.22);
border: 1rpx solid rgba(255, 255, 255, 0.42);
color: #ffffff;
font-size: 24rpx;
font-weight: 700;
box-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.1);
transition: opacity 0.2s ease, background 0.2s ease;
}
.ai-diet-refresh.disabled {
opacity: 0.6;
}
@media (prefers-reduced-motion: reduce) {
.ai-diet-blob,
.ai-diet-hero-icon.thinking .ai-diet-hero-icon-ring,
.ai-diet-badge-dot {
animation: none;
}
}
/* ===== 建议依据:近7天 / 近30天 血糖回顾 ===== */
.ai-diet-analysis {
padding: 22rpx 24rpx 24rpx;
background: linear-gradient(180deg, #ffffff 0%, #f6fbf7 100%);
border-top: 1rpx solid var(--border-soft);
}
.ai-diet-analysis-head {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 18rpx;
}
.ai-diet-analysis-title {
font-size: 26rpx;
font-weight: 700;
color: var(--primary-container);
letter-spacing: 0.5rpx;
}
.ai-diet-analysis-grid {
display: flex;
gap: 16rpx;
}
.ai-diet-analysis-col {
flex: 1;
min-width: 0;
padding: 20rpx 18rpx;
border-radius: 20rpx;
background: var(--surface);
border: 1rpx solid var(--border-soft);
box-shadow: 0 2rpx 10rpx rgba(32, 78, 43, 0.04);
}
.ai-diet-analysis-range {
display: block;
font-size: 24rpx;
font-weight: 700;
color: var(--text-secondary);
margin-bottom: 14rpx;
}
.ai-diet-analysis-rate {
display: flex;
align-items: flex-end;
gap: 10rpx;
padding-bottom: 14rpx;
margin-bottom: 14rpx;
border-bottom: 1rpx dashed var(--border-soft);
}
.ai-diet-analysis-rate-val {
display: flex;
align-items: baseline;
}
.ai-diet-analysis-rate-num {
font-size: 56rpx;
font-weight: 800;
line-height: 1;
color: var(--primary);
}
.ai-diet-analysis-rate-pct {
font-size: 26rpx;
font-weight: 700;
color: var(--primary);
margin-left: 2rpx;
}
.ai-diet-analysis-rate-label {
font-size: 24rpx;
color: var(--text-muted);
padding-bottom: 6rpx;
}
.ai-diet-analysis-rate.rate-good .ai-diet-analysis-rate-num,
.ai-diet-analysis-rate.rate-good .ai-diet-analysis-rate-pct {
color: var(--success);
}
.ai-diet-analysis-rate.rate-mid .ai-diet-analysis-rate-num,
.ai-diet-analysis-rate.rate-mid .ai-diet-analysis-rate-pct {
color: var(--warning);
}
.ai-diet-analysis-rate.rate-low .ai-diet-analysis-rate-num,
.ai-diet-analysis-rate.rate-low .ai-diet-analysis-rate-pct {
color: var(--danger);
}
.ai-diet-analysis-metrics {
display: flex;
justify-content: space-between;
gap: 8rpx;
}
.ai-diet-analysis-metric {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.ai-diet-analysis-m-label {
font-size: 22rpx;
color: var(--text-muted);
}
.ai-diet-analysis-m-value {
font-size: 30rpx;
font-weight: 700;
color: var(--text-primary);
line-height: 1.1;
}
.ai-diet-analysis-m-value.is-warn {
color: var(--danger);
}
.ai-diet-analysis-m-unit {
font-size: 20rpx;
font-weight: 600;
color: var(--text-muted);
margin-left: 2rpx;
}
.ai-diet-analysis-days {
display: block;
margin-top: 14rpx;
font-size: 22rpx;
color: var(--text-muted);
}
.ai-diet-analysis-empty {
display: flex;
align-items: center;
gap: 8rpx;
padding: 18rpx 0;
font-size: 24rpx;
color: var(--text-muted);
}
/* ===== 今日三餐分组标题 ===== */
.ai-diet-section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14rpx;
}
.ai-diet-section-title {
position: relative;
padding-left: 20rpx;
font-size: 28rpx;
font-weight: 800;
color: var(--text-primary);
letter-spacing: 0.5rpx;
}
.ai-diet-section-title::before {
content: '';
position: absolute;
left: 0;
top: 4rpx;
bottom: 4rpx;
width: 8rpx;
border-radius: 8rpx;
background: linear-gradient(180deg, #10b981 0%, #14b8a6 100%);
}
.ai-diet-section-tag {
font-size: 22rpx;
font-weight: 600;
color: var(--primary-container);
padding: 4rpx 16rpx;
border-radius: 999rpx;
background: var(--primary-light);
}
.ai-diet-skeleton {
padding: 24rpx;
}
.ai-diet-skeleton-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12rpx;
margin-bottom: 20rpx;
}
.ai-diet-skeleton-tag {
width: 88rpx;
height: 40rpx;
border-radius: 12rpx;
background: linear-gradient(90deg, var(--slate-100) 25%, var(--slate-200) 50%, var(--slate-100) 75%);
background-size: 200% 100%;
animation: ai-diet-shimmer 1.2s ease-in-out infinite;
}
.ai-diet-skeleton-line {
flex: 1;
min-width: 60%;
height: 28rpx;
border-radius: 8rpx;
background: linear-gradient(90deg, var(--slate-100) 25%, var(--slate-200) 50%, var(--slate-100) 75%);
background-size: 200% 100%;
animation: ai-diet-shimmer 1.2s ease-in-out infinite;
}
.ai-diet-skeleton-line.short {
flex-basis: 100%;
min-width: 40%;
max-width: 72%;
animation-delay: 0.15s;
}
.ai-diet-skeleton-hint {
display: block;
margin-top: 8rpx;
text-align: center;
font-size: 26rpx;
color: var(--primary-container);
}
@keyframes ai-diet-shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
@media (prefers-reduced-motion: reduce) {
.ai-diet-skeleton-tag,
.ai-diet-skeleton-line {
animation: none;
background: var(--slate-100);
}
}
.ai-diet-body {
padding: 20rpx 24rpx 8rpx;
}
.ai-diet-body.is-streaming .ai-diet-meal-text {
transition: color 0.15s ease;
}
.ai-diet-meal-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.ai-diet-meal-card {
position: relative;
padding: 22rpx 22rpx 22rpx 26rpx;
border-radius: 22rpx;
background: var(--surface);
border: 1rpx solid var(--border-soft);
overflow: hidden;
}
.ai-diet-meal-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 8rpx;
border-radius: 8rpx;
}
.ai-diet-meal-card.meal-breakfast {
background: linear-gradient(120deg, rgba(251, 191, 36, 0.14) 0%, rgba(255, 255, 255, 0) 45%);
}
.ai-diet-meal-card.meal-breakfast::before {
background: linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%);
}
.ai-diet-meal-card.meal-lunch {
background: linear-gradient(120deg, rgba(16, 185, 129, 0.16) 0%, rgba(255, 255, 255, 0) 45%);
}
.ai-diet-meal-card.meal-lunch::before {
background: linear-gradient(180deg, #10b981 0%, #0d8a82 100%);
}
.ai-diet-meal-card.meal-dinner {
background: linear-gradient(120deg, rgba(99, 102, 241, 0.14) 0%, rgba(255, 255, 255, 0) 45%);
}
.ai-diet-meal-card.meal-dinner::before {
background: linear-gradient(180deg, #818cf8 0%, #6366f1 100%);
}
.ai-diet-meal-head {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 12rpx;
}
.ai-diet-meal-icon {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
}
.meal-breakfast .ai-diet-meal-icon {
background: linear-gradient(150deg, #fde68a 0%, #fbbf24 100%);
}
.meal-lunch .ai-diet-meal-icon {
background: linear-gradient(150deg, #6ee7b7 0%, #10b981 100%);
}
.meal-dinner .ai-diet-meal-icon {
background: linear-gradient(150deg, #c7d2fe 0%, #818cf8 100%);
}
.ai-diet-meal-label {
font-size: 26rpx;
font-weight: 800;
color: var(--text-primary);
letter-spacing: 2rpx;
}
.ai-diet-meal-text {
display: block;
font-size: 30rpx;
line-height: 1.6;
color: var(--text-primary);
}
.ai-diet-tip-box {
margin-top: 16rpx;
padding: 18rpx 20rpx;
border-radius: 16rpx;
background: var(--primary-light);
border: 1rpx solid rgba(32, 78, 43, 0.12);
}
.ai-diet-tip-head {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 8rpx;
}
.ai-diet-tip-label {
font-size: 24rpx;
font-weight: 700;
color: var(--primary-container);
}
.ai-diet-tip-text {
display: block;
font-size: 28rpx;
line-height: 1.55;
color: var(--text-secondary);
}
.ai-diet-avoid {
margin-top: 16rpx;
padding: 18rpx 20rpx;
border-radius: 16rpx;
background: var(--danger-light);
border: 1rpx solid rgba(220, 38, 38, 0.15);
}
.ai-diet-avoid-head {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 12rpx;
}
.ai-diet-avoid-label {
font-size: 26rpx;
color: #b91c1c;
font-weight: 700;
}
.ai-diet-avoid-tags {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
}
.ai-diet-avoid-tag {
padding: 8rpx 16rpx;
border-radius: 999rpx;
font-size: 24rpx;
color: #991b1b;
background: #ffffff;
border: 1rpx solid rgba(220, 38, 38, 0.2);
}
.ai-diet-actions {
display: flex;
gap: 16rpx;
margin-top: 24rpx;
padding-bottom: 8rpx;
}
.ai-diet-btn {
flex: 1;
min-height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 20rpx;
font-size: 28rpx;
font-weight: 700;
transition: opacity 0.2s ease;
}
.ai-diet-btn.primary {
background: linear-gradient(135deg, #1f6f4a 0%, #0d8a82 55%, #14b8a6 100%);
color: #ffffff;
box-shadow: 0 10rpx 22rpx rgba(14, 138, 138, 0.28);
border: none;
}
.ai-diet-btn.ghost {
background: #ffffff;
border: 2rpx solid var(--border-soft);
color: var(--primary-container);
}
/* ===== 运动降糖建议 ===== */
.ai-diet-exercise {
margin: 8rpx 24rpx 24rpx;
padding: 24rpx;
border-radius: 20rpx;
background: linear-gradient(180deg, #f3f9f5 0%, #ffffff 60%);
border: 1rpx solid var(--border-soft);
border-left: 6rpx solid var(--primary);
}
.ai-diet-exercise.lv-high {
border-left-color: var(--danger);
background: linear-gradient(180deg, #fef4f3 0%, #ffffff 60%);
}
.ai-diet-exercise.lv-medium {
border-left-color: var(--warning);
background: linear-gradient(180deg, #fff8f0 0%, #ffffff 60%);
}
.ai-diet-exercise-head {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 14rpx;
}
.ai-diet-exercise-icon {
flex-shrink: 0;
width: 52rpx;
height: 52rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14rpx;
background: var(--primary-grad);
box-shadow: var(--shadow-glow-primary);
}
.ai-diet-exercise.lv-high .ai-diet-exercise-icon {
background: linear-gradient(160deg, #dc2626 0%, #ef4444 100%);
box-shadow: 0 8rpx 20rpx rgba(220, 38, 38, 0.18);
}
.ai-diet-exercise.lv-medium .ai-diet-exercise-icon {
background: linear-gradient(160deg, #d97706 0%, #f59e0b 100%);
box-shadow: 0 8rpx 20rpx rgba(217, 119, 6, 0.18);
}
.ai-diet-exercise-head-text {
display: flex;
align-items: center;
gap: 12rpx;
flex: 1;
min-width: 0;
}
.ai-diet-exercise-title {
font-size: 30rpx;
font-weight: 800;
color: var(--text-primary);
letter-spacing: 0.5rpx;
}
.ai-diet-exercise-tag {
font-size: 22rpx;
font-weight: 700;
padding: 4rpx 16rpx;
border-radius: 999rpx;
color: var(--primary);
background: var(--primary-light);
}
.ai-diet-exercise-tag.lv-high {
color: var(--danger);
background: var(--danger-light);
}
.ai-diet-exercise-tag.lv-medium {
color: var(--warning);
background: var(--warning-light);
}
.ai-diet-exercise-headline {
display: block;
font-size: 28rpx;
line-height: 1.55;
color: var(--text-secondary);
margin-bottom: 16rpx;
}
.ai-diet-exercise-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.ai-diet-exercise-item {
display: flex;
align-items: flex-start;
gap: 14rpx;
}
.ai-diet-exercise-bullet {
flex-shrink: 0;
width: 40rpx;
height: 40rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--primary-light);
color: var(--primary);
font-size: 24rpx;
font-weight: 800;
}
.ai-diet-exercise.lv-high .ai-diet-exercise-bullet {
background: var(--danger-light);
color: var(--danger);
}
.ai-diet-exercise.lv-medium .ai-diet-exercise-bullet {
background: var(--warning-light);
color: var(--warning);
}
.ai-diet-exercise-item-text {
flex: 1;
min-width: 0;
font-size: 30rpx;
line-height: 1.55;
color: var(--text-primary);
}
.ai-diet-exercise-meta {
display: flex;
align-items: center;
gap: 8rpx;
margin-top: 18rpx;
padding: 12rpx 18rpx;
border-radius: 14rpx;
background: var(--surface-muted);
font-size: 24rpx;
font-weight: 600;
color: var(--text-secondary);
}
.ai-diet-exercise-note {
display: block;
margin-top: 14rpx;
font-size: 24rpx;
line-height: 1.55;
color: var(--text-muted);
}
.ai-diet-ask-panel {
margin: 8rpx 24rpx 24rpx;
padding: 24rpx;
border-radius: 20rpx;
background: var(--slate-50);
border: 1rpx solid var(--border-soft);
}
.ai-diet-ask-head {
margin-bottom: 16rpx;
}
.ai-diet-ask-title {
display: block;
font-size: 30rpx;
font-weight: 800;
color: var(--primary);
line-height: 1.35;
}
.ai-diet-ask-hint {
display: block;
margin-top: 6rpx;
font-size: 24rpx;
color: var(--text-muted);
line-height: 1.45;
}
.ai-diet-ask-row {
display: flex;
gap: 12rpx;
align-items: stretch;
}
.ai-diet-ask-input {
flex: 1;
min-height: 88rpx;
padding: 0 24rpx;
border-radius: 16rpx;
background: #ffffff;
border: 2rpx solid var(--border-soft);
font-size: 30rpx;
color: var(--text-primary);
box-sizing: border-box;
}
.ai-diet-ask-btn {
flex-shrink: 0;
min-width: 148rpx;
min-height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 6rpx;
padding: 0 20rpx;
border-radius: 16rpx;
background: linear-gradient(135deg, #1f6f4a 0%, #0d8a82 55%, #14b8a6 100%);
color: #ffffff;
font-size: 28rpx;
font-weight: 700;
box-shadow: 0 8rpx 18rpx rgba(14, 138, 138, 0.26);
}
.ai-diet-ask-btn.disabled {
opacity: 0.55;
}
.ai-diet-ask-result {
margin-top: 16rpx;
padding: 20rpx;
border-radius: 16rpx;
background: #ffffff;
border: 1rpx solid var(--border-soft);
}
.ai-diet-ask-result.lv-high {
border-color: rgba(220, 38, 38, 0.25);
background: var(--danger-light);
}
.ai-diet-ask-result.lv-medium {
border-color: rgba(199, 125, 10, 0.3);
background: var(--warning-light);
}
.ai-diet-ask-badge {
display: inline-block;
padding: 6rpx 18rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 700;
margin-bottom: 12rpx;
background: var(--primary-light);
border: 2rpx solid var(--primary);
color: var(--primary);
}
.ai-diet-ask-badge.lv-high {
border-color: #dc2626;
background: #ffffff;
color: #dc2626;
}
.ai-diet-ask-badge.lv-medium {
border-color: #c77d0a;
background: #ffffff;
color: #c77d0a;
}
.ai-diet-ask-badge.lv-low {
border-color: var(--primary);
background: #ffffff;
color: var(--primary);
}
.ai-diet-ask-advice {
display: block;
font-size: 30rpx;
line-height: 1.6;
color: var(--text-primary);
}
.ai-stream-cursor {
display: inline;
color: var(--primary-container);
animation: ai-cursor-blink 0.9s step-end infinite;
}
@keyframes ai-cursor-blink {
50% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.ai-stream-cursor {
animation: none;
opacity: 0.6;
}
}
.ai-diet-ask-portion {
display: block;
margin-top: 12rpx;
padding-top: 12rpx;
border-top: 1rpx dashed var(--border-soft);
font-size: 26rpx;
color: var(--primary-container);
font-weight: 600;
}
.ai-diet-footer {
padding: 0 24rpx 24rpx;
}
.ai-diet-rules {
display: block;
font-size: 24rpx;
color: var(--primary);
line-height: 1.5;
font-weight: 600;
}
.ai-diet-disclaimer {
display: block;
margin-top: 10rpx;
font-size: 22rpx;
color: var(--text-muted);
line-height: 1.45;
}
</style>