Files
zyt/TUICallKit-Vue3/tongji/pages/index.vue
T
2026-05-26 18:05:48 +08:00

4221 lines
130 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="daily-page">
<!-- 顶部 Hero智能问候 + 今日状态-->
<view class="hero">
<view class="hero-bg" />
<!-- 漂浮装饰圆 —— 增添视觉层次 -->
<view class="hero-orb hero-orb-1" />
<view class="hero-orb hero-orb-2" />
<view class="hero-orb hero-orb-3" />
<view class="hero-content">
<view class="hero-row">
<view class="hero-main">
<view class="hero-greet-row">
<text class="hero-greet-emoji">{{ timeGreetingEmoji }}</text>
<text class="hero-greet-text">{{ timeGreeting }}</text>
</view>
<text class="hero-title">{{ patientName || '健康档案' }}</text>
<view class="hero-subtitle-row">
<text class="hero-subtitle">
{{ ageText }}<text v-if="genderText"> · {{ genderText }}</text>
</text>
<view
class="hero-status-chip"
:class="todayChecked ? 'done' : 'pending'"
>
<text class="hero-status-icon">{{ todayChecked ? '✓' : '!' }}</text>
<text class="hero-status-text">{{ todayChecked ? '今日已记录' : '今日待记录' }}</text>
</view>
</view>
</view>
<view class="hero-pill-group">
<view class="hero-pill input" @click="openInputForm">
<text class="hero-pill-icon"></text>
<text class="hero-pill-text">录入今日</text>
</view>
<view
class="hero-pill voice"
:class="{ speaking: ttsSpeaking, disabled: !ttsEnabled }"
@click="ttsSpeaking ? ttsStop() : speakLatestSummary()"
>
<text class="hero-pill-icon">{{ ttsSpeaking ? '⏸' : '🔊' }}</text>
<text class="hero-pill-text">{{ ttsSpeaking ? '停止' : '朗读' }}</text>
</view>
<view class="hero-pill" @click="onRefresh">
<text class="hero-pill-icon"></text>
<text class="hero-pill-text">刷新</text>
</view>
</view>
</view>
</view>
</view>
<!-- 三大核心指标卡白底悬浮强调最近健康数值-->
<view class="stat-grid">
<view
class="stat-card stat-fasting"
:class="{ 'has-data': latestStat.fasting.value !== '—', 'is-high': latestStat.fasting.isHigh }"
>
<view class="stat-card-head">
<view class="stat-card-tag">
<text class="stat-card-tag-dot" />
<text class="stat-card-tag-text">空腹血糖</text>
</view>
<view
v-if="latestStat.fasting.isHigh"
class="stat-card-flag"
>
<text class="stat-card-flag-icon"></text>
<text class="stat-card-flag-text">偏高</text>
</view>
</view>
<view class="stat-card-value-row">
<text class="stat-card-value" :class="{ 'is-high': latestStat.fasting.isHigh }">
{{ latestStat.fasting.value }}
</text>
<text class="stat-card-unit">mmol/L</text>
</view>
<view class="stat-card-foot">
<text class="stat-card-date">{{ latestStat.fasting.date }}</text>
<view
v-if="latestStat.fasting.trend"
class="stat-card-trend"
:class="`trend-${latestStat.fasting.trend.dir}`"
>
<text class="stat-card-trend-arrow">
{{ latestStat.fasting.trend.dir === 'up' ? '↑' : latestStat.fasting.trend.dir === 'down' ? '↓' : '→' }}
</text>
<text class="stat-card-trend-delta">{{ latestStat.fasting.trend.deltaText }}</text>
</view>
</view>
</view>
<view
class="stat-card stat-postprandial"
:class="{ 'has-data': latestStat.postprandial.value !== '—', 'is-high': latestStat.postprandial.isHigh }"
>
<view class="stat-card-head">
<view class="stat-card-tag">
<text class="stat-card-tag-dot" />
<text class="stat-card-tag-text">餐后血糖</text>
</view>
<view
v-if="latestStat.postprandial.isHigh"
class="stat-card-flag"
>
<text class="stat-card-flag-icon"></text>
<text class="stat-card-flag-text">偏高</text>
</view>
</view>
<view class="stat-card-value-row">
<text class="stat-card-value" :class="{ 'is-high': latestStat.postprandial.isHigh }">
{{ latestStat.postprandial.value }}
</text>
<text class="stat-card-unit">mmol/L</text>
</view>
<view class="stat-card-foot">
<text class="stat-card-date">{{ latestStat.postprandial.date }}</text>
<view
v-if="latestStat.postprandial.trend"
class="stat-card-trend"
:class="`trend-${latestStat.postprandial.trend.dir}`"
>
<text class="stat-card-trend-arrow">
{{ latestStat.postprandial.trend.dir === 'up' ? '↑' : latestStat.postprandial.trend.dir === 'down' ? '↓' : '→' }}
</text>
<text class="stat-card-trend-delta">{{ latestStat.postprandial.trend.deltaText }}</text>
</view>
</view>
</view>
<view
class="stat-card stat-other"
:class="{ 'has-data': latestStat.other.value !== '—', 'is-high': latestStat.other.isHigh }"
>
<view class="stat-card-head">
<view class="stat-card-tag">
<text class="stat-card-tag-dot" />
<text class="stat-card-tag-text">其他血糖</text>
</view>
<view
v-if="latestStat.other.isHigh"
class="stat-card-flag"
>
<text class="stat-card-flag-icon"></text>
<text class="stat-card-flag-text">偏高</text>
</view>
</view>
<view class="stat-card-value-row">
<text class="stat-card-value" :class="{ 'is-high': latestStat.other.isHigh }">
{{ latestStat.other.value }}
</text>
<text class="stat-card-unit">mmol/L</text>
</view>
<view class="stat-card-foot">
<text class="stat-card-date">{{ latestStat.other.date }}</text>
<view
v-if="latestStat.other.trend"
class="stat-card-trend"
:class="`trend-${latestStat.other.trend.dir}`"
>
<text class="stat-card-trend-arrow">
{{ latestStat.other.trend.dir === 'up' ? '↑' : latestStat.other.trend.dir === 'down' ? '↓' : '→' }}
</text>
<text class="stat-card-trend-delta">{{ latestStat.other.trend.deltaText }}</text>
</view>
</view>
</view>
</view>
<!-- 打卡条连续天数 + 7 dots —— 增加坚持感 -->
<view class="streak-strip" @click="openInputForm">
<view class="streak-strip-left">
<view class="streak-flame" :class="{ active: streakDays.days >= 3 }">
<text class="streak-flame-icon">{{ streakDays.days === 0 ? '✨' : '🔥' }}</text>
</view>
<view class="streak-info">
<view class="streak-num-row">
<text class="streak-num">{{ streakDays.days }}</text>
<text class="streak-num-unit"></text>
<text v-if="streakDays.days > 0 && streakDays.startedFromYesterday" class="streak-hint">截至昨天</text>
</view>
<text class="streak-label">{{ streakDays.days === 0 ? '记录身体每一天的变化' : '连续记录,坚持就是胜利' }}</text>
</view>
</view>
<view class="streak-strip-right">
<view class="streak-dots-row">
<view
v-for="dot in last7Dots"
:key="dot.date"
class="streak-dot"
:class="{ done: dot.hasRecord, today: dot.isToday }"
>
<text class="streak-dot-label">{{ dot.label }}</text>
<view class="streak-dot-mark" />
</view>
</view>
<text class="streak-week-text">本周 {{ last7CheckedCount }} / 7</text>
</view>
</view>
<!-- 就诊卡选择器精简为药丸 chip-->
<view v-if="cards.length > 0" class="card-switcher">
<view class="card-switcher-head">
<text class="card-switcher-label">就诊卡</text>
<text class="card-switcher-count">{{ cards.length }} </text>
</view>
<scroll-view scroll-x class="card-switcher-scroll" :show-scrollbar="false">
<view class="card-chip-row">
<view
v-for="card in cards"
:key="card.id"
class="card-chip"
:class="{ active: Number(card.id) === Number(diagnosisId) }"
@click="onSelectCard(card)"
>
<text class="card-chip-name">{{ card.patient_name || '未填' }}</text>
<text v-if="card.age" class="card-chip-meta">{{ card.age }}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 时间范围 -->
<view class="range-bar">
<view
v-for="opt in rangeOptions"
:key="opt.value"
class="range-item"
:class="{ active: rangeMode === opt.value }"
@click="onChangeRange(opt.value)"
>
<text>{{ opt.label }}</text>
</view>
</view>
<!-- 语音播报设置适老化-->
<view class="voice-bar">
<view class="voice-bar-left">
<text class="voice-bar-icon">🔊</text>
<view class="voice-bar-text">
<text class="voice-bar-title">语音播报</text>
<text class="voice-bar-hint">为长辈准备 · 大字号 · 慢语速</text>
</view>
</view>
<view class="voice-bar-actions">
<view
class="voice-switch"
:class="{ on: ttsAutoPlay }"
@click="ttsToggleAutoPlay"
>
<text class="voice-switch-label">自动</text>
<view class="voice-switch-track">
<view class="voice-switch-knob" />
</view>
</view>
<view
class="voice-toggle"
:class="{ off: !ttsEnabled }"
@click="ttsToggleEnabled"
>
<text class="voice-toggle-icon">{{ ttsEnabled ? '🔊' : '🔇' }}</text>
</view>
</view>
</view>
<!-- 血糖波浪图 -->
<view class="card chart-card">
<view class="card-head">
<view class="card-head-left">
<text class="card-title">血糖趋势</text>
<text class="card-subtitle">{{ rangeText }} · mmol/L</text>
</view>
<view class="card-legend">
<view class="legend-item">
<view class="legend-dot legend-dot-fasting" />
<text>空腹</text>
</view>
<view class="legend-item">
<view class="legend-dot legend-dot-postprandial" />
<text>餐后</text>
</view>
</view>
</view>
<view v-if="bloodHasData && bloodHighDayCount > 0" class="chart-summary">
<view class="chart-summary-item">
<text class="chart-summary-num">{{ bloodHighDayCount }}</text>
<text class="chart-summary-label">天偏高</text>
</view>
<view class="chart-summary-item">
<text class="chart-summary-num">{{ bloodNormalDayCount }}</text>
<text class="chart-summary-label">天正常</text>
</view>
<view class="chart-summary-item">
<text class="chart-summary-num">{{ bloodRecordDayCount }}</text>
<text class="chart-summary-label">天有记录</text>
</view>
</view>
<view class="chart-canvas-wrap">
<!--
注意微信小程序 <canvas> 是原生组件z-index 无法被普通 view 覆盖
打开任何弹层时必须 v-if 卸载它避免穿透到弹层上方
-->
<canvas
v-if="!inputForm.visible"
id="bloodWaveChart"
type="2d"
class="chart-canvas"
@touchstart="onChartTouch"
@touchmove="onChartTouch"
/>
<view v-if="inputForm.visible" class="chart-canvas-placeholder">
<text>图表已暂停显示</text>
</view>
<view v-if="!bloodHasData && !loading && !inputForm.visible" class="chart-empty">
<text class="chart-empty-icon">📈</text>
<text class="chart-empty-text">当前时间范围暂无血糖数据</text>
</view>
<view v-if="hoverInfo && !inputForm.visible" class="chart-tip" :style="{ left: hoverInfo.left + 'px', top: hoverInfo.top + 'px' }">
<text class="chart-tip-date">{{ hoverInfo.date }}</text>
<view v-if="hoverInfo.fasting != null" class="chart-tip-row">
<view class="legend-dot legend-dot-fasting" />
<text class="chart-tip-text">空腹 {{ hoverInfo.fasting }}</text>
</view>
<view v-if="hoverInfo.postprandial != null" class="chart-tip-row">
<view class="legend-dot legend-dot-postprandial" />
<text class="chart-tip-text">餐后 {{ hoverInfo.postprandial }}</text>
</view>
</view>
</view>
</view>
<!-- 健康日历与手机日历一致的月历布局老人一眼能看懂 -->
<view v-if="heatmapWeeks.length > 0" class="card calendar-card">
<view class="calendar-head">
<view class="calendar-head-left">
<text class="card-title">健康日历</text>
<text class="card-subtitle">{{ heatmapMonthLabel }} · 已记录 {{ heatmapTotalRecords }} / {{ heatmapInRangeDays }} </text>
</view>
<view class="calendar-stat">
<text class="calendar-stat-num">{{ heatmapTotalRecords }}</text>
<text class="calendar-stat-label">天有记录</text>
</view>
</view>
<!-- 星期表头 -->
<view class="calendar-weekdays">
<text class="calendar-weekday"></text>
<text class="calendar-weekday"></text>
<text class="calendar-weekday"></text>
<text class="calendar-weekday"></text>
<text class="calendar-weekday"></text>
<text class="calendar-weekday"></text>
<text class="calendar-weekday calendar-weekday-weekend"></text>
</view>
<!-- 月历主体星期 -->
<view class="calendar-grid">
<view
v-for="(week, wi) in heatmapWeeks"
:key="`wk-${wi}`"
class="calendar-row"
>
<view
v-for="cell in week"
:key="cell.date"
class="calendar-cell"
:class="[
cell.level < 0 ? 'off' : `level-${cell.level}`,
{
today: cell.isToday,
clickable: cell.inRange && cell.level > 0
}
]"
@click="cell.inRange && cell.level > 0 ? jumpToDayRecord(cell.date) : null"
>
<text class="calendar-cell-day">{{ cell.day }}</text>
<view v-if="cell.level > 0" class="calendar-cell-mark" />
<view v-if="cell.isToday" class="calendar-cell-today-tag">
<text>今天</text>
</view>
</view>
</view>
</view>
<!-- 图例 + 行动号召 -->
<view class="calendar-legend">
<view class="calendar-legend-row">
<view class="calendar-legend-item">
<view class="calendar-legend-cell level-1" />
<text class="calendar-legend-text">少量</text>
</view>
<view class="calendar-legend-item">
<view class="calendar-legend-cell level-2" />
<text class="calendar-legend-text">较多</text>
</view>
<view class="calendar-legend-item">
<view class="calendar-legend-cell level-3" />
<text class="calendar-legend-text">完整</text>
</view>
<view class="calendar-legend-item">
<view class="calendar-legend-cell legend-today" />
<text class="calendar-legend-text">今日</text>
</view>
</view>
</view>
<view class="heatmap-foot">
<text class="heatmap-foot-text">{{ todayChecked ? '今日已完成记录' : '今日还没有记录,加油' }}</text>
<view v-if="!todayChecked" class="heatmap-foot-cta" @click="openInputForm">
<text>立即录入</text>
</view>
</view>
</view>
<!-- 日常记录列表 -->
<view class="records-section">
<view class="records-head">
<text class="card-title">日常记录</text>
<text class="records-count"> {{ recordDays.length }} </text>
</view>
<view v-if="loading" class="state-card">
<text class="state-text">加载中...</text>
</view>
<view v-else-if="recordDays.length === 0" class="state-card">
<text class="state-icon">📭</text>
<text class="state-text">该时段暂无任何记录</text>
</view>
<view
v-for="day in recordDays"
:key="day.date"
:id="`day-${day.date.replace(/-/g, '')}`"
class="day-card"
>
<view class="day-head">
<view class="day-head-left">
<text class="day-date">{{ day.dateLabel }}</text>
<text v-if="day.isToday" class="day-today-pill">今天</text>
</view>
<view class="day-head-right">
<text class="day-weekday">{{ day.weekday }}</text>
<view class="day-speak-btn" @click="speakDay(day)">
<text class="day-speak-icon">🔊</text>
<text class="day-speak-text">朗读</text>
</view>
</view>
</view>
<!-- 血糖血压 -->
<view v-if="day.blood" class="day-block">
<view class="day-block-head">
<view class="day-block-tag tag-blood">血糖血压</view>
<text v-if="day.blood.record_time" class="day-block-time">{{ day.blood.record_time }}</text>
</view>
<view class="metric-grid">
<view class="metric-cell">
<view class="metric-row-head">
<text class="metric-label">空腹</text>
<view
v-if="day.blood.isHighFasting"
class="metric-flag up"
>
<text class="metric-flag-icon"></text>
<text class="metric-flag-text">偏高</text>
</view>
</view>
<view class="metric-value-row">
<text class="metric-value" :class="{ 'is-high': day.blood.isHighFasting }">
{{ formatValue(day.blood.fasting_blood_sugar) }}
</text>
<text class="metric-unit">mmol/L</text>
</view>
<view
v-if="getDayTrend(day.date, 'fasting_blood_sugar')"
class="metric-trend"
:class="`trend-${getDayTrend(day.date, 'fasting_blood_sugar').dir}`"
>
<text class="metric-trend-arrow">
{{ getDayTrend(day.date, 'fasting_blood_sugar').dir === 'up' ? '↑' : getDayTrend(day.date, 'fasting_blood_sugar').dir === 'down' ? '↓' : '→' }}
</text>
<text class="metric-trend-text">{{ getDayTrend(day.date, 'fasting_blood_sugar').deltaText }}</text>
</view>
</view>
<view class="metric-cell">
<view class="metric-row-head">
<text class="metric-label">餐后</text>
<view
v-if="day.blood.isHighPostprandial"
class="metric-flag up"
>
<text class="metric-flag-icon"></text>
<text class="metric-flag-text">偏高</text>
</view>
</view>
<view class="metric-value-row">
<text class="metric-value" :class="{ 'is-high': day.blood.isHighPostprandial }">
{{ formatValue(day.blood.postprandial_blood_sugar) }}
</text>
<text class="metric-unit">mmol/L</text>
</view>
<view
v-if="getDayTrend(day.date, 'postprandial_blood_sugar')"
class="metric-trend"
:class="`trend-${getDayTrend(day.date, 'postprandial_blood_sugar').dir}`"
>
<text class="metric-trend-arrow">
{{ getDayTrend(day.date, 'postprandial_blood_sugar').dir === 'up' ? '↑' : getDayTrend(day.date, 'postprandial_blood_sugar').dir === 'down' ? '↓' : '→' }}
</text>
<text class="metric-trend-text">{{ getDayTrend(day.date, 'postprandial_blood_sugar').deltaText }}</text>
</view>
</view>
<view class="metric-cell">
<view class="metric-row-head">
<text class="metric-label">血压</text>
<view
v-if="day.blood.isHighBp"
class="metric-flag up"
>
<text class="metric-flag-icon"></text>
<text class="metric-flag-text">偏高</text>
</view>
</view>
<view class="metric-value-row">
<text class="metric-value" :class="{ 'is-high': day.blood.isHighBp }">
{{ day.blood.bpText }}
</text>
<text class="metric-unit">mmHg</text>
</view>
<view
v-if="getDayBpTrend(day.date)"
class="metric-trend"
:class="`trend-${getDayBpTrend(day.date).dir}`"
>
<text class="metric-trend-arrow">
{{ getDayBpTrend(day.date).dir === 'up' ? '↑' : getDayBpTrend(day.date).dir === 'down' ? '↓' : '→' }}
</text>
<text class="metric-trend-text">收缩压 {{ getDayBpTrend(day.date).deltaText }}</text>
</view>
</view>
<view v-if="day.blood.other_blood_sugar" class="metric-cell">
<view class="metric-row-head">
<text class="metric-label">其他血糖</text>
<view
v-if="day.blood.isHighOther"
class="metric-flag up"
>
<text class="metric-flag-icon"></text>
<text class="metric-flag-text">偏高</text>
</view>
</view>
<view class="metric-value-row">
<text class="metric-value" :class="{ 'is-high': day.blood.isHighOther }">
{{ formatValue(day.blood.other_blood_sugar) }}
</text>
<text class="metric-unit">mmol/L</text>
</view>
<view
v-if="getDayTrend(day.date, 'other_blood_sugar')"
class="metric-trend"
:class="`trend-${getDayTrend(day.date, 'other_blood_sugar').dir}`"
>
<text class="metric-trend-arrow">
{{ getDayTrend(day.date, 'other_blood_sugar').dir === 'up' ? '↑' : getDayTrend(day.date, 'other_blood_sugar').dir === 'down' ? '↓' : '→' }}
</text>
<text class="metric-trend-text">{{ getDayTrend(day.date, 'other_blood_sugar').deltaText }}</text>
</view>
</view>
</view>
<view v-if="day.blood.western_medicine || day.blood.insulin" class="day-meta-row">
<text v-if="day.blood.western_medicine" class="meta-chip">💊 {{ day.blood.western_medicine }}</text>
<text v-if="day.blood.insulin" class="meta-chip">💉 {{ day.blood.insulin }}</text>
</view>
<view v-if="day.blood.remark" class="day-remark">
<text>{{ day.blood.remark }}</text>
</view>
</view>
<!-- 饮食 -->
<view v-if="day.diet" class="day-block">
<view class="day-block-head">
<view class="day-block-tag tag-diet">饮食</view>
</view>
<view class="diet-rows">
<view v-if="day.diet.breakfast_foods" class="diet-row">
<text class="diet-meal">🌞 早餐</text>
<text class="diet-text">{{ day.diet.breakfast_foods }}</text>
</view>
<view v-if="day.diet.lunch_foods" class="diet-row">
<text class="diet-meal">🍱 午餐</text>
<text class="diet-text">{{ day.diet.lunch_foods }}</text>
</view>
<view v-if="day.diet.dinner_foods" class="diet-row">
<text class="diet-meal">🌙 晚餐</text>
<text class="diet-text">{{ day.diet.dinner_foods }}</text>
</view>
</view>
<view v-if="day.diet.note" class="day-remark">
<text>{{ day.diet.note }}</text>
</view>
</view>
<!-- 运动 -->
<view v-if="day.exercise" class="day-block">
<view class="day-block-head">
<view class="day-block-tag tag-exercise">运动</view>
</view>
<view class="exercise-row">
<text class="exercise-type">{{ day.exercise.exercise_type || '运动' }}</text>
<text v-if="day.exercise.duration" class="exercise-duration">{{ day.exercise.duration }} 分钟</text>
<text v-if="day.exercise.intensityText" class="exercise-intensity">{{ day.exercise.intensityText }}</text>
</view>
<view v-if="day.exercise.note" class="day-remark">
<text>{{ day.exercise.note }}</text>
</view>
</view>
<!-- 跟踪备注 -->
<view v-if="day.tracking && day.tracking.content" class="day-block">
<view class="day-block-head">
<view class="day-block-tag tag-tracking">备注</view>
</view>
<view class="tracking-content">
<text
v-for="(line, idx) in splitTrackingLines(day.tracking.content)"
:key="idx"
class="tracking-line"
>
{{ line }}
</text>
</view>
</view>
</view>
</view>
<view class="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 scroll-y class="input-modal-body">
<view class="input-modal-tip">
<text>请如实填写留空表示该项不上传当天可多次修改</text>
</view>
<view class="input-section-title">血糖mmol/L</view>
<view class="input-field">
<view class="input-field-label">
<view class="input-field-dot input-field-dot-fasting" />
<text>空腹血糖</text>
</view>
<view class="input-field-input">
<input
type="digit"
:value="inputForm.fasting_blood_sugar"
placeholder="如 6.5"
placeholder-class="input-placeholder"
@input="onInputField('fasting_blood_sugar', $event)"
/>
</view>
</view>
<view class="input-field">
<view class="input-field-label">
<view class="input-field-dot input-field-dot-postprandial" />
<text>餐后血糖</text>
</view>
<view class="input-field-input">
<input
type="digit"
:value="inputForm.postprandial_blood_sugar"
placeholder="如 8.0"
placeholder-class="input-placeholder"
@input="onInputField('postprandial_blood_sugar', $event)"
/>
</view>
</view>
<view class="input-field">
<view class="input-field-label">
<view class="input-field-dot input-field-dot-other" />
<text>其他血糖</text>
</view>
<view class="input-field-input">
<input
type="digit"
:value="inputForm.other_blood_sugar"
placeholder="如 7.2"
placeholder-class="input-placeholder"
@input="onInputField('other_blood_sugar', $event)"
/>
</view>
</view>
<view class="input-section-title">血压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="inputForm.systolic_pressure"
placeholder="如 120"
placeholder-class="input-placeholder"
@input="onInputField('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="inputForm.diastolic_pressure"
placeholder="如 80"
placeholder-class="input-placeholder"
@input="onInputField('diastolic_pressure', $event)"
/>
</view>
</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>
</template>
<script setup>
import { ref, computed, getCurrentInstance, nextTick, watch } from 'vue'
import { onLoad, onShow, onHide, onUnload, onPullDownRefresh } from '@dcloudio/uni-app'
const { proxy } = getCurrentInstance()
const diagnosisId = ref(0)
const patientId = ref(0)
const patientName = ref('')
const age = ref(0)
const gender = ref(0)
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
// 画布上下文相关
let canvasNode = null
let canvasCtx = null
let canvasWidth = 0
let canvasHeight = 0
let canvasDpr = 1
let chartCoords = null
const ageText = computed(() => (age.value > 0 ? `${age.value} 岁` : ''))
const genderText = computed(() => {
const map = { 1: '男', 2: '女' }
return map[Number(gender.value)] || ''
})
const rangeText = computed(() => {
const opt = rangeOptions.find((o) => o.value === rangeMode.value)
return opt ? opt.label : ''
})
// ============ 时间区间 ============
function formatDate(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
function parseDate(str) {
if (!str) return new Date()
const [y, m, d] = str.split('-').map(Number)
return new Date(y, (m || 1) - 1, d || 1)
}
const currentRange = computed(() => {
const days = rangeMode.value === '30' ? 30 : 7
const end = new Date()
end.setHours(0, 0, 0, 0)
const start = new Date(end)
start.setDate(start.getDate() - (days - 1))
return { start: formatDate(start), end: formatDate(end) }
})
const visibleDates = computed(() => {
const { start, end } = currentRange.value
if (!start || !end) return []
const dates = []
const cursor = parseDate(end)
const lower = parseDate(start).getTime()
while (cursor.getTime() >= lower) {
dates.push(formatDate(cursor))
cursor.setDate(cursor.getDate() - 1)
}
return dates
})
const chartDates = computed(() => [...visibleDates.value].reverse())
// ============ 数据聚合 ============
function toNumber(v) {
if (v === null || v === undefined || v === '') return null
const n = Number(v)
return Number.isFinite(n) && n !== 0 ? n : null
}
function getBloodSugarThresholds(ageValue) {
const a = Number(ageValue)
if (!Number.isFinite(a) || a <= 0) return null
if (a < 50) return { fasting: 7, postprandial: 9 }
return { fasting: 8, postprandial: 10 }
}
function isHighFasting(v) {
const n = toNumber(v)
const t = getBloodSugarThresholds(age.value)
return n !== null && t !== null && n >= t.fasting
}
function isHighPostprandial(v) {
const n = toNumber(v)
const t = getBloodSugarThresholds(age.value)
return n !== null && t !== null && n >= t.postprandial
}
function isHighBp(row) {
if (!row) return false
const s = toNumber(row.systolic_pressure)
const d = toNumber(row.diastolic_pressure)
return (s !== null && s > 140) || (d !== null && d > 90)
}
function formatBp(row) {
const s = row?.systolic_pressure
const d = row?.diastolic_pressure
if ((s === null || s === undefined || s === '' || s === 0) && (d === null || d === undefined || d === '' || d === 0)) return '—'
return `${s || '—'} / ${d || '—'}`
}
function formatValue(v) {
const n = toNumber(v)
return n === null ? '—' : String(n)
}
const intensityTextMap = { 1: '低强度', 2: '中强度', 3: '高强度' }
const bloodByDate = computed(() => {
const map = {}
for (const r of bloodRecords.value) {
const date = String(r.record_date || '')
if (!date) continue
if (!map[date]) {
map[date] = {
record_date: date,
record_time: r.record_time || '',
fasting_blood_sugar: '',
postprandial_blood_sugar: '',
other_blood_sugar: '',
systolic_pressure: '',
diastolic_pressure: '',
western_medicine: '',
insulin: '',
remark: ''
}
}
const merged = map[date]
const fields = ['fasting_blood_sugar', 'postprandial_blood_sugar', 'other_blood_sugar', 'systolic_pressure', 'diastolic_pressure', 'western_medicine', 'insulin', 'remark']
for (const f of fields) {
if ((merged[f] === '' || merged[f] === null || merged[f] === undefined) && r[f] !== '' && r[f] !== null && r[f] !== undefined) {
merged[f] = r[f]
}
}
}
for (const date in map) {
const m = map[date]
m.isHighFasting = isHighFasting(m.fasting_blood_sugar)
m.isHighPostprandial = isHighPostprandial(m.postprandial_blood_sugar)
m.isHighOther = isHighPostprandial(m.other_blood_sugar)
m.isHighBp = isHighBp(m)
m.bpText = formatBp(m)
}
return map
})
const dietByDate = computed(() => {
const map = {}
for (const r of dietRecords.value) {
const date = String(r.record_date || '')
if (!date || map[date]) continue
map[date] = r
}
return map
})
const exerciseByDate = computed(() => {
const map = {}
for (const r of exerciseRecords.value) {
const date = String(r.record_date || '')
if (!date || map[date]) continue
const item = { ...r }
item.intensityText = intensityTextMap[Number(r.intensity)] || ''
map[date] = item
}
return map
})
const trackingByDate = computed(() => {
const map = {}
for (const r of trackingNotes.value) {
const date = String(r.note_date || '')
if (!date || map[date]) continue
map[date] = r
}
return map
})
const weekdayNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
function getWeekday(dateStr) {
return weekdayNames[parseDate(dateStr).getDay()] || ''
}
const todayStr = computed(() => formatDate(new Date()))
const recordDays = computed(() => {
const result = []
for (const date of visibleDates.value) {
const blood = bloodByDate.value[date]
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 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)
}
// ============ 就诊卡 ============
async function fetchCardList() {
const token = uni.getStorageSync('token')
if (!token) 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
}
}
function applyCard(card) {
if (!card) return
diagnosisId.value = Number(card.id) || 0
patientId.value = Number(card.patient_id) || 0
patientName.value = card.patient_name || ''
age.value = Number(card.age) || 0
gender.value = Number(card.gender) || 0
}
function onSelectCard(card) {
if (!card || Number(card.id) === Number(diagnosisId.value)) return
// 切换前清空当前数据,避免列表残留
bloodRecords.value = []
dietRecords.value = []
exerciseRecords.value = []
trackingNotes.value = []
hoverInfo.value = null
applyCard(card)
fetchAll()
}
// ============ 接口请求 ============
async function fetchAll() {
if (!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) uni.showToast({ title: winRes.msg, icon: 'none' })
}
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 = []
}
} catch (e) {
uni.showToast({ title: '加载失败,请稍后再试', icon: 'none' })
} finally {
loading.value = false
await nextTick()
scheduleChartRedraw()
// 自动播报:数据就绪后朗读一次最新摘要
if (ttsAutoPlay.value) {
const token = ++ttsAutoToken
setTimeout(() => {
if (token === ttsAutoToken) speakLatestSummary()
}, 350)
}
}
}
function onChangeRange(value) {
if (rangeMode.value === value) return
rangeMode.value = value
fetchAll()
}
function onRefresh() {
fetchAll()
}
// ============ 时段问候 / 打卡 / 健康日历 ============
const timeGreeting = computed(() => {
const h = new Date().getHours()
if (h < 5) return '夜深了'
if (h < 11) return '早上好'
if (h < 13) return '中午好'
if (h < 18) return '下午好'
if (h < 22) return '晚上好'
return '夜深了'
})
const timeGreetingEmoji = computed(() => {
const h = new Date().getHours()
if (h < 5) return '🌙'
if (h < 11) return '☀️'
if (h < 13) return '🌤'
if (h < 18) return '🌅'
if (h < 22) return '🌆'
return '🌙'
})
// 复用已有的 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)
// 健康日历热力图:取当前数据范围内所有日期,按周组装成 6x7(最多)网格
const heatmapWeeks = computed(() => {
const dates = chartDates.value
if (!dates || !dates.length) return []
// 确保按时间正序
const sorted = [...dates].sort()
// 找到第一天所在周的周一,最后一天所在周的周日
const first = new Date(sorted[0])
const last = new Date(sorted[sorted.length - 1])
// JS getDay: 0=Sun..6=Sat;我们以周一为起点
const firstDow = (first.getDay() + 6) % 7 // 0=Mon..6=Sun
const lastDow = (last.getDay() + 6) % 7
const gridStart = new Date(first)
gridStart.setDate(first.getDate() - firstDow)
const gridEnd = new Date(last)
gridEnd.setDate(last.getDate() + (6 - lastDow))
const totalDays = Math.round((gridEnd - gridStart) / 86400000) + 1
const weeks = []
let cur = new Date(gridStart)
for (let w = 0; w < Math.ceil(totalDays / 7); w++) {
const week = []
for (let i = 0; i < 7; i++) {
const key = formatDate(cur)
const inRange = sorted.includes(key)
week.push({
date: key,
day: cur.getDate(),
inRange,
isToday: key === todayStr.value,
level: inRange ? dayCoverageLevel(key) : -1
})
cur.setDate(cur.getDate() + 1)
}
weeks.push(week)
}
return weeks
})
const heatmapTotalRecords = computed(() => {
let c = 0
for (const w of heatmapWeeks.value) for (const c2 of w) if (c2.inRange && c2.level > 0) c++
return c
})
const heatmapInRangeDays = computed(() => {
let c = 0
for (const w of heatmapWeeks.value) for (const c2 of w) if (c2.inRange) c++
return c
})
// 月历的"月份标签":取热力图覆盖范围里跨度最大的月份
const heatmapMonthLabel = computed(() => {
const weeks = heatmapWeeks.value
if (!weeks.length) return ''
const counter = new Map()
for (const w of weeks) {
for (const c of w) {
if (!c.inRange) continue
const [y, m] = c.date.split('-')
const key = `${y}-${m}`
counter.set(key, (counter.get(key) || 0) + 1)
}
}
if (!counter.size) {
const first = weeks[0][0].date.split('-')
return `${Number(first[0])}${Number(first[1])}月`
}
let best = ''
let bestCount = -1
for (const [k, v] of counter) {
if (v > bestCount) { best = k; bestCount = v }
}
const [y, m] = best.split('-')
return `${Number(y)}${Number(m)}月`
})
function jumpToDayRecord(date) {
// 滚动到下方对应日期 records 列表项;若不在 visible 列表则提示
if (!recordDays.value.some((d) => d.date === date)) {
uni.showToast({ title: '该日无记录', icon: 'none' })
return
}
uni.createSelectorQuery().in(proxy)
.select(`#day-${date.replace(/-/g, '')}`)
.boundingClientRect()
.selectViewport()
.scrollOffset()
.exec((res) => {
if (!res || !res[0]) return
const top = res[0].top + (res[1] ? res[1].scrollTop : 0) - 80
uni.pageScrollTo({ scrollTop: top, duration: 280 })
})
}
// ============ 录入今日数据 ============
const todayText = computed(() => {
const d = new Date()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return `${d.getFullYear()}-${m}-${day} ${weekdays[d.getDay()]}`
})
const emptyInputForm = () => ({
visible: false,
loading: false,
submitting: false,
id: 0,
fasting_blood_sugar: '',
postprandial_blood_sugar: '',
other_blood_sugar: '',
systolic_pressure: '',
diastolic_pressure: '',
remark: ''
})
const inputForm = ref(emptyInputForm())
function onInputField(field, e) {
const val = e?.detail?.value ?? ''
inputForm.value[field] = val
}
async function openInputForm() {
if (!diagnosisId.value) {
uni.showToast({ title: '请先选择就诊卡', icon: 'none' })
return
}
const token = uni.getStorageSync('token')
if (!token) {
uni.showToast({ title: '请先登录后再录入', icon: 'none' })
return
}
// 微信小程序 canvas 是原生组件、z-index 无法被弹层覆盖,
// 这里通过 v-if 卸载它;下次显示时 selectorQuery 拿到的会是新节点,
// 因此先清空缓存的 ctx / node,避免下次 ensureCanvas 复用旧引用。
canvasCtx = null
canvasNode = null
// 打开弹层,先拉取今日已有记录预填
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.systolic_pressure = numToStr(r.systolic_pressure)
inputForm.value.diastolic_pressure = numToStr(r.diastolic_pressure)
inputForm.value.remark = String(r.remark || '')
}
} catch (e) {
// 静默:拉取失败时仍允许新增录入
} finally {
inputForm.value.loading = false
}
}
function numToStr(v) {
if (v === null || v === undefined || v === '' || v === 0 || v === '0') return ''
return String(v)
}
function closeInputForm() {
if (inputForm.value.submitting) return
inputForm.value.visible = false
// 关闭后 canvas 会重新挂载,需触发一次重绘
nextTick(() => scheduleChartRedraw())
}
async function submitInputForm() {
if (inputForm.value.submitting || inputForm.value.loading) return
// 至少填一个有效字段
const f = inputForm.value
const hasAny = [
f.fasting_blood_sugar, f.postprandial_blood_sugar, f.other_blood_sugar,
f.systolic_pressure, f.diastolic_pressure
].some((v) => v !== '' && v !== null && Number(v) > 0) || !!(f.remark && f.remark.trim())
if (!hasAny) {
uni.showToast({ title: '请至少填写一项数据', icon: 'none' })
return
}
inputForm.value.submitting = true
try {
const res = await proxy.apiUrl({
url: '/api/tcm/dailySaveBloodRecord',
method: 'POST',
data: {
diagnosis_id: diagnosisId.value,
fasting_blood_sugar: f.fasting_blood_sugar,
postprandial_blood_sugar: f.postprandial_blood_sugar,
other_blood_sugar: f.other_blood_sugar,
systolic_pressure: f.systolic_pressure,
diastolic_pressure: f.diastolic_pressure,
remark: f.remark
}
}, false)
if (res && res.code === 1) {
uni.showToast({ title: res.msg || '已保存', icon: 'success' })
inputForm.value.visible = false
fetchAll()
} else {
uni.showToast({ title: (res && res.msg) || '保存失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '网络异常,请稍后再试', icon: 'none' })
} finally {
inputForm.value.submitting = false
}
}
function deleteInputRecord() {
if (!inputForm.value.id || inputForm.value.submitting) return
uni.showModal({
title: '删除确认',
content: '确定删除今天已录入的数据吗?删除后无法恢复。',
confirmColor: '#dc2626',
success: async (m) => {
if (!m.confirm) return
inputForm.value.submitting = true
try {
const res = await proxy.apiUrl({
url: '/api/tcm/dailyDeleteBloodRecord',
method: 'POST',
data: {
diagnosis_id: diagnosisId.value,
id: inputForm.value.id
}
}, false)
if (res && res.code === 1) {
uni.showToast({ title: '已删除', icon: 'success' })
inputForm.value.visible = false
fetchAll()
} else {
uni.showToast({ title: (res && res.msg) || '删除失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '网络异常,请稍后再试', icon: 'none' })
} finally {
inputForm.value.submitting = false
}
}
})
}
function splitTrackingLines(content) {
return String(content || '')
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
}
// ============ 语音播报(适老化)============
// 优先用 微信同声传译插件 (mp-weixin)H5 走 window.speechSynthesis
// 都不可用则降级为 modal 弹窗显示文本,保证可读性。
const ttsEnabled = ref(true)
const ttsAutoPlay = ref(false)
const ttsSpeaking = ref(false)
let ttsAudioCtx = null
let ttsAutoToken = 0
function teardownTtsAudio() {
if (ttsAudioCtx) {
try { ttsAudioCtx.stop() } catch (e) {}
try { ttsAudioCtx.destroy && ttsAudioCtx.destroy() } catch (e) {}
ttsAudioCtx = null
}
}
function ttsStop() {
teardownTtsAudio()
// #ifdef H5
try {
if (typeof window !== 'undefined' && window.speechSynthesis) {
window.speechSynthesis.cancel()
}
} catch (e) {}
// #endif
ttsSpeaking.value = false
}
function ttsSpeak(text) {
if (!ttsEnabled.value) return
const content = String(text || '').replace(/\s+/g, ' ').trim()
if (!content) return
ttsStop()
// H5:浏览器 SpeechSynthesis(慢速适老化)
// #ifdef H5
try {
if (typeof window !== 'undefined' && window.speechSynthesis) {
const u = new window.SpeechSynthesisUtterance(content)
u.lang = 'zh-CN'
u.rate = 0.85
u.pitch = 1
u.volume = 1
u.onstart = () => { ttsSpeaking.value = true }
u.onend = () => { ttsSpeaking.value = false }
u.onerror = () => { ttsSpeaking.value = false }
window.speechSynthesis.speak(u)
return
}
} catch (e) {}
// #endif
// 微信小程序:同声传译插件 TTS → mp3 → innerAudioContext 播放
// #ifdef MP-WEIXIN
try {
// eslint-disable-next-line no-undef
const plugin = requirePlugin('WechatSI')
ttsSpeaking.value = true
plugin.textToSpeech({
lang: 'zh_CN',
tts: true,
content,
success: (res) => {
if (!res || !res.filename) {
ttsSpeaking.value = false
return
}
teardownTtsAudio()
ttsAudioCtx = uni.createInnerAudioContext()
ttsAudioCtx.src = res.filename
ttsAudioCtx.obeyMuteSwitch = false
ttsAudioCtx.onPlay(() => { ttsSpeaking.value = true })
ttsAudioCtx.onEnded(() => { ttsSpeaking.value = false })
ttsAudioCtx.onError(() => { ttsSpeaking.value = false })
ttsAudioCtx.onStop(() => { ttsSpeaking.value = false })
ttsAudioCtx.play()
},
fail: (err) => {
ttsSpeaking.value = false
console.warn('WechatSI textToSpeech failed', err)
uni.showToast({ title: '语音不可用,请检查网络/插件', icon: 'none', duration: 2000 })
}
})
return
} catch (e) {
console.warn('WechatSI plugin not available', e)
}
// #endif
// 其它端兜底:用 modal 显示要播报的内容
uni.showModal({
title: '语音内容',
content,
showCancel: false,
confirmText: '我知道了'
})
}
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) {
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: '#0ea5a4',
fillTop: 'rgba(14,165,164,0.20)',
fillBottom: 'rgba(14,165,164,0)',
label: '空腹',
highThreshold: threshold ? threshold.fasting : null
},
{
data: postSeries,
color: '#f59e0b',
fillTop: 'rgba(245,158,11,0.16)',
fillBottom: 'rgba(245,158,11,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)
}
watch(rangeMode, () => {
scheduleChartRedraw()
})
// ============ 生命周期 ============
function readUserContext() {
try {
const userData = uni.getStorageSync('userData')
if (userData && userData.diagnosis) {
if (!patientId.value) patientId.value = Number(userData.diagnosis.patient_id) || 0
if (!diagnosisId.value) diagnosisId.value = Number(userData.diagnosis.id) || 0
if (!patientName.value) patientName.value = userData.diagnosis.patient_name || ''
if (!age.value) age.value = Number(userData.diagnosis.age) || 0
if (!gender.value) gender.value = Number(userData.diagnosis.gender) || 0
}
} catch (e) {}
}
async function bootstrap(options) {
options = options || {}
// 1. 仅采纳来自页面参数的 diagnosis_id(例如 Card.vue 点「日常记录」时带的)
// storage 里的 userData.diagnosis.id 不再作为优先来源,避免覆盖默认逻辑
const passedDiagnosisId = Number(options.diagnosis_id || options.id || 0)
diagnosisId.value = passedDiagnosisId
patientId.value = Number(options.patient_id || 0)
if (options.patient_name) patientName.value = decodeURIComponent(options.patient_name)
if (options.age) age.value = Number(options.age) || 0
if (options.gender) gender.value = Number(options.gender) || 0
// 2. 永远拉一次就诊卡列表 —— 作为切换器数据源 + 默认选第一张的来源
const list = await fetchCardList()
if (Array.isArray(list) && list.length > 0) {
// 3. 默认选第一张;只有当 URL 明确传了 diagnosis_id 且能在列表中匹配时才用指定卡
let target = null
if (passedDiagnosisId) {
target = list.find((c) => Number(c.id) === Number(passedDiagnosisId))
}
if (!target) target = list[0]
applyCard(target)
} else {
// 4. 兜底:列表拉不到时再回退到 storage(极少见)
if (!diagnosisId.value || !patientId.value) {
readUserContext()
}
if (!diagnosisId.value) {
uni.showToast({ title: '暂无就诊卡,请先创建', icon: 'none' })
return
}
}
fetchAll()
}
onLoad((options) => {
bootstrap(options)
})
onShow(() => {
scheduleChartRedraw()
})
onPullDownRefresh(async () => {
await fetchAll()
uni.stopPullDownRefresh()
})
onHide(() => {
ttsStop()
})
onUnload(() => {
ttsStop()
})
</script>
<style lang="scss" scoped>
.daily-page {
min-height: 100vh;
background: #f1f5f9;
padding-bottom: 64rpx;
}
.hero {
position: relative;
padding: 40rpx 32rpx 100rpx; /* 适老化:增加 hero 上下空间 */
color: #ffffff;
overflow: hidden;
}
.hero-bg {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #0ea5a4 0%, #0891b2 60%, #1e40af 120%);
z-index: 0;
}
.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;
}
.hero-subtitle {
display: block;
font-size: 30rpx; /* 适老化:放大 */
color: rgba(255, 255, 255, 0.92); /* 提升对比度 */
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: flex;
align-items: center;
gap: 10rpx;
background: rgba(255, 255, 255, 0.22);
border-radius: 999rpx;
padding: 18rpx 30rpx;
backdrop-filter: blur(8rpx);
min-height: 80rpx; /* 适老化:>= 44pt 触控目标 */
box-sizing: border-box;
&:active {
background: rgba(255, 255, 255, 0.32);
}
&.voice {
background: rgba(255, 255, 255, 0.32);
border: 2rpx solid rgba(255, 255, 255, 0.55);
padding: 16rpx 30rpx;
}
&.voice.speaking {
background: #fbbf24;
border-color: #fde68a;
animation: voice-pulse 1.4s ease-in-out infinite;
}
&.voice.disabled {
opacity: 0.55;
}
&.input {
background: #ffffff;
border: 2rpx solid #ffffff;
padding: 16rpx 32rpx;
box-shadow: 0 6rpx 18rpx rgba(15, 23, 42, 0.18);
.hero-pill-icon {
color: #0ea5a4;
font-weight: 800;
font-size: 38rpx;
}
.hero-pill-text {
color: #0ea5a4;
font-weight: 700;
}
&:active {
background: #f0fdfa;
}
}
}
@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 {
font-size: 34rpx;
color: #ffffff;
line-height: 1;
}
.hero-pill-text {
font-size: 32rpx; /* 适老化:放大 */
color: #ffffff;
font-weight: 600;
}
/* === 三大核心指标卡 === */
.stat-grid {
display: flex;
gap: 14rpx;
margin: -60rpx 24rpx 0;
position: relative;
z-index: 3;
}
.stat-card {
flex: 1;
min-width: 0;
background: #ffffff;
border-radius: 24rpx;
padding: 26rpx 18rpx 22rpx;
box-shadow: 0 14rpx 36rpx rgba(15, 23, 42, 0.12);
display: flex;
flex-direction: column;
gap: 12rpx;
position: relative;
overflow: hidden;
border-top: 6rpx solid transparent;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
right: -30rpx;
width: 120rpx;
height: 120rpx;
border-radius: 999rpx;
opacity: 0.08;
pointer-events: none;
}
.stat-fasting {
border-top-color: #0ea5a4;
}
.stat-fasting::before {
background: #0ea5a4;
}
.stat-postprandial {
border-top-color: #f59e0b;
}
.stat-postprandial::before {
background: #f59e0b;
}
.stat-other {
border-top-color: #8b5cf6;
}
.stat-other::before {
background: #8b5cf6;
}
.stat-card.is-high {
box-shadow: 0 14rpx 36rpx rgba(220, 38, 38, 0.22);
}
.stat-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8rpx;
}
.stat-card-tag {
display: flex;
align-items: center;
gap: 6rpx;
}
.stat-card-tag-dot {
width: 16rpx;
height: 16rpx;
border-radius: 999rpx;
}
.stat-fasting .stat-card-tag-dot {
background: #0ea5a4;
}
.stat-postprandial .stat-card-tag-dot {
background: #f59e0b;
}
.stat-other .stat-card-tag-dot {
background: #8b5cf6;
}
.stat-card-tag-text {
font-size: 26rpx; /* 适老化:放大 */
color: #334155; /* 提升对比度 */
font-weight: 700;
}
.stat-card-flag {
display: inline-flex;
align-items: center;
gap: 4rpx;
padding: 4rpx 12rpx 4rpx 8rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #ef4444, #dc2626);
box-shadow: 0 3rpx 10rpx rgba(220, 38, 38, 0.28);
}
.stat-card-flag-icon {
font-size: 20rpx;
color: #ffffff;
font-weight: 700;
line-height: 1;
}
.stat-card-flag-text {
font-size: 22rpx; /* 适老化:放大 */
color: #ffffff;
font-weight: 700;
}
.stat-card-value-row {
display: flex;
align-items: baseline;
gap: 8rpx;
flex-wrap: wrap;
}
.stat-card-value {
font-size: 56rpx; /* 适老化:放大数字主角 */
font-weight: 800;
color: #0f172a;
line-height: 1.05;
letter-spacing: -1rpx;
&.is-high {
color: #dc2626;
}
}
.stat-card-unit {
font-size: 24rpx; /* 适老化:放大 */
color: #64748b; /* 提升对比度 */
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: #64748b; /* 提升对比度 */
font-weight: 500;
}
.stat-card-trend {
display: inline-flex;
align-items: center;
gap: 4rpx;
padding: 6rpx 12rpx 6rpx 9rpx;
border-radius: 999rpx;
background: #f1f5f9;
&.trend-up {
background: #fef3c7;
.stat-card-trend-arrow,
.stat-card-trend-delta {
color: #b45309;
}
}
&.trend-down {
background: #dcfce7;
.stat-card-trend-arrow,
.stat-card-trend-delta {
color: #15803d;
}
}
&.trend-flat {
.stat-card-trend-arrow,
.stat-card-trend-delta {
color: #475569;
}
}
}
.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: 14rpx 22rpx;
background: #ffffff;
border-radius: 999rpx;
box-shadow: 0 4rpx 14rpx rgba(15, 23, 42, 0.05);
}
.card-switcher-head {
display: flex;
align-items: center;
gap: 8rpx;
flex-shrink: 0;
padding-right: 10rpx;
border-right: 2rpx solid #e2e8f0;
}
.card-switcher-label {
font-size: 28rpx; /* 适老化:放大 */
color: #0f172a;
font-weight: 700;
}
.card-switcher-count {
font-size: 24rpx; /* 适老化:放大 */
color: #64748b;
background: #f1f5f9;
padding: 4rpx 14rpx;
border-radius: 999rpx;
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 24rpx; /* 适老化:扩大触控区 */
border-radius: 999rpx;
background: #f1f5f9;
border: 2rpx solid transparent;
transition: all 0.2s;
min-height: 68rpx;
box-sizing: border-box;
&.active {
background: linear-gradient(135deg, #0ea5a4, #0891b2);
border-color: transparent;
box-shadow: 0 4rpx 12rpx rgba(14, 165, 164, 0.32);
.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: #64748b;
line-height: 1.1;
}
.range-bar {
display: flex;
gap: 12rpx;
margin: 22rpx 24rpx 0;
background: #ffffff;
border-radius: 999rpx;
padding: 10rpx;
box-shadow: 0 4rpx 14rpx rgba(15, 23, 42, 0.05);
position: relative;
z-index: 2;
}
.voice-bar {
margin: 22rpx 24rpx 0;
background: #ffffff;
border-radius: 24rpx;
padding: 26rpx 26rpx;
box-shadow: 0 6rpx 20rpx rgba(15, 23, 42, 0.04);
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
border: 2rpx solid #e0f2fe;
}
.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: #0f172a;
}
.voice-bar-hint {
font-size: 26rpx; /* 适老化:放大 */
color: #475569; /* 提升对比度 */
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: 999rpx;
background: #f1f5f9;
transition: background 0.2s;
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: 76rpx;
height: 40rpx;
background: #cbd5e1;
border-radius: 999rpx;
position: relative;
transition: background 0.2s;
.voice-switch.on & {
background: #f59e0b;
}
}
.voice-switch-knob {
position: absolute;
width: 32rpx;
height: 32rpx;
border-radius: 999rpx;
background: #ffffff;
top: 4rpx;
left: 4rpx;
box-shadow: 0 2rpx 6rpx rgba(15, 23, 42, 0.2);
transition: transform 0.2s;
.voice-switch.on & {
transform: translateX(36rpx);
}
}
.voice-toggle {
width: 76rpx;
height: 76rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #0ea5a4, #0891b2);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 6rpx 16rpx rgba(14, 165, 164, 0.28);
&.off {
background: #cbd5e1;
box-shadow: none;
}
&:active {
transform: scale(0.95);
}
}
.voice-toggle-icon {
font-size: 36rpx;
color: #ffffff;
line-height: 1;
}
.range-item {
flex: 1;
text-align: center;
padding: 22rpx 0; /* 适老化:扩大触控区 */
border-radius: 999rpx;
font-size: 32rpx; /* 适老化:放大 */
color: #334155;
font-weight: 600;
min-height: 76rpx;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
&.active {
background: linear-gradient(135deg, #0ea5a4, #0891b2);
color: #ffffff;
font-weight: 700;
box-shadow: 0 4rpx 12rpx rgba(14, 165, 164, 0.35);
}
&:active:not(.active) {
background: #f1f5f9;
}
}
.card {
background: #ffffff;
border-radius: 24rpx;
margin: 28rpx 24rpx 0;
padding: 30rpx 28rpx; /* 适老化:增加内边距 */
box-shadow: 0 6rpx 20rpx rgba(15, 23, 42, 0.04);
animation: card-rise 0.45s ease-out both;
}
/* 让各卡片错位出现,营造层次感 */
.streak-strip { animation: card-rise 0.45s ease-out 0.05s both; }
.card-switcher { animation: card-rise 0.45s ease-out 0.10s both; }
.range-bar { animation: card-rise 0.4s ease-out 0.14s both; }
.voice-bar { animation: card-rise 0.4s ease-out 0.18s both; }
.chart-card { animation: card-rise 0.5s ease-out 0.20s both; }
.calendar-card { animation: card-rise 0.5s ease-out 0.25s both; }
.records-section { animation: card-rise 0.5s ease-out 0.30s both; }
@keyframes card-rise {
from {
opacity: 0;
transform: translateY(8rpx); /* 适老化:减小位移 */
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes stat-card-pop {
from {
opacity: 0;
transform: translateY(20rpx) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.stat-card {
animation: stat-card-pop 0.5s ease-out both;
}
.stat-card.stat-fasting { animation-delay: 0.06s; }
.stat-card.stat-postprandial { animation-delay: 0.14s; }
.stat-card.stat-other { animation-delay: 0.22s; }
.card-head {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-size: 36rpx; /* 适老化:放大区块标题 */
font-weight: 800;
color: #0f172a;
letter-spacing: 0.5rpx;
}
.card-subtitle {
display: block;
font-size: 26rpx; /* 适老化:放大 */
color: #64748b; /* 提升对比度 */
margin-top: 6rpx;
}
.card-legend {
display: flex;
gap: 18rpx;
}
.legend-item {
display: flex;
align-items: center;
gap: 8rpx;
font-size: 22rpx;
color: #475569;
}
.legend-dot {
width: 16rpx;
height: 16rpx;
border-radius: 999rpx;
}
.legend-dot-fasting {
background: #0ea5a4;
}
.legend-dot-postprandial {
background: #f59e0b;
}
.chart-summary {
display: flex;
align-items: stretch;
gap: 0;
margin-top: 18rpx;
padding: 16rpx 4rpx;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 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: #e2e8f0;
}
.chart-summary-num {
font-size: 44rpx; /* 适老化:放大 */
font-weight: 800;
color: #0f172a;
line-height: 1.1;
letter-spacing: -0.5rpx;
}
.chart-summary-item:first-child .chart-summary-num {
color: #dc2626;
}
.chart-summary-item:nth-child(2) .chart-summary-num {
color: #0ea5a4;
}
.chart-summary-label {
font-size: 24rpx; /* 适老化:放大 */
color: #64748b; /* 提升对比度 */
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, #f8fafc 0%, #f1f5f9 100%);
border-radius: 18rpx;
color: #64748b;
font-size: 28rpx;
letter-spacing: 1rpx;
font-weight: 500;
}
.chart-canvas {
width: 100%;
height: 100%;
display: block;
}
.chart-empty {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14rpx;
color: #64748b;
}
.chart-empty-icon {
font-size: 72rpx;
}
.chart-empty-text {
font-size: 30rpx; /* 适老化:放大空状态文案 */
color: #475569;
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: 10;
}
.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: #475569;
font-weight: 600;
}
.day-card {
background: #ffffff;
border-radius: 24rpx;
padding: 30rpx 28rpx;
margin-bottom: 22rpx;
box-shadow: 0 6rpx 20rpx rgba(15, 23, 42, 0.04);
}
.day-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14rpx;
border-bottom: 1rpx dashed #e2e8f0;
padding-bottom: 12rpx;
}
.day-head-left {
display: flex;
align-items: center;
gap: 10rpx;
}
.day-date {
font-size: 36rpx; /* 适老化:放大 */
font-weight: 800;
color: #0f172a;
}
.day-today-pill {
font-size: 24rpx; /* 适老化:放大 */
padding: 4rpx 16rpx;
border-radius: 999rpx;
background: #0ea5a4;
color: #ffffff;
font-weight: 600;
}
.day-head-right {
display: flex;
align-items: center;
gap: 14rpx;
}
.day-weekday {
font-size: 26rpx; /* 适老化:放大 */
color: #64748b;
font-weight: 500;
}
.day-speak-btn {
display: flex;
align-items: center;
gap: 8rpx;
padding: 16rpx 24rpx; /* 适老化:扩大触控区 */
border-radius: 999rpx;
background: linear-gradient(135deg, #0ea5a4, #0891b2);
box-shadow: 0 4rpx 12rpx rgba(14, 165, 164, 0.22);
min-height: 72rpx;
box-sizing: border-box;
&:active {
transform: scale(0.96);
}
}
.day-speak-icon {
font-size: 30rpx;
color: #ffffff;
line-height: 1;
}
.day-speak-text {
font-size: 28rpx; /* 适老化:放大 */
color: #ffffff;
font-weight: 700;
}
.day-block {
margin-top: 16rpx;
padding: 16rpx 18rpx;
background: #f8fafc;
border-radius: 16rpx;
}
.day-block-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.day-block-tag {
font-size: 26rpx; /* 适老化:放大 */
padding: 6rpx 20rpx;
border-radius: 999rpx;
color: #ffffff;
font-weight: 600;
&.tag-blood {
background: #0ea5a4;
}
&.tag-diet {
background: #f59e0b;
}
&.tag-exercise {
background: #6366f1;
}
&.tag-tracking {
background: #64748b;
}
}
.day-block-time {
font-size: 26rpx; /* 适老化:放大 */
color: #64748b;
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;
}
.metric-row-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8rpx;
}
.metric-label {
font-size: 26rpx; /* 适老化:放大 */
color: #475569; /* 提升对比度 */
font-weight: 600;
}
.metric-flag {
display: inline-flex;
align-items: center;
gap: 4rpx;
padding: 2rpx 10rpx 2rpx 6rpx;
border-radius: 999rpx;
background: #fee2e2;
border: 1rpx solid #fecaca;
&.up {
background: linear-gradient(135deg, #ef4444, #dc2626);
border-color: transparent;
box-shadow: 0 4rpx 12rpx rgba(220, 38, 38, 0.28);
}
}
.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: #0f172a;
line-height: 1.1;
&.is-high {
color: #dc2626;
}
}
.metric-unit {
font-size: 24rpx; /* 适老化:放大 */
color: #64748b; /* 提升对比度 */
font-weight: 500;
}
.metric-trend {
display: inline-flex;
align-items: center;
gap: 4rpx;
margin-top: 10rpx;
padding: 2rpx 12rpx 2rpx 8rpx;
border-radius: 999rpx;
background: #f1f5f9;
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: #f1f5f9;
border-color: #e2e8f0;
.metric-trend-arrow,
.metric-trend-text {
color: #64748b;
}
}
}
.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: #475569;
background: #ffffff;
border-radius: 999rpx;
padding: 6rpx 18rpx;
border: 1rpx solid #e2e8f0;
}
.day-remark {
margin-top: 14rpx;
font-size: 28rpx; /* 适老化:放大正文 */
color: #334155; /* 提升对比度 */
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;
font-weight: 600;
}
.diet-text {
flex: 1;
font-size: 28rpx; /* 适老化:放大正文 */
color: #0f172a;
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: #1f2937;
font-weight: 600;
}
.exercise-duration {
font-size: 24rpx;
color: #6366f1;
background: #eef2ff;
border-radius: 999rpx;
padding: 4rpx 16rpx;
}
.exercise-intensity {
font-size: 24rpx;
color: #475569;
}
.tracking-content {
margin-top: 12rpx;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.tracking-line {
font-size: 28rpx; /* 适老化:放大正文 */
color: #0f172a;
line-height: 1.7;
word-break: break-all;
}
.state-card {
background: #ffffff;
border-radius: 24rpx;
padding: 60rpx 24rpx;
text-align: center;
box-shadow: 0 6rpx 20rpx rgba(15, 23, 42, 0.04);
}
.state-icon {
display: block;
font-size: 76rpx;
margin-bottom: 16rpx;
}
.state-text {
font-size: 30rpx; /* 适老化:放大空状态 */
color: #475569;
font-weight: 500;
}
.footer-tip {
text-align: center;
font-size: 24rpx; /* 适老化:放大 */
color: #64748b;
margin-top: 40rpx;
line-height: 1.6;
}
/* === Hero 装饰圆(静态,适老化:去掉漂浮动画避免视觉干扰)=== */
.hero-orb {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.10);
pointer-events: none;
filter: blur(2rpx);
}
.hero-orb-1 {
width: 220rpx;
height: 220rpx;
top: -60rpx;
right: -40rpx;
background: rgba(255, 255, 255, 0.14);
}
.hero-orb-2 {
width: 140rpx;
height: 140rpx;
top: 40rpx;
right: 240rpx;
background: rgba(253, 224, 71, 0.14);
}
.hero-orb-3 {
width: 90rpx;
height: 90rpx;
bottom: 30rpx;
left: 40%;
background: rgba(255, 255, 255, 0.10);
}
.hero-greet-row {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 6rpx;
}
.hero-greet-emoji {
font-size: 36rpx;
}
.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: 999rpx;
font-size: 28rpx; /* 适老化:放大 */
font-weight: 600;
backdrop-filter: blur(8rpx);
&.done {
background: rgba(34, 197, 94, 0.28);
color: #ffffff;
border: 1rpx solid rgba(187, 247, 208, 0.6);
}
&.pending {
background: rgba(251, 191, 36, 0.32);
color: #ffffff;
border: 1rpx solid rgba(253, 230, 138, 0.6);
}
}
.hero-status-icon {
width: 36rpx;
height: 36rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.42);
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
line-height: 1;
font-weight: 800;
}
.hero-status-text {
line-height: 1.2;
}
/* === 打卡条 streak strip === */
.streak-strip {
position: relative;
margin: 22rpx 24rpx 0;
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
border: 1rpx solid #eef2f5;
border-radius: 24rpx;
padding: 28rpx 28rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
box-shadow: 0 6rpx 18rpx rgba(15, 23, 42, 0.06);
overflow: hidden;
min-height: 132rpx; /* 适老化:保证点击区足够大 */
box-sizing: border-box;
&: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.18) 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.42), 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: linear-gradient(135deg, #0ea5a4 0%, #0d9488 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.streak-num-unit {
font-size: 32rpx; /* 适老化:放大 */
color: #475569;
font-weight: 700;
}
.streak-hint {
margin-left: 8rpx;
font-size: 22rpx; /* 适老化:放大 */
color: #64748b;
background: #f1f5f9;
border-radius: 999rpx;
padding: 4rpx 14rpx;
font-weight: 500;
}
.streak-label {
font-size: 26rpx; /* 适老化:放大 */
color: #475569; /* 提升对比度 */
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: #64748b;
line-height: 1;
font-weight: 600;
}
.streak-dot-mark {
width: 26rpx; /* 适老化:放大点 */
height: 26rpx;
border-radius: 999rpx;
background: #e2e8f0;
}
.streak-dot.done .streak-dot-mark {
background: linear-gradient(135deg, #0ea5a4 0%, #14b8a6 100%);
box-shadow: 0 2rpx 6rpx rgba(14, 165, 164, 0.4);
}
.streak-dot.today {
.streak-dot-label {
color: #0f172a;
font-weight: 700;
}
.streak-dot-mark {
width: 30rpx;
height: 30rpx;
border: 2rpx solid #0ea5a4;
}
}
.streak-dot.today.done .streak-dot-mark {
border-color: #ffffff;
box-shadow: 0 0 0 3rpx #0ea5a4, 0 2rpx 8rpx rgba(14, 165, 164, 0.5);
}
.streak-week-text {
font-size: 24rpx; /* 适老化:放大 */
color: #64748b;
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, #f0fdfa 0%, #ccfbf1 100%);
border-radius: 20rpx;
border-left: 6rpx solid #0ea5a4;
}
.calendar-stat-num {
font-size: 48rpx;
font-weight: 800;
color: #0d9488;
line-height: 1.1;
letter-spacing: -1rpx;
}
.calendar-stat-label {
font-size: 22rpx;
color: #0f766e;
font-weight: 600;
}
/* 星期表头:7 列均分 */
.calendar-weekdays {
display: flex;
margin-top: 22rpx;
padding: 0 4rpx;
}
.calendar-weekday {
flex: 1;
text-align: center;
font-size: 26rpx;
color: #64748b;
font-weight: 700;
letter-spacing: 1rpx;
line-height: 1.4;
padding: 8rpx 0;
}
.calendar-weekday-weekend {
color: #f59e0b;
}
/* 月历主体 */
.calendar-grid {
margin-top: 6rpx;
background: #f8fafc;
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: 14rpx;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
position: relative;
transition: transform 0.15s ease;
box-sizing: border-box;
overflow: hidden;
border: 2rpx solid transparent;
/* 默认(in-range, 无记录):白底浅灰字 */
&.level-0 {
background: #ffffff;
border-color: #f1f5f9;
}
/* 非本范围日期:极淡 */
&.off {
background: transparent;
opacity: 0.4;
}
&.level-1 {
background: #ccfbf1;
border-color: #99f6e4;
}
&.level-2 {
background: linear-gradient(135deg, #5eead4 0%, #2dd4bf 100%);
border-color: #14b8a6;
}
&.level-3 {
background: linear-gradient(135deg, #0ea5a4 0%, #0d9488 100%);
box-shadow: 0 4rpx 10rpx rgba(14, 165, 164, 0.35);
}
/* 今日:粗橙色 ring,背景统一为深橙保证对比度 */
&.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.4);
}
&.clickable:active {
transform: scale(0.94);
}
}
.calendar-cell-day {
font-size: 32rpx; /* 适老化:醒目的日期数字 */
color: #1e293b;
font-weight: 700;
line-height: 1;
}
.calendar-cell.level-2 .calendar-cell-day,
.calendar-cell.level-3 .calendar-cell-day {
color: #ffffff;
}
.calendar-cell.today .calendar-cell-day {
color: #ffffff;
font-weight: 800;
transform: translateY(-6rpx); /* 让出底部"今天"条带空间 */
}
.calendar-cell.off .calendar-cell-day {
color: #cbd5e1;
font-weight: 500;
}
/* 记录日:右下角小圆点 */
.calendar-cell-mark {
position: absolute;
right: 8rpx;
bottom: 8rpx;
width: 10rpx;
height: 10rpx;
border-radius: 999rpx;
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: #14b8a6;
box-shadow: none;
}
/* 今日已被底部"今天"条带占用,隐藏角标点 */
.calendar-cell.today .calendar-cell-mark {
display: none;
}
/* "今天"角标:放在底部居中条带,避免被 overflow:hidden 裁掉 */
.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: #f1f5f9;
border: 1rpx solid #e2e8f0;
&.level-1 { background: #ccfbf1; border-color: #99f6e4; }
&.level-2 { background: linear-gradient(135deg, #5eead4 0%, #2dd4bf 100%); border-color: #14b8a6; }
&.level-3 { background: linear-gradient(135deg, #0ea5a4 0%, #0d9488 100%); 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: #475569;
font-weight: 600;
}
.heatmap-foot {
margin-top: 22rpx;
padding-top: 18rpx;
border-top: 1rpx dashed #e2e8f0;
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: linear-gradient(135deg, #0ea5a4 0%, #14b8a6 100%);
color: #ffffff;
font-size: 28rpx; /* 适老化:放大 */
font-weight: 700;
padding: 18rpx 30rpx; /* 适老化:扩大触控区 */
border-radius: 999rpx;
box-shadow: 0 4rpx 12rpx rgba(14, 165, 164, 0.4);
min-height: 76rpx;
display: flex;
align-items: center;
&:active {
transform: scale(0.96);
}
}
/* === 录入今日数据弹层(bottom sheet=== */
.input-modal-mask {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.55);
z-index: 999;
display: flex;
align-items: flex-end;
justify-content: center;
}
.input-modal {
width: 100%;
max-width: 750rpx;
background: #ffffff;
border-radius: 32rpx 32rpx 0 0;
padding: 16rpx 0 calc(20rpx + env(safe-area-inset-bottom));
box-shadow: 0 -10rpx 30rpx rgba(15, 23, 42, 0.18);
max-height: 88vh;
display: flex;
flex-direction: column;
animation: input-modal-rise 0.22s ease-out;
/* 微信小程序里 view 默认 z-index 无效,但配合上方 v-if 卸载 canvas
这里给一个高 z-index 进一步保证层级(防止其他普通 view 抢占)。 */
position: relative;
z-index: 1000;
}
@keyframes input-modal-rise {
from { transform: translateY(40rpx); opacity: 0.6; }
to { transform: translateY(0); opacity: 1; }
}
.input-modal-grip {
width: 80rpx;
height: 6rpx;
border-radius: 999rpx;
background: #e2e8f0;
margin: 0 auto 8rpx;
}
.input-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12rpx 32rpx 16rpx;
border-bottom: 1rpx solid #f1f5f9;
}
.input-modal-title-wrap {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.input-modal-title {
font-size: 40rpx; /* 适老化:放大弹层标题 */
font-weight: 800;
color: #0f172a;
letter-spacing: 0.5rpx;
}
.input-modal-sub {
font-size: 26rpx; /* 适老化:放大 */
color: #64748b;
margin-top: 4rpx;
}
.input-modal-close {
width: 80rpx; /* 适老化:扩大触控区 */
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999rpx;
background: #f1f5f9;
&:active {
background: #e2e8f0;
}
}
.input-modal-close-icon {
font-size: 42rpx;
color: #475569;
line-height: 1;
font-weight: 700;
}
.input-modal-loading {
padding: 80rpx 0;
text-align: center;
color: #64748b;
font-size: 30rpx;
}
.input-modal-body {
flex: 1;
padding: 20rpx 32rpx 28rpx;
overflow-y: auto;
overflow-x: hidden;
box-sizing: border-box;
width: 100%;
}
.input-modal-tip {
background: #f0fdfa;
border: 1rpx dashed #99f6e4;
border-radius: 14rpx;
padding: 18rpx 20rpx;
font-size: 26rpx; /* 适老化:放大提示 */
color: #0f766e;
line-height: 1.5;
margin-bottom: 22rpx;
}
.input-section-title {
font-size: 28rpx; /* 适老化:放大分段标题 */
font-weight: 700;
color: #334155;
letter-spacing: 1rpx;
margin: 18rpx 0 14rpx;
}
.input-field {
display: flex;
align-items: center;
gap: 18rpx;
padding: 14rpx 0;
border-bottom: 1rpx solid #f1f5f9;
min-width: 0; /* 防止 flex item 被内容撑开 */
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: #0f172a;
font-weight: 700;
}
.input-field-dot {
width: 14rpx;
height: 14rpx;
border-radius: 999rpx;
background: #94a3b8;
flex-shrink: 0;
}
.input-field-dot-fasting { background: #0ea5a4; }
.input-field-dot-postprandial { background: #f59e0b; }
.input-field-dot-other { background: #8b5cf6; }
.input-field-input {
flex: 1 1 0;
min-width: 0; /* 关键:允许 flex item 收缩到比 placeholder 还窄 */
background: #f8fafc;
border-radius: 14rpx;
padding: 18rpx 20rpx; /* 适老化:增加输入框高度 */
box-sizing: border-box;
overflow: hidden;
min-height: 76rpx;
display: flex;
align-items: center;
input {
width: 100%;
min-width: 0;
font-size: 34rpx; /* 适老化:放大输入字体 */
color: #0f172a;
text-align: right;
box-sizing: border-box;
font-weight: 600;
}
}
.input-placeholder {
color: #94a3b8; /* 提升对比度 */
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 #f1f5f9;
}
.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: #f8fafc;
border-radius: 14rpx;
padding: 20rpx 22rpx;
font-size: 30rpx; /* 适老化:放大 */
color: #0f172a;
box-sizing: border-box;
line-height: 1.6;
}
}
.input-modal-actions {
display: flex;
gap: 18rpx;
padding: 20rpx 32rpx 0;
border-top: 1rpx solid #f1f5f9;
}
.input-modal-btn {
flex: 1;
height: 108rpx; /* 适老化:扩大主要按钮 */
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 34rpx; /* 适老化:放大按钮文字 */
font-weight: 700;
letter-spacing: 2rpx;
&.primary {
background: linear-gradient(135deg, #0ea5a4 0%, #14b8a6 100%);
color: #ffffff;
box-shadow: 0 8rpx 20rpx rgba(14, 165, 164, 0.32);
&:active {
transform: translateY(1rpx);
}
}
&.danger {
flex: 0 0 200rpx;
background: #fff1f2;
color: #dc2626;
&:active {
background: #ffe4e6;
}
}
&.disabled {
opacity: 0.55;
pointer-events: none;
}
}
</style>