Files
zyt/TUICallKit-Vue3/tongji/pages/more.vue
T
2026-07-02 14:35:20 +08:00

8516 lines
263 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="more-page stitch-vitalmint" @touchend="onPageVoiceTouchEnd" @touchcancel="onPageVoiceTouchEnd">
<CelebrateBurst :show="celebrateShow" :title="celebrateTitle" :subtitle="celebrateSub" />
<!-- 浇水降糖粒子雨全屏 fixed对应 code.html sugar-particle -->
<view v-if="sugarParticles.length" class="mc-sugar-particles" aria-hidden="true">
<text
v-for="p in sugarParticles"
:key="p.key"
class="mc-sugar-particle"
:class="{ 'is-south': p.kind === 'south' }"
:style="{ left: p.left + 'vw', fontSize: p.size + 'rpx', animationDelay: p.delay + 's' }"
>{{ p.kind === 'south' ? '↓' : '▼' }}</text>
</view>
<view v-if="authChecking" class="page-gate-loading">
<text class="page-gate-loading-text">正在登录</text>
</view>
<block v-if="!authChecking">
<!-- Top App Barcode.html -->
<view class="mc-header" :style="headerStyle">
<view class="mc-header-inner" :style="headerInnerStyle">
<view class="mc-header-left">
<view class="mc-header-back" @click="goBack">
<TongjiIcon name="chevron-left" size="md" color="#ffffff" />
</view>
<view class="mc-header-text">
<text class="mc-header-name">{{ patientName || '健康档案' }}</text>
</view>
</view>
<view class="mc-header-actions">
<view class="mc-header-pill" @click="goGlucosePage">
<TongjiIcon name="glucose" size="sm" color="#ffffff" />
<text>血糖</text>
</view>
<view class="mc-header-icon-btn" @click="onRefresh">
<TongjiIcon name="refresh" size="md" color="#ffffff" />
</view>
</view>
</view>
</view>
<view class="mc-main" :style="mainPadStyle">
<view class="mc-card mc-paradise-card">
<view class="mc-paradise-head">
<view>
<text class="mc-card-title">稳糖乐园</text>
<text class="mc-card-sub">用健康打卡浇灌你的稳糖树</text>
</view>
<view class="mc-points-badge">
<text class="mc-points-num">{{ gamifyPoints }}</text>
<text class="mc-points-label">稳糖积分</text>
</view>
</view>
<view class="mc-tasks-section">
<text class="mc-tasks-title">今日控糖任务</text>
<view class="mc-tasks-grid">
<view
v-for="task in displayTasksForUi"
:key="task.id"
class="mc-task-block"
:class="{ 'is-done': task.completed }"
@click="onGamifyTaskTap(task)"
>
<view class="mc-task-block-top">
<view class="mc-task-check" :class="{ pending: !task.completed }">
<TongjiIcon
v-if="task.completed"
name="check"
size="sm"
color="#006c49"
/>
<view v-else class="mc-task-dot" />
</view>
<text class="mc-task-block-points">+{{ task.points }}积分</text>
</view>
<view class="mc-task-block-body">
<TongjiIcon :name="gamifyTaskIcon(task.id)" size="sm" color="#006c49" />
<text class="mc-task-block-name">{{ task.name }}</text>
</view>
<text v-if="task.completed && task.claimed" class="mc-task-block-status">已完成</text>
<view v-else-if="task.completed" class="mc-task-block-action is-water">
<text>去浇水</text>
</view>
<view v-else class="mc-task-block-action">
<text>去记录</text>
</view>
</view>
</view>
</view>
<view class="mc-tree-zone" style="margin-top: 10rpx;">
<view class="mc-tree-level-head">
<view>
<view class="mc-tree-level-row">
<text class="mc-tree-lv-badge">Lv.{{ treeLevel }}</text>
<text class="mc-tree-stage-name">{{ treeLevelName }}</text>
<view class="mc-tree-species-btn" @click.stop="openTreeSpeciesPicker">
<text>{{ currentTreeSpecies.name }}</text>
<TongjiIcon name="chevron-right" size="sm" color="#006c49" />
</view>
</view>
<text v-if="treeState.isMax" class="mc-tree-xp-hint">{{ treeXpUpgradeHint }}</text>
<text v-else class="mc-tree-xp-hint">
还差 <text class="mc-tree-xp-bold">{{ treeState.pointsToNext }} XP</text> 升级至 Lv.{{ treeLevel + 1 }}
</text>
</view>
<text class="mc-tree-xp-text">{{ treeProgressLabel }}</text>
</view>
<view class="mc-tree-progress-track">
<view class="mc-tree-progress-bar" :style="{ width: treeProgress + '%' }" />
</view>
<view class="mc-tree-display" @click="onTreeWhisper">
<text class="mc-tree-display-bg" aria-hidden="true">🌿</text>
<!-- 浇水水滴雨 -->
<view class="mc-rain" aria-hidden="true">
<view
v-for="d in waterDroplets"
:key="d.key"
class="mc-droplet"
:style="{ left: d.left + '%', animationDelay: d.delay + 's' }"
/>
</view>
<view class="mc-tree-emoji-wrap" :class="{ 'is-watering': treePulseActive }">
<text class="mc-tree-emoji">{{ treeEmoji }}</text>
</view>
<!-- 闪光星对应 code.html shimmer-particle / sparkles -->
<text
v-for="s in sparkles"
:key="s.key"
class="mc-sparkle"
:style="{ left: s.left + '%', top: s.top + '%', animationDuration: s.dur + 's' }"
></text>
<!-- 降糖飘字对应 code.html sugar-decrease-text在植物上方 -->
<view v-if="sugarDecrease" :key="sugarDecrease.key" class="mc-sugar-decrease-float">
<text>-0.5 mmol/L</text>
</view>
<view v-if="floatReward" :key="floatReward.key" class="mc-float-reward">
<text>+{{ floatReward.text }}</text>
</view>
</view>
<view class="mc-water-action">
<button
class="mc-water-btn"
:class="{ disabled: treeWaterLocked }"
@click="onWaterTreeTap"
>
<TongjiIcon name="droplet" size="md" color="#FFFFFF" />
<text>给小树浇水</text>
</button>
<text class="mc-water-hint">{{ waterHintText }}</text>
</view>
</view>
</view>
<template v-if="diagnosisId">
<!-- 7 / 30 天切换 -->
<view class="mc-range-toggle">
<view
v-for="opt in rangeOptions"
:key="opt.value"
class="mc-range-opt"
:class="{ active: rangeMode === opt.value }"
@click="onChangeRange(opt.value)"
>
<text>{{ opt.label }}</text>
</view>
</view>
<!-- 健康日历 -->
<view v-if="heatmapWeeks.length > 0" class="mc-card">
<view class="mc-card-head">
<view>
<text class="mc-card-title">健康日历</text>
<text class="mc-card-sub">{{ heatmapMonthLabel }} · 已记录 {{ heatmapTotalRecords }} / {{ heatmapInRangeDays }} </text>
</view>
<TongjiIcon name="chevron-right" size="md" color="#006c49" />
</view>
<view class="mc-cal-grid mc-cal-grid--full">
<text class="mc-cal-weekday"></text>
<text class="mc-cal-weekday"></text>
<text class="mc-cal-weekday"></text>
<text class="mc-cal-weekday"></text>
<text class="mc-cal-weekday"></text>
<text class="mc-cal-weekday"></text>
<text class="mc-cal-weekday is-weekend"></text>
<view
v-for="cell in heatmapCells"
:key="cell.date"
class="mc-cal-cell"
:class="{
off: !cell.inRange,
recorded: cell.inRange && cell.level > 0 && !cell.isHigh,
'recorded-high': cell.inRange && cell.isHigh,
'today-empty': cell.isToday && cell.inRange && cell.level === 0,
'in-range': cell.inRange && cell.level === 0 && !cell.isToday,
'future-off': !cell.inRange && cell.level < 0
}"
@click="cell.inRange && cell.level > 0 ? jumpToDayRecord(cell.date) : null"
>
<text>{{ cell.day }}</text>
<view
v-if="cell.inRange && cell.level > 0"
class="mc-cal-dot"
:class="{ 'is-high': cell.isHigh }"
/>
</view>
</view>
<view class="mc-cal-foot">
<text class="mc-cal-foot-text">{{ todayChecked ? '今日已完成记录' : '今日还没有记录' }}</text>
<view class="mc-cal-foot-btn" @click="openInputForm()">
<text>去记血糖</text>
</view>
</view>
</view>
<!-- 血糖明细 -->
<view class="mc-blood-section">
<view class="mc-section-head">
<text class="mc-card-title">血糖明细</text>
<text class="mc-section-count"> {{ bloodRecordDays.length }} </text>
</view>
<view v-if="loading" class="mc-card mc-state-card">
<text>加载中</text>
</view>
<view v-else-if="bloodRecordDays.length === 0" class="mc-card mc-state-card">
<text>该时段暂无血糖记录</text>
</view>
<view v-else class="mc-blood-list">
<view
v-for="day in bloodRecordDays"
:key="day.date"
:id="`day-${day.date.replace(/-/g, '')}`"
class="mc-card mc-day-card"
>
<view class="mc-day-head">
<text class="mc-day-date">{{ day.dateLabel }}</text>
<text class="mc-day-weekday">{{ day.weekday }}</text>
</view>
<view v-if="day.blood" class="mc-metric-stack">
<view
class="mc-metric-row"
:class="{
'is-abnormal-high': day.blood.isHighFasting,
'is-abnormal-low': day.blood.isLowFasting
}"
>
<text class="mc-metric-label">空腹</text>
<view class="mc-metric-value-wrap">
<text v-if="day.blood.isHighFasting" class="mc-metric-badge is-high">偏高</text>
<text v-else-if="day.blood.isLowFasting" class="mc-metric-badge is-low">偏低</text>
<text
class="mc-metric-value"
:class="{ 'is-high': day.blood.isHighFasting, 'is-low': day.blood.isLowFasting }"
>{{ formatBloodMetric(day.blood.fasting_blood_sugar) }}</text>
<text class="mc-metric-unit">mmol/L</text>
</view>
</view>
<view
class="mc-metric-row"
:class="{
'is-abnormal-high': day.blood.isHighPostprandial,
'is-abnormal-low': day.blood.isLowPostprandial
}"
>
<text class="mc-metric-label">餐后</text>
<view v-if="formatBloodMetric(day.blood.postprandial_blood_sugar) !== '——'" class="mc-metric-value-wrap">
<text v-if="day.blood.isHighPostprandial" class="mc-metric-badge is-high">偏高</text>
<text v-else-if="day.blood.isLowPostprandial" class="mc-metric-badge is-low">偏低</text>
<text
class="mc-metric-value"
:class="{ 'is-high': day.blood.isHighPostprandial, 'is-low': day.blood.isLowPostprandial }"
>{{ formatBloodMetric(day.blood.postprandial_blood_sugar) }}</text>
<text class="mc-metric-unit">mmol/L</text>
</view>
<view v-else class="mc-metric-empty">
<text class="mc-metric-empty-val">—— </text>
<text class="mc-metric-empty-unit">mmol/L</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 稳糖乐园 -->
<!-- 邀请家人点赞 -->
<view
class="mc-card mc-social-card"
@click="onFamilyLikeStripTap"
>
<view class="mc-social-left">
<view class="mc-social-icon">
<TongjiIcon name="heart" size="md" color="#a43a3a" />
</view>
<view>
<text class="mc-social-title">邀请家人点赞</text>
<text class="mc-social-sub">分享战报给家人邀请他们为您点赞鼓励</text>
</view>
</view>
<TongjiIcon name="chevron-right" size="md" color="#6c7a71" />
</view>
</template>
<!-- 每日鼓励code.html Status Tip -->
<view v-if="diagnosisId" class="mc-encourage" @click="onEncourageTap">
<view class="mc-encourage-icon-wrap">
<text class="mc-encourage-icon">💬</text>
</view>
<view class="mc-encourage-body">
<text class="mc-encourage-text">
<text>{{ encourageParts.prefix }}</text><text v-if="encourageParts.highlight" class="mc-encourage-highlight">{{ encourageParts.highlight }}</text><text>{{ encourageParts.suffix }}</text>
</text>
</view>
</view>
<!-- 就诊卡Care List Horizontal -->
<scroll-view v-if="cards.length > 0" scroll-x class="mc-card-scroll" :show-scrollbar="false">
<view class="mc-card-row">
<view class="mc-card-chip mc-card-chip-count-only">
<text class="mc-card-chip-count">就诊卡</text>
<text class="mc-card-chip-num">{{ cards.length }} </text>
</view>
<view
v-for="card in cards"
:key="card.id"
class="mc-card-chip"
:class="{ active: Number(card.id) === Number(diagnosisId) }"
@click="onSelectCard(card)"
>
<text>{{ card.patient_name || '未填' }}</text>
<text v-if="card.age" class="mc-card-chip-meta">{{ card.age }}</text>
</view>
</view>
</scroll-view>
</view>
<view class="mc-footer-tip">
<text>数据每日同步 · 阈值仅作参考请遵医嘱</text>
</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
class="input-modal-body input-modal-body-ready"
:scroll-y="!manualVoicePressing"
@touchend="onPageVoiceTouchEnd"
@touchcancel="onPageVoiceTouchEnd"
>
<view class="input-modal-tip">
<text>请如实填写留空表示该项不上传当天可多次修改</text>
</view>
<!-- 语音录入区长按为主一问一答为辅 -->
<view v-if="sttSupported" class="voice-entry-section">
<view class="voice-entry-head">
<text class="voice-entry-title">语音录入</text>
<text class="voice-entry-desc">选一种方式自动填入下方字段</text>
</view>
<view
class="voice-input-bar voice-input-bar--primary"
:class="{
active: isVoiceBarLive('glucose'),
muted: voiceDialog.active
}"
@touchstart.stop="startVoiceInput('glucose')"
@touchend="endVoiceInput"
@touchcancel="endVoiceInput"
@touchmove.stop.prevent="blockVoiceTouchMove"
>
<view class="voice-input-accent" />
<view class="voice-input-badge">推荐</view>
<view class="voice-input-icon">
<VoiceSpeakMascot
:active="isVoiceBarLive('glucose')"
:on-dark="isVoiceBarLive('glucose')"
/>
</view>
<view class="voice-input-text">
<text class="voice-input-title">{{ isVoiceBarLive('glucose') ? '正在聆听…松手结束' : '长按说话,自动填写' }}</text>
<text class="voice-input-hint">空腹6.5餐后8.2</text>
</view>
<!-- <view v-if="!isManualVoiceActive('glucose')" class="voice-input-hold-pill">
<text>按住说话</text>
</view> -->
</view>
<!-- <view class="voice-entry-or">
<view class="voice-entry-or-line" />
<text class="voice-entry-or-text"></text>
<view class="voice-entry-or-line" />
</view> -->
<!-- <view class="voice-dialog voice-dialog--secondary" :class="{ active: voiceDialog.active }">
<view class="voice-dialog-head">
<view class="voice-dialog-title-wrap">
<view class="voice-dialog-orb" :class="{ listening: voiceDialog.phase === 'listening', speaking: voiceDialog.phase === 'asking' }">
<TongjiIcon name="volume" size="sm" :color="voiceDialog.active ? '#ffffff' : '#006c49'" />
</view>
<view class="voice-dialog-title-text">
<view class="voice-dialog-title-row">
<text class="voice-dialog-title">语音问答录入</text>
<text v-if="!voiceDialog.active" class="voice-dialog-tag">可选</text>
</view>
<text class="voice-dialog-tip">{{ voiceDialog.active ? (voiceDialog.phase === 'listening' ? '按住下方作答区说话,松手结束' : '跟着提问回答数字即可自动填写') : '逐项提问,适合不方便一次性说完' }}</text>
</view>
</view>
<view v-if="voiceDialog.active" class="voice-dialog-stop" @click.stop="stopVoiceDialog()">
<text>结束</text>
</view>
<view v-else class="voice-dialog-start" @click="startGlucoseVoiceDialog">
<text>开始问答</text>
</view>
</view>
<view v-if="voiceDialog.active" class="voice-dialog-body">
<view class="voice-dialog-bubble bot">
<text>{{ voiceDialog.botText }}</text>
</view>
<view v-if="voiceDialog.heardText" class="voice-dialog-bubble user">
<text>{{ voiceDialog.heardText }}</text>
</view>
<view
v-if="voiceDialog.phase === 'listening'"
class="voice-dialog-hold-pad"
:class="{ active: sttListening || sttHolding }"
@touchstart.stop="onDialogAnswerTouchStart"
@touchend="onDialogAnswerTouchEnd"
@touchcancel="onDialogAnswerTouchEnd"
@touchmove.stop.prevent="blockVoiceTouchMove"
>
<view class="voice-dialog-hold-icon">
<TongjiIcon name="mic" size="md" :color="(sttListening || sttHolding) ? '#ffffff' : '#006c49'" />
</view>
<text class="voice-dialog-hold-title">{{ (sttListening || sttHolding) ? '正在聆听…松手结束' : '按住这里回答' }}</text>
<text v-if="voiceDialog.heardText" class="voice-dialog-hold-heard">{{ voiceDialog.heardText }}</text>
</view>
<view v-else class="voice-dialog-status">
<text class="voice-dialog-status-text">{{ voiceDialogStatusText }}</text>
</view>
<view class="voice-dialog-actions">
<view class="voice-dialog-btn" @click.stop="replayCurrentQuestion">
<text>{{ voiceDialog.phase === 'listening' ? '重说' : '重听' }}</text>
</view>
<view
v-if="voiceDialog.stage !== 'confirm'"
class="voice-dialog-btn"
@click.stop="skipCurrentStepManually"
>
<text>跳过本项</text>
</view>
</view>
</view>
</view> -->
</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>
<!-- 录入血压弹层 -->
<view v-if="bpForm.visible" class="input-modal-mask" @click="closeBpForm">
<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">{{ bpForm.id ? '修改今日血压' : '录入今日血压' }}</text>
<text class="input-modal-sub">{{ todayText }} · {{ patientName || '本人' }}</text>
</view>
<view class="input-modal-close" @click="closeBpForm"><text class="input-modal-close-icon">×</text></view>
</view>
<view v-if="bpForm.loading" class="input-modal-loading"><text>加载中</text></view>
<scroll-view
v-else
class="input-modal-body"
:scroll-y="!manualVoicePressing"
@touchend="onPageVoiceTouchEnd"
@touchcancel="onPageVoiceTouchEnd"
>
<view
class="voice-input-bar"
:class="{ active: isVoiceBarLive('bp') }"
@touchstart.stop="startVoiceInput('bp')"
@touchend="endVoiceInput"
@touchcancel="endVoiceInput"
@touchmove.stop.prevent="blockVoiceTouchMove"
>
<view class="voice-input-icon">
<VoiceSpeakMascot
:active="isVoiceBarLive('bp')"
:on-dark="isVoiceBarLive('bp')"
/>
</view>
<view class="voice-input-text">
<text class="voice-input-title">{{ isVoiceBarLive('bp') ? '正在聆听…松手结束' : '长按说话,自动填写' }}</text>
<text class="voice-input-hint">高压120低压80</text>
</view>
</view>
<view class="input-section-title">血压mmHg</view>
<view class="input-field-row">
<view class="input-field input-field-half">
<view class="input-field-label"><text>高压</text></view>
<view class="input-field-input">
<input type="number" :value="bpForm.systolic_pressure" placeholder="如 120" placeholder-class="input-placeholder" @input="onBpField('systolic_pressure', $event)" />
</view>
</view>
<view class="input-field input-field-half">
<view class="input-field-label"><text>低压</text></view>
<view class="input-field-input">
<input type="number" :value="bpForm.diastolic_pressure" placeholder="如 80" placeholder-class="input-placeholder" @input="onBpField('diastolic_pressure', $event)" />
</view>
</view>
</view>
<view class="input-section-title">用药选填</view>
<view class="input-field">
<view class="input-field-label"><text>西药</text></view>
<view class="input-field-input">
<input type="text" :value="bpForm.western_medicine" placeholder="如:二甲双胍" placeholder-class="input-placeholder" @input="onBpField('western_medicine', $event)" />
</view>
</view>
<view class="input-field">
<view class="input-field-label"><text>胰岛素</text></view>
<view class="input-field-input">
<input type="text" :value="bpForm.insulin" placeholder="如:门冬 8U" placeholder-class="input-placeholder" @input="onBpField('insulin', $event)" />
</view>
</view>
</scroll-view>
<view class="input-modal-actions">
<view v-if="bpForm.id" class="input-modal-btn danger" :class="{ disabled: bpForm.submitting }" @click="deleteBpRecord"><text>删除</text></view>
<view class="input-modal-btn primary" :class="{ disabled: bpForm.submitting || bpForm.loading }" @click="submitBpForm"><text>{{ bpForm.submitting ? '提交中…' : '保存' }}</text></view>
</view>
</view>
</view>
<!-- 录入今日饮食 -->
<view v-if="dietForm.visible" class="input-modal-mask" @click="closeDietForm">
<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">记今日饮食</text>
<text class="input-modal-sub">{{ todayText }}</text>
</view>
<view class="input-modal-close" @click="closeDietForm"><text class="input-modal-close-icon">×</text></view>
</view>
<view v-if="dietForm.loading" class="input-modal-loading"><text>加载中</text></view>
<scroll-view
v-else
class="input-modal-body"
:scroll-y="!manualVoicePressing"
@touchend="onPageVoiceTouchEnd"
@touchcancel="onPageVoiceTouchEnd"
>
<view v-if="dietAiPrefill && dietAiPrefill.breakfast" class="input-modal-ai-tip">
<text class="input-modal-ai-tip-label">AI 推荐</text>
<text class="input-modal-ai-tip-text"> {{ dietAiPrefill.breakfast }}<text v-if="dietAiPrefill.drinks"> · {{ dietAiPrefill.drinks }}</text> · {{ dietAiPrefill.lunch }} · {{ dietAiPrefill.dinner }}</text>
<view class="input-modal-ai-tip-btn" @click="fillDietFormFromAi">
<text>一键填入</text>
</view>
</view>
<view class="input-modal-tip"><text>至少填写一餐完成后可浇水领取积分</text></view>
<view
class="voice-input-bar"
:class="{ active: isVoiceBarLive('diet') }"
@touchstart.stop="startVoiceInput('diet')"
@touchend="endVoiceInput"
@touchcancel="endVoiceInput"
@touchmove.stop.prevent="blockVoiceTouchMove"
>
<view class="voice-input-icon">
<VoiceSpeakMascot
:active="isVoiceBarLive('diet')"
:on-dark="isVoiceBarLive('diet')"
/>
</view>
<view class="voice-input-text">
<text class="voice-input-title">{{ isVoiceBarLive('diet') ? '正在聆听…松手结束' : '长按说话,自动填写' }}</text>
<text class="voice-input-hint">早餐鸡蛋粥午餐米饭炒青菜</text>
</view>
</view>
<view class="input-field">
<view class="input-field-label"><text>早餐</text></view>
<view class="input-field-input">
<input type="text" :value="dietForm.breakfast_foods" placeholder="如:玉米碴粥、鸡蛋" placeholder-class="input-placeholder" @input="onDietField('breakfast_foods', $event)" />
</view>
</view>
<view class="input-field">
<view class="input-field-label"><text>午餐</text></view>
<view class="input-field-input">
<input type="text" :value="dietForm.lunch_foods" placeholder="如:二米饭、炖白菜豆腐" placeholder-class="input-placeholder" @input="onDietField('lunch_foods', $event)" />
</view>
</view>
<view class="input-field">
<view class="input-field-label"><text>晚餐</text></view>
<view class="input-field-input">
<input type="text" :value="dietForm.dinner_foods" placeholder="如:杂面窝头、炒茄子" placeholder-class="input-placeholder" @input="onDietField('dinner_foods', $event)" />
</view>
</view>
<view class="input-field input-field-textarea">
<textarea :value="dietForm.note" placeholder="饮食备注(选填)" placeholder-class="input-placeholder" :show-confirm-bar="false" :auto-height="true" @input="onDietField('note', $event)" />
</view>
</scroll-view>
<view class="input-modal-actions">
<view class="input-modal-btn primary" :class="{ disabled: dietForm.submitting }" @click="submitDietForm">
<text>{{ dietForm.submitting ? '提交中…' : '保存饮食' }}</text>
</view>
</view>
</view>
</view>
<!-- 录入今日运动 -->
<view v-if="exerciseForm.visible" class="input-modal-mask" @click="closeExerciseForm">
<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">记今日运动</text>
<text class="input-modal-sub">{{ todayText }}</text>
</view>
<view class="input-modal-close" @click="closeExerciseForm"><text class="input-modal-close-icon">×</text></view>
</view>
<view v-if="exerciseForm.loading" class="input-modal-loading"><text>加载中</text></view>
<scroll-view
v-else
class="input-modal-body"
:scroll-y="!manualVoicePressing"
@touchend="onPageVoiceTouchEnd"
@touchcancel="onPageVoiceTouchEnd"
>
<view class="input-modal-tip"><text>填写运动类型或时长完成后可浇水领取积分</text></view>
<view
class="voice-input-bar"
:class="{ active: isVoiceBarLive('exercise') }"
@touchstart.stop="startVoiceInput('exercise')"
@touchend="endVoiceInput"
@touchcancel="endVoiceInput"
@touchmove.stop.prevent="blockVoiceTouchMove"
>
<view class="voice-input-icon">
<VoiceSpeakMascot
:active="isVoiceBarLive('exercise')"
:on-dark="isVoiceBarLive('exercise')"
/>
</view>
<view class="voice-input-text">
<text class="voice-input-title">{{ isVoiceBarLive('exercise') ? '正在聆听…松手结束' : '长按说话,自动填写' }}</text>
<text class="voice-input-hint">散步30分钟中强度</text>
</view>
</view>
<view class="input-field">
<view class="input-field-label"><text>运动类型</text></view>
<view class="input-field-input">
<input type="text" :value="exerciseForm.exercise_type" placeholder="如:散步、太极拳" placeholder-class="input-placeholder" @input="onExerciseField('exercise_type', $event)" />
</view>
</view>
<view class="input-field">
<view class="input-field-label"><text>时长分钟</text></view>
<view class="input-field-input">
<input type="number" :value="exerciseForm.duration" placeholder="如:30" placeholder-class="input-placeholder" @input="onExerciseField('duration', $event)" />
</view>
</view>
<view class="input-section-title">运动强度</view>
<view class="intensity-row">
<view
v-for="opt in exerciseIntensityOptions"
:key="opt.value"
class="intensity-chip"
:class="{ active: exerciseForm.intensity === opt.value }"
@click="exerciseForm.intensity = opt.value"
>
<text>{{ opt.label }}</text>
</view>
</view>
</scroll-view>
<view class="input-modal-actions">
<view class="input-modal-btn primary" :class="{ disabled: exerciseForm.submitting }" @click="submitExerciseForm">
<text>{{ exerciseForm.submitting ? '提交中…' : '保存运动' }}</text>
</view>
</view>
</view>
</view>
<!-- 语音录音浮层仅非弹窗场景使用录入弹窗内由卡片自身状态提示 -->
<view v-if="(sttHolding || sttListening) && !recordOverlayOpen && !voiceDialog.active" class="voice-rec-overlay">
<view class="voice-rec-card">
<view class="voice-rec-icon" :class="{ active: sttListening }">
<TongjiIcon name="mic" size="lg" color="#ffffff" />
</view>
<text class="voice-rec-title">{{ sttListening ? '正在聆听…' : '准备录音…' }}</text>
<text class="voice-rec-sub">松开手指结束并识别</text>
</view>
</view>
<!-- 本周战报脱敏可分享-->
<view v-if="weeklyReportVisible" class="report-mask" @click="closeWeeklyReport">
<view class="report-panel" @click.stop>
<view class="report-panel-head">
<text class="report-panel-title">本周稳糖战报</text>
<text class="report-panel-close" @click="closeWeeklyReport">×</text>
</view>
<view class="report-poster">
<view class="report-poster-top">
<SugarTreeGraphic :level="reportTreeLevel" size="md" />
<text class="report-poster-name">{{ weeklyReportData.patientLabel }}</text>
</view>
<text class="report-poster-week">{{ weeklyReportData.weekLabel }}</text>
<view class="report-stat-grid">
<view class="report-stat-item">
<text class="report-stat-num">{{ weeklyReportData.recordDays }}</text>
<text class="report-stat-label">天有记录</text>
</view>
<view class="report-stat-item">
<text class="report-stat-num">{{ weeklyReportData.completeDays }}</text>
<text class="report-stat-label">完整一日</text>
</view>
<view class="report-stat-item">
<text class="report-stat-num">{{ weeklyReportData.streakDays }}</text>
<text class="report-stat-label">连续天数</text>
</view>
</view>
<text class="report-poster-quote">{{ weeklyReportData.quote }}</text>
<text class="report-poster-disclaimer">习惯参考不替代医嘱 · 家人打开分享可查看近7日记录</text>
</view>
<view class="report-actions">
<!-- #ifdef MP-WEIXIN -->
<button class="report-share-btn" open-type="share" @click="prepareShareWeekly">分享给家人</button>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<view class="report-share-btn" @click="shareWeeklyFallback"><text>分享给家人</text></view>
<!-- #endif -->
<view class="report-secondary-btn" @click="closeWeeklyReport"><text>关闭</text></view>
</view>
</view>
</view>
<!-- 今日任务 / 浇水状态 -->
<view v-if="waterStatusVisible" class="input-modal-mask" @click="closeWaterStatusPanel">
<view class="mc-water-panel" @click.stop>
<view class="mc-water-panel-head">
<text class="mc-water-panel-title">今日稳糖进度</text>
<view class="mc-water-panel-close" @click="closeWaterStatusPanel">
<text>×</text>
</view>
</view>
<text class="mc-water-panel-summary">{{ waterStatusSummary }}</text>
<view class="mc-water-panel-list">
<view
v-for="row in waterStatusRows"
:key="row.id"
class="mc-water-panel-row"
:class="'is-' + row.statusKind"
>
<TongjiIcon :name="gamifyTaskIcon(row.id)" size="sm" color="#006c49" />
<view class="mc-water-panel-row-main">
<text class="mc-water-panel-row-name">{{ row.name }}</text>
<text class="mc-water-panel-row-sub">{{ row.statusSub }}</text>
</view>
<text class="mc-water-panel-row-tag">{{ row.statusLabel }}</text>
</view>
</view>
<view v-if="waterStatusGrowthHint" class="mc-water-panel-growth">
<text>{{ waterStatusGrowthHint }}</text>
</view>
<view class="mc-water-panel-actions">
<button
v-if="canWaterTree"
class="mc-water-panel-btn primary"
@click="waterTreeFromPanel"
>
立即浇水领 {{ claimablePoints }} 积分
</button>
<button
v-else-if="firstPendingDisplayTask"
class="mc-water-panel-btn"
@click="goRecordFromWaterPanel"
>
去记录{{ firstPendingDisplayTask.name }}
</button>
<button v-else class="mc-water-panel-btn ghost" @click="closeWaterStatusPanel">
知道了
</button>
</view>
</view>
</view>
<!-- 树种选择 -->
<view v-if="treeSpeciesPickerVisible" class="input-modal-mask" @click="closeTreeSpeciesPicker">
<view class="mc-species-panel" @click.stop>
<view class="mc-water-panel-head">
<text class="mc-water-panel-title">选择你的稳糖树</text>
<view class="mc-water-panel-close" @click="closeTreeSpeciesPicker">
<text>×</text>
</view>
</view>
<text class="mc-species-panel-tip">
全勤打卡约 {{ speciesDaysToMax }} 天满级 · 每级 {{ TREE_XP_PER_LEVEL }} 积分 · 从小到大的 10 个阶段见下方
</text>
<scroll-view scroll-y class="mc-species-scroll">
<view
v-for="sp in TREE_SPECIES_LIST"
:key="sp.id"
class="mc-species-card"
:class="{ active: treeSpeciesId === sp.id }"
@click="selectTreeSpecies(sp.id)"
>
<view class="mc-species-card-head">
<text class="mc-species-card-emoji">{{ speciesEmojiAtLevel(sp.id, treeLevel) }}</text>
<view>
<text class="mc-species-card-name">{{ sp.name }}</text>
<text class="mc-species-card-tagline">{{ sp.tagline }}</text>
</view>
</view>
<view class="mc-species-stages">
<view
v-for="stage in speciesGrowthRoadmap(sp.id)"
:key="stage.level"
class="mc-species-stage"
:class="{ reached: treeLevel >= stage.level, current: treeLevel === stage.level }"
>
<text class="mc-species-stage-emoji">{{ stage.emoji }}</text>
<text class="mc-species-stage-lv">Lv.{{ stage.level }}</text>
<text class="mc-species-stage-name">{{ stage.name }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
</block>
</view>
</template>
<script setup>
import { ref, reactive, computed, getCurrentInstance, nextTick } 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 CelebrateBurst from '../components/CelebrateBurst.vue'
import VoiceSpeakMascot from '../components/VoiceSpeakMascot.vue'
import { TREE_LEVELS, TREE_MAX_LEVEL, TREE_XP_PER_LEVEL, calcTreeFromPoints, pickTreeWhisper } from '../utils/treeLevels.js'
import {
TREE_SPECIES_LIST,
DAILY_MAX_TASK_POINTS,
getTreeSpecies,
speciesEmojiAtLevel,
speciesGrowthRoadmap,
estimateDaysToMaxLevel
} from '../utils/treeSpecies.js'
import { consumeDietAiPrefill } from '../composables/useDietAi.js'
import { useSpeechToText, isBenignSttError } from '../composables/useSpeechToText.js'
import { parseGlucose, parseBloodPressure, parseDiet, parseExercise, summarizeParsed, normalizeNumbers } from '../utils/voiceParse.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: typeof options.duration === 'number' ? options.duration : 2000
})
}
const diagnosisId = ref(0)
const patientId = ref(0)
const patientName = ref('')
const headerStyle = ref({})
const headerInnerStyle = ref({})
const mainPadStyle = ref({})
const age = ref(0)
const gender = ref(0)
/** 登录 / 就诊卡门禁校验中(避免未登录时闪屏) */
const authChecking = ref(false)
let gateRedirected = 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 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
}
const BLOOD_LOW_THRESHOLD = 3.9
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 isLowGlucose(v) {
const n = toNumber(v)
return n !== null && n < BLOOD_LOW_THRESHOLD
}
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 isLowFasting(v) {
return isLowGlucose(v) && !isHighFasting(v)
}
function isLowPostprandial(v) {
return isLowGlucose(v) && !isHighPostprandial(v)
}
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)
}
function formatBloodMetric(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.isLowFasting = isLowFasting(m.fasting_blood_sugar)
m.isLowPostprandial = isLowPostprandial(m.postprandial_blood_sugar)
m.isLowOther = isLowPostprandial(m.other_blood_sugar)
m.isHighBp = isHighBp(m)
m.bpText = formatBp(m)
m.hasBpOrMed = m.bpText !== '—' || !!(m.western_medicine || m.insulin || m.remark)
}
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 bloodRaw = bloodByDate.value[date]
const blood = bloodRaw && bloodRaw.hasBpOrMed ? bloodRaw : null
const diet = dietByDate.value[date]
const exercise = exerciseByDate.value[date]
const tracking = trackingByDate.value[date]
const hasAny = blood || diet || exercise || (tracking && tracking.content)
if (!hasAny) continue
const [y, m, d] = date.split('-')
result.push({
date,
dateLabel: `${m}-${d}`,
weekday: getWeekday(date),
isToday: date === todayStr.value,
blood,
diet,
exercise,
tracking
})
}
return result
})
const bloodRecordDays = 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
})
// 给某天某字段计算 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
}
function redirectToCardEntry() {
if (gateRedirected) return
gateRedirected = true
const returnUrl = encodeURIComponent('/tongji/pages/more')
uni.redirectTo({
url: `/pages/Card/edit_card?add=1&returnUrl=${returnUrl}`
})
}
// ============ 就诊卡 ============
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
lastFamilyLikeCount.value = getStoredFamilyLikeSeen(Number(card.id) || 0)
familyLikeSummary.value = {
like_count: 0,
summary_line: '分享战报给家人,邀请他们为您点赞鼓劲',
recent: []
}
gamifyStatusTip.value = ''
loadTreeSpecies()
await fetchGamifyState()
}
function readUserMobile() {
try {
const userData = uni.getStorageSync('userData') || {}
return String(userData.mobile || userData.phone || '').trim()
} catch (e) {
return ''
}
}
/** 无就诊卡时:已绑定手机号则快捷建档(与 weekly 一致) */
async function ensureDailyContextByPhone() {
if (diagnosisId.value) return true
const res = await proxy.apiUrl({
url: '/api/tcm/dailyEnsurePhoneContext',
method: 'POST',
data: {}
}, 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 || res.data.linked_existing) {
await fetchCardList()
}
return true
}
if (res && res.data && res.data.need_mobile) {
return 'need_mobile'
}
showUserToast(formatUserMessage(res?.msg, '无法加载记录'))
return 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 fetchGamifyState()
await loadFamilyLikeSummary()
} catch (e) {
uni.showToast({ title: '加载失败,请稍后再试', icon: 'none' })
} finally {
loading.value = false
// 自动播报:数据就绪后朗读一次最新摘要
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()
}
// ============ 时段问候 / 打卡 / 健康日历 ============
/** 时段问候:文案 + 统一 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 ||
toNumber(b.systolic_pressure) !== null ||
toNumber(b.diastolic_pressure) !== null
}
// 给一天打个完整度分(0-3):0 无 / 1 仅 1 项 / 2 有 2~3 项 / 3 有 4 项以上
function dayCoverageLevel(key) {
const b = bloodByDate.value[key]
const dt = dietByDate.value[key]
const ex = exerciseByDate.value[key]
if (!b && !dt && !ex) return 0
let score = 0
if (b) {
const fields = ['fasting_blood_sugar', 'postprandial_blood_sugar', 'other_blood_sugar', 'systolic_pressure', 'diastolic_pressure']
score += fields.filter((f) => toNumber(b[f]) !== null).length
}
if (dt && (dt.breakfast_foods || dt.lunch_foods || dt.dinner_foods)) score += 1
if (ex && (ex.exercise_type || ex.duration)) score += 1
if (score === 0) return 0
if (score === 1) return 1
if (score <= 3) 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)
}
function dayHasHighBloodSugar(key) {
const b = bloodByDate.value[key]
if (!b) return false
return b.isHighFasting || b.isHighPostprandial
}
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,
isHigh: inRange ? dayHasHighBloodSugar(key) : false
})
cur.setDate(cur.getDate() + 1)
}
weeks.push(week)
}
return weeks
})
const heatmapCells = computed(() => {
const weeks = heatmapWeeks.value
return weeks.length ? weeks.flat() : []
})
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 (!bloodRecordDays.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())
const emptyBpForm = () => ({
visible: false,
loading: false,
submitting: false,
id: 0,
systolic_pressure: '',
diastolic_pressure: '',
western_medicine: '',
insulin: '',
remark: ''
})
const bpForm = ref(emptyBpForm())
function prepareRecordOverlay() {
stopVoiceDialog(true)
ttsStop()
}
function onInputField(field, e) {
inputForm.value[field] = e?.detail?.value ?? ''
}
async function openInputForm() {
if (!diagnosisId.value) {
showUserToast('请先选择就诊卡')
return
}
const token = uni.getStorageSync('token')
if (!token) {
showUserToast('请先登录后再录入')
return
}
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 || '')
if (todayHasGlucose.value) {
showUserToast('今日血糖已录入,如需修改可在下方调整', { duration: 2600 })
}
}
} catch (e) {
/* 静默:拉取失败时仍允许新增录入 */
} finally {
inputForm.value.loading = false
}
}
function closeInputForm() {
if (inputForm.value.submitting) return
cancelVoiceHold()
stopVoiceDialog(true)
inputForm.value.visible = false
}
async function submitInputForm() {
if (inputForm.value.submitting || inputForm.value.loading) return
if (voiceDialog.active) stopVoiceDialog(true)
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) {
showUserToast('请至少填写一项数据')
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()
await fetchGamifyState()
promptWaterTreeGuide('血糖')
} else {
showUserToast((res && res.msg) || '保存失败')
}
} catch (e) {
showUserToast('网络异常,请稍后再试')
} finally {
inputForm.value.submitting = false
}
}
function deleteInputRecord() {
if (!inputForm.value.id || inputForm.value.submitting) return
if (voiceDialog.active) stopVoiceDialog(true)
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
await fetchAll()
await fetchGamifyState()
} else {
showUserToast((res && res.msg) || '删除失败')
}
} catch (e) {
uni.showToast({ title: '网络异常,请稍后再试', icon: 'none' })
} finally {
inputForm.value.submitting = false
}
}
})
}
function onBpField(field, e) {
const val = e?.detail?.value ?? ''
bpForm.value[field] = val
}
async function openBpForm() {
if (!diagnosisId.value) {
showUserToast('请先选择就诊卡')
return
}
const token = uni.getStorageSync('token')
if (!token) {
showUserToast('请先登录后再录入')
return
}
// 微信小程序 canvas 是原生组件、z-index 无法被弹层覆盖,
// 这里通过 v-if 卸载它;下次显示时 selectorQuery 拿到的会是新节点,
// 因此先清空缓存的 ctx / node,避免下次 ensureCanvas 复用旧引用。
prepareRecordOverlay()
// 打开弹层,先拉取今日已有记录预填
bpForm.value = { ...emptyBpForm(), 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
bpForm.value.id = Number(r.id) || 0
bpForm.value.systolic_pressure = numToStr(r.systolic_pressure)
bpForm.value.diastolic_pressure = numToStr(r.diastolic_pressure)
bpForm.value.western_medicine = String(r.western_medicine || '')
bpForm.value.insulin = String(r.insulin || '')
bpForm.value.remark = String(r.remark || '')
if (todayHasBpPressure.value) {
showUserToast('今日血压已录入,如需修改可在下方调整', { duration: 2600 })
}
}
} catch (e) {
// 静默:拉取失败时仍允许新增录入
} finally {
bpForm.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 closeBpForm() {
if (bpForm.value.submitting) return
cancelVoiceHold()
bpForm.value.visible = false
// 关闭后 canvas 会重新挂载,需触发一次重绘
nextTick(() => {})
}
const emptyDietForm = () => ({
visible: false,
loading: false,
submitting: false,
id: 0,
breakfast_foods: '',
lunch_foods: '',
dinner_foods: '',
note: ''
})
const dietForm = ref(emptyDietForm())
const dietAiPrefill = ref(null)
function fillDietFormFromAi() {
const r = dietAiPrefill.value
if (!r) return
if (r.breakfast) dietForm.value.breakfast_foods = r.breakfast
if (r.lunch) dietForm.value.lunch_foods = r.lunch
if (r.dinner) dietForm.value.dinner_foods = r.dinner
if (r.tips) {
const note = String(dietForm.value.note || '').trim()
dietForm.value.note = note ? `${note}${r.tips}` : r.tips
}
}
function onDietField(field, e) {
dietForm.value[field] = e?.detail?.value ?? ''
}
async function openDietForm(options = {}) {
const autoFill = !!options.autoFill
if (!dietAiPrefill.value) {
dietAiPrefill.value = consumeDietAiPrefill()
}
if (!diagnosisId.value) {
showUserToast('请先选择就诊卡')
return
}
const token = uni.getStorageSync('token')
if (!token) {
showUserToast('请先登录后再录入')
return
}
prepareRecordOverlay()
dietForm.value = { ...emptyDietForm(), visible: true, loading: true }
try {
const res = await proxy.apiUrl({
url: '/api/tcm/dailyTodayDietRecord',
method: 'GET',
data: { diagnosis_id: diagnosisId.value }
}, false)
if (res?.code === 1 && res.data?.record) {
const r = res.data.record
dietForm.value.id = Number(r.id) || 0
dietForm.value.breakfast_foods = String(r.breakfast_foods || '')
dietForm.value.lunch_foods = String(r.lunch_foods || '')
dietForm.value.dinner_foods = String(r.dinner_foods || '')
dietForm.value.note = String(r.note || '')
if (todayHasDiet.value) {
showUserToast('今日饮食已录入,如需修改可在下方调整', { duration: 2600 })
}
}
} catch (e) {}
finally {
dietForm.value.loading = false
if (autoFill && dietAiPrefill.value?.breakfast) {
fillDietFormFromAi()
}
}
}
function closeDietForm() {
if (dietForm.value.submitting) return
cancelVoiceHold()
dietForm.value.visible = false
dietAiPrefill.value = null
nextTick(() => {})
}
async function submitDietForm() {
if (dietForm.value.submitting || dietForm.value.loading) return
const f = dietForm.value
if (!f.breakfast_foods.trim() && !f.lunch_foods.trim() && !f.dinner_foods.trim()) {
showUserToast('请至少填写一餐饮食')
return
}
dietForm.value.submitting = true
try {
const res = await proxy.apiUrl({
url: '/api/tcm/dailySaveDietRecord',
method: 'POST',
data: {
diagnosis_id: diagnosisId.value,
breakfast_foods: f.breakfast_foods,
lunch_foods: f.lunch_foods,
dinner_foods: f.dinner_foods,
note: f.note
}
}, false)
if (res?.code === 1) {
showUserToast(res.msg || '今日饮食已录入完成', { icon: 'success' })
dietForm.value.visible = false
await fetchAll()
await fetchGamifyState()
promptWaterTreeGuide('饮食')
} else {
showUserToast((res && res.msg) || '保存失败')
}
} catch (e) {
showUserToast('网络异常,请稍后再试')
} finally {
dietForm.value.submitting = false
}
}
const exerciseIntensityOptions = [
{ value: 1, label: '低强度' },
{ value: 2, label: '中强度' },
{ value: 3, label: '高强度' }
]
const emptyExerciseForm = () => ({
visible: false,
loading: false,
submitting: false,
id: 0,
exercise_type: '',
duration: '',
intensity: 2,
note: ''
})
const exerciseForm = ref(emptyExerciseForm())
/** 任一录入弹层打开时需卸载 canvas(小程序原生组件会盖住普通 view)*/
const recordOverlayOpen = computed(() =>
inputForm.value.visible || bpForm.value.visible || dietForm.value.visible || exerciseForm.value.visible
)
// ============ 语音录入(长按说话,本地解析回填) ============
/** 当前正在语音录入的目标表单:'glucose' | 'bp' | 'diet' | 'exercise' */
const voiceTarget = ref('')
const {
sttListening,
sttHolding,
sttSupported,
beginHoldSpeech,
endHoldSpeech
} = useSpeechToText({
onResult(text) {
// 语音一问一答进行中时,结果由 onSettle → 对话流程接管
if (voiceDialog.active) return
applyVoiceResult(text)
},
onSettle(text) {
if (voiceDialog.active) {
handleDialogSettle(text)
return true
}
return false
},
onError(msg) {
if (isBenignSttError(msg)) return true
if (voiceDialog.active) {
handleDialogError()
return true
}
return false
},
onPartial(text) {
if (voiceDialog.active) handleDialogPartial(text)
},
showToast: (msg) => showUserToast(msg)
})
/** 长按麦克风开始录音(短按不启动,避免误触) */
const VOICE_HOLD_DELAY_MS = 320
let voiceHoldTimer = null
let voiceHoldPendingTarget = ''
const manualVoicePressing = ref(false)
/** 按住期间锁定目标,避免 timer 与松手竞态 */
let manualVoicePressTarget = ''
function blockVoiceTouchMove() {
/* 阻止 scroll-view 抢走 touch 序列,保证 touchend 能回到按住区域 */
}
function isManualVoiceActive(target) {
return voiceTarget.value === target && (sttHolding.value || sttListening.value)
}
/** 按住尚未开录时也展示说话动画 */
function isVoicePressing(target) {
return manualVoicePressing.value && manualVoicePressTarget === target
}
function isVoiceBarLive(target) {
return isManualVoiceActive(target) || isVoicePressing(target)
}
function clearVoiceHoldTimer() {
if (voiceHoldTimer) {
clearTimeout(voiceHoldTimer)
voiceHoldTimer = null
}
}
function cancelVoiceHold() {
manualVoicePressing.value = false
manualVoicePressTarget = ''
clearVoiceHoldTimer()
voiceHoldPendingTarget = ''
forceEndHoldSpeech()
}
/** 无论底层回调是否滞后,都清掉 UI 与按住态 */
function forceEndHoldSpeech() {
if (sttHolding.value || sttListening.value) {
endHoldSpeech()
} else {
endHoldSpeech(true)
}
}
function startVoiceInput(target) {
if (!sttSupported.value) {
showUserToast('当前环境不支持语音输入')
return
}
// 长按手动录入与一问一答互斥:手动开始时先停掉对话
if (voiceDialog.active) stopVoiceDialog(true)
manualVoicePressing.value = true
manualVoicePressTarget = target
clearVoiceHoldTimer()
voiceHoldPendingTarget = target
voiceHoldTimer = setTimeout(() => {
voiceHoldTimer = null
if (!manualVoicePressing.value || manualVoicePressTarget !== target || voiceHoldPendingTarget !== target) return
voiceTarget.value = target
beginHoldSpeech()
}, VOICE_HOLD_DELAY_MS)
}
/** 松手结束录音 */
function endVoiceInput() {
const wasPressing = manualVoicePressing.value
const hadPendingOnly = !!voiceHoldPendingTarget
const wasRecording = sttHolding.value || sttListening.value
manualVoicePressing.value = false
manualVoicePressTarget = ''
clearVoiceHoldTimer()
voiceHoldPendingTarget = ''
if (wasRecording || wasPressing) {
forceEndHoldSpeech()
}
if (hadPendingOnly && !wasRecording) {
showUserToast('请长按说话', { icon: 'none', duration: 1500 })
}
}
/** 页面 / scroll-view 级 touchend 兜底 */
function onPageVoiceTouchEnd() {
if (manualVoicePressing.value) {
if (voiceDialog.active && voiceDialog.phase === 'listening') {
onDialogAnswerTouchEnd()
} else {
endVoiceInput()
}
return
}
// 按住态已丢但底层仍在录:强制收尾
if ((sttHolding.value || sttListening.value) && voiceTarget.value && !voiceDialog.active) {
endVoiceInput()
}
}
/** 把识别文本解析后回填到对应表单 */
function applyVoiceResult(text) {
const raw = String(text || '').trim()
if (!raw) return
const target = voiceTarget.value
let parsed = {}
let formRef = null
if (target === 'glucose') {
parsed = parseGlucose(raw)
formRef = inputForm
} else if (target === 'bp') {
parsed = parseBloodPressure(raw)
formRef = bpForm
} else if (target === 'diet') {
parsed = parseDiet(raw)
formRef = dietForm
} else if (target === 'exercise') {
parsed = parseExercise(raw)
formRef = exerciseForm
}
if (!formRef) return
const keys = Object.keys(parsed)
if (!keys.length) {
// 没解析出结构化字段:饮食/血糖把原文写入备注,避免丢失
if (target === 'diet') {
appendToField(formRef, 'note', raw)
showUserToast('已记入饮食备注,请确认')
} else if (target === 'glucose') {
appendToField(formRef, 'remark', raw)
showUserToast('未识别到血糖数值,已记入备注')
} else if (target === 'bp') {
showUserToast('未识别到血压数值,请重试或手动填写')
} else {
showUserToast('没听清,请再说一次')
}
return
}
keys.forEach((k) => {
if (k === 'note' || k === 'remark' || k === 'western_medicine' || k === 'insulin') {
appendToField(formRef, k, parsed[k])
} else {
formRef.value[k] = parsed[k]
}
})
const summary = summarizeParsed(parsed)
showUserToast(summary ? `已识别:${summary}` : '已填写', { icon: 'none', duration: 2200 })
}
/** 文本类字段追加(避免覆盖已有内容) */
function appendToField(formRef, field, value) {
const v = String(value || '').trim()
if (!v) return
const prev = String(formRef.value[field] || '').trim()
formRef.value[field] = prev ? `${prev}${v}` : v
}
// ============ 语音一问一答录入(默认关闭,逐项语音问询自动回填) ============
const VOICE_DIALOG_LISTEN_MS = 4000 // 问题播完后给用户的说话时长(兜底窗口)
const VOICE_DIALOG_PRE_MS = 250 // 读完问题到开始录音的缓冲,避开 TTS 尾音
const VOICE_DIALOG_ANSWER_MS = 550 // 听到数字后,用户停顿多久即定格(静默防抖,越小越快)
const VOICE_DIALOG_ANSWER_MAX_MS = 1800 // 听到数字后最长收尾时间(防止实时回调不断刷新而不定格)
const VOICE_DIALOG_MAX_RETRY = 2 // 没听清的最大重试次数
const VOICE_DIALOG_ERR_MAX_RETRY = 3 // 录音启动失败的最大自动重试次数
const VOICE_DIALOG_RETRY_MS = 600 // 录音失败后重试的间隔
/** 血糖语音问答的步骤定义 */
// 注意:采集类问题与录音并行,麦克风会录到问题回声,
// 因此问句中不要出现「跳过/没有」等指令词,否则回声会被误判为跳过。
const GLUCOSE_DIALOG_STEPS = [
{ field: 'fasting_blood_sugar', label: '空腹血糖', question: '请问空腹血糖是多少?' },
{ field: 'postprandial_blood_sugar', label: '餐后血糖', question: '餐后血糖是多少?' },
{ field: 'other_blood_sugar', label: '其他时段血糖', question: '其他时段血糖是多少?' }
]
const voiceDialog = reactive({
active: false, // 对话流程是否进行中
phase: 'idle', // idle | asking | listening (仅表示麦克风/播报状态)
stage: 'collect', // collect(逐项采集)| confirm(确认提交)
stepIndex: 0,
retries: 0,
errorRetries: 0, // 录音启动失败的自动重试计数
barged: false, // 用户是否已在问题播报中抢答
pendingValue: '', // 实时识别已捕获、待定格的数值
botText: '', // 当前机器人提示/问题
heardText: '', // 最近一次识别到的用户回答
steps: GLUCOSE_DIALOG_STEPS,
collected: [] // [{ label, value }]
})
let voiceListenTimer = null
let voiceAnswerTimer = null
let voiceFlowToken = 0 // 递增令牌,关闭/重启时让旧的异步回调失效
function clearVoiceListenTimer() {
if (voiceListenTimer) {
clearTimeout(voiceListenTimer)
voiceListenTimer = null
}
}
function clearAnswerTimer() {
if (voiceAnswerTimer) {
clearTimeout(voiceAnswerTimer)
voiceAnswerTimer = null
}
}
function clearVoiceTimers() {
clearVoiceListenTimer()
clearAnswerTimer()
}
/** 当前步骤 */
function currentDialogStep() {
return voiceDialog.steps[voiceDialog.stepIndex] || null
}
/** 启动血糖语音问答 */
function startGlucoseVoiceDialog() {
if (!sttSupported.value) {
showUserToast('当前环境不支持语音输入')
return
}
// 先停掉可能进行中的手动录音/旧对话
endHoldSpeech()
clearVoiceListenTimer()
voiceFlowToken += 1
voiceDialog.active = true
voiceDialog.phase = 'asking'
voiceDialog.stage = 'collect'
voiceDialog.stepIndex = 0
voiceDialog.retries = 0
voiceDialog.errorRetries = 0
voiceDialog.heardText = ''
voiceDialog.collected = []
askCurrentStep()
}
/**
* 播报当前步骤的问题,读完后再开始聆听。
* 微信平台已移除实时识别回调(onRecognize),无法做"读题中打断",
* 因此采用「读完短问题 → 按住作答区说话 → 松手结束并识别」的可靠模型。
*/
function askCurrentStep(prefix = '') {
const step = currentDialogStep()
if (!step) { finishVoiceDialog(); return }
const token = voiceFlowToken
voiceDialog.phase = 'asking'
voiceDialog.errorRetries = 0
voiceDialog.barged = false
voiceDialog.pendingValue = ''
voiceDialog.heardText = ''
voiceDialog.botText = `${prefix}${step.question}`
clearVoiceTimers()
ttsSpeak(voiceDialog.botText, {
force: true,
onDone: () => {
if (token !== voiceFlowToken || !voiceDialog.active) return
listenForAnswer()
}
})
}
/** 到时自动结束录音(最长按住窗口,防止一直录) */
function armListenTimeout(token) {
clearVoiceListenTimer()
voiceListenTimer = setTimeout(() => {
if (token !== voiceFlowToken || !voiceDialog.active) return
if (voiceDialog.phase !== 'listening') return
finishListeningEarly()
}, VOICE_DIALOG_LISTEN_MS)
}
/** 问完题后进入聆听:等用户按住作答区再开录(不自动开麦) */
function listenForAnswer() {
if (!voiceDialog.active) return
const token = voiceFlowToken
voiceDialog.pendingValue = ''
voiceDialog.heardText = ''
clearVoiceTimers()
voiceDialog.phase = 'asking'
voiceListenTimer = setTimeout(() => {
if (token !== voiceFlowToken || !voiceDialog.active) return
voiceDialog.phase = 'listening'
}, VOICE_DIALOG_PRE_MS)
}
/** 问答聆听:按住作答区开始录音 */
function onDialogAnswerTouchStart() {
if (!voiceDialog.active || voiceDialog.phase !== 'listening') return
if (sttHolding.value || sttListening.value) return
manualVoicePressing.value = true
manualVoicePressTarget = 'dialog'
clearVoiceTimers()
const token = voiceFlowToken
beginHoldSpeech()
armListenTimeout(token)
}
/** 问答聆听:松手结束并识别 */
function onDialogAnswerTouchEnd() {
manualVoicePressing.value = false
manualVoicePressTarget = ''
if (!voiceDialog.active || voiceDialog.phase !== 'listening') {
if (sttHolding.value || sttListening.value) forceEndHoldSpeech()
return
}
clearVoiceTimers()
if (sttHolding.value || sttListening.value) {
finishListeningEarly()
return
}
forceEndHoldSpeech()
}
/**
* 实时识别中间结果(仅在聆听阶段)。
* 注意:微信当前已不再触发 onRecognize,此处为「若平台支持则提速」的增益逻辑——
* 一旦实时拿到数字,用户停顿即定格、立即下一题;不支持时则走聆听窗口/「说完了」兜底。
*/
function handleDialogPartial(text) {
if (!voiceDialog.active) return
if (voiceDialog.stage !== 'collect') return
if (voiceDialog.phase !== 'listening') return
const value = extractGlucoseValue(text)
if (value === null) return
const token = voiceFlowToken
voiceDialog.pendingValue = value
voiceDialog.heardText = text
if (!voiceDialog.barged) {
// 首次拿到数字:取消默认长窗口,改为「停顿即定格」,并设绝对收尾上限
voiceDialog.barged = true
clearVoiceListenTimer()
voiceListenTimer = setTimeout(() => {
if (token !== voiceFlowToken || !voiceDialog.active) return
finalizeCollectAnswer()
}, VOICE_DIALOG_ANSWER_MAX_MS)
}
clearAnswerTimer()
voiceAnswerTimer = setTimeout(() => {
if (token !== voiceFlowToken || !voiceDialog.active) return
finalizeCollectAnswer()
}, VOICE_DIALOG_ANSWER_MS)
}
/** 以实时识别捕获到的数值定格当前采集项,并立即进入下一题 */
function finalizeCollectAnswer() {
if (!voiceDialog.active) return
const step = currentDialogStep()
const value = voiceDialog.pendingValue
voiceDialog.pendingValue = ''
clearVoiceTimers()
endHoldSpeech() // 停掉录音;其最终结果在切到下一题后由阶段判断忽略
if (!step || !value) return
inputForm.value[step.field] = value
voiceDialog.collected.push({ label: step.label, value })
voiceDialog.retries = 0
gotoNextStep(`好的,${step.label}已记录。`)
}
/** 录音/识别启动失败(如 record manager recordfailed):自动重试本题录音 */
function handleDialogError() {
if (!voiceDialog.active) return
clearVoiceTimers()
if (voiceDialog.errorRetries < VOICE_DIALOG_ERR_MAX_RETRY) {
voiceDialog.errorRetries += 1
const token = voiceFlowToken
// 暂离 listening,避免失败回调被当作答案处理;稍后自动重新录音(不重播问题)
voiceDialog.phase = 'asking'
voiceListenTimer = setTimeout(() => {
if (token !== voiceFlowToken || !voiceDialog.active) return
listenForAnswer()
}, VOICE_DIALOG_RETRY_MS)
} else {
voiceDialog.errorRetries = 0
// 多次启动失败:温和提示并跳过当前项,避免卡死(措辞不含「跳过」以防回声误判)
gotoNextStep('录音没成功,这项先空着。')
}
}
/** 用户点「说完了」:立即结束录音并识别(仅聆听阶段可用) */
function finishListeningEarly() {
if (!voiceDialog.active || voiceDialog.phase !== 'listening') return
// 采集类若已实时捕获到数字,直接定格(更快、避免丢失)
if (voiceDialog.stage === 'collect' && voiceDialog.pendingValue) {
finalizeCollectAnswer()
return
}
clearVoiceTimers()
endHoldSpeech()
}
/** 识别结束(含空结果)统一入口 */
function handleDialogSettle(text) {
if (!voiceDialog.active) return
// 仅在「聆听」阶段才采纳结果;重听/跳过/停止后产生的残留回调忽略
if (voiceDialog.phase !== 'listening') return
clearVoiceTimers()
// 抢答收尾过快导致最终结果为空时,回退使用最后一次实时识别(中间结果)
const partial = voiceDialog.heardText
let raw = String(text || '').trim()
if (!raw && voiceDialog.barged && partial) raw = partial
voiceDialog.heardText = raw
if (voiceDialog.stage === 'confirm') {
handleConfirmAnswer(raw)
return
}
const step = currentDialogStep()
if (!step) { finishVoiceDialog(); return }
// 先解析数字:最终结果优先,其次回退到实时识别已捕获的数值
// (只要听到了数值就以它为准,避免问题回声里的「跳过/没有」抢先误判为跳过)
const value = extractGlucoseValue(raw) ?? (voiceDialog.pendingValue || null)
voiceDialog.pendingValue = ''
if (value !== null) {
// 命中数值:回填并确认。注意:过渡播报里不要带数字,
// 否则下一题并行录音会把这句回声里的数字当成抢答而误触发。
inputForm.value[step.field] = value
voiceDialog.collected.push({ label: step.label, value })
voiceDialog.retries = 0
gotoNextStep(`好的,${step.label}已记录。`)
return
}
// 没有数字时再判断意图:取消 / 退出
if (raw && /退出|取消|关闭/.test(raw)) {
speakDialog('已为您关闭语音问答,可以手动填写。', () => stopVoiceDialog())
return
}
// 跳过当前项
if (raw && /跳过|没有|不测|没测|没量|不用|下一/.test(raw)) {
gotoNextStep(`好的,${step.label}先空着。`)
return
}
// 既没数字也没指令:没听清,重试或跳过
if (voiceDialog.retries < VOICE_DIALOG_MAX_RETRY) {
voiceDialog.retries += 1
askCurrentStep('没听清,')
} else {
voiceDialog.retries = 0
gotoNextStep(`暂时没听清${step.label},先空着。`)
}
}
/**
* 从口语文本中提取血糖数值,失败返回 null。
* 与手动输入保持一致:只要识别到正数即采纳,不做医学范围限制
* (避免「80」这类数值被误判为没听清)。
*/
function extractGlucoseValue(raw) {
if (!raw) return null
const norm = normalizeNumbers(raw)
const m = norm.match(/\d+(?:\.\d+)?/)
if (!m) return null
const num = Number(m[0])
if (!Number.isFinite(num) || num <= 0) return null
return m[0]
}
/** 进入下一步(带一句过渡播报) */
function gotoNextStep(prefix = '') {
voiceDialog.stepIndex += 1
voiceDialog.retries = 0
voiceDialog.errorRetries = 0
if (voiceDialog.stepIndex >= voiceDialog.steps.length) {
finishVoiceDialog(prefix)
} else {
askCurrentStep(prefix)
}
}
/** 全部问完:汇总并询问是否提交 */
function finishVoiceDialog(prefix = '') {
if (!voiceDialog.collected.length) {
speakDialog(`${prefix}还没有录入任何血糖数据,您可以手动填写或重新开始语音问答。`, () => stopVoiceDialog())
return
}
const summary = voiceDialog.collected.map((c) => `${c.label}${c.value}`).join('')
voiceDialog.stage = 'confirm'
voiceDialog.phase = 'asking'
voiceDialog.retries = 0
voiceDialog.heardText = ''
voiceDialog.botText = `${prefix}已为您填写:${summary}。要现在提交吗?说"提交"确认,或说"取消"。`
const token = voiceFlowToken
ttsSpeak(voiceDialog.botText, {
force: true,
onDone: () => {
if (token !== voiceFlowToken || !voiceDialog.active) return
listenForAnswer()
}
})
}
/** 处理确认环节的回答 */
function handleConfirmAnswer(raw) {
if (raw && /提交|确认|可以|好的|好了|是的|对|要|提交吧|保存/.test(raw)) {
speakDialog('好的,正在为您提交。', () => {
stopVoiceDialog(true)
submitInputForm()
})
return
}
if (raw && /取消|不提交|不要|先不|算了|退出|关闭/.test(raw)) {
speakDialog('已为您填好,请核对后手动提交。', () => stopVoiceDialog())
return
}
// 没听清,重问一次
if (voiceDialog.retries < VOICE_DIALOG_MAX_RETRY) {
voiceDialog.retries += 1
const token = voiceFlowToken
voiceDialog.stage = 'confirm'
voiceDialog.phase = 'asking'
voiceDialog.botText = '没听清,要现在提交吗?说"提交"或"取消"。'
ttsSpeak(voiceDialog.botText, {
force: true,
onDone: () => {
if (token !== voiceFlowToken || !voiceDialog.active) return
listenForAnswer()
}
})
} else {
speakDialog('已为您填好,请核对后手动提交。', () => stopVoiceDialog())
}
}
/** 仅播报一句话并在结束后回调(不进入聆听) */
function speakDialog(text, onDone) {
voiceDialog.botText = text
const token = voiceFlowToken
ttsSpeak(text, {
force: true,
onDone: () => {
if (token !== voiceFlowToken) return
if (typeof onDone === 'function') onDone()
}
})
}
/** 重听/重说当前问题 */
function replayCurrentQuestion() {
if (!voiceDialog.active) return
if (ttsSpeaking.value) ttsStop()
endHoldSpeech()
clearVoiceTimers()
voiceFlowToken += 1
if (voiceDialog.stage === 'confirm') {
finishVoiceDialog()
} else {
askCurrentStep()
}
}
/** 手动跳过当前项 */
function skipCurrentStepManually() {
if (!voiceDialog.active || voiceDialog.stage === 'confirm') return
if (ttsSpeaking.value) ttsStop()
endHoldSpeech()
clearVoiceTimers()
voiceFlowToken += 1
const step = currentDialogStep()
gotoNextStep(step ? `${step.label}先空着。` : '')
}
/** 停止/退出语音问答(始终停掉录音与播报);silent=true 时不再额外提示 */
function stopVoiceDialog() {
clearVoiceTimers()
voiceFlowToken += 1
endHoldSpeech()
ttsStop()
voiceDialog.active = false
voiceDialog.phase = 'idle'
voiceDialog.stage = 'collect'
voiceDialog.barged = false
voiceDialog.pendingValue = ''
voiceDialog.heardText = ''
}
const voiceDialogStatusText = computed(() => {
if (voiceDialog.phase === 'listening') {
return voiceDialog.stage === 'confirm' ? '按住作答区:说提交或取消,松手结束' : '按住下方作答区说话,松手结束'
}
if (voiceDialog.phase === 'asking') {
return '正在提问…'
}
return ''
})
function onExerciseField(field, e) {
exerciseForm.value[field] = e?.detail?.value ?? ''
}
async function openExerciseForm() {
if (!diagnosisId.value) {
showUserToast('请先选择就诊卡')
return
}
const token = uni.getStorageSync('token')
if (!token) {
showUserToast('请先登录后再录入')
return
}
prepareRecordOverlay()
exerciseForm.value = { ...emptyExerciseForm(), visible: true, loading: true }
try {
const res = await proxy.apiUrl({
url: '/api/tcm/dailyTodayExerciseRecord',
method: 'GET',
data: { diagnosis_id: diagnosisId.value }
}, false)
if (res?.code === 1 && res.data?.record) {
const r = res.data.record
exerciseForm.value.id = Number(r.id) || 0
exerciseForm.value.exercise_type = String(r.exercise_type || '')
exerciseForm.value.duration = numToStr(r.duration)
exerciseForm.value.intensity = Number(r.intensity) || 2
exerciseForm.value.note = String(r.note || '')
if (todayHasExercise.value) {
showUserToast('今日运动已录入,如需修改可在下方调整', { duration: 2600 })
}
}
} catch (e) {}
finally {
exerciseForm.value.loading = false
}
}
function closeExerciseForm() {
if (exerciseForm.value.submitting) return
cancelVoiceHold()
exerciseForm.value.visible = false
nextTick(() => {})
}
async function submitExerciseForm() {
if (exerciseForm.value.submitting || exerciseForm.value.loading) return
const f = exerciseForm.value
if (!f.exercise_type.trim() && !f.duration) {
showUserToast('请填写运动类型或时长')
return
}
exerciseForm.value.submitting = true
try {
const res = await proxy.apiUrl({
url: '/api/tcm/dailySaveExerciseRecord',
method: 'POST',
data: {
diagnosis_id: diagnosisId.value,
exercise_type: f.exercise_type,
duration: f.duration,
intensity: f.intensity,
note: f.note
}
}, false)
if (res?.code === 1) {
showUserToast(res.msg || '今日运动已录入完成', { icon: 'success' })
exerciseForm.value.visible = false
await fetchAll()
await fetchGamifyState()
promptWaterTreeGuide('运动')
} else {
showUserToast((res && res.msg) || '保存失败')
}
} catch (e) {
showUserToast('网络异常,请稍后再试')
} finally {
exerciseForm.value.submitting = false
}
}
async function submitBpForm() {
if (bpForm.value.submitting || bpForm.value.loading) return
// 至少填一个有效字段
const f = bpForm.value
const hasAny = [
f.systolic_pressure, f.diastolic_pressure
].some((v) => v !== '' && v !== null && Number(v) > 0)
|| !!(String(f.western_medicine || '').trim() || String(f.insulin || '').trim() || String(f.remark || '').trim())
if (!hasAny) {
uni.showToast({ title: '请至少填写一项数据', icon: 'none' })
return
}
bpForm.value.submitting = true
try {
const res = await proxy.apiUrl({
url: '/api/tcm/dailySaveBloodRecord',
method: 'POST',
data: {
diagnosis_id: diagnosisId.value,
systolic_pressure: f.systolic_pressure,
diastolic_pressure: f.diastolic_pressure,
western_medicine: f.western_medicine,
insulin: f.insulin,
remark: f.remark
}
}, false)
if (res && res.code === 1) {
showUserToast(res.msg || '今日血压已录入完成', { icon: 'success' })
bpForm.value.visible = false
await fetchAll()
await fetchGamifyState()
promptWaterTreeGuide('血压')
} else {
showUserToast((res && res.msg) || '保存失败')
}
} catch (e) {
uni.showToast({ title: '网络异常,请稍后再试', icon: 'none' })
} finally {
bpForm.value.submitting = false
}
}
function deleteBpRecord() {
if (!bpForm.value.id || bpForm.value.submitting) return
uni.showModal({
title: '删除确认',
content: '确定删除今天已录入的数据吗?删除后无法恢复。',
confirmColor: '#dc2626',
success: async (m) => {
if (!m.confirm) return
bpForm.value.submitting = true
try {
const res = await proxy.apiUrl({
url: '/api/tcm/dailyDeleteBloodRecord',
method: 'POST',
data: {
diagnosis_id: diagnosisId.value,
id: bpForm.value.id
}
}, false)
if (res && res.code === 1) {
uni.showToast({ title: '已删除', icon: 'success' })
bpForm.value.visible = false
fetchAll()
} else {
showUserToast((res && res.msg) || '删除失败')
}
} catch (e) {
uni.showToast({ title: '网络异常,请稍后再试', icon: 'none' })
} finally {
bpForm.value.submitting = false
}
}
})
}
function splitTrackingLines(content) {
return String(content || '')
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
}
// ============ 语音播报(适老化)============
// 优先用 微信同声传译插件 (mp-weixin)H5 走 window.speechSynthesis
// 都不可用则轻提示,避免 showModal 挡住录入弹层。
const ttsEnabled = ref(true)
const ttsAutoPlay = ref(false)
const ttsSpeaking = ref(false)
let ttsAudioCtx = null
let ttsAutoToken = 0
let ttsPlayToken = 0 // 每次 ttsStop/ttsSpeak 自增,作废被打断的异步播放(如抢答时仍在合成中的语音)
function teardownTtsAudio() {
if (ttsAudioCtx) {
try { ttsAudioCtx.stop() } catch (e) {}
try { ttsAudioCtx.destroy && ttsAudioCtx.destroy() } catch (e) {}
ttsAudioCtx = null
}
}
function ttsStop() {
ttsPlayToken += 1
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, '')
}
/**
* 文字转语音播报。
* @param {string|object|Array} text 播报内容
* @param {{ force?: boolean, onDone?: () => void }} [opts]
* - force: 忽略 ttsEnabled 开关强制播报(语音问答流程使用)
* - onDone: 播报结束/失败后回调(仅触发一次),用于串联「问→听」节奏
*/
function ttsSpeak(text, opts = {}) {
const { force = false, onDone } = opts || {}
let doneCalled = false
const fireDone = () => {
if (doneCalled) return
doneCalled = true
if (typeof onDone === 'function') {
try { onDone() } catch (e) {}
}
}
if (!ttsEnabled.value && !force) { fireDone(); return }
const content = normalizeTtsText(text)
if (!content) { fireDone(); return }
ttsStop()
const myToken = ++ttsPlayToken
// 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; fireDone() }
u.onerror = () => { ttsSpeaking.value = false; fireDone() }
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) => {
// 已被打断(如用户抢答触发 ttsStop):不要再播放这段迟到的语音
if (myToken !== ttsPlayToken) {
fireDone()
return
}
if (!res || !res.filename) {
ttsSpeaking.value = false
fireDone()
return
}
teardownTtsAudio()
ttsAudioCtx = uni.createInnerAudioContext()
ttsAudioCtx.src = res.filename
ttsAudioCtx.obeyMuteSwitch = false
ttsAudioCtx.onPlay(() => { ttsSpeaking.value = true })
ttsAudioCtx.onEnded(() => { ttsSpeaking.value = false; fireDone() })
ttsAudioCtx.onError(() => { ttsSpeaking.value = false; fireDone() })
ttsAudioCtx.onStop(() => { ttsSpeaking.value = false; fireDone() })
ttsAudioCtx.play()
},
fail: (err) => {
ttsSpeaking.value = false
console.warn('WechatSI textToSpeech failed', err)
uni.showToast({ title: '语音不可用,请检查网络/插件', icon: 'none', duration: 2000 })
fireDone()
}
})
return
} catch (e) {
console.warn('WechatSI plugin not available', e)
}
// #endif
// 其它端兜底:轻提示,勿用 modal 遮挡录入等操作
showUserToast(content, { duration: 3200 })
// 兜底估算播报时长后回调(按字数粗略估时)
setTimeout(fireDone, Math.min(6000, 1200 + content.length * 180))
}
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(series1, series2) {
const values = [...series1, ...series2].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 { yMin, yMax } = computeYRange(fastingSeries, postSeries)
const n = chartDates.value.length
const threshold = getBloodSugarThresholds(age.value)
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 seriesMeta = [
{
data: fastingSeries,
color: '#204E2B',
fillTop: 'rgba(32,78,43,0.18)',
fillBottom: 'rgba(32,78,43,0)',
label: '空腹',
highThreshold: threshold ? threshold.fasting : null
},
{
data: postSeries,
color: '#386641',
fillTop: 'rgba(56,102,65,0.14)',
fillBottom: 'rgba(56,102,65,0)',
label: '餐后',
highThreshold: threshold ? threshold.postprandial : null
}
]
const xAt = (i) => (n === 1 ? padding.left + cw / 2 : padding.left + (cw * i) / (n - 1))
const coordCache = []
// 7 天内显示圆点,30 天以纯曲线为主
const showDots = n <= 10
seriesMeta.forEach((meta) => {
const projected = meta.data.map((v, i) => ({
x: xAt(i),
y: v === null ? null : valueToY(v),
value: v
}))
coordCache.push(projected)
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: coordCache[0] || [],
postprandial: coordCache[1] || []
}
}
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 = chartCoords.fasting[idx]
const pp = chartCoords.postprandial[idx]
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)
}
// ============ 生命周期 ============
function readUserContext() {
try {
const userData = uni.getStorageSync('userData')
if (!userData) return
const name = String(userData.real_name || userData.nickname || '').trim()
if (!patientName.value && name) patientName.value = name
const sex = Number(userData.sex)
if (!gender.value && (sex === 1 || sex === 2)) gender.value = sex === 1 ? 1 : 0
if (!age.value && userData.age) age.value = Number(userData.age) || 0
} catch (e) {}
}
function initHeaderSafeArea() {
const sys = uni.getSystemInfoSync()
const statusBar = Number(sys.statusBarHeight) || 0
const greenBlockH = uni.upx2px(300)
let capsulePad = uni.upx2px(24)
try {
// #ifdef MP-WEIXIN
const menu = typeof uni.getMenuButtonBoundingClientRect === 'function'
? uni.getMenuButtonBoundingClientRect()
: null
if (menu && menu.width > 0) {
capsulePad = sys.windowWidth - menu.left + uni.upx2px(8)
}
// #endif
} catch (e) {}
const headerTotal = statusBar + greenBlockH
headerStyle.value = {
height: `${headerTotal-35}px`,
paddingTop: `${statusBar}px`,
boxSizing: 'border-box'
}
headerInnerStyle.value = {
paddingRight: `${capsulePad}px`
}
mainPadStyle.value = {
paddingTop: `${headerTotal + uni.upx2px(24)}px`
}
}
async function bootstrap(options) {
options = options || {}
initHeaderSafeArea()
gateRedirected = 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 {
diagnosisId.value = 0
patientId.value = 0
patientName.value = ''
age.value = 0
gender.value = 0
if (readUserMobile()) {
await ensureDailyContextByPhone()
}
if (!diagnosisId.value) {
authChecking.value = false
if (readUserMobile()) {
showUserToast('暂时无法加载记录,请稍后重试')
} else {
showUserToast('请先在统计页授权手机号后再查看记录')
}
setTimeout(() => {
const pages = getCurrentPages()
if (pages.length > 1) uni.navigateBack()
else uni.redirectTo({ url: '/tongji/pages/weekly' })
}, 1600)
return
}
}
authChecking.value = false
await fetchAll()
if (options.openDiet === '1' || options.openDiet === 1) {
await openDietForm({ autoFill: true })
}
}
onLoad((options) => {
bootstrap(options)
})
onShareAppMessage(() => {
const d = weeklyReportData.value
const code = pendingShareInviteCode.value
const title =
shareTitle.value ||
`【稳糖打卡】${d.patientLabel}本周记录${d.recordDays}天(邀请码今日有效)`
const path = code
? `/tongji/pages/index?from=share&invite_code=${code}`
: '/tongji/pages/more'
return { title, path }
})
onShow(() => {
initHeaderSafeArea()
if (authChecking.value || gateRedirected) return
if (diagnosisId.value) {
loadFamilyLikeSummary()
}
})
onPullDownRefresh(async () => {
if (await ensureLoggedIn()) {
await fetchAll()
}
uni.stopPullDownRefresh()
})
onHide(() => {
cancelVoiceHold()
stopVoiceDialog(true)
ttsStop()
})
onUnload(() => {
cancelVoiceHold()
stopVoiceDialog(true)
ttsStop()
clearWateringFx()
})
// ============ 稳糖乐园 (游戏化积分/小树) ============
const gamifyPoints = ref(0)
const gamifyBadges = ref([])
const gamifyTaskAwards = ref({})
const gamifyTodayTasks = ref([])
const gamifyClaimablePoints = ref(0)
const gamifyStatusTip = ref('')
const TREE_LEVEL_ROADMAP = TREE_LEVELS
const treeWaterLocked = ref(false)
const treePulseActive = ref(false)
function isApiSuccess(res) {
return !!(res && Number(res.code) === 1)
}
function applyGamifyPayload(data) {
if (!data || typeof data !== 'object') return
gamifyPoints.value = Number(data.points) || 0
gamifyBadges.value = Array.isArray(data.badges) ? [...data.badges] : []
try {
gamifyTaskAwards.value = data.task_awards ? JSON.parse(JSON.stringify(data.task_awards)) : {}
} catch (e) {
gamifyTaskAwards.value = data.task_awards || {}
}
gamifyTodayTasks.value = Array.isArray(data.today_tasks)
? normalizeGamifyTasks(data.today_tasks.map((t) => ({
id: String(t.id || ''),
name: String(t.name || ''),
points: Number(t.points) || 0,
completed: !!t.completed,
claimed: !!t.claimed
})))
: []
gamifyClaimablePoints.value = Number(data.claimable_points) || 0
if (data.message) {
gamifyStatusTip.value = String(data.message)
}
}
async function fetchGamifyState() {
if (!hasAuthToken() || !diagnosisId.value) return
try {
const res = await proxy.apiUrl({
url: '/api/tcm/dailyGetGamify',
method: 'GET',
data: { diagnosis_id: diagnosisId.value }
}, false)
if (isApiSuccess(res) && res.data) {
applyGamifyPayload(res.data)
}
} catch (e) {
console.error('获取游戏化数据失败', e)
}
}
const todayHasGlucose = computed(() => {
const b = bloodByDate.value[todayStr.value]
if (!b) return false
return toNumber(b.fasting_blood_sugar) !== null
|| toNumber(b.postprandial_blood_sugar) !== null
|| toNumber(b.other_blood_sugar) !== null
})
const todayHasBpPressure = computed(() => {
const b = bloodByDate.value[todayStr.value]
if (!b) return false
return toNumber(b.systolic_pressure) !== null || toNumber(b.diastolic_pressure) !== null
})
const todayHasDiet = computed(() => {
const today = todayStr.value
const d = dietByDate.value[today]
return !!(d && (d.breakfast_foods || d.lunch_foods || d.dinner_foods))
})
const todayHasExercise = computed(() => {
const today = todayStr.value
const ex = exerciseByDate.value[today]
return !!(ex && (ex.exercise_type || ex.duration))
})
const GAMIFY_TASK_ORDER = ['glucose', 'bp', 'diet', 'exercise']
const GAMIFY_TASK_ICONS = {
glucose: 'glucose',
bp: 'activity',
diet: 'utensils',
exercise: 'flame'
}
function isGamifyTaskClaimed(id, awards, completion = {}) {
if (awards[id]) return true
if (awards.blood) {
if (id === 'glucose' && completion.glucose) return true
if (id === 'bp' && completion.bp) return true
}
return false
}
function gamifyTaskCompletionMap() {
return {
glucose: todayHasGlucose.value,
bp: todayHasBpPressure.value,
diet: todayHasDiet.value,
exercise: todayHasExercise.value
}
}
function resolveGamifyTaskCompleted(id, fallback = false) {
const map = gamifyTaskCompletionMap()
if (Object.prototype.hasOwnProperty.call(map, id)) {
return !!map[id]
}
return !!fallback
}
function gamifyTaskIcon(id) {
return GAMIFY_TASK_ICONS[id] || 'check-circle'
}
function normalizeGamifyTasks(tasks) {
if (!Array.isArray(tasks) || !tasks.length) return []
const ids = new Set(tasks.map((t) => t.id))
if (ids.has('glucose') || ids.has('bp')) {
return tasks
}
if (ids.has('blood')) {
const blood = tasks.find((t) => t.id === 'blood')
const others = tasks.filter((t) => t.id !== 'blood')
if (!blood) return tasks
return [
{ ...blood, id: 'glucose', name: '测血糖' },
{ ...blood, id: 'bp', name: '测血压' },
...others
]
}
return tasks
}
const todayTasks = computed(() => {
const today = todayStr.value
const awards = gamifyTaskAwards.value[today] || {}
const completion = gamifyTaskCompletionMap()
const mapTask = (t) => ({
...t,
completed: resolveGamifyTaskCompleted(t.id, t.completed),
claimed: isGamifyTaskClaimed(t.id, awards, completion)
})
if (gamifyTodayTasks.value.length) {
return normalizeGamifyTasks(gamifyTodayTasks.value).map(mapTask)
}
return [
{
id: 'glucose',
name: '测血糖',
points: 10,
completed: completion.glucose,
claimed: isGamifyTaskClaimed('glucose', awards, completion)
},
{
id: 'bp',
name: '测血压',
points: 10,
completed: completion.bp,
claimed: isGamifyTaskClaimed('bp', awards, completion)
},
{
id: 'diet',
name: '饮食',
points: 10,
completed: completion.diet,
claimed: isGamifyTaskClaimed('diet', awards, completion)
},
{
id: 'exercise',
name: '运动',
points: 10,
completed: completion.exercise,
claimed: isGamifyTaskClaimed('exercise', awards, completion)
}
]
})
const displayTasksForUi = computed(() => {
const map = new Map(todayTasks.value.map((t) => [t.id, t]))
return GAMIFY_TASK_ORDER.map((id) => map.get(id)).filter(Boolean)
})
const claimablePoints = computed(() => {
return todayTasks.value
.filter((t) => t.completed && !t.claimed)
.reduce((sum, t) => sum + t.points, 0)
})
/** 有可领取任务积分时才执行浇水(与后端 dailyWaterTree 一致) */
const canWaterTree = computed(() => claimablePoints.value > 0)
/** 今日控糖任务(UI 展示项)是否全部完成并已领取 */
const displayTasksAllCleared = computed(() => {
const tasks = displayTasksForUi.value
return tasks.length > 0 && tasks.every((t) => t.completed && t.claimed)
})
/** 第一个未完成的展示任务,用于引导 */
const firstPendingDisplayTask = computed(() => {
return displayTasksForUi.value.find((t) => !t.completed) || null
})
const waterHintText = computed(() => {
if (canWaterTree.value) {
return `有 ${claimablePoints.value} 积分待领,点击浇水查看进度并领取`
}
if (displayTasksAllCleared.value) {
return '今日任务已全部完成,小树喝饱啦 🌱'
}
const pending = firstPendingDisplayTask.value
if (pending) {
return `还差「${pending.name}」等项,点击浇水查看今日进度`
}
return '点击浇水查看今日任务进度'
})
const allTasksCleared = computed(() => {
const tasks = todayTasks.value
return tasks.length > 0 && tasks.every((t) => t.completed && t.claimed)
})
const unlockedBadges = computed(() => {
const list = []
const streak = Number(streakDays.value?.days) || 0
if (todayChecked.value) {
list.push({ id: 'today', label: '今日打卡', icon: 'check-circle', color: '#386641' })
}
if (streak >= 3) {
list.push({ id: 'streak', label: `连击${streak}天`, icon: 'flame', color: '#EA580C' })
}
if (streak >= 7) {
list.push({ id: 'week', label: '一周达人', icon: 'trophy', color: '#CA8A04' })
}
if (last7CheckedCount.value >= 5) {
list.push({ id: 'w5', label: '本周5天+', icon: 'calendar', color: '#204E2B' })
}
if (gamifyPoints.value >= 50) {
list.push({ id: 'p50', label: '积分50+', icon: 'sparkles', color: '#204E2B' })
}
if (gamifyPoints.value >= 100) {
list.push({ id: 'p100', label: '积分破百', icon: 'trophy', color: '#7C3AED' })
}
if (gamifyPoints.value >= 200) {
list.push({ id: 'p200', label: '积分200+', icon: 'sparkles', color: '#7C3AED' })
}
if (treeLevel.value >= 3) {
list.push({ id: 'tree3', label: '小树苗', icon: 'glucose', color: '#0D9488' })
}
if (treeLevel.value >= 5) {
list.push({ id: 'tree5', label: '拔节高', icon: 'activity', color: '#386641' })
}
if (treeLevel.value >= 7) {
list.push({ id: 'tree7', label: '初绽香', icon: 'heart', color: '#EA580C' })
}
if (treeState.value.isMax) {
list.push({ id: 'tree9', label: '圆满树', icon: 'trophy', color: '#CA8A04' })
}
return list
})
const celebrateShow = ref(false)
const celebrateTitle = ref('')
const celebrateSub = ref('')
const floatReward = ref(null)
const inviteHeartBurst = ref(false)
const taskJustDoneId = ref('')
let celebrateTimer = null
let taskPopTimer = null
// ===== 浇水特效状态(对应 code.html 的 createRain / createSparkles / triggerSugarDecreaseEffect =====
const waterDroplets = ref([])
const sparkles = ref([])
const sugarDecrease = ref(null)
const sugarParticles = ref([])
let fxTimers = []
function clearWateringFx() {
fxTimers.forEach((t) => clearTimeout(t))
fxTimers = []
waterDroplets.value = []
sparkles.value = []
sugarDecrease.value = null
sugarParticles.value = []
}
/** 下雨水滴(点击即播,15 滴蓝色水珠) */
function startRain() {
const base = Date.now()
const drops = []
for (let i = 0; i < 15; i++) {
drops.push({
key: `d${base}_${i}`,
left: Math.random() * 100,
delay: Math.random() * 0.4
})
}
waterDroplets.value = drops
fxTimers.push(setTimeout(() => { waterDroplets.value = [] }, 600))
}
/** 600ms 后爆发:成长脉冲 + 闪光星 + 降糖飘字 + 全屏下落箭头(对应 code.html triggerGrowthPulse / createSparkles / triggerSugarDecreaseEffect */
function burstWateringFx() {
const base = Date.now()
const sps = []
for (let i = 0; i < 12; i++) {
sps.push({
key: `s${base}_${i}`,
left: 40 + (Math.random() - 0.5) * 40,
top: 40 + (Math.random() - 0.5) * 40,
dur: 0.6 + Math.random() * 0.4
})
}
sparkles.value = sps
fxTimers.push(setTimeout(() => { sparkles.value = [] }, 1000))
sugarDecrease.value = { key: `sd${base}` }
fxTimers.push(setTimeout(() => { sugarDecrease.value = null }, 2000))
const parts = []
for (let i = 0; i < 20; i++) {
parts.push({
key: `p${base}_${i}`,
left: Math.random() * 100,
size: 32 + Math.random() * 32,
delay: i * 0.05,
kind: Math.random() > 0.5 ? 'south' : 'tri'
})
}
sugarParticles.value = parts
fxTimers.push(setTimeout(() => { sugarParticles.value = [] }, 1500))
}
/** code.html:点击后 600ms 触发成长脉冲 + 全套特效 */
function scheduleWateringBurstFx() {
fxTimers.push(setTimeout(() => {
treePulseActive.value = true
burstWateringFx()
fxTimers.push(setTimeout(() => { treePulseActive.value = false }, 800))
}, 600))
}
const INVITE_TREE_WHISPERS = [
'Ta 正在努力控糖,您的鼓励很重要。',
'好习惯比完美数值更珍贵,一起加油。',
'每天记一笔,就是在照顾未来的自己。',
'点赞一下,让 Ta 知道家人在身边。'
]
function hapticLight() {
try {
uni.vibrateShort({ type: 'light' })
} catch (e) {}
}
function triggerCelebrate(title, sub = '') {
if (celebrateTimer) clearTimeout(celebrateTimer)
celebrateTitle.value = title
celebrateSub.value = sub
celebrateShow.value = true
celebrateTimer = setTimeout(() => {
celebrateShow.value = false
celebrateSub.value = ''
}, 2400)
}
function showFloatReward(points) {
floatReward.value = { text: points, key: Date.now() }
setTimeout(() => {
floatReward.value = null
}, 1100)
}
function popTaskDone(taskId) {
if (!taskId) return
taskJustDoneId.value = taskId
if (taskPopTimer) clearTimeout(taskPopTimer)
taskPopTimer = setTimeout(() => {
taskJustDoneId.value = ''
}, 700)
}
function onTreeWhisper() {
if (treeWaterLocked.value) return
const line = pickTreeWhisper(treeLevel.value)
showUserToast(line, { duration: 2800 })
hapticLight()
}
function onInviteTreeWhisper() {
const line = INVITE_TREE_WHISPERS[Math.floor(Math.random() * INVITE_TREE_WHISPERS.length)]
showUserToast(line, { duration: 2800 })
hapticLight()
}
/** 记录保存后引导用户给小树浇水领积分 */
function promptWaterTreeGuide(taskLabel = '', delayMs = 900) {
setTimeout(() => {
if (claimablePoints.value > 0) {
const prefix = taskLabel ? `今日${taskLabel}已录入完成,` : '今日已录入完成,'
showUserToast(
`${prefix}点击「给小树浇水」领取 ${claimablePoints.value} 积分`,
{ duration: 3200 }
)
return
}
const pending = firstPendingDisplayTask.value
if (pending) {
const prefix = taskLabel ? `今日${taskLabel}已录入完成。` : '今日该项已录入完成。'
showUserToast(
`${prefix}继续完成「${pending.name}」,再给小树浇水领积分`,
{ duration: 3200 }
)
return
}
if (displayTasksAllCleared.value) {
showUserToast('今日任务已全部完成,小树喝饱啦 🌱', { duration: 2800 })
}
}, delayMs)
}
async function waterTree() {
if (!diagnosisId.value) return
if (treeWaterLocked.value) return
if (!canWaterTree.value) return
const oldLevel = treeLevel.value
treeWaterLocked.value = true
clearWateringFx()
startRain()
scheduleWateringBurstFx()
hapticLight()
fxTimers.push(setTimeout(() => { treeWaterLocked.value = false }, 1400))
try {
const res = await proxy.apiUrl({
url: '/api/tcm/dailyWaterTree',
method: 'POST',
data: { diagnosis_id: diagnosisId.value }
}, false)
if (!isApiSuccess(res)) {
showUserToast((res && res.msg) || '浇水失败')
return
}
const data = res.data && typeof res.data === 'object' ? res.data : {}
const addedPoints = Number(data.added_points) || 0
applyGamifyPayload({
...data,
message: data.message || res.msg || ''
})
await nextTick()
const tip = formatUserMessage(data.message || res.msg, '')
if (addedPoints > 0) {
showFloatReward(addedPoints)
hapticLight()
closeWaterStatusPanel()
triggerCelebrate('浇水成功', tip || `+${addedPoints} 稳糖积分,小树咕咚喝饱啦`)
const claimedIds = Array.isArray(data.claimed_tasks) ? data.claimed_tasks : []
claimedIds.forEach((id) => popTaskDone(id))
const newLevel = treeLevel.value
if (newLevel > oldLevel) {
const meta = TREE_LEVELS[newLevel] || TREE_LEVELS[0]
setTimeout(() => triggerCelebrate('小树升级啦', `Lv.${newLevel} ${meta.name} · ${meta.desc}`), 520)
}
setTimeout(() => {
if (todayTasks.value.every((t) => t.completed && t.claimed)) {
triggerCelebrate('今日任务大满贯', '明天继续来浇浇水吧')
}
}, 900)
return
}
// 无可领积分:先同步 UI,再按服务端 pending 引导或提示
if (tip) {
showUserToast(tip, { duration: 2800 })
}
const needRecord = todayTasks.value.find((t) => !t.completed && !t.claimed)
if (needRecord) {
onGamifyTaskTap(needRecord)
return
}
const pending = Array.isArray(data.pending_tasks) ? data.pending_tasks : []
if (pending.length) {
const match = todayTasks.value.find((t) => t.id === pending[0].id)
|| todayTasks.value.find((t) => t.name === pending[0].name)
if (match && !match.completed) {
onGamifyTaskTap(match)
} else if (!tip) {
showUserToast(`先完成:${pending[0].name}`, { duration: 2800 })
}
return
}
if (Number(data.claimable_points) > 0) {
showUserToast('还有积分待领取,请再点一次浇水')
return
}
if (allTasksCleared.value && !tip) {
showUserToast('今日奖励已全部领取', { duration: 2800 })
}
} catch (e) {
showUserToast('浇水失败,请稍后重试')
}
}
const treeState = computed(() => calcTreeFromPoints(gamifyPoints.value))
const treeLevel = computed(() => treeState.value.level)
const treeProgress = computed(() => treeState.value.progress)
const treeLevelName = computed(() => treeState.value.name)
const treeEmoji = computed(() => speciesEmojiAtLevel(treeSpeciesId.value, treeLevel.value))
const treeMood = computed(() => treeState.value.mood)
// ===== 树种选择 =====
const treeSpeciesId = ref('classic')
const treeSpeciesPickerVisible = ref(false)
const speciesDaysToMax = estimateDaysToMaxLevel()
const currentTreeSpecies = computed(() => getTreeSpecies(treeSpeciesId.value))
function treeSpeciesStorageKey(dId = diagnosisId.value) {
return `tongji_tree_species_v1_${Number(dId) || 0}`
}
function loadTreeSpecies(dId = diagnosisId.value) {
if (!dId) {
treeSpeciesId.value = 'classic'
return
}
try {
const saved = uni.getStorageSync(treeSpeciesStorageKey(dId))
if (saved && getTreeSpecies(saved)) {
treeSpeciesId.value = saved
return
}
} catch (e) {}
treeSpeciesId.value = 'classic'
}
function selectTreeSpecies(id) {
if (!getTreeSpecies(id)) return
treeSpeciesId.value = id
try {
uni.setStorageSync(treeSpeciesStorageKey(), id)
} catch (e) {}
treeSpeciesPickerVisible.value = false
showUserToast(`已切换为「${getTreeSpecies(id).name}」`, { duration: 2200 })
hapticLight()
}
function openTreeSpeciesPicker() {
treeSpeciesPickerVisible.value = true
}
function closeTreeSpeciesPicker() {
treeSpeciesPickerVisible.value = false
}
// ===== 浇水 / 今日进度面板 =====
const waterStatusVisible = ref(false)
const waterStatusRows = computed(() => {
return displayTasksForUi.value.map((t) => {
let statusLabel = '未记录'
let statusKind = 'pending'
let statusSub = '今日尚未打开此项'
if (t.claimed) {
statusLabel = '已完成'
statusKind = 'claimed'
statusSub = '今日已录入,积分已领取'
} else if (t.completed) {
statusLabel = '待浇水'
statusKind = 'water'
statusSub = `今日已录入,浇水可领 +${t.points} 积分`
}
return { ...t, statusLabel, statusKind, statusSub }
})
})
const waterStatusSummary = computed(() => {
if (displayTasksAllCleared.value) {
return '今日四项任务已全部完成并领取,小树喝饱啦,明天继续加油 🌱'
}
const pending = displayTasksForUi.value.filter((t) => !t.completed)
const toWater = displayTasksForUi.value.filter((t) => t.completed && !t.claimed)
if (canWaterTree.value) {
return `今日已打开 ${toWater.length} 项记录,浇水可领 ${claimablePoints.value} 积分`
}
if (pending.length) {
return `今日还差 ${pending.length} 项未打开:${pending.map((t) => t.name).join('、')}`
}
return '先完成今日记录,再给小树浇水领积分'
})
const waterStatusGrowthHint = computed(() => {
const sp = currentTreeSpecies.value
if (treeState.value.isMax) {
return `${sp.name} 已满级 · 继续打卡巩固习惯`
}
return `${sp.name} · Lv.${treeLevel.value} ${treeLevelName.value} · 再积 ${treeState.value.pointsToNext} 分升级「${treeState.value.nextName}」`
})
function onWaterTreeTap() {
if (treeWaterLocked.value) return
openWaterStatusPanel()
hapticLight()
}
function openWaterStatusPanel() {
waterStatusVisible.value = true
}
function closeWaterStatusPanel() {
waterStatusVisible.value = false
}
function waterTreeFromPanel() {
closeWaterStatusPanel()
waterTree()
}
function goRecordFromWaterPanel() {
const pending = firstPendingDisplayTask.value
if (!pending) {
closeWaterStatusPanel()
return
}
closeWaterStatusPanel()
onGamifyTaskTap(pending, { skipGuide: true })
}
const treeProgressLabel = computed(() => {
if (treeState.value.isMax) return '已满级 · 圆满树'
return `${treeState.value.xpInLevel}/${treeState.value.xpNeed} XP`
})
const treeNextHint = computed(() => {
if (treeState.value.isMax) return '您已养成满级稳糖树,习惯就是最好的奖励'
if (claimablePoints.value > 0) return `浇水后可再积 ${claimablePoints.value} 分,离升级更近一步`
return `再积 ${treeState.value.pointsToNext} 分,升级「${treeState.value.nextName}」`
})
const treeXpUpgradeHint = computed(() => {
if (treeState.value.isMax) return '已满级,继续浇水巩固习惯'
const need = treeState.value.pointsToNext
const nextLv = treeLevel.value + 1
return `还差 ${need} XP 升级至 Lv.${nextLv}`
})
// ============ 分享 / 家人点赞 / 邀请观看 ============
const familyNicknameOptions = ['家人', '亲友', '老伴', '子女']
const weeklyReportVisible = ref(false)
const shareTitle = ref('')
const pendingShareInviteCode = ref('')
const familyLikeSummary = ref({
like_count: 0,
summary_line: '分享战报给家人,邀请他们为您点赞鼓劲',
recent: []
})
const lastFamilyLikeCount = ref(0)
const VIEWER_KEY_STORAGE = 'tongji_viewer_key_v1'
function familyLikeSeenStorageKey(dId = diagnosisId.value) {
return `tongji_family_like_seen_${Number(dId) || 0}_${todayStr.value}`
}
function getStoredFamilyLikeSeen(dId = diagnosisId.value) {
try {
return Number(uni.getStorageSync(familyLikeSeenStorageKey(dId))) || 0
} catch (e) {
return 0
}
}
function setStoredFamilyLikeSeen(count, dId = diagnosisId.value) {
try {
uni.setStorageSync(familyLikeSeenStorageKey(dId), Number(count) || 0)
} 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()}`
}
}
function getWeekOfYear(date) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
const dayNum = d.getUTCDay() || 7
d.setUTCDate(d.getUTCDate() + 4 - dayNum)
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7)
}
const weeklyReportData = computed(() => {
const name = (patientName.value || '我').trim()
const patientLabel = name.length > 6 ? `${name.slice(0, 6)}…` : name
const now = new Date()
const weekLabel = `${now.getFullYear()}年 第${getWeekOfYear(now)}周`
const rd = last7CheckedCount.value
const cd = weekCompleteDays.value
let quote = '坚持记录,就是在照顾未来的自己。'
if (rd >= 7) quote = '本周每天都留下了记录,这份自律值得骄傲!'
else if (cd >= 5) quote = `本周有 ${cd} 天完成了空腹+餐后记录,习惯越来越稳。`
else if (rd >= 4) quote = `本周已记录 ${rd} 天,比不记录强一百倍。`
else if (rd > 0) quote = '好的开始!下周试着多记录几天。'
else quote = '从今天记一笔开始,小树就会发芽。'
return {
patientLabel,
weekLabel,
recordDays: rd,
completeDays: cd,
streakDays: streakDays.value.days,
quote
}
})
const reportTreeLevel = computed(() => treeLevel.value)
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 ENCOURAGE_LINES = [
'每天记一笔,就是在照顾未来的自己。',
'坚持记录比纠结数值更重要,您做得很好。',
'给树浇浇水,也给自己点个赞。',
'家人会看到您的努力,习惯比完美更珍贵。',
'空腹、餐后各记一次,完整一日更有成就感。'
]
const encourageIndex = ref(0)
const encourageLine = computed(() => {
const pending = displayTasksForUi.value.find((t) => !t.completed)
if (pending?.id === 'glucose') return '先完成血糖记录,再给小树浇水领积分。'
if (pending?.id === 'bp') return '先测血压并记录,再给小树浇水领积分。'
if (pending?.id === 'diet') return '记下今日饮食,再给小树浇水领积分。'
if (pending?.id === 'exercise') return '动一动、记一记,再给小树浇水领积分。'
if (canWaterTree.value) {
return `有 ${claimablePoints.value} 积分待领,点击「给小树浇水」吧。`
}
if (displayTasksAllCleared.value) {
return '今日任务已全部完成,小树喝饱啦,明天继续加油。'
}
return ENCOURAGE_LINES[encourageIndex.value % ENCOURAGE_LINES.length]
})
const encourageParts = computed(() => {
const line = encourageLine.value
const highlights = ['给小树浇水', '今日第一格就亮起来']
for (const highlight of highlights) {
if (line.includes(highlight)) {
const i = line.indexOf(highlight)
return {
prefix: line.slice(0, i),
highlight,
suffix: line.slice(i + highlight.length)
}
}
}
return { prefix: line, highlight: '', suffix: '' }
})
function onEncourageTap() {
encourageIndex.value = (encourageIndex.value + 1) % ENCOURAGE_LINES.length
const line = ENCOURAGE_LINES[encourageIndex.value]
if (ttsEnabled.value) {
ttsSpeak(line)
} else {
showUserToast(line, { duration: 2800 })
}
}
function goBack() {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack({ delta: 1 })
return
}
uni.navigateTo({ url: '/tongji/pages/weekly' })
}
function goGlucosePage() {
uni.navigateTo({ url: '/tongji/pages/weekly' })
}
function onGamifyTaskTap(task, options = {}) {
if (!task) return
const skipGuide = !!options.skipGuide
if (!task.completed) {
if (resolveGamifyTaskCompleted(task.id, task.completed)) {
showUserToast(
`今日「${task.name}」已录入${task.claimed ? ',积分已领取' : ',请去浇水领积分'}`,
{ duration: 2800 }
)
if (!task.claimed) openWaterStatusPanel()
return
}
if (!diagnosisId.value) {
showUserToast('请先选择就诊卡')
return
}
const token = uni.getStorageSync('token')
if (!token) {
showUserToast('请先登录后再录入')
return
}
if (!skipGuide) {
showUserToast(`完成「${task.name}」后,点击「给小树浇水」领积分`, { duration: 2600 })
}
if (task.id === 'glucose') {
openInputForm()
return
}
if (task.id === 'bp') {
openBpForm()
return
}
if (task.id === 'diet') {
openDietForm()
return
}
if (task.id === 'exercise') {
openExerciseForm()
return
}
if (task.id === 'blood') {
openInputForm()
return
}
showUserToast(`请先完成:${task.name}`)
return
}
if (task.claimed) {
showUserToast(`今日「${task.name}」已录入,积分已领取`)
return
}
openWaterStatusPanel()
}
function shareInviteCacheKey() {
return `tongji_invite_${diagnosisId.value}_${todayStr.value}`
}
async function ensureShareInvite() {
if (!diagnosisId.value) return ''
const cacheKey = shareInviteCacheKey()
try {
const cached = uni.getStorageSync(cacheKey)
if (cached && cached.invite_code && cached.invite_date === todayStr.value) {
pendingShareInviteCode.value = cached.invite_code
return cached.invite_code
}
} catch (e) {}
try {
const res = await proxy.apiUrl({
url: '/api/tcm/dailyCreateShareInvite',
method: 'POST',
data: { diagnosis_id: diagnosisId.value }
}, false)
if (res && res.code === 1 && res.data && res.data.invite_code) {
const code = res.data.invite_code
pendingShareInviteCode.value = code
try {
uni.setStorageSync(cacheKey, {
invite_code: code,
invite_date: todayStr.value
})
} catch (e) {}
return code
}
if (res?.msg) showUserToast(res.msg)
} catch (e) {
showUserToast('生成邀请码失败')
}
return ''
}
async function prepareShareWeekly() {
const code = await ensureShareInvite()
if (!code) return
const d = weeklyReportData.value
shareTitle.value = `【稳糖打卡】${d.patientLabel}本周记录${d.recordDays}天,邀请码 ${code}(今日有效)`
}
function shareWeeklyFallback() {
prepareShareWeekly().then((code) => {
if (!code) return
const d = weeklyReportData.value
const text = `【稳糖打卡】${d.patientLabel} ${d.weekLabel}\n本周 ${d.recordDays} 天有记录、${d.completeDays} 个完整日、连续 ${d.streakDays}\n${d.quote}\n邀请码:${code}(仅今日有效)\n打开小程序「日常记录」输入邀请码查看`
uni.setClipboardData({
data: text,
success: () => showUserToast('战报文案已复制,可发给家人')
})
})
}
async function openWeeklyReport() {
if (!diagnosisId.value) {
showUserToast('请先选择就诊卡')
return
}
await ensureShareInvite()
weeklyReportVisible.value = true
}
function closeWeeklyReport() {
weeklyReportVisible.value = false
}
async function loadFamilyLikeSummary() {
if (!hasAuthToken() || !diagnosisId.value) return
const token = uni.getStorageSync('token')
if (!token) return
try {
const res = await proxy.apiUrl({
url: '/api/tcm/dailyFamilyLikeSummary',
method: 'GET',
data: { diagnosis_id: diagnosisId.value }
}, false)
if (res && res.code === 1 && res.data) {
const storedSeen = getStoredFamilyLikeSeen()
const prev = Math.max(lastFamilyLikeCount.value, storedSeen)
const next = Number(res.data.like_count) || 0
familyLikeSummary.value = {
like_count: next,
summary_line: res.data.summary_line || '',
recent: Array.isArray(res.data.recent) ? res.data.recent : []
}
// 仅在有「新增」点赞时庆祝,避免刷新/重进页面重复弹层
if (next > prev) {
const delta = next - prev
triggerCelebrate(
delta === 1 && prev === 0 ? '首个家人点赞' : '收到家人鼓励',
delta === 1 ? '分享真的有用,继续加油' : `新增 ${delta} 位家人点赞,共 ${next} 人`
)
hapticLight()
}
lastFamilyLikeCount.value = next
setStoredFamilyLikeSeen(next)
}
} catch (e) {}
}
function onFamilyLikeStripTap() {
if (familyLikeSummary.value.like_count > 0) {
if (ttsEnabled.value) {
ttsSpeak(familyLikeSummary.value.summary_line)
} else {
showUserToast(familyLikeSummary.value.summary_line, { duration: 2400 })
}
return
}
openWeeklyReport()
}
</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;
}
.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);
margin: 28rpx 24rpx 0;
padding: 30rpx 28rpx;
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;
font-size: 22rpx;
color: var(--slate-600);
}
.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 0;
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;
overflow: hidden;
}
@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;
}
.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: none;
height: 50vh;
max-height: 50vh;
padding: 20rpx 32rpx 28rpx;
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;
}
/* === 语音录入区(血糖弹窗) === */
.voice-entry-section {
margin-bottom: 24rpx;
padding: 22rpx 20rpx 20rpx;
border-radius: 22rpx;
background: #f8fafc;
border: 1rpx solid #e2e8f0;
}
.voice-entry-head {
display: flex;
flex-direction: column;
gap: 6rpx;
margin-bottom: 18rpx;
}
.voice-entry-title {
font-size: 28rpx;
font-weight: 700;
color: #334155;
letter-spacing: 1rpx;
}
.voice-entry-desc {
font-size: 24rpx;
color: #64748b;
line-height: 1.45;
}
.voice-entry-or {
display: flex;
align-items: center;
gap: 16rpx;
margin: 16rpx 0;
}
.voice-entry-or-line {
flex: 1;
height: 1rpx;
background: #e2e8f0;
}
.voice-entry-or-text {
font-size: 22rpx;
color: #94a3b8;
flex-shrink: 0;
}
/* === 语音录入条 === */
.voice-input-bar {
display: flex;
align-items: center;
gap: 18rpx;
padding: 20rpx 24rpx;
margin-bottom: 22rpx;
border-radius: 18rpx;
background: linear-gradient(135deg, #ecfdf5, #d1fae5);
border: 2rpx solid #a7f3d0;
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease, opacity 0.18s ease;
position: relative;
&:active {
transform: scale(0.985);
}
&.active {
background: linear-gradient(135deg, #006c49, #0d9488);
border-color: #006c49;
box-shadow: 0 10rpx 28rpx rgba(0, 108, 73, 0.3);
.voice-input-icon {
background: rgba(255, 255, 255, 0.22);
}
.voice-input-title { color: #ffffff; }
.voice-input-hint { color: rgba(255, 255, 255, 0.88); }
.voice-input-badge { background: rgba(255, 255, 255, 0.22); color: #ffffff; }
.voice-input-accent { background: rgba(255, 255, 255, 0.35); }
}
&.muted {
opacity: 0.55;
}
}
.voice-entry-section .voice-input-bar {
margin-bottom: 0;
overflow: hidden;
}
.voice-input-bar--primary {
min-height: 112rpx;
padding: 36rpx 20rpx 22rpx 28rpx;
box-shadow: 0 6rpx 20rpx rgba(0, 108, 73, 0.08);
align-items: center;
.voice-input-icon,
.voice-input-text,
.voice-input-hold-pill {
position: relative;
z-index: 2;
}
}
.voice-input-accent {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
background: #006c49;
z-index: 1;
}
.voice-input-badge {
position: absolute;
top: 0;
right: 0;
z-index: 2;
padding: 6rpx 16rpx;
border-radius: 0 16rpx 0 14rpx;
background: #006c49;
color: #ffffff;
font-size: 20rpx;
font-weight: 700;
letter-spacing: 1rpx;
line-height: 1.2;
}
.voice-input-hold-pill {
flex-shrink: 0;
padding: 14rpx 22rpx;
border-radius: 999rpx;
background: #ffffff;
border: 2rpx solid #a7f3d0;
box-shadow: 0 4rpx 12rpx rgba(0, 108, 73, 0.08);
text {
font-size: 24rpx;
font-weight: 700;
color: #006c49;
white-space: nowrap;
}
}
.voice-input-icon {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4rpx 12rpx rgba(0, 108, 73, 0.12);
overflow: visible;
}
.voice-input-text {
display: flex;
flex-direction: column;
gap: 4rpx;
flex: 1;
min-width: 0;
}
.voice-input-title {
font-size: 30rpx;
font-weight: 700;
color: #134e4a;
letter-spacing: 0.5rpx;
}
.voice-input-hint {
font-size: 24rpx;
color: #475569;
line-height: 1.4;
}
@keyframes voice-icon-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.12); }
}
/* === 语音录音浮层 === */
.voice-rec-overlay {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 12000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(15, 23, 42, 0.45);
}
.voice-rec-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 18rpx;
padding: 56rpx 64rpx;
border-radius: 28rpx;
background: rgba(0, 108, 73, 0.94);
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.3);
}
.voice-rec-icon {
width: 132rpx;
height: 132rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.18);
display: flex;
align-items: center;
justify-content: center;
&.active {
animation: voice-rec-wave 1.1s ease-in-out infinite;
}
}
.voice-rec-title {
font-size: 34rpx;
font-weight: 700;
color: #ffffff;
letter-spacing: 2rpx;
}
.voice-rec-sub {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.8);
}
@keyframes voice-rec-wave {
0%, 100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.4);
}
50% {
transform: scale(1.06);
box-shadow: 0 0 0 24rpx rgba(255, 255, 255, 0);
}
}
/* === 语音一问一答面板 === */
.voice-dialog {
margin-bottom: 22rpx;
border-radius: 20rpx;
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
border: 2rpx solid #bbf7d0;
overflow: hidden;
&.active {
border-color: #006c49;
box-shadow: 0 10rpx 28rpx rgba(0, 108, 73, 0.16);
}
}
.voice-entry-section .voice-dialog {
margin-bottom: 0;
}
.voice-dialog--secondary {
background: #ffffff;
border: 2rpx solid #e2e8f0;
&.active {
background: linear-gradient(180deg, #f0fdf4 0%, #ffffff 100%);
border-color: #006c49;
}
.voice-dialog-orb {
background: #ecfdf5;
border: 2rpx solid #a7f3d0;
&.speaking {
background: #0d9488;
border-color: #0d9488;
}
&.listening {
background: #006c49;
border-color: #006c49;
}
}
.voice-dialog-start {
background: #ffffff;
color: #006c49;
border: 2rpx solid #006c49;
}
}
.voice-dialog-title-row {
display: flex;
align-items: center;
gap: 10rpx;
flex-wrap: wrap;
}
.voice-dialog-tag {
padding: 2rpx 12rpx;
border-radius: 999rpx;
font-size: 20rpx;
font-weight: 600;
color: #64748b;
background: #f1f5f9;
border: 1rpx solid #e2e8f0;
}
.voice-dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
gap: 16rpx;
}
.voice-dialog-title-wrap {
display: flex;
align-items: center;
gap: 16rpx;
flex: 1;
min-width: 0;
}
.voice-dialog-orb {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: #0d9488;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.speaking { animation: voice-icon-pulse 1.3s ease-in-out infinite; }
&.listening {
background: #006c49;
animation: voice-rec-wave 1.1s ease-in-out infinite;
}
}
.voice-dialog-title-text {
display: flex;
flex-direction: column;
gap: 4rpx;
min-width: 0;
}
.voice-dialog-title {
font-size: 30rpx;
font-weight: 700;
color: #134e4a;
letter-spacing: 0.5rpx;
}
.voice-dialog-tip {
font-size: 23rpx;
color: #64748b;
line-height: 1.45;
}
.voice-dialog-start,
.voice-dialog-stop {
flex-shrink: 0;
min-height: 64rpx;
padding: 0 28rpx;
border-radius: 999rpx;
font-size: 26rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
&:active { transform: scale(0.97); }
}
.voice-dialog-stop {
background: #f1f5f9;
color: #475569;
border: 2rpx solid #e2e8f0;
}
.voice-dialog-body {
padding: 0 24rpx 22rpx;
display: flex;
flex-direction: column;
gap: 14rpx;
border-top: 1rpx solid #e2e8f0;
margin-top: 4rpx;
padding-top: 18rpx;
}
.voice-dialog-hold-pad {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12rpx;
min-height: 168rpx;
padding: 28rpx 24rpx;
border-radius: 18rpx;
background: linear-gradient(135deg, #ecfdf5, #d1fae5);
border: 2rpx solid #a7f3d0;
transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
&:active,
&.active {
background: linear-gradient(135deg, #006c49, #0d9488);
border-color: #006c49;
box-shadow: 0 10rpx 28rpx rgba(0, 108, 73, 0.24);
.voice-dialog-hold-title,
.voice-dialog-hold-heard { color: #ffffff; }
.voice-dialog-hold-icon { background: rgba(255, 255, 255, 0.22); }
}
}
.voice-dialog-hold-icon {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(0, 108, 73, 0.12);
}
.voice-dialog-hold-title {
font-size: 28rpx;
font-weight: 700;
color: #134e4a;
letter-spacing: 0.5rpx;
}
.voice-dialog-hold-heard {
font-size: 24rpx;
color: #475569;
max-width: 100%;
text-align: center;
line-height: 1.4;
}
.voice-dialog-bubble {
max-width: 86%;
padding: 16rpx 20rpx;
border-radius: 18rpx;
font-size: 28rpx;
line-height: 1.5;
&.bot {
align-self: flex-start;
background: #ffffff;
color: #1f2937;
border: 2rpx solid #d1fae5;
border-bottom-left-radius: 6rpx;
}
&.user {
align-self: flex-end;
background: #006c49;
color: #ffffff;
border-bottom-right-radius: 6rpx;
}
}
.voice-dialog-status {
display: flex;
align-items: center;
gap: 14rpx;
min-height: 36rpx;
}
.voice-dialog-status-text {
font-size: 24rpx;
color: #475569;
}
.voice-dialog-wave {
display: flex;
align-items: flex-end;
gap: 6rpx;
height: 32rpx;
}
.voice-dialog-wave-bar {
width: 6rpx;
border-radius: 4rpx;
background: #006c49;
animation: voice-dialog-wave-jump 0.9s ease-in-out infinite;
&:nth-child(1) { height: 14rpx; animation-delay: 0s; }
&:nth-child(2) { height: 26rpx; animation-delay: 0.15s; }
&:nth-child(3) { height: 20rpx; animation-delay: 0.3s; }
&:nth-child(4) { height: 30rpx; animation-delay: 0.45s; }
}
@keyframes voice-dialog-wave-jump {
0%, 100% { transform: scaleY(0.5); }
50% { transform: scaleY(1); }
}
.voice-dialog-actions {
display: flex;
gap: 14rpx;
margin-top: 4rpx;
}
.voice-dialog-btn {
flex: 1;
text-align: center;
padding: 16rpx 0;
border-radius: 14rpx;
font-size: 26rpx;
font-weight: 600;
color: #006c49;
background: rgba(0, 108, 73, 0.08);
&:active { transform: scale(0.98); }
&.primary {
color: #ffffff;
background: #006c49;
}
}
.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;
flex-shrink: 0;
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
border-top: 1rpx solid #eef6ee;
background: #ffffff;
}
.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: opacity 0.2s ease, transform 0.2s ease;
&.primary {
background: #006c49;
color: #ffffff;
box-shadow: 0 8rpx 24rpx rgba(0, 108, 73, 0.25);
&:active {
transform: translateY(1rpx);
opacity: 0.92;
}
}
&.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/stitch-vitalmint-theme.scss';
@import '../styles/more-stitch.scss';
@import '../styles/daily-theme.scss';
.input-modal-ai-tip {
margin-bottom: 20rpx;
padding: 20rpx;
border-radius: 16rpx;
background: rgba(32, 78, 43, 0.04);
border: 1rpx solid rgba(32, 78, 43, 0.12);
}
.input-modal-ai-tip-label {
display: block;
font-size: 24rpx;
font-weight: 700;
color: var(--primary);
margin-bottom: 8rpx;
}
.input-modal-ai-tip-text {
display: block;
font-size: 24rpx;
line-height: 1.5;
color: var(--primary-container);
}
.input-modal-ai-tip-btn {
display: inline-flex;
margin-top: 12rpx;
padding: 8rpx 20rpx;
border-radius: 999rpx;
border: 2rpx solid var(--primary);
color: var(--primary);
font-size: 24rpx;
font-weight: 600;
}
</style>